enhance(actions): create commit status for workflow_dispatch runs (#39961)

Runs triggered by `workflow_dispatch` never created a commit status, so
the commit showed no sign that a workflow ran on it.

- Post the status on the run's commit, e.g. `ci / e2e
(workflow_dispatch)`. `schedule` runs stay unchanged.
- These statuses don't count for pull requests. They don't show in a
PR's checks and can't satisfy or fail its required checks, even through
broad globs like `ci / e2e*`.

Closes https://github.com/go-gitea/gitea/issues/39639

---------

Co-authored-by: silverwind <me@silverwind.io>
This commit is contained in:
Mitrahsoft
2026-11-07 12:55:19 +00:00
committed by GitHub
parent 4f69599981
commit 3aca9c8fb7
12 changed files with 48 additions and 36 deletions

View File

@@ -165,11 +165,12 @@ func ScopedWorkflowStatusContextName(prefix, displayName, jobName, event string)
return strings.TrimSpace(fmt.Sprintf("%s: %s", prefix, WorkflowStatusContextName(displayName, jobName, event)))
}
// ShouldEventCreateCommitStatus reports whether a run triggered by the given workflow `on:` event posts a commit status,
// IsPullRequestCheckEvent reports whether a run triggered by the given workflow `on:` event posts a commit status that pull requests count,
// so its context can serve as a required status check.
// TODO: this allowlist duplicates the truth in services/actions.getCommitStatusEventNameAndCommitID, which decides the actual event string and whether a status is posted.
// The two are kept in sync by hand and can drift; unify them into a single source so adding a status-producing event in one place automatically updates the other.
func ShouldEventCreateCommitStatus(event string) bool {
// TODO: this allowlist duplicates the truth in services/actions.getCommitStatusEventNameAndCommitID, which decides the actual event string and whether a status is posted,
// and in services/pull.GetLatestPullCommitStatuses, which decides whether pull requests count it.
// They are kept in sync by hand and can drift, unify them into a single source so adding a status-producing event in one place automatically updates the others.
func IsPullRequestCheckEvent(event string) bool {
switch event {
case "push", "pull_request", "pull_request_target", "pull_request_review", "pull_request_review_comment", "release":
return true

View File

@@ -37,9 +37,10 @@ const (
// FIXME: This event should be a group of pull_request_review_xxx events
HookEventPullRequestReview HookEventType = "pull_request_review"
// Actions event only
HookEventSchedule HookEventType = "schedule"
HookEventWorkflowRun HookEventType = "workflow_run"
HookEventWorkflowJob HookEventType = "workflow_job"
HookEventSchedule HookEventType = "schedule"
HookEventWorkflowDispatch HookEventType = "workflow_dispatch"
HookEventWorkflowRun HookEventType = "workflow_run"
HookEventWorkflowJob HookEventType = "workflow_job"
)
func AllEvents() []HookEventType {

View File

@@ -420,9 +420,9 @@ func (prInfo *pullRequestViewInfo) prepareMergeBoxStatusCheckData(ctx *context.C
statusCheckData := &pullCommitStatusCheckData{}
data.StatusCheckData = statusCheckData
commitStatuses, err := git_model.GetLatestCommitStatus(ctx, ctx.Repo.Repository.ID, prInfo.CompareInfo.HeadCommitID, db.ListOptionsAll)
commitStatuses, err := pull_service.GetLatestPullCommitStatuses(ctx, ctx.Repo.Repository.ID, prInfo.CompareInfo.HeadCommitID)
if err != nil {
log.Error("GetLatestCommitStatus: %v", err)
log.Error("GetLatestPullCommitStatuses: %v", err)
}
// Effective required contexts = branch-protection contexts + required scoped workflow checks.

View File

@@ -158,8 +158,7 @@ func deriveScopedStatusContexts(prefix, displayName string, content []byte, even
}
eventNames := make([]string, 0, len(events))
for _, e := range events {
// only events whose runs post a commit status can be a required check; workflow_dispatch, schedule, etc. post none.
if actions_module.ShouldEventCreateCommitStatus(e.Name) {
if actions_module.IsPullRequestCheckEvent(e.Name) {
eventNames = append(eventNames, e.Name)
}
}

View File

@@ -48,7 +48,7 @@ jobs:
}, got)
})
t.Run("only status-producing events; workflow_dispatch/schedule/workflow_call skipped", func(t *testing.T) {
t.Run("only pull request check events; workflow_dispatch/schedule/workflow_call skipped", func(t *testing.T) {
content := []byte(`name: CI
on:
push:
@@ -65,21 +65,6 @@ jobs:
events, err := actions_module.GetEventsFromContent(content)
require.NoError(t, err)
got := deriveScopedStatusContexts("org/src", "CI", content, events)
assert.Equal(t, []string{"org/src: CI / j (push)"}, got) // only push posts a commit status
})
t.Run("a workflow_dispatch-only workflow has no expected contexts", func(t *testing.T) {
content := []byte(`name: Deploy
on: workflow_dispatch
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- run: echo
`)
events, err := actions_module.GetEventsFromContent(content)
require.NoError(t, err)
got := deriveScopedStatusContexts("org/src", "Deploy", content, events)
assert.Empty(t, got) // workflow_dispatch posts no commit status -> nothing to preview (and it cannot be a required check)
assert.Equal(t, []string{"org/src: CI / j (push)"}, got)
})
}

View File

@@ -121,7 +121,7 @@ func getCommitStatusEventNameAndCommitID(run *actions_model.ActionRun) (event, c
webhook_module.HookEventPullRequestReviewComment:
event = run.TriggerEvent
commitID = run.CommitSHA
case webhook_module.HookEventRelease:
case webhook_module.HookEventRelease, webhook_module.HookEventWorkflowDispatch:
event = string(run.Event)
commitID = run.CommitSHA
default: // do nothing, return empty

View File

@@ -12,6 +12,7 @@ import (
"gitea.dev/modules/actions/jobparser"
"gitea.dev/modules/setting"
"gitea.dev/modules/util"
webhook_module "gitea.dev/modules/webhook"
"go.yaml.in/yaml/v4"
)
@@ -29,7 +30,7 @@ func EvaluateRunConcurrencyFillModel(ctx context.Context, run *actions_model.Act
actionsRunCtx := GenerateGiteaContext(ctx, run, attempt, nil)
jobResults := map[string]*jobparser.JobResult{"": {}}
if inputs == nil {
if run.Event == "workflow_dispatch" {
if run.Event == webhook_module.HookEventWorkflowDispatch {
setting.PanicInDevOrTesting("run %d: workflow_dispatch inputs must be resolved by the caller", run.ID)
}
inputs = map[string]any{}

View File

@@ -17,11 +17,12 @@ import (
"gitea.dev/modules/log"
api "gitea.dev/modules/structs"
"gitea.dev/modules/util"
webhook_module "gitea.dev/modules/webhook"
)
// dispatchInputsForJob types a top-level job's `inputs.*` from EventPayload, empty for other events.
func dispatchInputsForJob(run *actions_model.ActionRun, job *actions_model.ActionRunJob) (map[string]any, error) {
if run.Event != "workflow_dispatch" {
if run.Event != webhook_module.HookEventWorkflowDispatch {
return map[string]any{}, nil
}
var payload api.WorkflowDispatchPayload
@@ -85,7 +86,7 @@ func decodeWorkflowCallInputs(caller *actions_model.ActionRunJob) (map[string]an
// calledWorkflowInputs overlays the run's dispatch inputs with `workflowCallInputs`, as GitHub does.
func calledWorkflowInputs(ctx context.Context, run *actions_model.ActionRun, caller *actions_model.ActionRunJob, workflowCallInputs map[string]any) (map[string]any, error) {
top := caller
for run.Event == "workflow_dispatch" && top.ParentJobID != 0 {
for run.Event == webhook_module.HookEventWorkflowDispatch && top.ParentJobID != 0 {
parent, err := actions_model.GetRunJobByRunAndID(ctx, run.ID, top.ParentJobID)
if err != nil {
return nil, fmt.Errorf("load caller job %d: %w", top.ParentJobID, err)

View File

@@ -22,6 +22,7 @@ import (
"gitea.dev/modules/reqctx"
api "gitea.dev/modules/structs"
"gitea.dev/modules/util"
webhook_module "gitea.dev/modules/webhook"
"gitea.dev/services/audit"
gitea_context "gitea.dev/services/context"
"gitea.dev/services/convert"
@@ -129,7 +130,7 @@ func DispatchActionWorkflow(ctx reqctx.RequestContext, doer *user_model.User, re
Ref: string(refName),
CommitSHA: runTargetCommit.ID.String(),
IsForkPullRequest: false,
Event: "workflow_dispatch",
Event: webhook_module.HookEventWorkflowDispatch,
TriggerEvent: "workflow_dispatch",
Status: actions_model.StatusWaiting,
// local dispatch: own repo at the target commit; the scoped path overrides these below

View File

@@ -9,12 +9,14 @@ import (
"errors"
"fmt"
"slices"
"strings"
actions_model "gitea.dev/models/actions"
"gitea.dev/models/db"
git_model "gitea.dev/models/git"
issues_model "gitea.dev/models/issues"
repo_model "gitea.dev/models/repo"
user_model "gitea.dev/models/user"
"gitea.dev/modules/commitstatus"
"gitea.dev/modules/container"
"gitea.dev/modules/git"
@@ -139,9 +141,9 @@ func GetPullRequestCommitStatusState(ctx context.Context, pr *issues_model.PullR
return "", fmt.Errorf("LoadBaseRepo: %w", err)
}
commitStatuses, err := git_model.GetLatestCommitStatus(ctx, pr.BaseRepo.ID, sha, db.ListOptionsAll)
commitStatuses, err := GetLatestPullCommitStatuses(ctx, pr.BaseRepo.ID, sha)
if err != nil {
return "", fmt.Errorf("GetLatestCommitStatus: %w", err)
return "", fmt.Errorf("GetLatestPullCommitStatuses: %w", err)
}
pb, err := git_model.GetFirstMatchProtectedBranchRule(ctx, pr.BaseRepoID, pr.BaseBranch)
@@ -156,6 +158,14 @@ func GetPullRequestCommitStatusState(ctx context.Context, pr *issues_model.PullR
return MergeRequiredContextsCommitStatus(commitStatuses, requiredContexts), nil
}
// GetLatestPullCommitStatuses returns a pull request head commit's latest statuses without workflow_dispatch runs', which don't count for pull requests
func GetLatestPullCommitStatuses(ctx context.Context, repoID int64, sha string) ([]*git_model.CommitStatus, error) {
statuses, err := git_model.GetLatestCommitStatus(ctx, repoID, sha, db.ListOptionsAll)
return slices.DeleteFunc(statuses, func(status *git_model.CommitStatus) bool {
return status.CreatorID == user_model.ActionsUserID && strings.HasSuffix(status.Context, " (workflow_dispatch)")
}), err
}
// EffectiveRequiredContexts returns the required status-check contexts to enforce, drawn from:
// 1. every required scoped workflow's status-check patterns effective for the repo (always)
// 2. each given protected branch rule's own configured contexts, only when that rule's status check is enabled

View File

@@ -937,7 +937,7 @@ func getAllCommitStatus(ctx context.Context, doer *user_model.User, gitRepo *git
return nil, nil, shaErr
}
statuses, err = git_model.GetLatestCommitStatus(ctx, pr.BaseRepo.ID, sha, db.ListOptionsAll)
statuses, err = GetLatestPullCommitStatuses(ctx, pr.BaseRepo.ID, sha)
for _, status := range statuses {
status.Repo = pr.BaseRepo // the repo is already loaded, spare the permission lookup a query
}

View File

@@ -11,11 +11,15 @@ import (
actions_model "gitea.dev/models/actions"
auth_model "gitea.dev/models/auth"
"gitea.dev/models/db"
git_model "gitea.dev/models/git"
repo_model "gitea.dev/models/repo"
"gitea.dev/models/unittest"
user_model "gitea.dev/models/user"
pull_service "gitea.dev/services/pull"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestWorkflowWithInputsContext(t *testing.T) {
@@ -73,6 +77,15 @@ jobs:
task := wRunner.fetchTask(t)
_, _, run := getTaskAndJobAndRunByTaskID(t, task.Id)
assert.Equal(t, "Build APP on windows", run.Title)
statuses, err := git_model.GetLatestCommitStatus(t.Context(), repo.ID, run.CommitSHA, db.ListOptionsAll)
require.NoError(t, err)
require.Len(t, statuses, 1)
assert.Equal(t, "Test Inputs Context / build (workflow_dispatch)", statuses[0].Context)
pullStatuses, err := pull_service.GetLatestPullCommitStatuses(t.Context(), repo.ID, run.CommitSHA)
require.NoError(t, err)
assert.Empty(t, pullStatuses)
})
}