Files
gitea/modules/actions/jobparser/model_test.go
T
silverwindandGitHub f757631a47 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>
2026-09-25 00:06:42 +02:00

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)
}
})
}