mirror of
https://code.forgejo.org/forgejo/runner.git
synced 2025-08-31 18:30:58 +00:00
feat!: validate workflows with a schema (#170)
Reviewed-on: https://code.forgejo.org/forgejo/act/pulls/170 Reviewed-by: Michael Kriese <michael.kriese@gmx.de>
This commit is contained in:
commit
ff48b5b0f6
18 changed files with 3026 additions and 138 deletions
|
@ -42,16 +42,6 @@ func TestParse(t *testing.T) {
|
||||||
options: nil,
|
options: nil,
|
||||||
wantErr: false,
|
wantErr: false,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: "has_secrets",
|
|
||||||
options: nil,
|
|
||||||
wantErr: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "empty_step",
|
|
||||||
options: nil,
|
|
||||||
wantErr: false,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
|
8
act/jobparser/testdata/empty_step.in.yaml
vendored
8
act/jobparser/testdata/empty_step.in.yaml
vendored
|
@ -1,8 +0,0 @@
|
||||||
name: test
|
|
||||||
jobs:
|
|
||||||
job1:
|
|
||||||
name: job1
|
|
||||||
runs-on: linux
|
|
||||||
steps:
|
|
||||||
- run: echo job-1
|
|
||||||
-
|
|
7
act/jobparser/testdata/empty_step.out.yaml
vendored
7
act/jobparser/testdata/empty_step.out.yaml
vendored
|
@ -1,7 +0,0 @@
|
||||||
name: test
|
|
||||||
jobs:
|
|
||||||
job1:
|
|
||||||
name: job1
|
|
||||||
runs-on: linux
|
|
||||||
steps:
|
|
||||||
- run: echo job-1
|
|
6
act/jobparser/testdata/has_secrets.in.yaml
vendored
6
act/jobparser/testdata/has_secrets.in.yaml
vendored
|
@ -3,12 +3,14 @@ jobs:
|
||||||
job1:
|
job1:
|
||||||
name: job1
|
name: job1
|
||||||
runs-on: linux
|
runs-on: linux
|
||||||
uses: .gitea/workflows/build.yml
|
|
||||||
secrets:
|
secrets:
|
||||||
secret: hideme
|
secret: hideme
|
||||||
|
steps:
|
||||||
|
- uses: .gitea/workflows/build.yml
|
||||||
|
|
||||||
job2:
|
job2:
|
||||||
name: job2
|
name: job2
|
||||||
runs-on: linux
|
runs-on: linux
|
||||||
uses: .gitea/workflows/build.yml
|
|
||||||
secrets: inherit
|
secrets: inherit
|
||||||
|
steps:
|
||||||
|
- uses: .gitea/workflows/build.yml
|
||||||
|
|
16
act/jobparser/testdata/has_secrets.out.yaml
vendored
16
act/jobparser/testdata/has_secrets.out.yaml
vendored
|
@ -1,16 +0,0 @@
|
||||||
name: test
|
|
||||||
jobs:
|
|
||||||
job1:
|
|
||||||
name: job1
|
|
||||||
runs-on: linux
|
|
||||||
uses: .gitea/workflows/build.yml
|
|
||||||
secrets:
|
|
||||||
secret: hideme
|
|
||||||
---
|
|
||||||
name: test
|
|
||||||
jobs:
|
|
||||||
job2:
|
|
||||||
name: job2
|
|
||||||
runs-on: linux
|
|
||||||
uses: .gitea/workflows/build.yml
|
|
||||||
secrets: inherit
|
|
14
act/jobparser/testdata/has_with.in.yaml
vendored
14
act/jobparser/testdata/has_with.in.yaml
vendored
|
@ -3,13 +3,15 @@ jobs:
|
||||||
job1:
|
job1:
|
||||||
name: job1
|
name: job1
|
||||||
runs-on: linux
|
runs-on: linux
|
||||||
uses: .gitea/workflows/build.yml
|
steps:
|
||||||
with:
|
- uses: .gitea/workflows/build.yml
|
||||||
package: service
|
with:
|
||||||
|
package: service
|
||||||
|
|
||||||
job2:
|
job2:
|
||||||
name: job2
|
name: job2
|
||||||
runs-on: linux
|
runs-on: linux
|
||||||
uses: .gitea/workflows/build.yml
|
steps:
|
||||||
with:
|
- uses: .gitea/workflows/build.yml
|
||||||
package: module
|
with:
|
||||||
|
package: module
|
||||||
|
|
14
act/jobparser/testdata/has_with.out.yaml
vendored
14
act/jobparser/testdata/has_with.out.yaml
vendored
|
@ -3,15 +3,17 @@ jobs:
|
||||||
job1:
|
job1:
|
||||||
name: job1
|
name: job1
|
||||||
runs-on: linux
|
runs-on: linux
|
||||||
uses: .gitea/workflows/build.yml
|
steps:
|
||||||
with:
|
- uses: .gitea/workflows/build.yml
|
||||||
package: service
|
with:
|
||||||
|
package: service
|
||||||
---
|
---
|
||||||
name: test
|
name: test
|
||||||
jobs:
|
jobs:
|
||||||
job2:
|
job2:
|
||||||
name: job2
|
name: job2
|
||||||
runs-on: linux
|
runs-on: linux
|
||||||
uses: .gitea/workflows/build.yml
|
steps:
|
||||||
with:
|
- uses: .gitea/workflows/build.yml
|
||||||
package: module
|
with:
|
||||||
|
package: module
|
||||||
|
|
|
@ -5,6 +5,7 @@ import (
|
||||||
"io"
|
"io"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/nektos/act/pkg/schema"
|
||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -82,6 +83,18 @@ type Action struct {
|
||||||
} `yaml:"branding"`
|
} `yaml:"branding"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *Action) UnmarshalYAML(node *yaml.Node) error {
|
||||||
|
// Validate the schema before deserializing it into our model
|
||||||
|
if err := (&schema.Node{
|
||||||
|
Definition: "action-root",
|
||||||
|
Schema: schema.GetActionSchema(),
|
||||||
|
}).UnmarshalYAML(node); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
type ActionDefault Action
|
||||||
|
return node.Decode((*ActionDefault)(a))
|
||||||
|
}
|
||||||
|
|
||||||
// Input parameters allow you to specify data that the action expects to use during runtime. GitHub stores input parameters as environment variables. Input ids with uppercase letters are converted to lowercase during runtime. We recommended using lowercase input ids.
|
// Input parameters allow you to specify data that the action expects to use during runtime. GitHub stores input parameters as environment variables. Input ids with uppercase letters are converted to lowercase during runtime. We recommended using lowercase input ids.
|
||||||
type Input struct {
|
type Input struct {
|
||||||
Description string `yaml:"description"`
|
Description string `yaml:"description"`
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
package model
|
package model
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"reflect"
|
"reflect"
|
||||||
|
@ -9,6 +10,7 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/nektos/act/pkg/common"
|
"github.com/nektos/act/pkg/common"
|
||||||
|
"github.com/nektos/act/pkg/schema"
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
|
@ -92,6 +94,18 @@ func (w *Workflow) OnSchedule() []string {
|
||||||
return []string{}
|
return []string{}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (w *Workflow) UnmarshalYAML(node *yaml.Node) error {
|
||||||
|
// Validate the schema before deserializing it into our model
|
||||||
|
if err := (&schema.Node{
|
||||||
|
Definition: "workflow-root",
|
||||||
|
Schema: schema.GetWorkflowSchema(),
|
||||||
|
}).UnmarshalYAML(node); err != nil {
|
||||||
|
return errors.Join(err, fmt.Errorf("Forgejo Actions YAML Schema validation error"))
|
||||||
|
}
|
||||||
|
type WorkflowDefault Workflow
|
||||||
|
return node.Decode((*WorkflowDefault)(w))
|
||||||
|
}
|
||||||
|
|
||||||
type WorkflowDispatchInput struct {
|
type WorkflowDispatchInput struct {
|
||||||
Description string `yaml:"description"`
|
Description string `yaml:"description"`
|
||||||
Required bool `yaml:"required"`
|
Required bool `yaml:"required"`
|
||||||
|
@ -488,7 +502,7 @@ func (j *Job) GetMatrixes() ([]map[string]interface{}, error) {
|
||||||
return matrixes, nil
|
return matrixes, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func commonKeysMatch(a map[string]interface{}, b map[string]interface{}) bool {
|
func commonKeysMatch(a, b map[string]interface{}) bool {
|
||||||
for aKey, aVal := range a {
|
for aKey, aVal := range a {
|
||||||
if bVal, ok := b[aKey]; ok && !reflect.DeepEqual(aVal, bVal) {
|
if bVal, ok := b[aKey]; ok && !reflect.DeepEqual(aVal, bVal) {
|
||||||
return false
|
return false
|
||||||
|
@ -497,7 +511,7 @@ func commonKeysMatch(a map[string]interface{}, b map[string]interface{}) bool {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
func commonKeysMatch2(a map[string]interface{}, b map[string]interface{}, m map[string][]interface{}) bool {
|
func commonKeysMatch2(a, b map[string]interface{}, m map[string][]interface{}) bool {
|
||||||
for aKey, aVal := range a {
|
for aKey, aVal := range a {
|
||||||
_, useKey := m[aKey]
|
_, useKey := m[aKey]
|
||||||
if bVal, ok := b[aKey]; useKey && ok && !reflect.DeepEqual(aVal, bVal) {
|
if bVal, ok := b[aKey]; useKey && ok && !reflect.DeepEqual(aVal, bVal) {
|
||||||
|
|
|
@ -112,32 +112,25 @@ jobs:
|
||||||
func TestReadWorkflow_Notifications(t *testing.T) {
|
func TestReadWorkflow_Notifications(t *testing.T) {
|
||||||
for _, testCase := range []struct {
|
for _, testCase := range []struct {
|
||||||
expected bool
|
expected bool
|
||||||
hasErr bool
|
err string
|
||||||
snippet string
|
snippet string
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
expected: false,
|
expected: false,
|
||||||
hasErr: false,
|
|
||||||
snippet: "# nothing",
|
snippet: "# nothing",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
expected: true,
|
expected: true,
|
||||||
hasErr: false,
|
|
||||||
snippet: "enable-email-notifications: true",
|
snippet: "enable-email-notifications: true",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
expected: false,
|
expected: false,
|
||||||
hasErr: false,
|
|
||||||
snippet: "enable-email-notifications: false",
|
snippet: "enable-email-notifications: false",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
hasErr: true,
|
err: "`invalid` into bool",
|
||||||
snippet: "enable-email-notifications: invalid",
|
snippet: "enable-email-notifications: invalid",
|
||||||
},
|
},
|
||||||
{
|
|
||||||
hasErr: true,
|
|
||||||
snippet: "enable-email-notifications: [1,2]",
|
|
||||||
},
|
|
||||||
} {
|
} {
|
||||||
t.Run(testCase.snippet, func(t *testing.T) {
|
t.Run(testCase.snippet, func(t *testing.T) {
|
||||||
yaml := fmt.Sprintf(`
|
yaml := fmt.Sprintf(`
|
||||||
|
@ -154,12 +147,12 @@ jobs:
|
||||||
`, testCase.snippet)
|
`, testCase.snippet)
|
||||||
|
|
||||||
workflow, err := ReadWorkflow(strings.NewReader(yaml))
|
workflow, err := ReadWorkflow(strings.NewReader(yaml))
|
||||||
assert.NoError(t, err, "read workflow should succeed")
|
if testCase.err != "" {
|
||||||
|
assert.ErrorContains(t, err, testCase.err)
|
||||||
notification, err := workflow.Notifications()
|
|
||||||
if testCase.hasErr {
|
|
||||||
assert.Error(t, err)
|
|
||||||
} else {
|
} else {
|
||||||
|
assert.NoError(t, err, "read workflow should succeed")
|
||||||
|
|
||||||
|
notification, err := workflow.Notifications()
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.Equal(t, testCase.expected, notification)
|
assert.Equal(t, testCase.expected, notification)
|
||||||
}
|
}
|
||||||
|
@ -226,9 +219,8 @@ jobs:
|
||||||
foo: {{ a }}
|
foo: {{ a }}
|
||||||
`
|
`
|
||||||
|
|
||||||
workflow, err := ReadWorkflow(strings.NewReader(yaml))
|
_, err := ReadWorkflow(strings.NewReader(yaml))
|
||||||
assert.NoError(t, err, "read workflow should succeed")
|
assert.ErrorContains(t, err, "Line: 11 Column 16: Expected a scalar got mapping")
|
||||||
assert.Nil(t, workflow.GetJob("test").Steps[0].GetEnv())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestReadWorkflow_RunsOnLabels(t *testing.T) {
|
func TestReadWorkflow_RunsOnLabels(t *testing.T) {
|
||||||
|
@ -440,15 +432,8 @@ jobs:
|
||||||
uses: ./local-action
|
uses: ./local-action
|
||||||
`
|
`
|
||||||
|
|
||||||
workflow, err := ReadWorkflow(strings.NewReader(yaml))
|
_, err := ReadWorkflow(strings.NewReader(yaml))
|
||||||
assert.NoError(t, err, "read workflow should succeed")
|
assert.Error(t, err, "read workflow should fail")
|
||||||
assert.Len(t, workflow.Jobs, 1)
|
|
||||||
assert.Len(t, workflow.Jobs["test"].Steps, 5)
|
|
||||||
assert.Equal(t, workflow.Jobs["test"].Steps[0].Type(), StepTypeInvalid)
|
|
||||||
assert.Equal(t, workflow.Jobs["test"].Steps[1].Type(), StepTypeRun)
|
|
||||||
assert.Equal(t, workflow.Jobs["test"].Steps[2].Type(), StepTypeUsesActionRemote)
|
|
||||||
assert.Equal(t, workflow.Jobs["test"].Steps[3].Type(), StepTypeUsesDockerURL)
|
|
||||||
assert.Equal(t, workflow.Jobs["test"].Steps[4].Type(), StepTypeUsesActionLocal)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// See: https://docs.github.com/en/actions/reference/workflow-syntax-for-github-actions#jobsjob_idoutputs
|
// See: https://docs.github.com/en/actions/reference/workflow-syntax-for-github-actions#jobsjob_idoutputs
|
||||||
|
|
|
@ -197,16 +197,20 @@ func (j *TestJobFileInfo) runTest(ctx context.Context, t *testing.T, cfg *Config
|
||||||
assert.Nil(t, err, j.workflowPath)
|
assert.Nil(t, err, j.workflowPath)
|
||||||
|
|
||||||
planner, err := model.NewWorkflowPlanner(fullWorkflowPath, true)
|
planner, err := model.NewWorkflowPlanner(fullWorkflowPath, true)
|
||||||
assert.Nil(t, err, fullWorkflowPath)
|
if err != nil {
|
||||||
|
assert.Error(t, err, j.errorMessage)
|
||||||
|
} else {
|
||||||
|
assert.Nil(t, err, fullWorkflowPath)
|
||||||
|
|
||||||
plan, err := planner.PlanEvent(j.eventName)
|
plan, err := planner.PlanEvent(j.eventName)
|
||||||
assert.True(t, (err == nil) != (plan == nil), "PlanEvent should return either a plan or an error")
|
assert.True(t, (err == nil) != (plan == nil), "PlanEvent should return either a plan or an error")
|
||||||
if err == nil && plan != nil {
|
if err == nil && plan != nil {
|
||||||
err = runner.NewPlanExecutor(plan)(ctx)
|
err = runner.NewPlanExecutor(plan)(ctx)
|
||||||
if j.errorMessage == "" {
|
if j.errorMessage == "" {
|
||||||
assert.Nil(t, err, fullWorkflowPath)
|
assert.Nil(t, err, fullWorkflowPath)
|
||||||
} else {
|
} else {
|
||||||
assert.Error(t, err, j.errorMessage)
|
assert.Error(t, err, j.errorMessage)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -331,7 +335,7 @@ func TestRunEvent(t *testing.T) {
|
||||||
config.EventPath = eventFile
|
config.EventPath = eventFile
|
||||||
}
|
}
|
||||||
|
|
||||||
testConfigFile := filepath.Join(workdir, table.workflowPath, "config.yml")
|
testConfigFile := filepath.Join(workdir, table.workflowPath, "config/config.yml")
|
||||||
if file, err := os.ReadFile(testConfigFile); err == nil {
|
if file, err := os.ReadFile(testConfigFile); err == nil {
|
||||||
testConfig := &TestConfig{}
|
testConfig := &TestConfig{}
|
||||||
if yaml.Unmarshal(file, testConfig) == nil {
|
if yaml.Unmarshal(file, testConfig) == nil {
|
||||||
|
|
|
@ -1,44 +1,44 @@
|
||||||
inputs:
|
inputs:
|
||||||
who-to-greet:
|
who-to-greet:
|
||||||
default: 'Mona the Octocat'
|
default: 'Mona the Octocat'
|
||||||
runs:
|
runs:
|
||||||
using: composite
|
using: composite
|
||||||
steps:
|
steps:
|
||||||
# Test if GITHUB_ACTION_PATH is set correctly before all steps
|
# Test if GITHUB_ACTION_PATH is set correctly before all steps
|
||||||
- run: stat $GITHUB_ACTION_PATH/push.yml
|
- run: stat $GITHUB_ACTION_PATH/../push.yml
|
||||||
shell: bash
|
shell: bash
|
||||||
- run: stat $GITHUB_ACTION_PATH/action.yml
|
- run: stat $GITHUB_ACTION_PATH/action.yml
|
||||||
shell: bash
|
shell: bash
|
||||||
- run: '[[ "$GITHUB_ACTION_REPOSITORY" == "" ]] && [[ "$GITHUB_ACTION_REF" == "" ]]'
|
- run: '[[ "$GITHUB_ACTION_REPOSITORY" == "" ]] && [[ "$GITHUB_ACTION_REF" == "" ]]'
|
||||||
shell: bash
|
shell: bash
|
||||||
- uses: ./actions/docker-local
|
- uses: ./actions/docker-local
|
||||||
id: dockerlocal
|
id: dockerlocal
|
||||||
with:
|
with:
|
||||||
who-to-greet: ${{inputs.who-to-greet}}
|
who-to-greet: ${{inputs.who-to-greet}}
|
||||||
- run: '[[ "${{ env.SOMEVAR }}" == "${{inputs.who-to-greet}}" ]]'
|
- run: '[[ "${{ env.SOMEVAR }}" == "${{inputs.who-to-greet}}" ]]'
|
||||||
shell: bash
|
shell: bash
|
||||||
- run: '[ "${SOMEVAR}" = "Not Mona" ] || exit 1'
|
- run: '[ "${SOMEVAR}" = "Not Mona" ] || exit 1'
|
||||||
shell: bash
|
shell: bash
|
||||||
env:
|
env:
|
||||||
SOMEVAR: 'Not Mona'
|
SOMEVAR: 'Not Mona'
|
||||||
- run: '[[ "${{ steps.dockerlocal.outputs.whoami }}" == "${{inputs.who-to-greet}}" ]]'
|
- run: '[[ "${{ steps.dockerlocal.outputs.whoami }}" == "${{inputs.who-to-greet}}" ]]'
|
||||||
shell: bash
|
shell: bash
|
||||||
# Test if overriding args doesn't leak inputs
|
# Test if overriding args doesn't leak inputs
|
||||||
- uses: ./actions/docker-local-noargs
|
- uses: ./actions/docker-local-noargs
|
||||||
with:
|
with:
|
||||||
args: ${{format('"{0}"', 'Mona is not the Octocat') }}
|
args: ${{format('"{0}"', 'Mona is not the Octocat') }}
|
||||||
who-to-greet: ${{inputs.who-to-greet}}
|
who-to-greet: ${{inputs.who-to-greet}}
|
||||||
- run: '[[ "${{ env.SOMEVAR }}" == "Mona is not the Octocat" ]]'
|
- run: '[[ "${{ env.SOMEVAR }}" == "Mona is not the Octocat" ]]'
|
||||||
shell: bash
|
shell: bash
|
||||||
- uses: ./localdockerimagetest_
|
- uses: ./localdockerimagetest_
|
||||||
# Also test a remote docker action here
|
# Also test a remote docker action here
|
||||||
- uses: actions/hello-world-docker-action@v1
|
- uses: actions/hello-world-docker-action@v1
|
||||||
with:
|
with:
|
||||||
who-to-greet: 'Mona the Octocat'
|
who-to-greet: 'Mona the Octocat'
|
||||||
# Test if GITHUB_ACTION_PATH is set correctly after all steps
|
# Test if GITHUB_ACTION_PATH is set correctly after all steps
|
||||||
- run: stat $GITHUB_ACTION_PATH/push.yml
|
- run: stat $GITHUB_ACTION_PATH/../push.yml
|
||||||
shell: bash
|
shell: bash
|
||||||
- run: stat $GITHUB_ACTION_PATH/action.yml
|
- run: stat $GITHUB_ACTION_PATH/action.yml
|
||||||
shell: bash
|
shell: bash
|
||||||
- run: '[[ "$GITHUB_ACTION_REPOSITORY" == "" ]] && [[ "$GITHUB_ACTION_REF" == "" ]]'
|
- run: '[[ "$GITHUB_ACTION_REPOSITORY" == "" ]] && [[ "$GITHUB_ACTION_REF" == "" ]]'
|
||||||
shell: bash
|
shell: bash
|
|
@ -6,4 +6,4 @@ jobs:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v2
|
||||||
- uses: ./local-action-via-composite-dockerfile
|
- uses: ./local-action-via-composite-dockerfile/action
|
268
act/schema/action_schema.json
Normal file
268
act/schema/action_schema.json
Normal file
|
@ -0,0 +1,268 @@
|
||||||
|
{
|
||||||
|
"definitions": {
|
||||||
|
"action-root": {
|
||||||
|
"description": "Action file",
|
||||||
|
"mapping": {
|
||||||
|
"properties": {
|
||||||
|
"name": "string",
|
||||||
|
"description": "string",
|
||||||
|
"inputs": "inputs",
|
||||||
|
"runs": "runs",
|
||||||
|
"outputs": "outputs"
|
||||||
|
},
|
||||||
|
"loose-key-type": "non-empty-string",
|
||||||
|
"loose-value-type": "any"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"inputs": {
|
||||||
|
"mapping": {
|
||||||
|
"loose-key-type": "non-empty-string",
|
||||||
|
"loose-value-type": "input"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"input": {
|
||||||
|
"mapping": {
|
||||||
|
"properties": {
|
||||||
|
"default": "input-default-context"
|
||||||
|
},
|
||||||
|
"loose-key-type": "non-empty-string",
|
||||||
|
"loose-value-type": "any"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"outputs": {
|
||||||
|
"mapping": {
|
||||||
|
"loose-key-type": "non-empty-string",
|
||||||
|
"loose-value-type": "output-definition"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"output-definition": {
|
||||||
|
"mapping": {
|
||||||
|
"properties": {
|
||||||
|
"description": "string",
|
||||||
|
"value": "output-value"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"runs": {
|
||||||
|
"one-of": ["container-runs", "node-runs", "plugin-runs", "composite-runs"]
|
||||||
|
},
|
||||||
|
"container-runs": {
|
||||||
|
"mapping": {
|
||||||
|
"properties": {
|
||||||
|
"using": "non-empty-string",
|
||||||
|
"image": "non-empty-string",
|
||||||
|
"entrypoint": "non-empty-string",
|
||||||
|
"args": "container-runs-args",
|
||||||
|
"env": "container-runs-env",
|
||||||
|
"pre-entrypoint": "non-empty-string",
|
||||||
|
"pre-if": "non-empty-string",
|
||||||
|
"post-entrypoint": "non-empty-string",
|
||||||
|
"post-if": "non-empty-string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"container-runs-args": {
|
||||||
|
"sequence": {
|
||||||
|
"item-type": "container-runs-context"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"container-runs-env": {
|
||||||
|
"context": ["inputs"],
|
||||||
|
"mapping": {
|
||||||
|
"loose-key-type": "non-empty-string",
|
||||||
|
"loose-value-type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node-runs": {
|
||||||
|
"mapping": {
|
||||||
|
"properties": {
|
||||||
|
"using": "non-empty-string",
|
||||||
|
"main": "non-empty-string",
|
||||||
|
"pre": "non-empty-string",
|
||||||
|
"pre-if": "non-empty-string",
|
||||||
|
"post": "non-empty-string",
|
||||||
|
"post-if": "non-empty-string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"plugin-runs": {
|
||||||
|
"mapping": {
|
||||||
|
"properties": {
|
||||||
|
"plugin": "non-empty-string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"composite-runs": {
|
||||||
|
"mapping": {
|
||||||
|
"properties": {
|
||||||
|
"using": "non-empty-string",
|
||||||
|
"steps": "composite-steps"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"composite-steps": {
|
||||||
|
"sequence": {
|
||||||
|
"item-type": "composite-step"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"composite-step": {
|
||||||
|
"one-of": ["run-step", "uses-step"]
|
||||||
|
},
|
||||||
|
"run-step": {
|
||||||
|
"mapping": {
|
||||||
|
"properties": {
|
||||||
|
"name": "string-steps-context",
|
||||||
|
"id": "non-empty-string",
|
||||||
|
"if": "step-if",
|
||||||
|
"run": {
|
||||||
|
"type": "string-steps-context",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
"env": "step-env",
|
||||||
|
"continue-on-error": "boolean-steps-context",
|
||||||
|
"working-directory": "string-steps-context",
|
||||||
|
"shell": {
|
||||||
|
"type": "string-steps-context",
|
||||||
|
"required": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"uses-step": {
|
||||||
|
"mapping": {
|
||||||
|
"properties": {
|
||||||
|
"name": "string-steps-context",
|
||||||
|
"id": "non-empty-string",
|
||||||
|
"if": "step-if",
|
||||||
|
"uses": {
|
||||||
|
"type": "non-empty-string",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
"continue-on-error": "boolean-steps-context",
|
||||||
|
"with": "step-with",
|
||||||
|
"env": "step-env"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"container-runs-context": {
|
||||||
|
"context": ["inputs"],
|
||||||
|
"string": {}
|
||||||
|
},
|
||||||
|
"output-value": {
|
||||||
|
"context": [
|
||||||
|
"forge",
|
||||||
|
"github",
|
||||||
|
"strategy",
|
||||||
|
"matrix",
|
||||||
|
"steps",
|
||||||
|
"inputs",
|
||||||
|
"job",
|
||||||
|
"runner",
|
||||||
|
"env"
|
||||||
|
],
|
||||||
|
"string": {}
|
||||||
|
},
|
||||||
|
"input-default-context": {
|
||||||
|
"context": [
|
||||||
|
"forge",
|
||||||
|
"github",
|
||||||
|
"strategy",
|
||||||
|
"matrix",
|
||||||
|
"job",
|
||||||
|
"runner",
|
||||||
|
"hashFiles(1,255)"
|
||||||
|
],
|
||||||
|
"string": {}
|
||||||
|
},
|
||||||
|
"non-empty-string": {
|
||||||
|
"string": {
|
||||||
|
"require-non-empty": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"string-steps-context": {
|
||||||
|
"context": [
|
||||||
|
"forge",
|
||||||
|
"github",
|
||||||
|
"inputs",
|
||||||
|
"strategy",
|
||||||
|
"matrix",
|
||||||
|
"steps",
|
||||||
|
"job",
|
||||||
|
"runner",
|
||||||
|
"env",
|
||||||
|
"hashFiles(1,255)"
|
||||||
|
],
|
||||||
|
"string": {}
|
||||||
|
},
|
||||||
|
"boolean-steps-context": {
|
||||||
|
"context": [
|
||||||
|
"forge",
|
||||||
|
"github",
|
||||||
|
"inputs",
|
||||||
|
"strategy",
|
||||||
|
"matrix",
|
||||||
|
"steps",
|
||||||
|
"job",
|
||||||
|
"runner",
|
||||||
|
"env",
|
||||||
|
"hashFiles(1,255)"
|
||||||
|
],
|
||||||
|
"boolean": {}
|
||||||
|
},
|
||||||
|
"step-env": {
|
||||||
|
"context": [
|
||||||
|
"forge",
|
||||||
|
"github",
|
||||||
|
"inputs",
|
||||||
|
"strategy",
|
||||||
|
"matrix",
|
||||||
|
"steps",
|
||||||
|
"job",
|
||||||
|
"runner",
|
||||||
|
"env",
|
||||||
|
"hashFiles(1,255)"
|
||||||
|
],
|
||||||
|
"mapping": {
|
||||||
|
"loose-key-type": "non-empty-string",
|
||||||
|
"loose-value-type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"step-if": {
|
||||||
|
"context": [
|
||||||
|
"forge",
|
||||||
|
"github",
|
||||||
|
"inputs",
|
||||||
|
"strategy",
|
||||||
|
"matrix",
|
||||||
|
"steps",
|
||||||
|
"job",
|
||||||
|
"runner",
|
||||||
|
"env",
|
||||||
|
"always(0,0)",
|
||||||
|
"failure(0,0)",
|
||||||
|
"cancelled(0,0)",
|
||||||
|
"success(0,0)",
|
||||||
|
"hashFiles(1,255)"
|
||||||
|
],
|
||||||
|
"string": {}
|
||||||
|
},
|
||||||
|
"step-with": {
|
||||||
|
"context": [
|
||||||
|
"forge",
|
||||||
|
"github",
|
||||||
|
"inputs",
|
||||||
|
"strategy",
|
||||||
|
"matrix",
|
||||||
|
"steps",
|
||||||
|
"job",
|
||||||
|
"runner",
|
||||||
|
"env",
|
||||||
|
"hashFiles(1,255)"
|
||||||
|
],
|
||||||
|
"mapping": {
|
||||||
|
"loose-key-type": "non-empty-string",
|
||||||
|
"loose-value-type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
405
act/schema/schema.go
Normal file
405
act/schema/schema.go
Normal file
|
@ -0,0 +1,405 @@
|
||||||
|
package schema
|
||||||
|
|
||||||
|
import (
|
||||||
|
_ "embed"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/rhysd/actionlint"
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed workflow_schema.json
|
||||||
|
var workflowSchema string
|
||||||
|
|
||||||
|
//go:embed action_schema.json
|
||||||
|
var actionSchema string
|
||||||
|
|
||||||
|
var functions = regexp.MustCompile(`^([a-zA-Z0-9_]+)\(([0-9]+),([0-9]+|MAX)\)$`)
|
||||||
|
|
||||||
|
type Schema struct {
|
||||||
|
Definitions map[string]Definition
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Schema) GetDefinition(name string) Definition {
|
||||||
|
def, ok := s.Definitions[name]
|
||||||
|
if !ok {
|
||||||
|
switch name {
|
||||||
|
case "any":
|
||||||
|
return Definition{OneOf: &[]string{"sequence", "mapping", "number", "boolean", "string", "null"}}
|
||||||
|
case "sequence":
|
||||||
|
return Definition{Sequence: &SequenceDefinition{ItemType: "any"}}
|
||||||
|
case "mapping":
|
||||||
|
return Definition{Mapping: &MappingDefinition{LooseKeyType: "any", LooseValueType: "any"}}
|
||||||
|
case "number":
|
||||||
|
return Definition{Number: &NumberDefinition{}}
|
||||||
|
case "string":
|
||||||
|
return Definition{String: &StringDefinition{}}
|
||||||
|
case "boolean":
|
||||||
|
return Definition{Boolean: &BooleanDefinition{}}
|
||||||
|
case "null":
|
||||||
|
return Definition{Null: &NullDefinition{}}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
|
||||||
|
type Definition struct {
|
||||||
|
Context []string
|
||||||
|
Mapping *MappingDefinition
|
||||||
|
Sequence *SequenceDefinition
|
||||||
|
OneOf *[]string `json:"one-of"`
|
||||||
|
AllowedValues *[]string `json:"allowed-values"`
|
||||||
|
String *StringDefinition
|
||||||
|
Number *NumberDefinition
|
||||||
|
Boolean *BooleanDefinition
|
||||||
|
Null *NullDefinition
|
||||||
|
}
|
||||||
|
|
||||||
|
type MappingDefinition struct {
|
||||||
|
Properties map[string]MappingProperty
|
||||||
|
LooseKeyType string `json:"loose-key-type"`
|
||||||
|
LooseValueType string `json:"loose-value-type"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type MappingProperty struct {
|
||||||
|
Type string
|
||||||
|
Required bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *MappingProperty) UnmarshalJSON(data []byte) error {
|
||||||
|
if json.Unmarshal(data, &s.Type) != nil {
|
||||||
|
type MProp MappingProperty
|
||||||
|
return json.Unmarshal(data, (*MProp)(s))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type SequenceDefinition struct {
|
||||||
|
ItemType string `json:"item-type"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type StringDefinition struct {
|
||||||
|
Constant string
|
||||||
|
IsExpression bool `json:"is-expression"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type NumberDefinition struct {
|
||||||
|
}
|
||||||
|
|
||||||
|
type BooleanDefinition struct {
|
||||||
|
}
|
||||||
|
|
||||||
|
type NullDefinition struct {
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetWorkflowSchema() *Schema {
|
||||||
|
sh := &Schema{}
|
||||||
|
_ = json.Unmarshal([]byte(workflowSchema), sh)
|
||||||
|
return sh
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetActionSchema() *Schema {
|
||||||
|
sh := &Schema{}
|
||||||
|
_ = json.Unmarshal([]byte(actionSchema), sh)
|
||||||
|
return sh
|
||||||
|
}
|
||||||
|
|
||||||
|
type Node struct {
|
||||||
|
Definition string
|
||||||
|
Schema *Schema
|
||||||
|
Context []string
|
||||||
|
}
|
||||||
|
|
||||||
|
type FunctionInfo struct {
|
||||||
|
name string
|
||||||
|
min int
|
||||||
|
max int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Node) checkSingleExpression(exprNode actionlint.ExprNode) error {
|
||||||
|
if len(s.Context) == 0 {
|
||||||
|
switch exprNode.Token().Kind {
|
||||||
|
case actionlint.TokenKindInt:
|
||||||
|
case actionlint.TokenKindFloat:
|
||||||
|
case actionlint.TokenKindString:
|
||||||
|
return nil
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("expressions are not allowed here")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
funcs := s.GetFunctions()
|
||||||
|
|
||||||
|
var err error
|
||||||
|
actionlint.VisitExprNode(exprNode, func(node, _ actionlint.ExprNode, entering bool) {
|
||||||
|
if funcCallNode, ok := node.(*actionlint.FuncCallNode); entering && ok {
|
||||||
|
for _, v := range *funcs {
|
||||||
|
if strings.EqualFold(funcCallNode.Callee, v.name) {
|
||||||
|
if v.min > len(funcCallNode.Args) {
|
||||||
|
err = errors.Join(err, fmt.Errorf("Missing parameters for %s expected >= %v got %v", funcCallNode.Callee, v.min, len(funcCallNode.Args)))
|
||||||
|
}
|
||||||
|
if v.max < len(funcCallNode.Args) {
|
||||||
|
err = errors.Join(err, fmt.Errorf("Too many parameters for %s expected <= %v got %v", funcCallNode.Callee, v.max, len(funcCallNode.Args)))
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
err = errors.Join(err, fmt.Errorf("Unknown Function Call %s", funcCallNode.Callee))
|
||||||
|
}
|
||||||
|
if varNode, ok := node.(*actionlint.VariableNode); entering && ok {
|
||||||
|
for _, v := range s.Context {
|
||||||
|
if strings.EqualFold(varNode.Name, v) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
err = errors.Join(err, fmt.Errorf("Unknown Variable Access %s", varNode.Name))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Node) GetFunctions() *[]FunctionInfo {
|
||||||
|
funcs := &[]FunctionInfo{}
|
||||||
|
AddFunction(funcs, "contains", 2, 2)
|
||||||
|
AddFunction(funcs, "endsWith", 2, 2)
|
||||||
|
AddFunction(funcs, "format", 1, 255)
|
||||||
|
AddFunction(funcs, "join", 1, 2)
|
||||||
|
AddFunction(funcs, "startsWith", 2, 2)
|
||||||
|
AddFunction(funcs, "toJson", 1, 1)
|
||||||
|
AddFunction(funcs, "fromJson", 1, 1)
|
||||||
|
for _, v := range s.Context {
|
||||||
|
i := strings.Index(v, "(")
|
||||||
|
if i == -1 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
smatch := functions.FindStringSubmatch(v)
|
||||||
|
if len(smatch) > 0 {
|
||||||
|
functionName := smatch[1]
|
||||||
|
minParameters, _ := strconv.ParseInt(smatch[2], 10, 32)
|
||||||
|
maxParametersRaw := smatch[3]
|
||||||
|
var maxParameters int64
|
||||||
|
if strings.EqualFold(maxParametersRaw, "MAX") {
|
||||||
|
maxParameters = math.MaxInt32
|
||||||
|
} else {
|
||||||
|
maxParameters, _ = strconv.ParseInt(maxParametersRaw, 10, 32)
|
||||||
|
}
|
||||||
|
*funcs = append(*funcs, FunctionInfo{
|
||||||
|
name: functionName,
|
||||||
|
min: int(minParameters),
|
||||||
|
max: int(maxParameters),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return funcs
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Node) checkExpression(node *yaml.Node) (bool, error) {
|
||||||
|
val := node.Value
|
||||||
|
hadExpr := false
|
||||||
|
var err error
|
||||||
|
for {
|
||||||
|
if i := strings.Index(val, "${{"); i != -1 {
|
||||||
|
val = val[i+3:]
|
||||||
|
} else {
|
||||||
|
return hadExpr, err
|
||||||
|
}
|
||||||
|
hadExpr = true
|
||||||
|
|
||||||
|
parser := actionlint.NewExprParser()
|
||||||
|
lexer := actionlint.NewExprLexer(val)
|
||||||
|
exprNode, parseErr := parser.Parse(lexer)
|
||||||
|
if parseErr != nil {
|
||||||
|
err = errors.Join(err, fmt.Errorf("%sFailed to parse: %s", formatLocation(node), parseErr.Message))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
val = val[lexer.Offset():]
|
||||||
|
cerr := s.checkSingleExpression(exprNode)
|
||||||
|
if cerr != nil {
|
||||||
|
err = errors.Join(err, fmt.Errorf("%s%w", formatLocation(node), cerr))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func AddFunction(funcs *[]FunctionInfo, s string, i1, i2 int) {
|
||||||
|
*funcs = append(*funcs, FunctionInfo{
|
||||||
|
name: s,
|
||||||
|
min: i1,
|
||||||
|
max: i2,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Node) UnmarshalYAML(node *yaml.Node) error {
|
||||||
|
if node != nil && node.Kind == yaml.DocumentNode {
|
||||||
|
return s.UnmarshalYAML(node.Content[0])
|
||||||
|
}
|
||||||
|
def := s.Schema.GetDefinition(s.Definition)
|
||||||
|
if s.Context == nil {
|
||||||
|
s.Context = def.Context
|
||||||
|
}
|
||||||
|
|
||||||
|
isExpr, err := s.checkExpression(node)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if isExpr {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if def.Mapping != nil {
|
||||||
|
return s.checkMapping(node, def)
|
||||||
|
} else if def.Sequence != nil {
|
||||||
|
return s.checkSequence(node, def)
|
||||||
|
} else if def.OneOf != nil {
|
||||||
|
return s.checkOneOf(def, node)
|
||||||
|
}
|
||||||
|
|
||||||
|
if node.Kind != yaml.ScalarNode {
|
||||||
|
return fmt.Errorf("%sExpected a scalar got %v", formatLocation(node), getStringKind(node.Kind))
|
||||||
|
}
|
||||||
|
|
||||||
|
if def.String != nil {
|
||||||
|
return s.checkString(node, def)
|
||||||
|
} else if def.Number != nil {
|
||||||
|
var num float64
|
||||||
|
return node.Decode(&num)
|
||||||
|
} else if def.Boolean != nil {
|
||||||
|
var b bool
|
||||||
|
return node.Decode(&b)
|
||||||
|
} else if def.AllowedValues != nil {
|
||||||
|
s := node.Value
|
||||||
|
for _, v := range *def.AllowedValues {
|
||||||
|
if s == v {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fmt.Errorf("%sExpected one of %s got %s", formatLocation(node), strings.Join(*def.AllowedValues, ","), s)
|
||||||
|
} else if def.Null != nil {
|
||||||
|
var myNull *byte
|
||||||
|
return node.Decode(&myNull)
|
||||||
|
}
|
||||||
|
return errors.ErrUnsupported
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Node) checkString(node *yaml.Node, def Definition) error {
|
||||||
|
val := node.Value
|
||||||
|
if def.String.Constant != "" && def.String.Constant != val {
|
||||||
|
return fmt.Errorf("%sExpected %s got %s", formatLocation(node), def.String.Constant, val)
|
||||||
|
}
|
||||||
|
if def.String.IsExpression {
|
||||||
|
parser := actionlint.NewExprParser()
|
||||||
|
lexer := actionlint.NewExprLexer(val + "}}")
|
||||||
|
exprNode, parseErr := parser.Parse(lexer)
|
||||||
|
if parseErr != nil {
|
||||||
|
return fmt.Errorf("%sFailed to parse: %s", formatLocation(node), parseErr.Message)
|
||||||
|
}
|
||||||
|
cerr := s.checkSingleExpression(exprNode)
|
||||||
|
if cerr != nil {
|
||||||
|
return fmt.Errorf("%s%w", formatLocation(node), cerr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Node) checkOneOf(def Definition, node *yaml.Node) error {
|
||||||
|
var allErrors error
|
||||||
|
for _, v := range *def.OneOf {
|
||||||
|
sub := &Node{
|
||||||
|
Definition: v,
|
||||||
|
Schema: s.Schema,
|
||||||
|
Context: append(append([]string{}, s.Context...), s.Schema.GetDefinition(v).Context...),
|
||||||
|
}
|
||||||
|
|
||||||
|
err := sub.UnmarshalYAML(node)
|
||||||
|
if err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
allErrors = errors.Join(allErrors, fmt.Errorf("%sFailed to match %s: %w", formatLocation(node), v, err))
|
||||||
|
}
|
||||||
|
return allErrors
|
||||||
|
}
|
||||||
|
|
||||||
|
func getStringKind(k yaml.Kind) string {
|
||||||
|
switch k {
|
||||||
|
case yaml.DocumentNode:
|
||||||
|
return "document"
|
||||||
|
case yaml.SequenceNode:
|
||||||
|
return "sequence"
|
||||||
|
case yaml.MappingNode:
|
||||||
|
return "mapping"
|
||||||
|
case yaml.ScalarNode:
|
||||||
|
return "scalar"
|
||||||
|
case yaml.AliasNode:
|
||||||
|
return "alias"
|
||||||
|
default:
|
||||||
|
return "unknown"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Node) checkSequence(node *yaml.Node, def Definition) error {
|
||||||
|
if node.Kind != yaml.SequenceNode {
|
||||||
|
return fmt.Errorf("%sExpected a sequence got %v", formatLocation(node), getStringKind(node.Kind))
|
||||||
|
}
|
||||||
|
var allErrors error
|
||||||
|
for _, v := range node.Content {
|
||||||
|
allErrors = errors.Join(allErrors, (&Node{
|
||||||
|
Definition: def.Sequence.ItemType,
|
||||||
|
Schema: s.Schema,
|
||||||
|
Context: append(append([]string{}, s.Context...), s.Schema.GetDefinition(def.Sequence.ItemType).Context...),
|
||||||
|
}).UnmarshalYAML(v))
|
||||||
|
}
|
||||||
|
return allErrors
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatLocation(node *yaml.Node) string {
|
||||||
|
return fmt.Sprintf("Line: %v Column %v: ", node.Line, node.Column)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Node) checkMapping(node *yaml.Node, def Definition) error {
|
||||||
|
if node.Kind != yaml.MappingNode {
|
||||||
|
return fmt.Errorf("%sExpected a mapping got %v", formatLocation(node), getStringKind(node.Kind))
|
||||||
|
}
|
||||||
|
insertDirective := regexp.MustCompile(`\${{\s*insert\s*}}`)
|
||||||
|
var allErrors error
|
||||||
|
for i, k := range node.Content {
|
||||||
|
if i%2 == 0 {
|
||||||
|
if insertDirective.MatchString(k.Value) {
|
||||||
|
if len(s.Context) == 0 {
|
||||||
|
allErrors = errors.Join(allErrors, fmt.Errorf("%sinsert is not allowed here", formatLocation(k)))
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
isExpr, err := s.checkExpression(k)
|
||||||
|
if err != nil {
|
||||||
|
allErrors = errors.Join(allErrors, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if isExpr {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
vdef, ok := def.Mapping.Properties[k.Value]
|
||||||
|
if !ok {
|
||||||
|
if def.Mapping.LooseValueType == "" {
|
||||||
|
allErrors = errors.Join(allErrors, fmt.Errorf("%sUnknown Property %v", formatLocation(k), k.Value))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
vdef = MappingProperty{Type: def.Mapping.LooseValueType}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := (&Node{
|
||||||
|
Definition: vdef.Type,
|
||||||
|
Schema: s.Schema,
|
||||||
|
Context: append(append([]string{}, s.Context...), s.Schema.GetDefinition(vdef.Type).Context...),
|
||||||
|
}).UnmarshalYAML(node.Content[i+1]); err != nil {
|
||||||
|
allErrors = errors.Join(allErrors, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return allErrors
|
||||||
|
}
|
92
act/schema/schema_test.go
Normal file
92
act/schema/schema_test.go
Normal file
|
@ -0,0 +1,92 @@
|
||||||
|
package schema
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAdditionalFunctions(t *testing.T) {
|
||||||
|
var node yaml.Node
|
||||||
|
err := yaml.Unmarshal([]byte(`
|
||||||
|
on: push
|
||||||
|
jobs:
|
||||||
|
job-with-condition:
|
||||||
|
runs-on: self-hosted
|
||||||
|
if: success() || success('joba', 'jobb') || failure() || failure('joba', 'jobb') || always() || cancelled()
|
||||||
|
steps:
|
||||||
|
- run: exit 0
|
||||||
|
`), &node)
|
||||||
|
if !assert.NoError(t, err) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
err = (&Node{
|
||||||
|
Definition: "workflow-root-strict",
|
||||||
|
Schema: GetWorkflowSchema(),
|
||||||
|
}).UnmarshalYAML(&node)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdditionalFunctionsFailure(t *testing.T) {
|
||||||
|
var node yaml.Node
|
||||||
|
err := yaml.Unmarshal([]byte(`
|
||||||
|
on: push
|
||||||
|
jobs:
|
||||||
|
job-with-condition:
|
||||||
|
runs-on: self-hosted
|
||||||
|
if: success() || success('joba', 'jobb') || failure() || failure('joba', 'jobb') || always('error')
|
||||||
|
steps:
|
||||||
|
- run: exit 0
|
||||||
|
`), &node)
|
||||||
|
if !assert.NoError(t, err) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
err = (&Node{
|
||||||
|
Definition: "workflow-root-strict",
|
||||||
|
Schema: GetWorkflowSchema(),
|
||||||
|
}).UnmarshalYAML(&node)
|
||||||
|
assert.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdditionalFunctionsSteps(t *testing.T) {
|
||||||
|
var node yaml.Node
|
||||||
|
err := yaml.Unmarshal([]byte(`
|
||||||
|
on: push
|
||||||
|
jobs:
|
||||||
|
job-with-condition:
|
||||||
|
runs-on: self-hosted
|
||||||
|
steps:
|
||||||
|
- run: exit 0
|
||||||
|
if: success() || failure() || always()
|
||||||
|
`), &node)
|
||||||
|
if !assert.NoError(t, err) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
err = (&Node{
|
||||||
|
Definition: "workflow-root-strict",
|
||||||
|
Schema: GetWorkflowSchema(),
|
||||||
|
}).UnmarshalYAML(&node)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdditionalFunctionsStepsExprSyntax(t *testing.T) {
|
||||||
|
var node yaml.Node
|
||||||
|
err := yaml.Unmarshal([]byte(`
|
||||||
|
on: push
|
||||||
|
jobs:
|
||||||
|
job-with-condition:
|
||||||
|
runs-on: self-hosted
|
||||||
|
steps:
|
||||||
|
- run: exit 0
|
||||||
|
if: ${{ success() || failure() || always() }}
|
||||||
|
`), &node)
|
||||||
|
if !assert.NoError(t, err) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
err = (&Node{
|
||||||
|
Definition: "workflow-root-strict",
|
||||||
|
Schema: GetWorkflowSchema(),
|
||||||
|
}).UnmarshalYAML(&node)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
}
|
2142
act/schema/workflow_schema.json
Normal file
2142
act/schema/workflow_schema.json
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue