mirror of
https://github.com/go-gitea/gitea.git
synced 2026-09-26 06:33:42 +09:00
feat(actions): update actionslib, support self:, misc fixes (#39358)
Updates actionslib to https://gitea.com/gitea/actionslib/releases/tag/v1.2.1, moves workflow parsing into it and aligns behaviour with GitHub. 1. `uses:` supports `self:` (Gitea-only feature) and `$/` paths. 1. `strategy`, `matrix`, `max-parallel` and `fail-fast` accept expressions, including over `needs`. A job whose `name`, `runs-on` or `continue-on-error` reads `needs` is resolved once they finish. 1. A job `if:` may only read `github`, `needs`, `vars` and `inputs` and is decided before the matrix, as on github.com. 1. Matrix `fail-fast` cancels the other combinations, and `always()` jobs keep running when a run is cancelled. 1. Invalid workflow files, including a malformed `on:` and unknown or cyclic `needs`, show up on push as failed runs with the error. 1. A job whose `if:` or `concurrency:` fails to evaluate is skipped or failed with the error, instead of staying blocked. 1. Reusable workflows: a missing and an unreadable repository fail alike, public callers cannot use private workflows, nested jobs cannot exceed the caller's token permissions. 1. Runner labels match case-insensitively, and `runs-on` accepts an array from an expression. Runner PR: https://gitea.com/gitea/runner/pulls/1247 Docs PR: https://gitea.com/gitea/docs/pulls/553 Fixes: https://github.com/go-gitea/gitea/issues/38990 Fixes: https://github.com/go-gitea/gitea/issues/39382 Fixes: https://github.com/go-gitea/gitea/issues/32364 Fixes: https://github.com/go-gitea/gitea/issues/36077 Fixes: https://github.com/go-gitea/gitea/issues/23277 Fixes: https://github.com/go-gitea/gitea/issues/29020 Co-authored-by: Claude (Opus 5) <noreply@anthropic.com> Co-authored-by: Zettat123 <zettat123@gmail.com>
This commit is contained in:
@@ -4,7 +4,6 @@
|
||||
package jobparser
|
||||
|
||||
import (
|
||||
"maps"
|
||||
"testing"
|
||||
|
||||
"gitea.dev/actionslib/pkg/model"
|
||||
@@ -14,235 +13,51 @@ import (
|
||||
"go.yaml.in/yaml/v4"
|
||||
)
|
||||
|
||||
func TestParseWorkflowCallSpec(t *testing.T) {
|
||||
t.Run("malformed YAML surfaces a parse error", func(t *testing.T) {
|
||||
// Mismatched flow-sequence brackets — yaml.Unmarshal must reject this.
|
||||
_, err := ParseWorkflowCallSpec([]byte(`name: bad
|
||||
on: [workflow_call
|
||||
jobs:
|
||||
noop: { }
|
||||
`))
|
||||
require.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("workflow without on.workflow_call is rejected", func(t *testing.T) {
|
||||
notCallable := []byte(`name: ordinary
|
||||
on: push
|
||||
jobs:
|
||||
noop:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo
|
||||
`)
|
||||
_, err := ParseWorkflowCallSpec(notCallable)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "does not declare on.workflow_call")
|
||||
})
|
||||
|
||||
t.Run("input missing the required type field is rejected", func(t *testing.T) {
|
||||
content := callableWorkflow(t, `inputs:
|
||||
x:
|
||||
description: missing type
|
||||
`)
|
||||
_, err := ParseWorkflowCallSpec(content)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), `missing required field "type"`)
|
||||
})
|
||||
|
||||
t.Run("inputs/secrets/outputs are decoded", func(t *testing.T) {
|
||||
content := callableWorkflow(t, `inputs:
|
||||
env:
|
||||
type: string
|
||||
required: true
|
||||
secrets:
|
||||
DEPLOY_KEY:
|
||||
required: true
|
||||
outputs:
|
||||
sha:
|
||||
value: ${{ jobs.build.outputs.commit }}
|
||||
`)
|
||||
spec, err := ParseWorkflowCallSpec(content)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, InputTypeString, spec.Inputs["env"].Type)
|
||||
assert.True(t, spec.Inputs["env"].Required)
|
||||
assert.True(t, spec.Secrets["DEPLOY_KEY"].Required)
|
||||
assert.Equal(t, "${{ jobs.build.outputs.commit }}", spec.Outputs["sha"].Value)
|
||||
})
|
||||
}
|
||||
|
||||
func TestEvaluateCallerWith(t *testing.T) {
|
||||
t.Run("empty with: returns empty map", func(t *testing.T) {
|
||||
out, err := EvaluateCallerWith("caller", &Job{}, nil, callerResults("caller", nil, nil), nil, nil)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, out)
|
||||
})
|
||||
|
||||
t.Run("non-string raw values pass through unchanged", func(t *testing.T) {
|
||||
job := &Job{With: map[string]any{
|
||||
"already_bool": true,
|
||||
"already_int": 42,
|
||||
"already_slice": []any{"a", "b"},
|
||||
}}
|
||||
out, err := EvaluateCallerWith("caller", job, nil, callerResults("caller", nil, nil), nil, nil)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, true, out["already_bool"])
|
||||
assert.Equal(t, 42, out["already_int"])
|
||||
assert.Equal(t, []any{"a", "b"}, out["already_slice"])
|
||||
})
|
||||
|
||||
t.Run("expressions resolve against vars/inputs/results", func(t *testing.T) {
|
||||
job := &Job{With: map[string]any{
|
||||
"env_name": "${{ vars.ENV }}",
|
||||
"from_inputs": "${{ inputs.PARENT_VAR }}",
|
||||
"from_needs": "${{ needs.upstream.outputs.commit }}",
|
||||
}}
|
||||
gitCtx := map[string]any{"event": map[string]any{}}
|
||||
results := callerResults("caller", []string{"upstream"}, map[string]*JobResult{
|
||||
"upstream": {Result: "success", Outputs: map[string]string{"commit": "abc123"}},
|
||||
})
|
||||
vars := map[string]string{"ENV": "staging"}
|
||||
inputs := map[string]any{"PARENT_VAR": "from-parent"}
|
||||
out, err := EvaluateCallerWith("caller", job, gitCtx, results, vars, inputs)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "staging", out["env_name"])
|
||||
assert.Equal(t, "from-parent", out["from_inputs"])
|
||||
assert.Equal(t, "abc123", out["from_needs"])
|
||||
})
|
||||
|
||||
t.Run("matrix.X resolves to this caller row's matrix instance", func(t *testing.T) {
|
||||
var rawMatrix yaml.Node
|
||||
require.NoError(t, rawMatrix.Encode(map[string][]any{"target": {"staging"}}))
|
||||
job := &Job{
|
||||
With: map[string]any{"env": "${{ matrix.target }}"},
|
||||
Strategy: Strategy{RawMatrix: rawMatrix},
|
||||
}
|
||||
out, err := EvaluateCallerWith("caller", job, nil, callerResults("caller", nil, nil), nil, nil)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "staging", out["env"])
|
||||
})
|
||||
}
|
||||
|
||||
func TestMatchCallerInputsAgainstSpec(t *testing.T) {
|
||||
// mustParseSpec wraps ParseWorkflowCallSpec for test brevity.
|
||||
mustParseSpec := func(t *testing.T, content []byte) *WorkflowCallSpec {
|
||||
t.Helper()
|
||||
spec, err := ParseWorkflowCallSpec(content)
|
||||
require.NoError(t, err)
|
||||
return spec
|
||||
func TestResolveCallerInputs(t *testing.T) {
|
||||
config := &model.WorkflowCall{Inputs: map[string]model.WorkflowCallInput{
|
||||
"env": {Type: "string"},
|
||||
"from_inputs": {Type: "string"},
|
||||
"from_needs": {Type: "string"},
|
||||
"count": {Type: "number"},
|
||||
"index": {Type: "number"},
|
||||
}}
|
||||
results := map[string]*JobResult{
|
||||
"caller": {Needs: []string{"upstream"}},
|
||||
"upstream": {Result: "success", Outputs: map[string]string{"commit": "abc123"}},
|
||||
}
|
||||
var job Job
|
||||
require.NoError(t, yaml.Unmarshal([]byte(`strategy: {matrix: {target: [staging]}, job-index: 1, job-total: 2}
|
||||
with: {env: "${{ matrix.target }}", from_inputs: "${{ inputs.PARENT_VAR }}", from_needs: "${{ needs.upstream.outputs.commit }}", count: 42, index: "${{ strategy.job-index }}"}`), &job))
|
||||
out, err := ResolveCallerInputs("caller", &job, config, map[string]any{"event": map[string]any{}}, results,
|
||||
nil, map[string]any{"PARENT_VAR": "from-parent"})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, map[string]any{
|
||||
"env": "staging", "from_inputs": "from-parent", "from_needs": "abc123", "count": 42.0, "index": 1.0,
|
||||
}, out)
|
||||
|
||||
t.Run("default is filled when caller does not provide the input", func(t *testing.T) {
|
||||
spec := mustParseSpec(t, callableWorkflow(t, `inputs:
|
||||
greeting:
|
||||
type: string
|
||||
default: hi
|
||||
`))
|
||||
out, err := MatchCallerInputsAgainstSpec(spec, nil)
|
||||
for _, tt := range []struct {
|
||||
with string
|
||||
inputs map[string]model.WorkflowCallInput
|
||||
want map[string]any
|
||||
wantErr bool
|
||||
}{
|
||||
{with: "with: {EXTRA: value}", want: map[string]any{}},
|
||||
{with: `with: ${{ fromJSON('{"env":"prod"}') }}`, wantErr: true},
|
||||
{inputs: map[string]model.WorkflowCallInput{"required": {Type: "string", Required: true, Default: "fallback"}}, want: map[string]any{"required": "fallback"}},
|
||||
{inputs: map[string]model.WorkflowCallInput{"derived": {Type: "string", Default: "${{ inputs.PARENT_VAR }}"}}, want: map[string]any{"derived": "from-parent"}},
|
||||
{with: "with: {value: 'false'}", inputs: map[string]model.WorkflowCallInput{"value": {Type: "boolean"}}, wantErr: true},
|
||||
{with: `with: {value: "${{ '5' }}"}`, inputs: map[string]model.WorkflowCallInput{"value": {Type: "number"}}, wantErr: true},
|
||||
} {
|
||||
job = Job{}
|
||||
require.NoError(t, yaml.Unmarshal([]byte(tt.with), &job))
|
||||
out, err := ResolveCallerInputs("caller", &job, &model.WorkflowCall{Inputs: tt.inputs}, nil, nil, nil, map[string]any{"PARENT_VAR": "from-parent"})
|
||||
if tt.wantErr {
|
||||
require.Error(t, err, tt.with)
|
||||
continue
|
||||
}
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, map[string]any{"greeting": "hi"}, out)
|
||||
})
|
||||
|
||||
t.Run("caller-provided value wins over default", func(t *testing.T) {
|
||||
spec := mustParseSpec(t, callableWorkflow(t, `inputs:
|
||||
greeting:
|
||||
type: string
|
||||
default: hi
|
||||
`))
|
||||
out, err := MatchCallerInputsAgainstSpec(spec, map[string]any{"greeting": "hello"})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, map[string]any{"greeting": "hello"}, out)
|
||||
})
|
||||
|
||||
t.Run("required input must be provided", func(t *testing.T) {
|
||||
spec := mustParseSpec(t, callableWorkflow(t, `inputs:
|
||||
target:
|
||||
type: string
|
||||
required: true
|
||||
`))
|
||||
_, err := MatchCallerInputsAgainstSpec(spec, nil)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), `"target" is required`)
|
||||
})
|
||||
|
||||
t.Run("required input is satisfied by a default value", func(t *testing.T) {
|
||||
spec := mustParseSpec(t, callableWorkflow(t, `inputs:
|
||||
target:
|
||||
type: string
|
||||
required: true
|
||||
default: prod
|
||||
`))
|
||||
out, err := MatchCallerInputsAgainstSpec(spec, nil)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, map[string]any{"target": "prod"}, out)
|
||||
})
|
||||
|
||||
t.Run("boolean inputs accept native bool values and bool defaults", func(t *testing.T) {
|
||||
spec := mustParseSpec(t, callableWorkflow(t, `inputs:
|
||||
flag1:
|
||||
type: boolean
|
||||
flag2:
|
||||
type: boolean
|
||||
default: true
|
||||
flag3:
|
||||
type: boolean
|
||||
`))
|
||||
out, err := MatchCallerInputsAgainstSpec(spec, map[string]any{
|
||||
"flag1": true,
|
||||
"flag3": false,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, true, out["flag1"])
|
||||
assert.Equal(t, true, out["flag2"]) // from default
|
||||
assert.Equal(t, false, out["flag3"])
|
||||
})
|
||||
|
||||
t.Run("boolean input rejects strings", func(t *testing.T) {
|
||||
spec := mustParseSpec(t, callableWorkflow(t, `inputs:
|
||||
flag:
|
||||
type: boolean
|
||||
`))
|
||||
_, err := MatchCallerInputsAgainstSpec(spec, map[string]any{"flag": "true"})
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "expects boolean")
|
||||
})
|
||||
|
||||
t.Run("number inputs accept native numeric values and number defaults", func(t *testing.T) {
|
||||
spec := mustParseSpec(t, callableWorkflow(t, `inputs:
|
||||
count:
|
||||
type: number
|
||||
ratio:
|
||||
type: number
|
||||
default: 0.5
|
||||
`))
|
||||
out, err := MatchCallerInputsAgainstSpec(spec, map[string]any{"count": 42})
|
||||
require.NoError(t, err)
|
||||
assert.InDelta(t, 42.0, out["count"], 0)
|
||||
assert.InDelta(t, 0.5, out["ratio"], 0)
|
||||
})
|
||||
|
||||
t.Run("number input rejects strings", func(t *testing.T) {
|
||||
spec := mustParseSpec(t, callableWorkflow(t, `inputs:
|
||||
count:
|
||||
type: number
|
||||
`))
|
||||
_, err := MatchCallerInputsAgainstSpec(spec, map[string]any{"count": "42"})
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "expects number")
|
||||
})
|
||||
|
||||
t.Run("unknown caller-with key is silently dropped", func(t *testing.T) {
|
||||
spec := mustParseSpec(t, callableWorkflow(t, `inputs:
|
||||
known:
|
||||
type: string
|
||||
default: ok
|
||||
`))
|
||||
out, err := MatchCallerInputsAgainstSpec(spec, map[string]any{
|
||||
"known": "yes",
|
||||
"unknown": "ignored",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, map[string]any{"known": "yes"}, out)
|
||||
})
|
||||
assert.Equal(t, tt.want, out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCallerSecrets(t *testing.T) {
|
||||
@@ -312,161 +127,3 @@ deploy_key: ${{ secrets.gitea_deploy_key }}
|
||||
assert.Contains(t, err.Error(), `must be of the form ${{ secrets.NAME }}`)
|
||||
})
|
||||
}
|
||||
|
||||
func TestValidateCallerSecrets(t *testing.T) {
|
||||
specWith := func(secrets map[string]SecretSpec) *WorkflowCallSpec {
|
||||
return &WorkflowCallSpec{Secrets: secrets}
|
||||
}
|
||||
|
||||
t.Run("explicit mapping with all required + only declared aliases is accepted", func(t *testing.T) {
|
||||
spec := specWith(map[string]SecretSpec{
|
||||
"DEPLOY_KEY": {Required: true},
|
||||
"OPTIONAL": {},
|
||||
})
|
||||
mapping := map[string]string{
|
||||
"DEPLOY_KEY": "PROD_DEPLOY_KEY",
|
||||
"OPTIONAL": "SOMETHING_ELSE",
|
||||
}
|
||||
require.NoError(t, ValidateCallerSecrets(spec, mapping))
|
||||
})
|
||||
|
||||
t.Run("alias not in callee schema is rejected", func(t *testing.T) {
|
||||
spec := specWith(map[string]SecretSpec{"DEPLOY_KEY": {}})
|
||||
mapping := map[string]string{
|
||||
"DEPLOY_KEY": "PROD_DEPLOY_KEY",
|
||||
"EXTRA": "SOMETHING_NOT_DECLARED",
|
||||
}
|
||||
err := ValidateCallerSecrets(spec, mapping)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), `caller secret "EXTRA"`)
|
||||
assert.Contains(t, err.Error(), `not declared`)
|
||||
})
|
||||
|
||||
t.Run("missing required secret is rejected", func(t *testing.T) {
|
||||
spec := specWith(map[string]SecretSpec{
|
||||
"MUST_HAVE": {Required: true},
|
||||
"OPTIONAL": {},
|
||||
})
|
||||
mapping := map[string]string{"OPTIONAL": "X"}
|
||||
err := ValidateCallerSecrets(spec, mapping)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), `required secret "MUST_HAVE"`)
|
||||
assert.Contains(t, err.Error(), `not provided`)
|
||||
})
|
||||
|
||||
t.Run("callee with no secrets schema accepts an empty mapping", func(t *testing.T) {
|
||||
spec := specWith(map[string]SecretSpec{})
|
||||
require.NoError(t, ValidateCallerSecrets(spec, nil))
|
||||
require.NoError(t, ValidateCallerSecrets(spec, map[string]string{}))
|
||||
})
|
||||
|
||||
t.Run("callee with no secrets schema rejects a non-empty mapping", func(t *testing.T) {
|
||||
spec := specWith(map[string]SecretSpec{})
|
||||
err := ValidateCallerSecrets(spec, map[string]string{"X": "Y"})
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), `caller secret "X"`)
|
||||
})
|
||||
|
||||
t.Run("name matching is case-insensitive", func(t *testing.T) {
|
||||
// declared name and caller alias differ only in case; both should match.
|
||||
spec := specWith(map[string]SecretSpec{"deploy_key": {Required: true}})
|
||||
mapping := map[string]string{"DEPLOY_KEY": "PROD_DEPLOY_KEY"}
|
||||
require.NoError(t, ValidateCallerSecrets(spec, mapping))
|
||||
})
|
||||
|
||||
t.Run("nil spec is rejected", func(t *testing.T) {
|
||||
err := ValidateCallerSecrets(nil, map[string]string{"X": "Y"})
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "nil workflow_call spec")
|
||||
})
|
||||
}
|
||||
|
||||
func TestEvaluateWorkflowCallOutputs(t *testing.T) {
|
||||
t.Run("nil spec returns empty map", func(t *testing.T) {
|
||||
out, err := EvaluateWorkflowCallOutputs(nil, &model.GithubContext{}, nil, nil, nil)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, out)
|
||||
})
|
||||
|
||||
t.Run("spec with no outputs returns empty map", func(t *testing.T) {
|
||||
spec := &WorkflowCallSpec{Outputs: map[string]OutputSpec{}}
|
||||
out, err := EvaluateWorkflowCallOutputs(spec, &model.GithubContext{}, nil, nil, nil)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, out)
|
||||
})
|
||||
|
||||
t.Run("plain string value passes through unchanged", func(t *testing.T) {
|
||||
spec := &WorkflowCallSpec{Outputs: map[string]OutputSpec{
|
||||
"name": {Value: "static-value"},
|
||||
}}
|
||||
out, err := EvaluateWorkflowCallOutputs(spec, &model.GithubContext{}, nil, nil, nil)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, map[string]string{"name": "static-value"}, out)
|
||||
})
|
||||
|
||||
t.Run("output references jobs.<id>.outputs.<name>", func(t *testing.T) {
|
||||
spec := &WorkflowCallSpec{Outputs: map[string]OutputSpec{
|
||||
"sha": {Value: "${{ jobs.build.outputs.commit }}"},
|
||||
}}
|
||||
jobOutputs := JobOutputs{
|
||||
"build": {"commit": "deadbeef"},
|
||||
}
|
||||
out, err := EvaluateWorkflowCallOutputs(spec, &model.GithubContext{}, nil, nil, jobOutputs)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "deadbeef", out["sha"])
|
||||
})
|
||||
|
||||
t.Run("output references inputs.<name>", func(t *testing.T) {
|
||||
spec := &WorkflowCallSpec{Outputs: map[string]OutputSpec{
|
||||
"target": {Value: "${{ inputs.env_name }}"},
|
||||
}}
|
||||
inputs := map[string]any{"env_name": "staging"}
|
||||
out, err := EvaluateWorkflowCallOutputs(spec, &model.GithubContext{}, nil, inputs, nil)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "staging", out["target"])
|
||||
})
|
||||
|
||||
t.Run("multiple outputs are all evaluated", func(t *testing.T) {
|
||||
spec := &WorkflowCallSpec{Outputs: map[string]OutputSpec{
|
||||
"static": {Value: "static-value"},
|
||||
"dynamic": {Value: "${{ vars.SUFFIX }}"},
|
||||
}}
|
||||
vars := map[string]string{"SUFFIX": "abc"}
|
||||
out, err := EvaluateWorkflowCallOutputs(spec, &model.GithubContext{}, vars, nil, nil)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "static-value", out["static"])
|
||||
assert.Equal(t, "abc", out["dynamic"])
|
||||
})
|
||||
|
||||
t.Run("expression referencing an undefined symbol surfaces an error", func(t *testing.T) {
|
||||
spec := &WorkflowCallSpec{Outputs: map[string]OutputSpec{
|
||||
"bad": {Value: "${{ this.is.not.valid() }}"},
|
||||
}}
|
||||
_, err := EvaluateWorkflowCallOutputs(spec, &model.GithubContext{}, nil, nil, nil)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), `output "bad"`)
|
||||
})
|
||||
}
|
||||
|
||||
// callableWorkflow returns a minimal valid called-workflow YAML with on.workflow_call.
|
||||
func callableWorkflow(t *testing.T, body string) []byte {
|
||||
t.Helper()
|
||||
return []byte(`name: callable
|
||||
on:
|
||||
workflow_call:
|
||||
` + body + `
|
||||
jobs:
|
||||
noop:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: "echo"
|
||||
`)
|
||||
}
|
||||
|
||||
// callerResults returns the minimum results map shape that NewInterpeter expects
|
||||
func callerResults(callerJobID string, callerNeeds []string, deps map[string]*JobResult) map[string]*JobResult {
|
||||
out := make(map[string]*JobResult, len(deps)+1)
|
||||
maps.Copy(out, deps)
|
||||
out[callerJobID] = &JobResult{Needs: callerNeeds}
|
||||
return out
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user