From 4eca71d6d40ea1cf0f9154a97d595553153f70d1 Mon Sep 17 00:00:00 2001 From: Giteabot Date: Fri, 10 Apr 2026 04:57:04 +0800 Subject: [PATCH] Report structurally invalid workflows to users (#37116) (#37164) Backport #37116 by @bircni `model.ReadWorkflow` succeeds for YAML that is syntactically valid but fails deeper parsing in `jobparser.Parse` (e.g. blank lines inside `run: |` blocks cause a SetJob round-trip error). Add `ValidateWorkflowContent` which runs the full `jobparser.Parse` to catch these cases, and use it in the file view, the actions workflow list, and the workflow detection loop so users see the error instead of silently getting a 500 or a dropped workflow. Fixes #37115 Signed-off-by: Nicolas Co-authored-by: Nicolas Co-authored-by: Claude Sonnet 4.6 Co-authored-by: Zettat123 Co-authored-by: wxiaoguang --- modules/actions/workflows.go | 10 ++++++++++ modules/actions/workflows_test.go | 22 ++++++++++++++++------ routers/web/repo/actions/actions.go | 9 +++++++++ routers/web/repo/view_file.go | 5 +---- 4 files changed, 36 insertions(+), 10 deletions(-) diff --git a/modules/actions/workflows.go b/modules/actions/workflows.go index 4ac06def4d..ba1aee7d72 100644 --- a/modules/actions/workflows.go +++ b/modules/actions/workflows.go @@ -103,10 +103,20 @@ func GetEventsFromContent(content []byte) ([]*jobparser.Event, error) { if err != nil { return nil, err } + if err := ValidateWorkflowContent(content); err != nil { + return nil, err + } return events, nil } +// ValidateWorkflowContent catches structural errors (e.g. blank lines in run: | blocks) +// that model.ReadWorkflow alone does not detect. +func ValidateWorkflowContent(content []byte) error { + _, err := jobparser.Parse(content) + return err +} + func DetectWorkflows( gitRepo *git.Repository, commit *git.Commit, diff --git a/modules/actions/workflows_test.go b/modules/actions/workflows_test.go index ea027366f7..cda2de13e2 100644 --- a/modules/actions/workflows_test.go +++ b/modules/actions/workflows_test.go @@ -9,16 +9,26 @@ import ( "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/setting" api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/test" webhook_module "code.gitea.io/gitea/modules/webhook" "github.com/stretchr/testify/assert" ) +func fullWorkflowContent(part string) []byte { + return []byte(` +name: test +` + part + ` +jobs: + test: + runs-on: ubuntu-latest + steps: + - run: echo hello +`) +} + func TestIsWorkflow(t *testing.T) { - oldDirs := setting.Actions.WorkflowDirs - defer func() { - setting.Actions.WorkflowDirs = oldDirs - }() + defer test.MockVariableValue(&setting.Actions.WorkflowDirs)() tests := []struct { name string @@ -218,7 +228,7 @@ func TestDetectMatched(t *testing.T) { for _, tc := range testCases { t.Run(tc.desc, func(t *testing.T) { - evts, err := GetEventsFromContent([]byte(tc.yamlOn)) + evts, err := GetEventsFromContent(fullWorkflowContent(tc.yamlOn)) assert.NoError(t, err) assert.Len(t, evts, 1) assert.Equal(t, tc.expected, detectMatched(nil, tc.commit, tc.triggedEvent, tc.payload, evts[0])) @@ -373,7 +383,7 @@ func TestMatchIssuesEvent(t *testing.T) { for _, tc := range testCases { t.Run(tc.desc, func(t *testing.T) { - evts, err := GetEventsFromContent([]byte(tc.yamlOn)) + evts, err := GetEventsFromContent(fullWorkflowContent(tc.yamlOn)) assert.NoError(t, err) assert.Len(t, evts, 1) diff --git a/routers/web/repo/actions/actions.go b/routers/web/repo/actions/actions.go index 988d2d0a99..644a53f28a 100644 --- a/routers/web/repo/actions/actions.go +++ b/routers/web/repo/actions/actions.go @@ -151,6 +151,11 @@ func prepareWorkflowTemplate(ctx *context.Context, commit *git.Commit) (workflow workflows = append(workflows, workflow) continue } + if err := actions.ValidateWorkflowContent(content); err != nil { + workflow.ErrMsg = ctx.Locale.TrString("actions.runs.invalid_workflow_helper", err.Error()) + workflows = append(workflows, workflow) + continue + } workflow.Workflow = wf // The workflow must contain at least one job without "needs". Otherwise, a deadlock will occur and no jobs will be able to run. hasJobWithoutNeeds := false @@ -315,6 +320,10 @@ func prepareWorkflowList(ctx *context.Context, workflows []WorkflowInfo) { if !job.Status.IsWaiting() { continue } + if err := actions.ValidateWorkflowContent(job.WorkflowPayload); err != nil { + runErrors[run.ID] = ctx.Locale.TrString("actions.runs.invalid_workflow_helper", err.Error()) + break + } hasOnlineRunner := false for _, runner := range runners { if !runner.IsDisabled && runner.CanMatchLabels(job.RunsOn) { diff --git a/routers/web/repo/view_file.go b/routers/web/repo/view_file.go index 3ae0dab25b..65fcb8adba 100644 --- a/routers/web/repo/view_file.go +++ b/routers/web/repo/view_file.go @@ -25,8 +25,6 @@ import ( "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/services/context" issue_service "code.gitea.io/gitea/services/issue" - - "github.com/nektos/act/pkg/model" ) func prepareLatestCommitInfo(ctx *context.Context) bool { @@ -184,8 +182,7 @@ func prepareFileView(ctx *context.Context, entry *git.TreeEntry) { if err != nil { log.Error("actions.GetContentFromEntry: %v", err) } - _, workFlowErr := model.ReadWorkflow(bytes.NewReader(content)) - if workFlowErr != nil { + if workFlowErr := actions.ValidateWorkflowContent(content); workFlowErr != nil { ctx.Data["FileError"] = ctx.Locale.Tr("actions.runs.invalid_workflow_helper", workFlowErr.Error()) } } else if issue_service.IsCodeOwnerFile(ctx.Repo.TreePath) {