mirror of
https://github.com/go-gitea/gitea.git
synced 2026-08-09 05:38:54 +09:00
2087d4a1a5
Pairs with https://gitea.com/gitea/runner/pulls/1143. Gitea depends on `gitea.com/gitea/runner` for exactly two packages: `act/model` and `act/exprparser`, the workflow model and the expression evaluator it needs to parse workflows and to build the task payload the runner consumes. Pulling the whole runner module in for that is heavy and puts shared code in the repository of one of the two consumers. Both packages now live in `gitea.dev/actionslib` (`pkg/model`, `pkg/exprparser`), the module Gitea and the runner already share for the runner API, so the dependency on the runner repository is dropped here. ### Changes - `gitea.com/gitea/runner/act/model` -> `gitea.dev/actionslib/pkg/model`, `.../act/exprparser` -> `gitea.dev/actionslib/pkg/exprparser` (22 files, import paths only). - `routers/api/actions/runner/interceptor.go` takes the `x-runner-uuid` / `x-runner-token` names from `gitea.dev/actionslib/pkg/protocol` instead of repeating the literals the runner also has. - `go.mod`: `gitea.com/gitea/runner` removed. --------- Signed-off-by: Lunny Xiao <xiaolunwen@gmail.com> Co-authored-by: silverwind <me@silverwind.io> Co-authored-by: Zettat123 <zettat123@gmail.com>
576 lines
13 KiB
Go
576 lines
13 KiB
Go
// Copyright 2026 The Gitea Authors. All rights reserved.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package jobparser
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
"testing"
|
|
|
|
"gitea.dev/actionslib/pkg/model"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"go.yaml.in/yaml/v4"
|
|
)
|
|
|
|
func TestParseRawOn(t *testing.T) {
|
|
kases := []struct {
|
|
input string
|
|
result []*Event
|
|
}{
|
|
{
|
|
input: "on: issue_comment",
|
|
result: []*Event{
|
|
{
|
|
Name: "issue_comment",
|
|
},
|
|
},
|
|
},
|
|
{
|
|
input: "on:\n push",
|
|
result: []*Event{
|
|
{
|
|
Name: "push",
|
|
},
|
|
},
|
|
},
|
|
|
|
{
|
|
input: "on:\n - push\n - pull_request",
|
|
result: []*Event{
|
|
{
|
|
Name: "push",
|
|
},
|
|
{
|
|
Name: "pull_request",
|
|
},
|
|
},
|
|
},
|
|
{
|
|
input: "on:\n push:\n branches:\n - master",
|
|
result: []*Event{
|
|
{
|
|
Name: "push",
|
|
acts: map[string][]string{
|
|
"branches": {
|
|
"master",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
input: "on:\n push:\n branches: main",
|
|
result: []*Event{
|
|
{
|
|
Name: "push",
|
|
acts: map[string][]string{
|
|
"branches": {
|
|
"main",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
input: "on:\n branch_protection_rule:\n types: [created, deleted]",
|
|
result: []*Event{
|
|
{
|
|
Name: "branch_protection_rule",
|
|
acts: map[string][]string{
|
|
"types": {
|
|
"created",
|
|
"deleted",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
input: "on:\n project:\n types: [created, deleted]\n milestone:\n types: [opened, deleted]",
|
|
result: []*Event{
|
|
{
|
|
Name: "project",
|
|
acts: map[string][]string{
|
|
"types": {
|
|
"created",
|
|
"deleted",
|
|
},
|
|
},
|
|
},
|
|
{
|
|
Name: "milestone",
|
|
acts: map[string][]string{
|
|
"types": {
|
|
"opened",
|
|
"deleted",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
input: "on:\n pull_request:\n types:\n - opened\n branches:\n - 'releases/**'",
|
|
result: []*Event{
|
|
{
|
|
Name: "pull_request",
|
|
acts: map[string][]string{
|
|
"types": {
|
|
"opened",
|
|
},
|
|
"branches": {
|
|
"releases/**",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
input: "on:\n push:\n branches:\n - main\n pull_request:\n types:\n - opened\n branches:\n - '**'",
|
|
result: []*Event{
|
|
{
|
|
Name: "push",
|
|
acts: map[string][]string{
|
|
"branches": {
|
|
"main",
|
|
},
|
|
},
|
|
},
|
|
{
|
|
Name: "pull_request",
|
|
acts: map[string][]string{
|
|
"types": {
|
|
"opened",
|
|
},
|
|
"branches": {
|
|
"**",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
input: "on:\n push:\n branches:\n - 'main'\n - 'releases/**'",
|
|
result: []*Event{
|
|
{
|
|
Name: "push",
|
|
acts: map[string][]string{
|
|
"branches": {
|
|
"main",
|
|
"releases/**",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
input: "on:\n push:\n tags:\n - v1.**",
|
|
result: []*Event{
|
|
{
|
|
Name: "push",
|
|
acts: map[string][]string{
|
|
"tags": {
|
|
"v1.**",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
input: "on: [pull_request, workflow_dispatch]",
|
|
result: []*Event{
|
|
{
|
|
Name: "pull_request",
|
|
},
|
|
{
|
|
Name: "workflow_dispatch",
|
|
},
|
|
},
|
|
},
|
|
{
|
|
input: "on:\n schedule:\n - cron: '20 6 * * *'",
|
|
result: []*Event{
|
|
{
|
|
Name: "schedule",
|
|
schedules: []map[string]string{
|
|
{
|
|
"cron": "20 6 * * *",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
input: `on:
|
|
workflow_dispatch:
|
|
inputs:
|
|
logLevel:
|
|
description: 'Log level'
|
|
required: true
|
|
default: 'warning'
|
|
type: choice
|
|
options:
|
|
- info
|
|
- warning
|
|
- debug
|
|
tags:
|
|
description: 'Test scenario tags'
|
|
required: false
|
|
type: boolean
|
|
environment:
|
|
description: 'Environment to run tests against'
|
|
type: environment
|
|
required: true
|
|
push:
|
|
`,
|
|
result: []*Event{
|
|
{
|
|
Name: "workflow_dispatch",
|
|
inputs: []WorkflowDispatchInput{
|
|
{
|
|
Name: "logLevel",
|
|
Description: "Log level",
|
|
Required: true,
|
|
Default: "warning",
|
|
Type: "choice",
|
|
Options: []string{"info", "warning", "debug"},
|
|
},
|
|
{
|
|
Name: "tags",
|
|
Description: "Test scenario tags",
|
|
Required: false,
|
|
Type: "boolean",
|
|
},
|
|
{
|
|
Name: "environment",
|
|
Description: "Environment to run tests against",
|
|
Type: "environment",
|
|
Required: true,
|
|
},
|
|
},
|
|
},
|
|
{
|
|
Name: "push",
|
|
},
|
|
},
|
|
},
|
|
{
|
|
// `workflow_call` is only fired by another workflow's `uses:`, so ParseRawOn intentionally excludes it from trigger detection.
|
|
input: `on:
|
|
workflow_call:
|
|
inputs:
|
|
env:
|
|
type: string
|
|
required: true
|
|
outputs:
|
|
sha:
|
|
value: ${{ jobs.build.outputs.commit }}
|
|
secrets:
|
|
DEPLOY_KEY:
|
|
required: true
|
|
`,
|
|
result: []*Event{},
|
|
},
|
|
{
|
|
// Mixed: a workflow that is both callable AND triggered by push. Only the "push" event surfaces.
|
|
input: `on:
|
|
workflow_call:
|
|
inputs:
|
|
env:
|
|
type: string
|
|
push:
|
|
branches: [main]
|
|
`,
|
|
result: []*Event{
|
|
{
|
|
Name: "push",
|
|
acts: map[string][]string{"branches": {"main"}},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
// Scalar form: a purely reusable workflow has no event triggers.
|
|
input: "on: workflow_call",
|
|
result: []*Event{},
|
|
},
|
|
{
|
|
// Sequence form: `workflow_call` is excluded while sibling events are kept.
|
|
input: "on:\n - push\n - workflow_call\n - pull_request",
|
|
result: []*Event{
|
|
{Name: "push"},
|
|
{Name: "pull_request"},
|
|
},
|
|
},
|
|
}
|
|
for _, kase := range kases {
|
|
t.Run(kase.input, func(t *testing.T) {
|
|
origin, err := model.ReadWorkflow(strings.NewReader(kase.input))
|
|
assert.NoError(t, err)
|
|
|
|
events, err := ParseRawOn(&origin.RawOn)
|
|
assert.NoError(t, err)
|
|
assert.Equal(t, kase.result, events, events)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestSingleWorkflow_SetJob(t *testing.T) {
|
|
t.Run("erase needs", func(t *testing.T) {
|
|
content := ReadTestdata(t, "erase_needs.in.yaml")
|
|
want := ReadTestdata(t, "erase_needs.out.yaml")
|
|
swf, err := Parse(content)
|
|
require.NoError(t, err)
|
|
builder := &strings.Builder{}
|
|
for _, v := range swf {
|
|
id, job := v.Job()
|
|
require.NoError(t, v.SetJob(id, job.EraseNeeds()))
|
|
|
|
if builder.Len() > 0 {
|
|
builder.WriteString("---\n")
|
|
}
|
|
encoder := yaml.NewEncoder(builder)
|
|
encoder.SetIndent(2)
|
|
require.NoError(t, encoder.Encode(v))
|
|
}
|
|
assert.Equal(t, string(want), builder.String())
|
|
})
|
|
}
|
|
|
|
func TestGetContinueOnError(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
yaml string
|
|
want bool
|
|
}{
|
|
{
|
|
name: "absent",
|
|
yaml: "name: test\non: push\njobs:\n job1:\n runs-on: ubuntu-22.04\n steps:\n - run: echo hi\n",
|
|
want: false,
|
|
},
|
|
{
|
|
name: "static true",
|
|
yaml: "name: test\non: push\njobs:\n job1:\n runs-on: ubuntu-22.04\n continue-on-error: true\n steps:\n - run: echo hi\n",
|
|
want: true,
|
|
},
|
|
{
|
|
name: "static false",
|
|
yaml: "name: test\non: push\njobs:\n job1:\n runs-on: ubuntu-22.04\n continue-on-error: false\n steps:\n - run: echo hi\n",
|
|
want: false,
|
|
},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got, err := Parse([]byte(tt.yaml))
|
|
require.NoError(t, err)
|
|
require.Len(t, got, 1)
|
|
_, job := got[0].Job()
|
|
assert.Equal(t, tt.want, job.GetContinueOnError())
|
|
})
|
|
}
|
|
|
|
// Expression case: ${{ matrix.experimental }} must resolve per matrix variant.
|
|
t.Run("matrix expression", func(t *testing.T) {
|
|
content := ReadTestdata(t, "continue_on_error_expr.in.yaml")
|
|
got, err := Parse(content)
|
|
require.NoError(t, err)
|
|
require.Len(t, got, 2)
|
|
// sorted by matrix name: (false) before (true)
|
|
_, jobFalse := got[0].Job()
|
|
_, jobTrue := got[1].Job()
|
|
assert.False(t, jobFalse.GetContinueOnError())
|
|
assert.True(t, jobTrue.GetContinueOnError())
|
|
})
|
|
}
|
|
|
|
func TestParseMappingNode(t *testing.T) {
|
|
tests := []struct {
|
|
input string
|
|
scalars []string
|
|
datas []any
|
|
}{
|
|
{
|
|
input: "on:\n push:\n branches:\n - master",
|
|
scalars: []string{"push"},
|
|
datas: []any{
|
|
map[string]any{
|
|
"branches": []any{"master"},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
input: "on:\n branch_protection_rule:\n types: [created, deleted]",
|
|
scalars: []string{"branch_protection_rule"},
|
|
datas: []any{
|
|
map[string]any{
|
|
"types": []any{"created", "deleted"},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
input: "on:\n project:\n types: [created, deleted]\n milestone:\n types: [opened, deleted]",
|
|
scalars: []string{"project", "milestone"},
|
|
datas: []any{
|
|
map[string]any{
|
|
"types": []any{"created", "deleted"},
|
|
},
|
|
map[string]any{
|
|
"types": []any{"opened", "deleted"},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
input: "on:\n pull_request:\n types:\n - opened\n branches:\n - 'releases/**'",
|
|
scalars: []string{"pull_request"},
|
|
datas: []any{
|
|
map[string]any{
|
|
"types": []any{"opened"},
|
|
"branches": []any{"releases/**"},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
input: "on:\n push:\n branches:\n - main\n pull_request:\n types:\n - opened\n branches:\n - '**'",
|
|
scalars: []string{"push", "pull_request"},
|
|
datas: []any{
|
|
map[string]any{
|
|
"branches": []any{"main"},
|
|
},
|
|
map[string]any{
|
|
"types": []any{"opened"},
|
|
"branches": []any{"**"},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
input: "on:\n schedule:\n - cron: '20 6 * * *'",
|
|
scalars: []string{"schedule"},
|
|
datas: []any{
|
|
[]any{map[string]any{
|
|
"cron": "20 6 * * *",
|
|
}},
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
t.Run(test.input, func(t *testing.T) {
|
|
workflow, err := model.ReadWorkflow(strings.NewReader(test.input))
|
|
assert.NoError(t, err)
|
|
|
|
scalars, datas, err := parseMappingNode[any](&workflow.RawOn)
|
|
assert.NoError(t, err)
|
|
assert.Equal(t, test.scalars, scalars, scalars)
|
|
assert.Equal(t, test.datas, datas, datas)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestEvaluateJobIfExpressionMatrix(t *testing.T) {
|
|
ifExprs := []string{
|
|
`${{ contains(fromJSON('["linux","windows"]'), matrix.target) }}`,
|
|
`${{ contains('["linux","windows"]', matrix.target) }}`,
|
|
}
|
|
|
|
want := map[string]bool{
|
|
"build (linux)": true,
|
|
"build (windows)": true,
|
|
"build (macos)": false,
|
|
}
|
|
|
|
for _, ifExpr := range ifExprs {
|
|
t.Run(ifExpr, func(t *testing.T) {
|
|
content := fmt.Sprintf(`
|
|
name: test
|
|
on: push
|
|
jobs:
|
|
build:
|
|
runs-on: ubuntu-latest
|
|
if: %s
|
|
strategy:
|
|
fail-fast: false
|
|
matrix:
|
|
target: [linux, windows, macos]
|
|
steps:
|
|
- run: echo ${{ matrix.target }}
|
|
`, ifExpr)
|
|
|
|
swfs, err := Parse([]byte(content))
|
|
require.NoError(t, err)
|
|
require.Len(t, swfs, 3)
|
|
|
|
got := make(map[string]bool, len(swfs))
|
|
for _, swf := range swfs {
|
|
id, job := swf.Job()
|
|
shouldRun, err := EvaluateJobIfExpression(id, job, map[string]any{}, map[string]*JobResult{id: {}}, nil, nil, false)
|
|
require.NoError(t, err)
|
|
got[job.Name] = shouldRun
|
|
}
|
|
assert.Equal(t, want, got)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestEvaluateJobIfExpression(t *testing.T) {
|
|
kases := []struct {
|
|
name string
|
|
ifCond string
|
|
needResult string
|
|
expected bool
|
|
}{
|
|
{name: "empty need success", ifCond: "${{ 1 == 1 }}", needResult: "success", expected: true},
|
|
{name: "always", ifCond: "${{ always() }}", needResult: "failure", expected: true},
|
|
{name: "failure true", ifCond: "${{ failure() }}", needResult: "failure", expected: true},
|
|
{name: "failure false", ifCond: "${{ failure() }}", needResult: "success", expected: false},
|
|
{name: "success true", ifCond: "${{ success() }}", needResult: "success", expected: true},
|
|
// cancelled() is always false on the server: a cancelled run never evaluates a blocked job's `if:`
|
|
{name: "cancelled", ifCond: "${{ cancelled() }}", needResult: "success", expected: false},
|
|
{name: "not cancelled or failure", ifCond: "${{ !(cancelled() || failure()) }}", needResult: "success", expected: true},
|
|
{name: "not cancelled or failure, need failed", ifCond: "${{ !(cancelled() || failure()) }}", needResult: "failure", expected: false},
|
|
// a condition is an expression with or without `${{ }}`, literal text around one makes it a string
|
|
{name: "bare expression", ifCond: "always()", needResult: "failure", expected: true},
|
|
{name: "literal text keeps the success() default", ifCond: "x ${{ 1 }}", needResult: "failure", expected: false},
|
|
{name: "literal text around a status function drops it", ifCond: "x ${{ always() }}", needResult: "failure", expected: true},
|
|
}
|
|
for _, kase := range kases {
|
|
t.Run(kase.name, func(t *testing.T) {
|
|
content := strings.ReplaceAll(`
|
|
name: test
|
|
on: push
|
|
jobs:
|
|
job1:
|
|
runs-on: ubuntu-latest
|
|
steps:
|
|
- run: echo job1
|
|
job2:
|
|
runs-on: ubuntu-latest
|
|
needs: [job1]
|
|
if: IF_COND
|
|
steps:
|
|
- run: echo job2
|
|
`, "IF_COND", kase.ifCond)
|
|
|
|
workflows, err := Parse([]byte(content))
|
|
require.NoError(t, err)
|
|
|
|
var job2 *Job
|
|
for _, wf := range workflows {
|
|
if id, job := wf.Job(); id == "job2" {
|
|
job2 = job
|
|
}
|
|
}
|
|
require.NotNil(t, job2)
|
|
|
|
// mirrors findJobNeedsAndFillJobResults: the needs' results plus a self entry carrying Needs
|
|
results := map[string]*JobResult{
|
|
"job1": {Result: kase.needResult},
|
|
"job2": {Needs: []string{"job1"}},
|
|
}
|
|
got, err := EvaluateJobIfExpression("job2", job2, map[string]any{}, results, nil, nil, false)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, kase.expected, got)
|
|
})
|
|
}
|
|
}
|