enhance(actions): list workflow_call-only workflows in their own sidebar group (#40030)

A workflow whose only trigger is `workflow_call` never runs on its own,
so its sidebar entry always showed an empty run list and a "Disable
workflow" action that has no effect.

Following the discussion with @bircni and @Zettat123, these workflows
are now listed in a separate collapsed "Reusable workflows" group. Their
page shows "This workflow can only be called by other workflows."
instead of "no runs", and "Disable workflow" and "Create status badge"
are hidden. A workflow disabled before it became `workflow_call`-only
keeps its label and "Enable workflow". "Other workflows" again only
lists workflows whose file is gone.

Fixes: https://github.com/go-gitea/gitea/issues/39385
Co-authored-by: bircni <bircni@icloud.com>
Co-authored-by: silverwind <me@silverwind.io>
This commit is contained in:
authored and GitHub committed 2026-10-30 21:41:59 +00:00
1 parent 1aff9b8cd6
commit ab434f4b38
6 files changed
+82 -6

No files matched your search

+3
View File
@@ -3854,6 +3854,9 @@
"actions.runs.all_workflows": "All Workflows",
"actions.runs.other_workflows": "Other workflows",
"actions.runs.other_workflows_tooltip": "Workflows that were executed in this repository but do not exist on the default branch.",
"actions.runs.reusable_workflows": "Reusable workflows",
"actions.runs.reusable_workflows_tooltip": "Workflows that can only be called by other workflows.",
"actions.runs.reusable_workflow_no_runs": "This workflow can only be called by other workflows.",
"actions.runs.workflow_run_count_1": "%d workflow run",
"actions.runs.workflow_run_count_n": "%d workflow runs",
"actions.runs.commit": "Commit",
+8 -1
View File
@@ -47,6 +47,7 @@ type WorkflowInfo struct {
EntryName string
ErrMsg string
Workflow *act_model.Workflow
Reusable bool // only triggered by workflow_call
}
type workflowBadge struct {
@@ -226,11 +227,13 @@ func prepareWorkflowTemplate(ctx *context.Context, commit *git.Commit) (workflow
workflows = append(workflows, workflow)
continue
}
if _, err := actions.GetEventsFromContent(content); err != nil {
events, err := actions.GetEventsFromContent(content)
if err != nil {
workflow.ErrMsg = ctx.Locale.TrString("actions.runs.invalid_workflow_helper", err.Error())
workflows = append(workflows, workflow)
continue
}
workflow.Reusable = len(events) == 0 && slices.Contains(wf.On(), "workflow_call") // events never include workflow_call
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
@@ -262,7 +265,11 @@ func prepareWorkflowTemplate(ctx *context.Context, commit *git.Commit) (workflow
workflows = append(workflows, workflow)
}
reusableWorkflows := slices.DeleteFunc(slices.Clone(workflows), func(w WorkflowInfo) bool { return !w.Reusable })
ctx.Data["workflows"] = workflows
ctx.Data["ReusableWorkflows"] = reusableWorkflows
ctx.Data["CurWorkflowIsReusable"] = ctx.FormInt64("scoped_workflow_source_repo_id") == 0 &&
slices.ContainsFunc(reusableWorkflows, func(w WorkflowInfo) bool { return w.EntryName == curWorkflowID })
ctx.Data["RepoLink"] = ctx.Repo.Repository.Link()
ctx.Data["RepoID"] = ctx.Repo.Repository.ID
ctx.Data["AllowDisableOrEnableWorkflow"] = ctx.Repo.Permission.IsAdmin()
+3 -3
View File
@@ -1,7 +1,7 @@
{{template "base/head" .}}
{{$showCreateWorkflowBadge := Iif .WorkflowBadge true false}}
{{$showEnableDisableWorkflow := and .AllowDisableOrEnableWorkflow .CurWorkflowIsListed $.CurWorkflow}}
{{$showCreateWorkflowBadge := and .WorkflowBadge (not .CurWorkflowIsReusable)}}
{{$showEnableDisableWorkflow := and .AllowDisableOrEnableWorkflow .CurWorkflowIsListed $.CurWorkflow (or (not .CurWorkflowIsReusable) .CurWorkflowDisabled)}}
<div class="page-content repository actions">
{{template "repo/header" .}}
@@ -101,7 +101,7 @@
{{end}}
<div class="ui attached segment">
{{template "repo/actions/runs_list" dict "ActionRunListData" $.ActionRunListData}}
{{template "repo/actions/runs_list" dict "ActionRunListData" $.ActionRunListData "CurWorkflowIsReusable" $.CurWorkflowIsReusable}}
{{template "base/paginate" dict "Page" $.Page}}
</div>
</div>
+1 -1
View File
@@ -6,7 +6,7 @@
{{if not $data.ActionRuns}}
<div class="empty-placeholder">
{{svg "octicon-no-entry" 48}}
<h2>{{if $data.IsFiltered}}{{ctx.Locale.Tr "actions.runs.no_results"}}{{else}}{{ctx.Locale.Tr "actions.runs.no_runs"}}{{end}}</h2>
<h2>{{if $data.IsFiltered}}{{ctx.Locale.Tr "actions.runs.no_results"}}{{else if $.CurWorkflowIsReusable}}{{ctx.Locale.Tr "actions.runs.reusable_workflow_no_runs"}}{{else}}{{ctx.Locale.Tr "actions.runs.no_runs"}}{{end}}</h2>
</div>
{{end}}
{{range $run := $data.ActionRuns}}
+24 -1
View File
@@ -1,7 +1,7 @@
<div class="flex-container-nav">
<div class="ui fluid vertical menu">
<a class="item {{if and (not $.CurWorkflow) (not $.PageIsActionsJobQueue)}}active{{end}}" href="{{$.RepoLink}}/actions?actor={{$.CurActor}}&status={{$.CurStatus}}&branch={{$.CurBranch}}">{{ctx.Locale.Tr "actions.runs.all_workflows"}}</a>
{{range .workflows}}
{{range .workflows}}{{if not .Reusable}}
<a class="item flex-text-block {{if and (eq .EntryName $.CurWorkflow) (not $.CurWorkflowScopedRepoID)}}active{{end}}" href="{{$.RepoLink}}/actions?workflow={{.EntryName}}&actor={{$.CurActor}}&status={{$.CurStatus}}&branch={{$.CurBranch}}">
<span class="gt-ellipsis" data-tooltip-content="{{.DisplayName}}">{{.DisplayName}}</span>
@@ -13,6 +13,29 @@
<div class="ui red label tw-shrink-0">{{ctx.Locale.Tr "disabled"}}</div>
{{end}}
</a>
{{end}}{{end}}
{{if .ReusableWorkflows}}
<details class="item"{{if $.CurWorkflowIsReusable}} open{{end}}>
<summary data-tooltip-content="{{ctx.Locale.Tr "actions.runs.reusable_workflows_tooltip"}}">
<span class="flex-text-block">
{{ctx.Locale.Tr "actions.runs.reusable_workflows"}}
<span class="ui label">{{len .ReusableWorkflows}}</span>
</span>
</summary>
<div class="menu items-full-width">
{{range .ReusableWorkflows}}
<a class="item flex-text-block {{if and (eq .EntryName $.CurWorkflow) (not $.CurWorkflowScopedRepoID)}}active{{end}}" href="{{$.RepoLink}}/actions?workflow={{.EntryName}}&actor={{$.CurActor}}&status={{$.CurStatus}}&branch={{$.CurBranch}}">
<span class="gt-ellipsis" data-tooltip-content="{{.DisplayName}}">{{.DisplayName}}</span>
{{if .ErrMsg}}
<span class="flex-text-inline tw-shrink-0" data-tooltip-content="{{.ErrMsg}}">{{svg "octicon-alert" 16 "tw-text-red"}}</span>
{{end}}
{{if $.ActionsConfig.IsWorkflowDisabled .EntryName}}
<div class="ui red label tw-shrink-0">{{ctx.Locale.Tr "disabled"}}</div>
{{end}}
</a>
{{end}}
</div>
</details>
{{end}}
{{range .ScopedWorkflowGroups}}
<details class="item scoped-workflow-group"{{if .IsActive}} open{{end}}>
@@ -8,12 +8,14 @@ import (
"fmt"
"net/http"
"net/url"
"strings"
"testing"
"time"
runnerv1 "gitea.dev/actionslib/runner/v1"
actions_model "gitea.dev/models/actions"
auth_model "gitea.dev/models/auth"
"gitea.dev/models/db"
perm_model "gitea.dev/models/perm"
repo_model "gitea.dev/models/repo"
"gitea.dev/models/unit"
@@ -359,6 +361,47 @@ jobs:
run = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: runID})
assert.Equal(t, actions_model.StatusSuccess, run.Status)
})
t.Run("Sidebar groups workflow_call-only workflows", func(t *testing.T) {
require.NoError(t, db.Insert(t.Context(), &actions_model.ActionRun{
Title: "former push run", RepoID: repo.ID, OwnerID: repo.OwnerID, WorkflowID: "reusable2.yaml", Index: 9701,
TriggerUserID: user2.ID, Ref: "refs/heads/" + repo.DefaultBranch, Event: "push", TriggerEvent: "push",
Status: actions_model.StatusSuccess,
}))
job := "jobs:\n job1:\n runs-on: ubuntu-latest\n steps:\n - run: echo\n"
testCreateFileInBranch(t, user2, repo, createFileInBranchOptions{}, map[string]string{
".gitea/workflows/mixed.yaml": "on: [workflow_call, workflow_dispatch]\n" + job,
".gitea/workflows/no-events.yaml": "on: []\n" + job,
})
MakeRequest(t, NewRequest(t, "PUT", fmt.Sprintf("/api/v1/repos/%s/actions/workflows/reusable2.yaml/disable", repo.FullName())).
AddTokenAuth(user2Token), http.StatusNoContent)
doc := NewHTMLParser(t, user2Session.MakeRequest(t, NewRequest(t, "GET", repo.Link()+"/actions?workflow=reusable1.yaml"), http.StatusOK).Body)
listed := func(name string) int {
return doc.Find(fmt.Sprintf(`.flex-container-nav > .menu > a.item[href*="workflow=%s&"]`, name)).Length()
}
grouped := func(name string) int {
return doc.Find(fmt.Sprintf(`.flex-container-nav details .menu a.item[href*="workflow=%s&"]`, name)).Length()
}
assert.Equal(t, "2", doc.Find(".flex-container-nav details summary .ui.label").Text())
assert.Equal(t, 1, grouped("reusable1.yaml"))
assert.Equal(t, 1, grouped("reusable2.yaml"))
assert.Equal(t, 1, doc.Find(`.flex-container-nav details a.item[href*="workflow=reusable2.yaml&"] .ui.red.label`).Length())
assert.Zero(t, listed("reusable1.yaml"))
assert.Equal(t, 1, listed("caller.yaml"))
assert.Equal(t, 1, listed("mixed.yaml"))
assert.Equal(t, 1, listed("no-events.yaml"))
assert.Equal(t, "This workflow can only be called by other workflows.", strings.TrimSpace(doc.Find(".empty-placeholder h2").Text()))
assert.Zero(t, doc.Find(`[data-url*="/disable?workflow=reusable1.yaml"]`).Length())
assert.Zero(t, doc.Find(`[data-modal="#workflow-status-badge-modal"]`).Length())
doc = NewHTMLParser(t, user2Session.MakeRequest(t, NewRequest(t, "GET", repo.Link()+"/actions?workflow=reusable2.yaml"), http.StatusOK).Body)
assert.Equal(t, 1, doc.Find(`[data-url*="/enable?workflow=reusable2.yaml"]`).Length())
doc = NewHTMLParser(t, user2Session.MakeRequest(t, NewRequest(t, "GET", repo.Link()+"/actions?workflow=mixed.yaml"), http.StatusOK).Body)
assert.Equal(t, 1, doc.Find(`[data-url*="/disable?workflow=mixed.yaml"]`).Length())
assert.Equal(t, 1, doc.Find(`[data-modal="#workflow-status-badge-modal"]`).Length())
})
})
t.Run("Cross-repo reusable workflow with collaborative owner", func(t *testing.T) {