mirror of
https://github.com/go-gitea/gitea.git
synced 2026-09-26 06:33:42 +09:00
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>
537 lines
13 KiB
Go
537 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
|
|
wantErr bool
|
|
}{
|
|
{input: "on:\n push:\n branches:\n a: b", wantErr: true},
|
|
{input: "on: [push, {pull_request: null}]", wantErr: true},
|
|
{input: "on:", wantErr: true},
|
|
{input: "on: 42", wantErr: true},
|
|
{input: "jobs: {}", wantErr: true},
|
|
{input: "on:\n schedule: []", wantErr: true},
|
|
{input: "on:\n schedule:\n - {}", wantErr: true},
|
|
{input: "on:\n schedule:\n - cron: nope", wantErr: true},
|
|
{
|
|
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 * * *'\n timezone: UTC",
|
|
result: []*Event{
|
|
{
|
|
Name: "schedule",
|
|
schedules: []map[string]string{
|
|
{
|
|
"cron": "20 6 * * *",
|
|
"timezone": "UTC",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
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",
|
|
},
|
|
{
|
|
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)
|
|
if kase.wantErr {
|
|
assert.Error(t, err)
|
|
return
|
|
}
|
|
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},
|
|
// 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)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, kase.expected, got)
|
|
})
|
|
}
|
|
|
|
t.Run("stored unavailable context", func(t *testing.T) {
|
|
for condition, wantErr := range map[string]string{
|
|
"matrix.os == 'a'": "Unrecognized named-value: 'matrix'",
|
|
"${{ strategy.fail-fast }}": "Unrecognized named-value: 'strategy'",
|
|
"secrets.TOKEN != ''": "Unrecognized named-value: 'secrets'",
|
|
"${{ matrix.os == }}": "Unexpected end of expression",
|
|
} {
|
|
_, job, err := ParseRawSingleWorkflow(fmt.Appendf(nil, "jobs: {job2: {if: %q, strategy: {matrix: {os: [a]}}}}", condition))
|
|
require.NoError(t, err)
|
|
_, err = EvaluateJobIfExpression("job2", job, map[string]any{}, map[string]*JobResult{"job2": {}}, nil, nil)
|
|
assert.ErrorContains(t, err, wantErr, condition)
|
|
}
|
|
})
|
|
}
|