mirror of
https://github.com/go-gitea/gitea.git
synced 2026-07-17 11:55:25 +02:00
Fix #38485
#37478 moved `if` evaluation from runner to server. But `NewInterpeter`
always provides a nil `JobContext` for the evaluator, which makes
[`cancelled()`](ad967330a8/act/exprparser/functions.go (L299))
panic on `Job.Status` because `Job` is a nil pointer.
525 lines
12 KiB
Go
525 lines
12 KiB
Go
// Copyright 2026 The Gitea Authors. All rights reserved.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package jobparser
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
|
|
"gitea.com/gitea/runner/act/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 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},
|
|
}
|
|
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)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, kase.expected, got)
|
|
})
|
|
}
|
|
}
|