mirror of
https://github.com/go-gitea/gitea.git
synced 2026-09-25 22:23: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>
520 lines
19 KiB
Go
520 lines
19 KiB
Go
// Copyright 2026 The Gitea Authors. All rights reserved.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package jobparser
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
"testing"
|
|
|
|
"gitea.dev/actionslib/pkg/expreval"
|
|
"gitea.dev/actionslib/pkg/exprparser"
|
|
"gitea.dev/actionslib/pkg/model"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"go.yaml.in/yaml/v4"
|
|
)
|
|
|
|
func TestParse(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
options []ParseOption
|
|
wantErr bool
|
|
}{
|
|
{
|
|
name: "multiple_jobs",
|
|
options: nil,
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "multiple_matrix",
|
|
options: nil,
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "has_needs",
|
|
options: nil,
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "has_with",
|
|
options: nil,
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "has_secrets",
|
|
options: nil,
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "empty_step",
|
|
options: nil,
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "job_name_with_matrix",
|
|
options: nil,
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "prefixed_newline",
|
|
options: nil,
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "continue_on_error_expr",
|
|
options: nil,
|
|
wantErr: false,
|
|
},
|
|
}
|
|
invalidFileTests := []struct {
|
|
name string
|
|
}{
|
|
{name: "null_job_implicit"},
|
|
{name: "null_job_explicit"},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
content := ReadTestdata(t, tt.name+".in.yaml")
|
|
want := ReadTestdata(t, tt.name+".out.yaml")
|
|
got, err := Parse(content, append([]ParseOption{WithGitContext(&model.GithubContext{})}, tt.options...)...)
|
|
if tt.wantErr {
|
|
require.Error(t, err)
|
|
}
|
|
require.NoError(t, err)
|
|
|
|
builder := &strings.Builder{}
|
|
for _, v := range got {
|
|
if builder.Len() > 0 {
|
|
builder.WriteString("---\n")
|
|
}
|
|
encoder := yaml.NewEncoder(builder)
|
|
encoder.SetIndent(2)
|
|
require.NoError(t, encoder.Encode(v))
|
|
id, job := v.Job()
|
|
assert.NotEmpty(t, id)
|
|
assert.NotNil(t, job)
|
|
}
|
|
assert.Equal(t, string(want), builder.String())
|
|
})
|
|
}
|
|
|
|
for _, tt := range invalidFileTests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
content := ReadTestdata(t, tt.name+".in.yaml")
|
|
require.NotPanics(t, func() {
|
|
_, err := Parse(content)
|
|
require.Error(t, err)
|
|
})
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestParseDefersDynamicMatrix(t *testing.T) {
|
|
const workflow = `
|
|
on: push
|
|
jobs:
|
|
setup:
|
|
steps: [{run: echo}]
|
|
build:
|
|
%s
|
|
%s
|
|
steps: [{run: echo}]
|
|
`
|
|
for _, tt := range []struct {
|
|
name string
|
|
needs string
|
|
strategy string
|
|
deferred bool
|
|
want int
|
|
}{
|
|
{"needs outputs", "needs: setup", "strategy:\n matrix:\n os: [a, b]\n version: ${{ fromJson(needs.setup.outputs.v) }}", true, 1},
|
|
{"static", "needs: setup", "strategy:\n matrix: {os: [a, b], version: [1, 2]}", false, 4},
|
|
{"max-parallel over needs outputs", "needs: setup", "strategy:\n max-parallel: ${{ needs.setup.outputs.limit }}\n matrix: {os: [a, b], version: [1, 2]}", true, 1},
|
|
{"fail-fast without matrix", "needs: setup", "strategy:\n fail-fast: ${{ needs.setup.outputs.fast }}", true, 1},
|
|
{"whole strategy without matrix", "needs: setup", "strategy: ${{ fromJSON(needs.setup.outputs.strategy) }}", true, 1},
|
|
{"expression without needs", "", "strategy:\n matrix:\n os: [a, b]\n version: [\"${{ github.sha }}\"]", false, 2},
|
|
{"expression over another context", "needs: setup", "strategy:\n matrix:\n os: [a, b]\n version: [\"${{ github.sha }}\"]", false, 2},
|
|
{"needs inside a string literal", "needs: setup", "strategy:\n matrix:\n os: [a, b]\n version: [\"${{ format('needs.setup.outputs.v {0}', github.sha) }}\"]", false, 2},
|
|
{"vars", "needs: setup", "strategy:\n matrix:\n os: [a, b]\n version: ${{ fromJSON(vars.VERSIONS) }}", false, 4},
|
|
} {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
result, err := Parse(fmt.Appendf(nil, workflow, tt.needs, tt.strategy), WithGitContext(&model.GithubContext{}), WithVars(map[string]string{"VERSIONS": "[1, 2]"}))
|
|
require.NoError(t, err)
|
|
|
|
var builds []*Job
|
|
for _, w := range result {
|
|
if id, job := w.Job(); id == "build" {
|
|
builds = append(builds, job)
|
|
}
|
|
}
|
|
require.Len(t, builds, tt.want)
|
|
assert.Equal(t, tt.deferred, HasDeferredMatrix(builds[0]))
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestParseWholeValueExpressions(t *testing.T) {
|
|
const workflow = `
|
|
on: push
|
|
env: ${{ fromJSON(vars.ENV) }}
|
|
jobs:
|
|
setup:
|
|
runs-on: ubuntu-latest
|
|
steps: [{run: echo}]
|
|
build:
|
|
%s
|
|
runs-on: ${{ matrix.os }}
|
|
strategy: ${{ fromJSON(%s) }}
|
|
services: ${{ fromJSON(vars.SERVICES) }}
|
|
defaults:
|
|
run: ${{ fromJSON(vars.DEFAULTS) }}
|
|
steps:
|
|
- uses: actions/checkout@v4
|
|
with: ${{ fromJSON(vars.WITH) }}
|
|
`
|
|
const strategy = `{"max-parallel": 1, "matrix": {"os": ["a", "b-${{ secrets.X }}"]}}`
|
|
runnerView := func(t *testing.T, job *model.Job) []any {
|
|
t.Helper()
|
|
runner := expreval.New(exprparser.NewInterpeter(&exprparser.EvaluationEnvironment{Secrets: map[string]string{"X": "leaked"}}, exprparser.Config{}).Evaluate)
|
|
require.NoError(t, runner.EvaluateYamlNode(&job.Strategy.RawMatrix))
|
|
require.NoError(t, runner.EvaluateYamlNode(&job.RawRunsOn))
|
|
matrixes, err := job.GetMatrixes()
|
|
require.NoError(t, err)
|
|
name, err := runner.Interpolate(job.Name)
|
|
require.NoError(t, err)
|
|
return []any{matrixes[0]["os"], name, job.RunsOn()}
|
|
}
|
|
payloads := func(t *testing.T, needs, source string, options ...ParseOption) []string {
|
|
t.Helper()
|
|
workflows, err := Parse(fmt.Appendf(nil, workflow, needs, source), options...)
|
|
require.NoError(t, err)
|
|
var payloads []string
|
|
for _, w := range workflows {
|
|
if id, _ := w.Job(); id == "build" {
|
|
payload, err := w.Marshal()
|
|
require.NoError(t, err)
|
|
payloads = append(payloads, string(payload))
|
|
}
|
|
}
|
|
return payloads
|
|
}
|
|
planning := []ParseOption{WithGitContext(&model.GithubContext{}), WithVars(map[string]string{"STRATEGY": strategy})}
|
|
|
|
planned := payloads(t, "", "vars.STRATEGY", planning...)
|
|
require.Len(t, planned, 2)
|
|
for _, raw := range []string{`max-parallel: "1"`, "env: ${{ fromJSON(vars.ENV) }}", "services: ${{ fromJSON(vars.SERVICES) }}", "run: ${{ fromJSON(vars.DEFAULTS) }}", "with: ${{ fromJSON(vars.WITH) }}"} {
|
|
assert.Contains(t, planned[0], raw)
|
|
}
|
|
for index, payload := range planned {
|
|
os := []string{"a", "b-${{ secrets.X }}"}[index]
|
|
read, err := model.ReadWorkflow(strings.NewReader(payload))
|
|
require.NoError(t, err)
|
|
assert.Equal(t, []any{os, "build (" + os + ")", []string{os}}, runnerView(t, read.Jobs["build"]))
|
|
_, job, err := ParseRawSingleWorkflow([]byte(payload))
|
|
require.NoError(t, err)
|
|
group, _, err := EvaluateConcurrency(&model.RawConcurrency{Group: "${{ matrix.os }}"}, "build", job, map[string]any{}, nil, nil, nil)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, []any{"build (" + os + ")", []string{os}, os}, []any{job.DisplayName(), job.RunsOn(), group})
|
|
_, err = Parse([]byte(payload))
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
assert.Len(t, payloads(t, "", "vars.STRATEGY"), 1)
|
|
|
|
deferred := payloads(t, "needs: setup", "needs.setup.outputs.strategy", planning...)
|
|
require.Len(t, deferred, 1)
|
|
_, placeholder, err := ParseRawSingleWorkflow([]byte(deferred[0]))
|
|
require.NoError(t, err)
|
|
expanded, err := ExpandMatrixWithNeeds("build", placeholder, &model.GithubContext{}, map[string]*JobResult{
|
|
"build": {Needs: []string{"setup"}},
|
|
"setup": {Result: "success", Outputs: map[string]string{"strategy": strategy}},
|
|
}, nil, nil, 256)
|
|
require.NoError(t, err)
|
|
require.Len(t, expanded, 2)
|
|
assert.Equal(t, "1", expanded[0].Strategy.MaxParallelString)
|
|
assert.Equal(t, []any{"build (b-${{ secrets.X }})", []string{"b-${{ secrets.X }}"}}, []any{expanded[1].DisplayName(), expanded[1].RunsOn()})
|
|
assert.Equal(t, []any{"b-${{ secrets.X }}", "build (b-${{ secrets.X }})", []string{"b-${{ secrets.X }}"}},
|
|
runnerView(t, &model.Job{Name: expanded[1].Name, RawRunsOn: expanded[1].RawRunsOn, Strategy: expanded[1].Strategy.actStrategy()}))
|
|
|
|
_, err = Parse(fmt.Appendf(nil, workflow, "", "vars.STRATEGY"), planning[0], WithVars(map[string]string{"STRATEGY": `"a"`}))
|
|
require.ErrorContains(t, err, "is not a map of strategy keys to values")
|
|
}
|
|
|
|
func TestParseInterpolatesRunName(t *testing.T) {
|
|
workflow := func(runName string) []byte {
|
|
return []byte("name: t\nrun-name: \"" + runName + "\"\non: push\njobs:\n build:\n runs-on: ubuntu-latest\n steps: [{run: echo}]\n")
|
|
}
|
|
|
|
for _, tt := range []struct{ name, runName, want string }{
|
|
{"bool", "${{ true }}", "true"},
|
|
{"int", "${{ 1 }}", "1"},
|
|
{"float", "${{ 1.0 }}", "1"},
|
|
{"null", "${{ null }}", ""},
|
|
{"object", `${{ fromJSON('{\"a\":1}') }}`, "Object"},
|
|
{"array", "${{ fromJSON('[1,2]') }}", "Array"},
|
|
{"context", "${{ github }}", "Object"},
|
|
{"surrounding literals", "run ${{ 1 }} now", "run 1 now"},
|
|
{"two expressions", "${{ 1 }}-${{ true }}", "1-true"},
|
|
{"closing brace inside a string", "${{ 'a}}b' }}", "a}}b"},
|
|
} {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
result, err := Parse(workflow(tt.runName), WithGitContext(&model.GithubContext{EventName: "push"}))
|
|
require.NoError(t, err)
|
|
require.Len(t, result, 1)
|
|
assert.Equal(t, tt.want, result[0].RunName)
|
|
payload, err := result[0].Marshal()
|
|
require.NoError(t, err)
|
|
assert.NotContains(t, string(payload), "run-name")
|
|
})
|
|
}
|
|
|
|
t.Run("unclosed expression errors", func(t *testing.T) {
|
|
_, err := Parse(workflow("${{ 1"), WithGitContext(&model.GithubContext{EventName: "push"}))
|
|
require.ErrorContains(t, err, "unclosed expression")
|
|
})
|
|
|
|
// a malformed part must not restructure the surrounding expression
|
|
for _, runName := range []string{"${{ 1) && (2 }}", "run ${{ 1) && (2 }} now", "${{ 'a' }} ${{ b", "${{ 'a }}"} {
|
|
_, err := Parse(workflow(runName), WithGitContext(&model.GithubContext{EventName: "push"}))
|
|
assert.Error(t, err, runName)
|
|
}
|
|
|
|
// callers such as commit status parse without a git context, leaving `github` a nil pointer
|
|
result, err := Parse(workflow("${{ github }}"))
|
|
require.NoError(t, err)
|
|
require.Len(t, result, 1)
|
|
assert.Empty(t, result[0].RunName)
|
|
}
|
|
|
|
func TestParseRunsOnFromJSONArray(t *testing.T) {
|
|
content := []byte("on: push\njobs:\n build:\n runs-on: ${{ fromJSON(vars.RUNNER) }}\n steps: [{run: echo}]\n")
|
|
_, err := Parse(content)
|
|
require.NoError(t, err)
|
|
for runner, want := range map[string][]string{`["self-hosted", "linux"]`: {"self-hosted", "linux"}, "[]": {""}} {
|
|
result, err := Parse(content, WithGitContext(&model.GithubContext{}), WithVars(map[string]string{"RUNNER": runner}))
|
|
require.NoError(t, err)
|
|
require.Len(t, result, 1)
|
|
_, job := result[0].Job()
|
|
assert.Equal(t, want, job.RunsOn(), runner)
|
|
}
|
|
}
|
|
|
|
func TestJobFieldsWithoutMatrix(t *testing.T) {
|
|
const workflow = `on: push
|
|
jobs:
|
|
seed:
|
|
steps: [{run: echo}]
|
|
build:
|
|
name: build-${{ github.ref_name }}
|
|
continue-on-error: ${{ fromJSON(vars.CONTINUE) }}
|
|
steps: [{run: echo}]
|
|
target:
|
|
needs: seed
|
|
name: target-${{ needs.seed.outputs.runner }}
|
|
runs-on: ${{ needs.seed.outputs.runner }}
|
|
continue-on-error: ${{ needs.seed.outputs.tolerate == 'true' }}
|
|
strategy: ${{ fromJSON(needs.seed.outputs.strategy) }}
|
|
steps: [{run: echo}]
|
|
`
|
|
parse := func(t *testing.T, continueOnError string) map[string]*SingleWorkflow {
|
|
t.Helper()
|
|
parsed, err := Parse([]byte(workflow), WithGitContext(&model.GithubContext{RefName: "main"}), WithVars(map[string]string{"CONTINUE": continueOnError}))
|
|
require.NoError(t, err)
|
|
workflows := map[string]*SingleWorkflow{}
|
|
for _, parsedWorkflow := range parsed {
|
|
id, _ := parsedWorkflow.Job()
|
|
workflows[id] = parsedWorkflow
|
|
}
|
|
return workflows
|
|
}
|
|
workflows := parse(t, "true")
|
|
_, build := workflows["build"].Job()
|
|
assert.Equal(t, []any{"build-main", true}, []any{build.Name, build.GetContinueOnError()})
|
|
payload, err := parse(t, `"${{ true }}"`)["build"].Marshal()
|
|
require.NoError(t, err)
|
|
assert.Contains(t, string(payload), "${{ '$' }}{{ true }}")
|
|
|
|
_, placeholder := workflows["target"].Job()
|
|
require.True(t, HasDeferredMatrix(placeholder))
|
|
expanded, err := ExpandMatrixWithNeeds("target", placeholder, &model.GithubContext{}, map[string]*JobResult{
|
|
"target": {Needs: []string{"seed"}},
|
|
"seed": {Result: "success", Outputs: map[string]string{"runner": "ubuntu-latest", "tolerate": "true", "strategy": `{"fail-fast":false,"max-parallel":3}`}},
|
|
}, nil, nil, 256)
|
|
require.NoError(t, err)
|
|
require.Len(t, expanded, 1)
|
|
group, _, err := EvaluateConcurrency(&model.RawConcurrency{Group: "${{ strategy.job-index }}:${{ strategy.job-total }}:${{ strategy.fail-fast }}"}, "target", expanded[0], map[string]any{}, nil, nil, nil)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, []any{"target-ubuntu-latest", []string{"ubuntu-latest"}, true, "3", "0:1:false"},
|
|
[]any{expanded[0].DisplayName(), expanded[0].RunsOn(), expanded[0].GetContinueOnError(), expanded[0].Strategy.MaxParallelString, group})
|
|
}
|
|
|
|
func TestExpandMatrixWithNeeds(t *testing.T) {
|
|
// matrixYAML is the YAML value of the `matrix:` key, so a case can replace the whole node.
|
|
expandMax := func(t *testing.T, matrixYAML string, maxCombinations int) ([]*Job, error) {
|
|
t.Helper()
|
|
var strategy Strategy
|
|
require.NoError(t, yaml.Unmarshal([]byte("matrix:"+matrixYAML), &strategy))
|
|
job := &Job{Name: "build", Strategy: strategy}
|
|
require.NoError(t, job.RawRunsOn.Encode("${{ matrix.os || 'ubuntu-latest' }}"))
|
|
require.NoError(t, job.RawNeeds.Encode([]string{"setup"}))
|
|
// The results map must describe the job itself too, as findJobNeedsAndFillJobResults does.
|
|
return ExpandMatrixWithNeeds("build", job, &model.GithubContext{}, map[string]*JobResult{
|
|
"build": {Needs: []string{"setup"}},
|
|
"setup": {Result: "success", Outputs: map[string]string{
|
|
"versions": `["1.20", "1.21"]`,
|
|
"os": `["linux", "darwin"]`,
|
|
"include": `[{"os":"linux","fast":true},{"os":"windows","fast":false}]`,
|
|
"empty": "[]",
|
|
"limit": "3",
|
|
}},
|
|
}, nil, nil, maxCombinations)
|
|
}
|
|
expand := func(t *testing.T, matrixYAML string) ([]*Job, error) {
|
|
t.Helper()
|
|
return expandMax(t, matrixYAML, 256)
|
|
}
|
|
|
|
t.Run("expands the product and interpolates runs-on", func(t *testing.T) {
|
|
got, err := expand(t, "\n os: ${{ fromJson(needs.setup.outputs.os) }}\n version: ${{ fromJson(needs.setup.outputs.versions) }}\nmax-parallel: ${{ needs.setup.outputs.limit }}\n")
|
|
require.NoError(t, err)
|
|
indexes := make(map[string]int, len(got))
|
|
for _, combo := range got {
|
|
indexes[combo.Name] = combo.Strategy.JobIndex
|
|
assert.Contains(t, []string{"linux", "darwin"}, combo.RunsOn()[0])
|
|
assert.Equal(t, "3", combo.Strategy.MaxParallelString)
|
|
assert.Equal(t, 4, combo.Strategy.JobTotal)
|
|
}
|
|
assert.Equal(t, map[string]int{
|
|
"build (linux, 1.20)": 0, "build (linux, 1.21)": 1, "build (darwin, 1.20)": 2, "build (darwin, 1.21)": 3,
|
|
}, indexes)
|
|
})
|
|
|
|
t.Run("static and dynamic dimensions expand together, once", func(t *testing.T) {
|
|
got, err := expand(t, "\n os: [linux, darwin]\n version: ${{ fromJson(needs.setup.outputs.versions) }}\n")
|
|
require.NoError(t, err)
|
|
assert.Len(t, got, 4)
|
|
})
|
|
|
|
t.Run("include-only matrix expands", func(t *testing.T) {
|
|
got, err := expand(t, "\n include: ${{ fromJson(needs.setup.outputs.include) }}\n")
|
|
require.NoError(t, err)
|
|
assert.Len(t, got, 2)
|
|
})
|
|
|
|
for _, tt := range []struct{ name, matrix, errHas string }{
|
|
{"empty vector", "\n version: ${{ fromJson(needs.setup.outputs.empty) }}\n", `Matrix vector "version" does not contain any values`},
|
|
{"empty include", "\n include: ${{ fromJson(needs.setup.outputs.empty) }}\n", "Matrix must define at least one vector"},
|
|
{"whole matrix not a mapping", " ${{ fromJson(needs.setup.outputs.empty) }}\n", `Matrix "" is not a map of matrix keys to values`},
|
|
} {
|
|
t.Run(tt.name+" errors", func(t *testing.T) {
|
|
_, err := expand(t, tt.matrix)
|
|
require.ErrorContains(t, err, tt.errHas)
|
|
})
|
|
}
|
|
|
|
t.Run("fully excluded matrix runs once without matrix values", func(t *testing.T) {
|
|
got, err := expand(t, "\n os: [linux]\n exclude:\n - os: linux\n")
|
|
require.NoError(t, err)
|
|
require.Len(t, got, 1)
|
|
assert.Equal(t, "build", got[0].Name)
|
|
assert.Equal(t, 1, got[0].Strategy.JobTotal)
|
|
})
|
|
|
|
t.Run("unresolved need errors", func(t *testing.T) {
|
|
_, err := expand(t, "\n v: ${{ fromJson(needs.missing.outputs.v) }}\n")
|
|
require.ErrorContains(t, err, "evaluate matrix")
|
|
})
|
|
|
|
t.Run("invalid strategy field errors", func(t *testing.T) {
|
|
_, err := expand(t, "\n os: [linux]\nmax-parallel: ${{ fromJSON('bad') }}\n")
|
|
require.ErrorContains(t, err, "evaluate strategy")
|
|
})
|
|
|
|
// The combination count comes from a runtime output, so it must be rejected before one Job per
|
|
// combination is built rather than after.
|
|
t.Run("too many combinations errors", func(t *testing.T) {
|
|
_, err := expandMax(t, "\n version: ${{ fromJson(needs.setup.outputs.versions) }}\n", 1)
|
|
require.ErrorContains(t, err, "exceeding the limit of 1")
|
|
})
|
|
}
|
|
|
|
func TestReadWorkflowJobConditionContexts(t *testing.T) {
|
|
for condition, unavailable := range map[string]string{
|
|
"matrix.os == 'a'": "matrix",
|
|
"'${{ strategy.job-index == 0 }}'": "strategy",
|
|
"'${{ github.ref }} ${{ secrets.X }}'": "secrets",
|
|
"github.event.matrix && gitea.ref && needs.a.result && vars.X && inputs.y && always() && fromJSON('true')": "",
|
|
} {
|
|
_, err := ReadWorkflow([]byte("jobs: {build: {if: " + condition + "}}"))
|
|
if unavailable == "" {
|
|
assert.NoError(t, err, condition)
|
|
} else {
|
|
assert.ErrorContains(t, err, "Unrecognized named-value: '"+unavailable+"'", condition)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRejectsUnevaluatedMatrixFilters(t *testing.T) {
|
|
for _, filter := range []string{"include", "exclude"} {
|
|
t.Run(filter, func(t *testing.T) {
|
|
_, err := Parse(fmt.Appendf(nil,
|
|
"name: t\non: push\njobs:\n build:\n runs-on: ubuntu-latest\n strategy:\n matrix:\n os: [a]\n %s: ${{ fromJson(vars.MATRIX) }}\n steps: [{run: echo}]\n", filter),
|
|
WithGitContext(&model.GithubContext{}), WithVars(map[string]string{"MATRIX": `"a"`}))
|
|
require.ErrorContains(t, err, "is not a list of maps")
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestParseRawSingleWorkflowRoundTripsDeferredPlaceholder(t *testing.T) {
|
|
const workflow = `
|
|
on: push
|
|
jobs:
|
|
setup:
|
|
steps: [{run: echo}]
|
|
build:
|
|
needs: setup
|
|
runs-on: ubuntu-latest
|
|
strategy:
|
|
matrix:
|
|
%s
|
|
steps: [{run: echo}]
|
|
`
|
|
for name, matrix := range map[string]string{
|
|
"include expression": "include: ${{ fromJson(needs.setup.outputs.m) }}",
|
|
"static vector and expression": "os: [a, b]\n version: ${{ fromJson(needs.setup.outputs.m) }}",
|
|
"single expression vector": "version: ${{ fromJson(needs.setup.outputs.m) }}",
|
|
} {
|
|
t.Run(name, func(t *testing.T) {
|
|
planned, err := Parse(fmt.Appendf(nil, workflow, matrix))
|
|
require.NoError(t, err)
|
|
|
|
var payload []byte
|
|
for _, w := range planned {
|
|
id, job := w.Job()
|
|
if id != "build" {
|
|
continue
|
|
}
|
|
require.True(t, HasDeferredMatrix(job), "build must be planned as a placeholder")
|
|
require.NoError(t, w.SetJob(id, job.EraseNeeds()))
|
|
payload, err = w.Marshal()
|
|
require.NoError(t, err)
|
|
}
|
|
require.NotEmpty(t, payload, "no placeholder was planned for build")
|
|
|
|
_, job, err := ParseRawSingleWorkflow(payload)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "build", job.Name)
|
|
assert.Empty(t, job.Needs())
|
|
assert.False(t, HasDeferredMatrix(job))
|
|
|
|
reparsed, err := Parse(payload)
|
|
require.NoError(t, err)
|
|
assert.Len(t, reparsed, 1)
|
|
})
|
|
}
|
|
}
|