From 523751dc82bbb9d3f8d413f232e23ab0476eb4d4 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Bence=20S=C3=A1ntha?=
 <7604637+bencurio@users.noreply.github.com>
Date: Sun, 9 Feb 2025 22:23:57 +0100
Subject: [PATCH] Feature: Support workflow event dispatch via API (#32059)

ref: https://github.com/go-gitea/gitea/issues/31765

---------

Signed-off-by: Bence Santha <git@santha.eu>
Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: Christopher Homberger <christopher.homberger@web.de>
---
 modules/structs/repo_actions.go           |  33 ++
 routers/api/v1/api.go                     |  19 +
 routers/api/v1/repo/action.go             | 297 ++++++++++
 routers/api/v1/swagger/action.go          |  14 +
 routers/api/v1/swagger/options.go         |   3 +
 routers/web/repo/actions/view.go          | 162 +-----
 services/actions/workflow.go              | 296 ++++++++++
 services/actions/workflow_interface.go    |  20 +
 templates/swagger/v1_json.tmpl            | 354 ++++++++++++
 tests/integration/actions_trigger_test.go | 624 ++++++++++++++++++++++
 10 files changed, 1685 insertions(+), 137 deletions(-)
 create mode 100644 services/actions/workflow.go
 create mode 100644 services/actions/workflow_interface.go

diff --git a/modules/structs/repo_actions.go b/modules/structs/repo_actions.go
index b13f344738..109cea85c4 100644
--- a/modules/structs/repo_actions.go
+++ b/modules/structs/repo_actions.go
@@ -32,3 +32,36 @@ type ActionTaskResponse struct {
 	Entries    []*ActionTask `json:"workflow_runs"`
 	TotalCount int64         `json:"total_count"`
 }
+
+// CreateActionWorkflowDispatch represents the payload for triggering a workflow dispatch event
+// swagger:model
+type CreateActionWorkflowDispatch struct {
+	// required: true
+	// example: refs/heads/main
+	Ref string `json:"ref" binding:"Required"`
+	// required: false
+	Inputs map[string]any `json:"inputs,omitempty"`
+}
+
+// ActionWorkflow represents a ActionWorkflow
+type ActionWorkflow struct {
+	ID    string `json:"id"`
+	Name  string `json:"name"`
+	Path  string `json:"path"`
+	State string `json:"state"`
+	// swagger:strfmt date-time
+	CreatedAt time.Time `json:"created_at"`
+	// swagger:strfmt date-time
+	UpdatedAt time.Time `json:"updated_at"`
+	URL       string    `json:"url"`
+	HTMLURL   string    `json:"html_url"`
+	BadgeURL  string    `json:"badge_url"`
+	// swagger:strfmt date-time
+	DeletedAt time.Time `json:"deleted_at,omitempty"`
+}
+
+// ActionWorkflowResponse returns a ActionWorkflow
+type ActionWorkflowResponse struct {
+	Workflows  []*ActionWorkflow `json:"workflows"`
+	TotalCount int64             `json:"total_count"`
+}
diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go
index 2ffd6b129b..6d6e09bb8e 100644
--- a/routers/api/v1/api.go
+++ b/routers/api/v1/api.go
@@ -915,6 +915,21 @@ func Routes() *web.Router {
 		})
 	}
 
+	addActionsWorkflowRoutes := func(
+		m *web.Router,
+		actw actions.WorkflowAPI,
+	) {
+		m.Group("/actions", func() {
+			m.Group("/workflows", func() {
+				m.Get("", reqToken(), actw.ListRepositoryWorkflows)
+				m.Get("/{workflow_id}", reqToken(), actw.GetWorkflow)
+				m.Put("/{workflow_id}/disable", reqToken(), reqRepoWriter(unit.TypeActions), actw.DisableWorkflow)
+				m.Post("/{workflow_id}/dispatches", reqToken(), reqRepoWriter(unit.TypeActions), bind(api.CreateActionWorkflowDispatch{}), actw.DispatchWorkflow)
+				m.Put("/{workflow_id}/enable", reqToken(), reqRepoWriter(unit.TypeActions), actw.EnableWorkflow)
+			}, context.ReferencesGitRepo(), reqRepoReader(unit.TypeActions))
+		})
+	}
+
 	m.Group("", func() {
 		// Miscellaneous (no scope required)
 		if setting.API.EnableSwagger {
@@ -1160,6 +1175,10 @@ func Routes() *web.Router {
 					reqOwner(),
 					repo.NewAction(),
 				)
+				addActionsWorkflowRoutes(
+					m,
+					repo.NewActionWorkflow(),
+				)
 				m.Group("/hooks/git", func() {
 					m.Combo("").Get(repo.ListGitHooks)
 					m.Group("/{id}", func() {
diff --git a/routers/api/v1/repo/action.go b/routers/api/v1/repo/action.go
index d27e8d2427..8933a10b4b 100644
--- a/routers/api/v1/repo/action.go
+++ b/routers/api/v1/repo/action.go
@@ -5,6 +5,7 @@ package repo
 
 import (
 	"errors"
+	"fmt"
 	"net/http"
 
 	actions_model "code.gitea.io/gitea/models/actions"
@@ -19,6 +20,8 @@ import (
 	"code.gitea.io/gitea/services/context"
 	"code.gitea.io/gitea/services/convert"
 	secret_service "code.gitea.io/gitea/services/secrets"
+
+	"github.com/nektos/act/pkg/model"
 )
 
 // ListActionsSecrets list an repo's actions secrets
@@ -581,3 +584,297 @@ func ListActionTasks(ctx *context.APIContext) {
 
 	ctx.JSON(http.StatusOK, &res)
 }
+
+// ActionWorkflow implements actions_service.WorkflowAPI
+type ActionWorkflow struct{}
+
+// NewActionWorkflow creates a new ActionWorkflow service
+func NewActionWorkflow() actions_service.WorkflowAPI {
+	return ActionWorkflow{}
+}
+
+func (a ActionWorkflow) ListRepositoryWorkflows(ctx *context.APIContext) {
+	// swagger:operation GET /repos/{owner}/{repo}/actions/workflows repository ListRepositoryWorkflows
+	// ---
+	// summary: List repository workflows
+	// produces:
+	// - application/json
+	// parameters:
+	// - name: owner
+	//   in: path
+	//   description: owner of the repo
+	//   type: string
+	//   required: true
+	// - name: repo
+	//   in: path
+	//   description: name of the repo
+	//   type: string
+	//   required: true
+	// responses:
+	//   "200":
+	//     "$ref": "#/responses/ActionWorkflowList"
+	//   "400":
+	//     "$ref": "#/responses/error"
+	//   "403":
+	//     "$ref": "#/responses/forbidden"
+	//   "404":
+	//     "$ref": "#/responses/notFound"
+	//   "422":
+	//     "$ref": "#/responses/validationError"
+	//   "500":
+	//     "$ref": "#/responses/error"
+
+	workflows, err := actions_service.ListActionWorkflows(ctx)
+	if err != nil {
+		ctx.Error(http.StatusInternalServerError, "ListActionWorkflows", err)
+		return
+	}
+
+	ctx.JSON(http.StatusOK, &api.ActionWorkflowResponse{Workflows: workflows, TotalCount: int64(len(workflows))})
+}
+
+func (a ActionWorkflow) GetWorkflow(ctx *context.APIContext) {
+	// swagger:operation GET /repos/{owner}/{repo}/actions/workflows/{workflow_id} repository GetWorkflow
+	// ---
+	// summary: Get a workflow
+	// produces:
+	// - application/json
+	// parameters:
+	// - name: owner
+	//   in: path
+	//   description: owner of the repo
+	//   type: string
+	//   required: true
+	// - name: repo
+	//   in: path
+	//   description: name of the repo
+	//   type: string
+	//   required: true
+	// - name: workflow_id
+	//   in: path
+	//   description: id of the workflow
+	//   type: string
+	//   required: true
+	// responses:
+	//   "200":
+	//     "$ref": "#/responses/ActionWorkflow"
+	//   "400":
+	//     "$ref": "#/responses/error"
+	//   "403":
+	//     "$ref": "#/responses/forbidden"
+	//   "404":
+	//     "$ref": "#/responses/notFound"
+	//   "422":
+	//     "$ref": "#/responses/validationError"
+	//   "500":
+	//     "$ref": "#/responses/error"
+
+	workflowID := ctx.PathParam("workflow_id")
+	if len(workflowID) == 0 {
+		ctx.Error(http.StatusUnprocessableEntity, "MissingWorkflowParameter", util.NewInvalidArgumentErrorf("workflow_id is required parameter"))
+		return
+	}
+
+	workflow, err := actions_service.GetActionWorkflow(ctx, workflowID)
+	if err != nil {
+		ctx.Error(http.StatusInternalServerError, "GetActionWorkflow", err)
+		return
+	}
+
+	if workflow == nil {
+		ctx.Error(http.StatusNotFound, "GetActionWorkflow", err)
+		return
+	}
+
+	ctx.JSON(http.StatusOK, workflow)
+}
+
+func (a ActionWorkflow) DisableWorkflow(ctx *context.APIContext) {
+	// swagger:operation PUT /repos/{owner}/{repo}/actions/workflows/{workflow_id}/disable repository DisableWorkflow
+	// ---
+	// summary: Disable a workflow
+	// produces:
+	// - application/json
+	// parameters:
+	// - name: owner
+	//   in: path
+	//   description: owner of the repo
+	//   type: string
+	//   required: true
+	// - name: repo
+	//   in: path
+	//   description: name of the repo
+	//   type: string
+	//   required: true
+	// - name: workflow_id
+	//   in: path
+	//   description: id of the workflow
+	//   type: string
+	//   required: true
+	// responses:
+	//   "204":
+	//     description: No Content
+	//   "400":
+	//     "$ref": "#/responses/error"
+	//   "403":
+	//     "$ref": "#/responses/forbidden"
+	//   "404":
+	//     "$ref": "#/responses/notFound"
+	//   "422":
+	//     "$ref": "#/responses/validationError"
+
+	workflowID := ctx.PathParam("workflow_id")
+	if len(workflowID) == 0 {
+		ctx.Error(http.StatusUnprocessableEntity, "MissingWorkflowParameter", util.NewInvalidArgumentErrorf("workflow_id is required parameter"))
+		return
+	}
+
+	err := actions_service.DisableActionWorkflow(ctx, workflowID)
+	if err != nil {
+		ctx.Error(http.StatusInternalServerError, "DisableActionWorkflow", err)
+		return
+	}
+
+	ctx.Status(http.StatusNoContent)
+}
+
+func (a ActionWorkflow) DispatchWorkflow(ctx *context.APIContext) {
+	// swagger:operation POST /repos/{owner}/{repo}/actions/workflows/{workflow_id}/dispatches repository DispatchWorkflow
+	// ---
+	// summary: Create a workflow dispatch event
+	// produces:
+	// - application/json
+	// parameters:
+	// - name: owner
+	//   in: path
+	//   description: owner of the repo
+	//   type: string
+	//   required: true
+	// - name: repo
+	//   in: path
+	//   description: name of the repo
+	//   type: string
+	//   required: true
+	// - name: workflow_id
+	//   in: path
+	//   description: id of the workflow
+	//   type: string
+	//   required: true
+	// - name: body
+	//   in: body
+	//   schema:
+	//     "$ref": "#/definitions/CreateActionWorkflowDispatch"
+	// responses:
+	//   "204":
+	//     description: No Content
+	//   "400":
+	//     "$ref": "#/responses/error"
+	//   "403":
+	//     "$ref": "#/responses/forbidden"
+	//   "404":
+	//     "$ref": "#/responses/notFound"
+	//   "422":
+	//     "$ref": "#/responses/validationError"
+
+	opt := web.GetForm(ctx).(*api.CreateActionWorkflowDispatch)
+
+	workflowID := ctx.PathParam("workflow_id")
+	if len(workflowID) == 0 {
+		ctx.Error(http.StatusUnprocessableEntity, "MissingWorkflowParameter", util.NewInvalidArgumentErrorf("workflow_id is required parameter"))
+		return
+	}
+
+	ref := opt.Ref
+	if len(ref) == 0 {
+		ctx.Error(http.StatusUnprocessableEntity, "MissingWorkflowParameter", util.NewInvalidArgumentErrorf("ref is required parameter"))
+		return
+	}
+
+	err := actions_service.DispatchActionWorkflow(&context.Context{
+		Base: ctx.Base,
+		Doer: ctx.Doer,
+		Repo: ctx.Repo,
+	}, workflowID, ref, func(workflowDispatch *model.WorkflowDispatch, inputs *map[string]any) error {
+		if workflowDispatch != nil {
+			// TODO figure out why the inputs map is empty for url form encoding workaround
+			if opt.Inputs == nil {
+				for name, config := range workflowDispatch.Inputs {
+					value := ctx.FormString("inputs["+name+"]", config.Default)
+					(*inputs)[name] = value
+				}
+			} else {
+				for name, config := range workflowDispatch.Inputs {
+					value, ok := opt.Inputs[name]
+					if ok {
+						(*inputs)[name] = value
+					} else {
+						(*inputs)[name] = config.Default
+					}
+				}
+			}
+		}
+		return nil
+	})
+	if err != nil {
+		if terr, ok := err.(*actions_service.TranslateableError); ok {
+			msg := ctx.Locale.TrString(terr.Translation, terr.Args...)
+			ctx.Error(terr.GetCode(), msg, fmt.Errorf("%s", msg))
+			return
+		}
+		ctx.Error(http.StatusInternalServerError, err.Error(), err)
+		return
+	}
+
+	ctx.Status(http.StatusNoContent)
+}
+
+func (a ActionWorkflow) EnableWorkflow(ctx *context.APIContext) {
+	// swagger:operation PUT /repos/{owner}/{repo}/actions/workflows/{workflow_id}/enable repository EnableWorkflow
+	// ---
+	// summary: Enable a workflow
+	// produces:
+	// - application/json
+	// parameters:
+	// - name: owner
+	//   in: path
+	//   description: owner of the repo
+	//   type: string
+	//   required: true
+	// - name: repo
+	//   in: path
+	//   description: name of the repo
+	//   type: string
+	//   required: true
+	// - name: workflow_id
+	//   in: path
+	//   description: id of the workflow
+	//   type: string
+	//   required: true
+	// responses:
+	//   "204":
+	//     description: No Content
+	//   "400":
+	//     "$ref": "#/responses/error"
+	//   "403":
+	//     "$ref": "#/responses/forbidden"
+	//   "404":
+	//     "$ref": "#/responses/notFound"
+	//   "409":
+	//     "$ref": "#/responses/conflict"
+	//   "422":
+	//     "$ref": "#/responses/validationError"
+
+	workflowID := ctx.PathParam("workflow_id")
+	if len(workflowID) == 0 {
+		ctx.Error(http.StatusUnprocessableEntity, "MissingWorkflowParameter", util.NewInvalidArgumentErrorf("workflow_id is required parameter"))
+		return
+	}
+
+	err := actions_service.EnableActionWorkflow(ctx, workflowID)
+	if err != nil {
+		ctx.Error(http.StatusInternalServerError, "EnableActionWorkflow", err)
+		return
+	}
+
+	ctx.Status(http.StatusNoContent)
+}
diff --git a/routers/api/v1/swagger/action.go b/routers/api/v1/swagger/action.go
index 665f4d0b85..16a250184a 100644
--- a/routers/api/v1/swagger/action.go
+++ b/routers/api/v1/swagger/action.go
@@ -32,3 +32,17 @@ type swaggerResponseVariableList struct {
 	// in:body
 	Body []api.ActionVariable `json:"body"`
 }
+
+// ActionWorkflow
+// swagger:response ActionWorkflow
+type swaggerResponseActionWorkflow struct {
+	// in:body
+	Body api.ActionWorkflow `json:"body"`
+}
+
+// ActionWorkflowList
+// swagger:response ActionWorkflowList
+type swaggerResponseActionWorkflowList struct {
+	// in:body
+	Body []api.ActionWorkflow `json:"body"`
+}
diff --git a/routers/api/v1/swagger/options.go b/routers/api/v1/swagger/options.go
index 353d6de89b..aa5990eb38 100644
--- a/routers/api/v1/swagger/options.go
+++ b/routers/api/v1/swagger/options.go
@@ -211,6 +211,9 @@ type swaggerParameterBodies struct {
 	// in:body
 	RenameOrgOption api.RenameOrgOption
 
+	// in:body
+	CreateActionWorkflowDispatch api.CreateActionWorkflowDispatch
+
 	// in:body
 	UpdateVariableOption api.UpdateVariableOption
 }
diff --git a/routers/web/repo/actions/view.go b/routers/web/repo/actions/view.go
index fc346b83b4..6e09cd3de8 100644
--- a/routers/web/repo/actions/view.go
+++ b/routers/web/repo/actions/view.go
@@ -20,8 +20,6 @@ import (
 	actions_model "code.gitea.io/gitea/models/actions"
 	"code.gitea.io/gitea/models/db"
 	git_model "code.gitea.io/gitea/models/git"
-	"code.gitea.io/gitea/models/perm"
-	access_model "code.gitea.io/gitea/models/perm/access"
 	repo_model "code.gitea.io/gitea/models/repo"
 	"code.gitea.io/gitea/models/unit"
 	"code.gitea.io/gitea/modules/actions"
@@ -30,16 +28,13 @@ import (
 	"code.gitea.io/gitea/modules/log"
 	"code.gitea.io/gitea/modules/setting"
 	"code.gitea.io/gitea/modules/storage"
-	api "code.gitea.io/gitea/modules/structs"
 	"code.gitea.io/gitea/modules/templates"
 	"code.gitea.io/gitea/modules/timeutil"
 	"code.gitea.io/gitea/modules/util"
 	"code.gitea.io/gitea/modules/web"
 	actions_service "code.gitea.io/gitea/services/actions"
 	context_module "code.gitea.io/gitea/services/context"
-	"code.gitea.io/gitea/services/convert"
 
-	"github.com/nektos/act/pkg/jobparser"
 	"github.com/nektos/act/pkg/model"
 	"xorm.io/builder"
 )
@@ -792,142 +787,35 @@ func Run(ctx *context_module.Context) {
 		ctx.ServerError("ref", nil)
 		return
 	}
-
-	// can not rerun job when workflow is disabled
-	cfgUnit := ctx.Repo.Repository.MustGetUnit(ctx, unit.TypeActions)
-	cfg := cfgUnit.ActionsConfig()
-	if cfg.IsWorkflowDisabled(workflowID) {
-		ctx.Flash.Error(ctx.Tr("actions.workflow.disabled"))
-		ctx.Redirect(redirectURL)
-		return
-	}
-
-	// get target commit of run from specified ref
-	refName := git.RefName(ref)
-	var runTargetCommit *git.Commit
-	var err error
-	if refName.IsTag() {
-		runTargetCommit, err = ctx.Repo.GitRepo.GetTagCommit(refName.TagName())
-	} else if refName.IsBranch() {
-		runTargetCommit, err = ctx.Repo.GitRepo.GetBranchCommit(refName.BranchName())
-	} else {
-		ctx.Flash.Error(ctx.Tr("form.git_ref_name_error", ref))
-		ctx.Redirect(redirectURL)
-		return
-	}
-	if err != nil {
-		ctx.Flash.Error(ctx.Tr("form.target_ref_not_exist", ref))
-		ctx.Redirect(redirectURL)
-		return
-	}
-
-	// get workflow entry from runTargetCommit
-	entries, err := actions.ListWorkflows(runTargetCommit)
-	if err != nil {
-		ctx.Error(http.StatusInternalServerError, err.Error())
-		return
-	}
-
-	// find workflow from commit
-	var workflows []*jobparser.SingleWorkflow
-	for _, entry := range entries {
-		if entry.Name() == workflowID {
-			content, err := actions.GetContentFromEntry(entry)
-			if err != nil {
-				ctx.Error(http.StatusInternalServerError, err.Error())
-				return
-			}
-			workflows, err = jobparser.Parse(content)
-			if err != nil {
-				ctx.ServerError("workflow", err)
-				return
-			}
-			break
-		}
-	}
-
-	if len(workflows) == 0 {
-		ctx.Flash.Error(ctx.Tr("actions.workflow.not_found", workflowID))
-		ctx.Redirect(redirectURL)
-		return
-	}
-
-	// get inputs from post
-	workflow := &model.Workflow{
-		RawOn: workflows[0].RawOn,
-	}
-	inputs := make(map[string]any)
-	if workflowDispatch := workflow.WorkflowDispatchConfig(); workflowDispatch != nil {
-		for name, config := range workflowDispatch.Inputs {
-			value := ctx.Req.PostFormValue(name)
-			if config.Type == "boolean" {
-				// https://www.w3.org/TR/html401/interact/forms.html
-				// https://stackoverflow.com/questions/11424037/do-checkbox-inputs-only-post-data-if-theyre-checked
-				// Checkboxes (and radio buttons) are on/off switches that may be toggled by the user.
-				// A switch is "on" when the control element's checked attribute is set.
-				// When a form is submitted, only "on" checkbox controls can become successful.
-				inputs[name] = strconv.FormatBool(value == "on")
-			} else if value != "" {
-				inputs[name] = value
-			} else {
-				inputs[name] = config.Default
+	err := actions_service.DispatchActionWorkflow(ctx, workflowID, ref, func(workflowDispatch *model.WorkflowDispatch, inputs *map[string]any) error {
+		if workflowDispatch != nil {
+			for name, config := range workflowDispatch.Inputs {
+				value := ctx.Req.PostFormValue(name)
+				if config.Type == "boolean" {
+					// https://www.w3.org/TR/html401/interact/forms.html
+					// https://stackoverflow.com/questions/11424037/do-checkbox-inputs-only-post-data-if-theyre-checked
+					// Checkboxes (and radio buttons) are on/off switches that may be toggled by the user.
+					// A switch is "on" when the control element's checked attribute is set.
+					// When a form is submitted, only "on" checkbox controls can become successful.
+					(*inputs)[name] = strconv.FormatBool(value == "on")
+				} else if value != "" {
+					(*inputs)[name] = value
+				} else {
+					(*inputs)[name] = config.Default
+				}
 			}
 		}
-	}
-
-	// ctx.Req.PostForm -> WorkflowDispatchPayload.Inputs -> ActionRun.EventPayload -> runner: ghc.Event
-	// https://docs.github.com/en/actions/learn-github-actions/contexts#github-context
-	// https://docs.github.com/en/webhooks/webhook-events-and-payloads#workflow_dispatch
-	workflowDispatchPayload := &api.WorkflowDispatchPayload{
-		Workflow:   workflowID,
-		Ref:        ref,
-		Repository: convert.ToRepo(ctx, ctx.Repo.Repository, access_model.Permission{AccessMode: perm.AccessModeNone}),
-		Inputs:     inputs,
-		Sender:     convert.ToUserWithAccessMode(ctx, ctx.Doer, perm.AccessModeNone),
-	}
-	var eventPayload []byte
-	if eventPayload, err = workflowDispatchPayload.JSONPayload(); err != nil {
-		ctx.ServerError("JSONPayload", err)
-		return
-	}
-
-	run := &actions_model.ActionRun{
-		Title:             strings.SplitN(runTargetCommit.CommitMessage, "\n", 2)[0],
-		RepoID:            ctx.Repo.Repository.ID,
-		OwnerID:           ctx.Repo.Repository.OwnerID,
-		WorkflowID:        workflowID,
-		TriggerUserID:     ctx.Doer.ID,
-		Ref:               ref,
-		CommitSHA:         runTargetCommit.ID.String(),
-		IsForkPullRequest: false,
-		Event:             "workflow_dispatch",
-		TriggerEvent:      "workflow_dispatch",
-		EventPayload:      string(eventPayload),
-		Status:            actions_model.StatusWaiting,
-	}
-
-	// cancel running jobs of the same workflow
-	if err := actions_model.CancelPreviousJobs(
-		ctx,
-		run.RepoID,
-		run.Ref,
-		run.WorkflowID,
-		run.Event,
-	); err != nil {
-		log.Error("CancelRunningJobs: %v", err)
-	}
-
-	// Insert the action run and its associated jobs into the database
-	if err := actions_model.InsertRun(ctx, run, workflows); err != nil {
-		ctx.ServerError("workflow", err)
-		return
-	}
-
-	alljobs, err := db.Find[actions_model.ActionRunJob](ctx, actions_model.FindRunJobOptions{RunID: run.ID})
+		return nil
+	})
 	if err != nil {
-		log.Error("FindRunJobs: %v", err)
+		if terr, ok := err.(*actions_service.TranslateableError); ok {
+			ctx.Flash.Error(ctx.Tr(terr.Translation, terr.Args...))
+			ctx.Redirect(redirectURL)
+			return
+		}
+		ctx.ServerError(err.Error(), err)
+		return
 	}
-	actions_service.CreateCommitStatus(ctx, alljobs...)
 
 	ctx.Flash.Success(ctx.Tr("actions.workflow.run_success", workflowID))
 	ctx.Redirect(redirectURL)
diff --git a/services/actions/workflow.go b/services/actions/workflow.go
new file mode 100644
index 0000000000..0877e62ea1
--- /dev/null
+++ b/services/actions/workflow.go
@@ -0,0 +1,296 @@
+// Copyright 2024 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package actions
+
+import (
+	"fmt"
+	"net/http"
+	"path"
+	"strings"
+
+	actions_model "code.gitea.io/gitea/models/actions"
+	"code.gitea.io/gitea/models/db"
+	"code.gitea.io/gitea/models/perm"
+	access_model "code.gitea.io/gitea/models/perm/access"
+	repo_model "code.gitea.io/gitea/models/repo"
+	"code.gitea.io/gitea/models/unit"
+	"code.gitea.io/gitea/modules/actions"
+	"code.gitea.io/gitea/modules/git"
+	"code.gitea.io/gitea/modules/log"
+	api "code.gitea.io/gitea/modules/structs"
+	"code.gitea.io/gitea/services/context"
+	"code.gitea.io/gitea/services/convert"
+
+	"github.com/nektos/act/pkg/jobparser"
+	"github.com/nektos/act/pkg/model"
+)
+
+type TranslateableError struct {
+	Translation string
+	Args        []any
+	Code        int
+}
+
+func (t TranslateableError) Error() string {
+	return t.Translation
+}
+
+func (t TranslateableError) GetCode() int {
+	if t.Code == 0 {
+		return http.StatusInternalServerError
+	}
+	return t.Code
+}
+
+func getActionWorkflowPath(commit *git.Commit) string {
+	paths := []string{".gitea/workflows", ".github/workflows"}
+	for _, path := range paths {
+		if _, err := commit.SubTree(path); err == nil {
+			return path
+		}
+	}
+	return ""
+}
+
+func getActionWorkflowEntry(ctx *context.APIContext, commit *git.Commit, folder string, entry *git.TreeEntry) *api.ActionWorkflow {
+	cfgUnit := ctx.Repo.Repository.MustGetUnit(ctx, unit.TypeActions)
+	cfg := cfgUnit.ActionsConfig()
+
+	defaultBranch, _ := commit.GetBranchName()
+
+	URL := fmt.Sprintf("%s/actions/workflows/%s", ctx.Repo.Repository.APIURL(), entry.Name())
+	HTMLURL := fmt.Sprintf("%s/src/branch/%s/%s/%s", ctx.Repo.Repository.HTMLURL(ctx), defaultBranch, folder, entry.Name())
+	badgeURL := fmt.Sprintf("%s/actions/workflows/%s/badge.svg?branch=%s", ctx.Repo.Repository.HTMLURL(ctx), entry.Name(), ctx.Repo.Repository.DefaultBranch)
+
+	// See https://docs.github.com/en/rest/actions/workflows?apiVersion=2022-11-28#get-a-workflow
+	// State types:
+	// - active
+	// - deleted
+	// - disabled_fork
+	// - disabled_inactivity
+	// - disabled_manually
+	state := "active"
+	if cfg.IsWorkflowDisabled(entry.Name()) {
+		state = "disabled_manually"
+	}
+
+	// The CreatedAt and UpdatedAt fields currently reflect the timestamp of the latest commit, which can later be refined
+	// by retrieving the first and last commits for the file history. The first commit would indicate the creation date,
+	// while the last commit would represent the modification date. The DeletedAt could be determined by identifying
+	// the last commit where the file existed. However, this implementation has not been done here yet, as it would likely
+	// cause a significant performance degradation.
+	createdAt := commit.Author.When
+	updatedAt := commit.Author.When
+
+	return &api.ActionWorkflow{
+		ID:        entry.Name(),
+		Name:      entry.Name(),
+		Path:      path.Join(folder, entry.Name()),
+		State:     state,
+		CreatedAt: createdAt,
+		UpdatedAt: updatedAt,
+		URL:       URL,
+		HTMLURL:   HTMLURL,
+		BadgeURL:  badgeURL,
+	}
+}
+
+func disableOrEnableWorkflow(ctx *context.APIContext, workflowID string, isEnable bool) error {
+	workflow, err := GetActionWorkflow(ctx, workflowID)
+	if err != nil {
+		return err
+	}
+
+	cfgUnit := ctx.Repo.Repository.MustGetUnit(ctx, unit.TypeActions)
+	cfg := cfgUnit.ActionsConfig()
+
+	if isEnable {
+		cfg.EnableWorkflow(workflow.ID)
+	} else {
+		cfg.DisableWorkflow(workflow.ID)
+	}
+
+	return repo_model.UpdateRepoUnit(ctx, cfgUnit)
+}
+
+func ListActionWorkflows(ctx *context.APIContext) ([]*api.ActionWorkflow, error) {
+	defaultBranchCommit, err := ctx.Repo.GitRepo.GetBranchCommit(ctx.Repo.Repository.DefaultBranch)
+	if err != nil {
+		ctx.Error(http.StatusInternalServerError, "WorkflowDefaultBranchError", err.Error())
+		return nil, err
+	}
+
+	entries, err := actions.ListWorkflows(defaultBranchCommit)
+	if err != nil {
+		ctx.Error(http.StatusNotFound, "WorkflowListNotFound", err.Error())
+		return nil, err
+	}
+
+	folder := getActionWorkflowPath(defaultBranchCommit)
+
+	workflows := make([]*api.ActionWorkflow, len(entries))
+	for i, entry := range entries {
+		workflows[i] = getActionWorkflowEntry(ctx, defaultBranchCommit, folder, entry)
+	}
+
+	return workflows, nil
+}
+
+func GetActionWorkflow(ctx *context.APIContext, workflowID string) (*api.ActionWorkflow, error) {
+	entries, err := ListActionWorkflows(ctx)
+	if err != nil {
+		return nil, err
+	}
+
+	for _, entry := range entries {
+		if entry.Name == workflowID {
+			return entry, nil
+		}
+	}
+
+	return nil, fmt.Errorf("workflow '%s' not found", workflowID)
+}
+
+func DisableActionWorkflow(ctx *context.APIContext, workflowID string) error {
+	return disableOrEnableWorkflow(ctx, workflowID, false)
+}
+
+func DispatchActionWorkflow(ctx *context.Context, workflowID, ref string, processInputs func(model *model.WorkflowDispatch, inputs *map[string]any) error) error {
+	if len(workflowID) == 0 {
+		return fmt.Errorf("workflowID is empty")
+	}
+
+	if len(ref) == 0 {
+		return fmt.Errorf("ref is empty")
+	}
+
+	// can not rerun job when workflow is disabled
+	cfgUnit := ctx.Repo.Repository.MustGetUnit(ctx, unit.TypeActions)
+	cfg := cfgUnit.ActionsConfig()
+	if cfg.IsWorkflowDisabled(workflowID) {
+		return &TranslateableError{
+			Translation: "actions.workflow.disabled",
+		}
+	}
+
+	// get target commit of run from specified ref
+	refName := git.RefName(ref)
+	var runTargetCommit *git.Commit
+	var err error
+	if refName.IsTag() {
+		runTargetCommit, err = ctx.Repo.GitRepo.GetTagCommit(refName.TagName())
+	} else if refName.IsBranch() {
+		runTargetCommit, err = ctx.Repo.GitRepo.GetBranchCommit(refName.BranchName())
+	} else {
+		refName = git.RefNameFromBranch(ref)
+		runTargetCommit, err = ctx.Repo.GitRepo.GetBranchCommit(ref)
+	}
+	if err != nil {
+		return &TranslateableError{
+			Code:        http.StatusNotFound,
+			Translation: "form.target_ref_not_exist",
+			Args:        []any{ref},
+		}
+	}
+
+	// get workflow entry from runTargetCommit
+	entries, err := actions.ListWorkflows(runTargetCommit)
+	if err != nil {
+		return err
+	}
+
+	// find workflow from commit
+	var workflows []*jobparser.SingleWorkflow
+	for _, entry := range entries {
+		if entry.Name() != workflowID {
+			continue
+		}
+
+		content, err := actions.GetContentFromEntry(entry)
+		if err != nil {
+			return err
+		}
+		workflows, err = jobparser.Parse(content)
+		if err != nil {
+			return err
+		}
+		break
+	}
+
+	if len(workflows) == 0 {
+		return &TranslateableError{
+			Code:        http.StatusNotFound,
+			Translation: "actions.workflow.not_found",
+			Args:        []any{workflowID},
+		}
+	}
+
+	// get inputs from post
+	workflow := &model.Workflow{
+		RawOn: workflows[0].RawOn,
+	}
+	inputsWithDefaults := make(map[string]any)
+	workflowDispatch := workflow.WorkflowDispatchConfig()
+	if err := processInputs(workflowDispatch, &inputsWithDefaults); err != nil {
+		return err
+	}
+
+	// ctx.Req.PostForm -> WorkflowDispatchPayload.Inputs -> ActionRun.EventPayload -> runner: ghc.Event
+	// https://docs.github.com/en/actions/learn-github-actions/contexts#github-context
+	// https://docs.github.com/en/webhooks/webhook-events-and-payloads#workflow_dispatch
+	workflowDispatchPayload := &api.WorkflowDispatchPayload{
+		Workflow:   workflowID,
+		Ref:        ref,
+		Repository: convert.ToRepo(ctx, ctx.Repo.Repository, access_model.Permission{AccessMode: perm.AccessModeNone}),
+		Inputs:     inputsWithDefaults,
+		Sender:     convert.ToUserWithAccessMode(ctx, ctx.Doer, perm.AccessModeNone),
+	}
+	var eventPayload []byte
+	if eventPayload, err = workflowDispatchPayload.JSONPayload(); err != nil {
+		return fmt.Errorf("JSONPayload: %w", err)
+	}
+
+	run := &actions_model.ActionRun{
+		Title:             strings.SplitN(runTargetCommit.CommitMessage, "\n", 2)[0],
+		RepoID:            ctx.Repo.Repository.ID,
+		OwnerID:           ctx.Repo.Repository.OwnerID,
+		WorkflowID:        workflowID,
+		TriggerUserID:     ctx.Doer.ID,
+		Ref:               string(refName),
+		CommitSHA:         runTargetCommit.ID.String(),
+		IsForkPullRequest: false,
+		Event:             "workflow_dispatch",
+		TriggerEvent:      "workflow_dispatch",
+		EventPayload:      string(eventPayload),
+		Status:            actions_model.StatusWaiting,
+	}
+
+	// cancel running jobs of the same workflow
+	if err := actions_model.CancelPreviousJobs(
+		ctx,
+		run.RepoID,
+		run.Ref,
+		run.WorkflowID,
+		run.Event,
+	); err != nil {
+		log.Error("CancelRunningJobs: %v", err)
+	}
+
+	// Insert the action run and its associated jobs into the database
+	if err := actions_model.InsertRun(ctx, run, workflows); err != nil {
+		return fmt.Errorf("workflow: %w", err)
+	}
+
+	alljobs, err := db.Find[actions_model.ActionRunJob](ctx, actions_model.FindRunJobOptions{RunID: run.ID})
+	if err != nil {
+		log.Error("FindRunJobs: %v", err)
+	}
+	CreateCommitStatus(ctx, alljobs...)
+
+	return nil
+}
+
+func EnableActionWorkflow(ctx *context.APIContext, workflowID string) error {
+	return disableOrEnableWorkflow(ctx, workflowID, true)
+}
diff --git a/services/actions/workflow_interface.go b/services/actions/workflow_interface.go
new file mode 100644
index 0000000000..43fa92bdf8
--- /dev/null
+++ b/services/actions/workflow_interface.go
@@ -0,0 +1,20 @@
+// Copyright 2024 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package actions
+
+import "code.gitea.io/gitea/services/context"
+
+// WorkflowAPI for action workflow of a repository
+type WorkflowAPI interface {
+	// ListRepositoryWorkflows list repository workflows
+	ListRepositoryWorkflows(*context.APIContext)
+	// GetWorkflow get a workflow
+	GetWorkflow(*context.APIContext)
+	// DisableWorkflow disable a workflow
+	DisableWorkflow(*context.APIContext)
+	// DispatchWorkflow create a workflow dispatch event
+	DispatchWorkflow(*context.APIContext)
+	// EnableWorkflow enable a workflow
+	EnableWorkflow(*context.APIContext)
+}
diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl
index d22e01c787..3f80d3fd9e 100644
--- a/templates/swagger/v1_json.tmpl
+++ b/templates/swagger/v1_json.tmpl
@@ -4421,6 +4421,275 @@
         }
       }
     },
+    "/repos/{owner}/{repo}/actions/workflows": {
+      "get": {
+        "produces": [
+          "application/json"
+        ],
+        "tags": [
+          "repository"
+        ],
+        "summary": "List repository workflows",
+        "operationId": "ListRepositoryWorkflows",
+        "parameters": [
+          {
+            "type": "string",
+            "description": "owner of the repo",
+            "name": "owner",
+            "in": "path",
+            "required": true
+          },
+          {
+            "type": "string",
+            "description": "name of the repo",
+            "name": "repo",
+            "in": "path",
+            "required": true
+          }
+        ],
+        "responses": {
+          "200": {
+            "$ref": "#/responses/ActionWorkflowList"
+          },
+          "400": {
+            "$ref": "#/responses/error"
+          },
+          "403": {
+            "$ref": "#/responses/forbidden"
+          },
+          "404": {
+            "$ref": "#/responses/notFound"
+          },
+          "422": {
+            "$ref": "#/responses/validationError"
+          },
+          "500": {
+            "$ref": "#/responses/error"
+          }
+        }
+      }
+    },
+    "/repos/{owner}/{repo}/actions/workflows/{workflow_id}": {
+      "get": {
+        "produces": [
+          "application/json"
+        ],
+        "tags": [
+          "repository"
+        ],
+        "summary": "Get a workflow",
+        "operationId": "GetWorkflow",
+        "parameters": [
+          {
+            "type": "string",
+            "description": "owner of the repo",
+            "name": "owner",
+            "in": "path",
+            "required": true
+          },
+          {
+            "type": "string",
+            "description": "name of the repo",
+            "name": "repo",
+            "in": "path",
+            "required": true
+          },
+          {
+            "type": "string",
+            "description": "id of the workflow",
+            "name": "workflow_id",
+            "in": "path",
+            "required": true
+          }
+        ],
+        "responses": {
+          "200": {
+            "$ref": "#/responses/ActionWorkflow"
+          },
+          "400": {
+            "$ref": "#/responses/error"
+          },
+          "403": {
+            "$ref": "#/responses/forbidden"
+          },
+          "404": {
+            "$ref": "#/responses/notFound"
+          },
+          "422": {
+            "$ref": "#/responses/validationError"
+          },
+          "500": {
+            "$ref": "#/responses/error"
+          }
+        }
+      }
+    },
+    "/repos/{owner}/{repo}/actions/workflows/{workflow_id}/disable": {
+      "put": {
+        "produces": [
+          "application/json"
+        ],
+        "tags": [
+          "repository"
+        ],
+        "summary": "Disable a workflow",
+        "operationId": "DisableWorkflow",
+        "parameters": [
+          {
+            "type": "string",
+            "description": "owner of the repo",
+            "name": "owner",
+            "in": "path",
+            "required": true
+          },
+          {
+            "type": "string",
+            "description": "name of the repo",
+            "name": "repo",
+            "in": "path",
+            "required": true
+          },
+          {
+            "type": "string",
+            "description": "id of the workflow",
+            "name": "workflow_id",
+            "in": "path",
+            "required": true
+          }
+        ],
+        "responses": {
+          "204": {
+            "description": "No Content"
+          },
+          "400": {
+            "$ref": "#/responses/error"
+          },
+          "403": {
+            "$ref": "#/responses/forbidden"
+          },
+          "404": {
+            "$ref": "#/responses/notFound"
+          },
+          "422": {
+            "$ref": "#/responses/validationError"
+          }
+        }
+      }
+    },
+    "/repos/{owner}/{repo}/actions/workflows/{workflow_id}/dispatches": {
+      "post": {
+        "produces": [
+          "application/json"
+        ],
+        "tags": [
+          "repository"
+        ],
+        "summary": "Create a workflow dispatch event",
+        "operationId": "DispatchWorkflow",
+        "parameters": [
+          {
+            "type": "string",
+            "description": "owner of the repo",
+            "name": "owner",
+            "in": "path",
+            "required": true
+          },
+          {
+            "type": "string",
+            "description": "name of the repo",
+            "name": "repo",
+            "in": "path",
+            "required": true
+          },
+          {
+            "type": "string",
+            "description": "id of the workflow",
+            "name": "workflow_id",
+            "in": "path",
+            "required": true
+          },
+          {
+            "name": "body",
+            "in": "body",
+            "schema": {
+              "$ref": "#/definitions/CreateActionWorkflowDispatch"
+            }
+          }
+        ],
+        "responses": {
+          "204": {
+            "description": "No Content"
+          },
+          "400": {
+            "$ref": "#/responses/error"
+          },
+          "403": {
+            "$ref": "#/responses/forbidden"
+          },
+          "404": {
+            "$ref": "#/responses/notFound"
+          },
+          "422": {
+            "$ref": "#/responses/validationError"
+          }
+        }
+      }
+    },
+    "/repos/{owner}/{repo}/actions/workflows/{workflow_id}/enable": {
+      "put": {
+        "produces": [
+          "application/json"
+        ],
+        "tags": [
+          "repository"
+        ],
+        "summary": "Enable a workflow",
+        "operationId": "EnableWorkflow",
+        "parameters": [
+          {
+            "type": "string",
+            "description": "owner of the repo",
+            "name": "owner",
+            "in": "path",
+            "required": true
+          },
+          {
+            "type": "string",
+            "description": "name of the repo",
+            "name": "repo",
+            "in": "path",
+            "required": true
+          },
+          {
+            "type": "string",
+            "description": "id of the workflow",
+            "name": "workflow_id",
+            "in": "path",
+            "required": true
+          }
+        ],
+        "responses": {
+          "204": {
+            "description": "No Content"
+          },
+          "400": {
+            "$ref": "#/responses/error"
+          },
+          "403": {
+            "$ref": "#/responses/forbidden"
+          },
+          "404": {
+            "$ref": "#/responses/notFound"
+          },
+          "409": {
+            "$ref": "#/responses/conflict"
+          },
+          "422": {
+            "$ref": "#/responses/validationError"
+          }
+        }
+      }
+    },
     "/repos/{owner}/{repo}/activities/feeds": {
       "get": {
         "produces": [
@@ -18680,6 +18949,56 @@
       },
       "x-go-package": "code.gitea.io/gitea/modules/structs"
     },
+    "ActionWorkflow": {
+      "description": "ActionWorkflow represents a ActionWorkflow",
+      "type": "object",
+      "properties": {
+        "badge_url": {
+          "type": "string",
+          "x-go-name": "BadgeURL"
+        },
+        "created_at": {
+          "type": "string",
+          "format": "date-time",
+          "x-go-name": "CreatedAt"
+        },
+        "deleted_at": {
+          "type": "string",
+          "format": "date-time",
+          "x-go-name": "DeletedAt"
+        },
+        "html_url": {
+          "type": "string",
+          "x-go-name": "HTMLURL"
+        },
+        "id": {
+          "type": "string",
+          "x-go-name": "ID"
+        },
+        "name": {
+          "type": "string",
+          "x-go-name": "Name"
+        },
+        "path": {
+          "type": "string",
+          "x-go-name": "Path"
+        },
+        "state": {
+          "type": "string",
+          "x-go-name": "State"
+        },
+        "updated_at": {
+          "type": "string",
+          "format": "date-time",
+          "x-go-name": "UpdatedAt"
+        },
+        "url": {
+          "type": "string",
+          "x-go-name": "URL"
+        }
+      },
+      "x-go-package": "code.gitea.io/gitea/modules/structs"
+    },
     "Activity": {
       "type": "object",
       "properties": {
@@ -19688,6 +20007,26 @@
       },
       "x-go-package": "code.gitea.io/gitea/modules/structs"
     },
+    "CreateActionWorkflowDispatch": {
+      "description": "CreateActionWorkflowDispatch represents the payload for triggering a workflow dispatch event",
+      "type": "object",
+      "required": [
+        "ref"
+      ],
+      "properties": {
+        "inputs": {
+          "type": "object",
+          "additionalProperties": {},
+          "x-go-name": "Inputs"
+        },
+        "ref": {
+          "type": "string",
+          "x-go-name": "Ref",
+          "example": "refs/heads/main"
+        }
+      },
+      "x-go-package": "code.gitea.io/gitea/modules/structs"
+    },
     "CreateBranchProtectionOption": {
       "description": "CreateBranchProtectionOption options for creating a branch protection",
       "type": "object",
@@ -25687,6 +26026,21 @@
         "$ref": "#/definitions/ActionVariable"
       }
     },
+    "ActionWorkflow": {
+      "description": "ActionWorkflow",
+      "schema": {
+        "$ref": "#/definitions/ActionWorkflow"
+      }
+    },
+    "ActionWorkflowList": {
+      "description": "ActionWorkflowList",
+      "schema": {
+        "type": "array",
+        "items": {
+          "$ref": "#/definitions/ActionWorkflow"
+        }
+      }
+    },
     "ActivityFeedsList": {
       "description": "ActivityFeedsList",
       "schema": {
diff --git a/tests/integration/actions_trigger_test.go b/tests/integration/actions_trigger_test.go
index 8ea9b34efe..e2c97662f2 100644
--- a/tests/integration/actions_trigger_test.go
+++ b/tests/integration/actions_trigger_test.go
@@ -5,6 +5,7 @@ package integration
 
 import (
 	"fmt"
+	"net/http"
 	"net/url"
 	"strings"
 	"testing"
@@ -22,6 +23,7 @@ import (
 	actions_module "code.gitea.io/gitea/modules/actions"
 	"code.gitea.io/gitea/modules/git"
 	"code.gitea.io/gitea/modules/gitrepo"
+	"code.gitea.io/gitea/modules/json"
 	"code.gitea.io/gitea/modules/setting"
 	api "code.gitea.io/gitea/modules/structs"
 	"code.gitea.io/gitea/modules/test"
@@ -651,3 +653,625 @@ func insertFakeStatus(t *testing.T, repo *repo_model.Repository, sha, targetURL,
 	})
 	assert.NoError(t, err)
 }
+
+func TestWorkflowDispatchPublicApi(t *testing.T) {
+	onGiteaRun(t, func(t *testing.T, u *url.URL) {
+		user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+		session := loginUser(t, user2.Name)
+		token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
+
+		// create the repo
+		repo, err := repo_service.CreateRepository(db.DefaultContext, user2, user2, repo_service.CreateRepoOptions{
+			Name:          "workflow-dispatch-event",
+			Description:   "test workflow-dispatch ci event",
+			AutoInit:      true,
+			Gitignores:    "Go",
+			License:       "MIT",
+			Readme:        "Default",
+			DefaultBranch: "main",
+			IsPrivate:     false,
+		})
+		assert.NoError(t, err)
+		assert.NotEmpty(t, repo)
+
+		// add workflow file to the repo
+		addWorkflowToBaseResp, err := files_service.ChangeRepoFiles(git.DefaultContext, repo, user2, &files_service.ChangeRepoFilesOptions{
+			Files: []*files_service.ChangeRepoFile{
+				{
+					Operation:     "create",
+					TreePath:      ".gitea/workflows/dispatch.yml",
+					ContentReader: strings.NewReader("name: test\non:\n  workflow_dispatch\njobs:\n  test:\n    runs-on: ubuntu-latest\n    steps:\n      - run: echo helloworld\n"),
+				},
+			},
+			Message:   "add workflow",
+			OldBranch: "main",
+			NewBranch: "main",
+			Author: &files_service.IdentityOptions{
+				GitUserName:  user2.Name,
+				GitUserEmail: user2.Email,
+			},
+			Committer: &files_service.IdentityOptions{
+				GitUserName:  user2.Name,
+				GitUserEmail: user2.Email,
+			},
+			Dates: &files_service.CommitDateOptions{
+				Author:    time.Now(),
+				Committer: time.Now(),
+			},
+		})
+		assert.NoError(t, err)
+		assert.NotEmpty(t, addWorkflowToBaseResp)
+
+		// Get the commit ID of the default branch
+		gitRepo, err := gitrepo.OpenRepository(git.DefaultContext, repo)
+		assert.NoError(t, err)
+		defer gitRepo.Close()
+		branch, err := git_model.GetBranch(db.DefaultContext, repo.ID, repo.DefaultBranch)
+		assert.NoError(t, err)
+		values := url.Values{}
+		values.Set("ref", "main")
+		req := NewRequestWithURLValues(t, "POST", fmt.Sprintf("/api/v1/repos/%s/actions/workflows/dispatch.yml/dispatches", repo.FullName()), values).
+			AddTokenAuth(token)
+		_ = MakeRequest(t, req, http.StatusNoContent)
+
+		run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{
+			Title:      "add workflow",
+			RepoID:     repo.ID,
+			Event:      "workflow_dispatch",
+			Ref:        "refs/heads/main",
+			WorkflowID: "dispatch.yml",
+			CommitSHA:  branch.CommitID,
+		})
+		assert.NotNil(t, run)
+	})
+}
+
+func TestWorkflowDispatchPublicApiWithInputs(t *testing.T) {
+	onGiteaRun(t, func(t *testing.T, u *url.URL) {
+		user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+		session := loginUser(t, user2.Name)
+		token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
+
+		// create the repo
+		repo, err := repo_service.CreateRepository(db.DefaultContext, user2, user2, repo_service.CreateRepoOptions{
+			Name:          "workflow-dispatch-event",
+			Description:   "test workflow-dispatch ci event",
+			AutoInit:      true,
+			Gitignores:    "Go",
+			License:       "MIT",
+			Readme:        "Default",
+			DefaultBranch: "main",
+			IsPrivate:     false,
+		})
+		assert.NoError(t, err)
+		assert.NotEmpty(t, repo)
+
+		// add workflow file to the repo
+		addWorkflowToBaseResp, err := files_service.ChangeRepoFiles(git.DefaultContext, repo, user2, &files_service.ChangeRepoFilesOptions{
+			Files: []*files_service.ChangeRepoFile{
+				{
+					Operation:     "create",
+					TreePath:      ".gitea/workflows/dispatch.yml",
+					ContentReader: strings.NewReader("name: test\non:\n  workflow_dispatch: { inputs: { myinput: { default: def }, myinput2: { default: def2 }, myinput3: { type: boolean, default: false } } }\njobs:\n  test:\n    runs-on: ubuntu-latest\n    steps:\n      - run: echo helloworld\n"),
+				},
+			},
+			Message:   "add workflow",
+			OldBranch: "main",
+			NewBranch: "main",
+			Author: &files_service.IdentityOptions{
+				GitUserName:  user2.Name,
+				GitUserEmail: user2.Email,
+			},
+			Committer: &files_service.IdentityOptions{
+				GitUserName:  user2.Name,
+				GitUserEmail: user2.Email,
+			},
+			Dates: &files_service.CommitDateOptions{
+				Author:    time.Now(),
+				Committer: time.Now(),
+			},
+		})
+		assert.NoError(t, err)
+		assert.NotEmpty(t, addWorkflowToBaseResp)
+
+		// Get the commit ID of the default branch
+		gitRepo, err := gitrepo.OpenRepository(git.DefaultContext, repo)
+		assert.NoError(t, err)
+		defer gitRepo.Close()
+		branch, err := git_model.GetBranch(db.DefaultContext, repo.ID, repo.DefaultBranch)
+		assert.NoError(t, err)
+		values := url.Values{}
+		values.Set("ref", "main")
+		values.Set("inputs[myinput]", "val0")
+		values.Set("inputs[myinput3]", "true")
+		req := NewRequestWithURLValues(t, "POST", fmt.Sprintf("/api/v1/repos/%s/actions/workflows/dispatch.yml/dispatches", repo.FullName()), values).
+			AddTokenAuth(token)
+		_ = MakeRequest(t, req, http.StatusNoContent)
+
+		run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{
+			Title:      "add workflow",
+			RepoID:     repo.ID,
+			Event:      "workflow_dispatch",
+			Ref:        "refs/heads/main",
+			WorkflowID: "dispatch.yml",
+			CommitSHA:  branch.CommitID,
+		})
+		assert.NotNil(t, run)
+		dispatchPayload := &api.WorkflowDispatchPayload{}
+		err = json.Unmarshal([]byte(run.EventPayload), dispatchPayload)
+		assert.NoError(t, err)
+		assert.Contains(t, dispatchPayload.Inputs, "myinput")
+		assert.Contains(t, dispatchPayload.Inputs, "myinput2")
+		assert.Contains(t, dispatchPayload.Inputs, "myinput3")
+		assert.Equal(t, "val0", dispatchPayload.Inputs["myinput"])
+		assert.Equal(t, "def2", dispatchPayload.Inputs["myinput2"])
+		assert.Equal(t, "true", dispatchPayload.Inputs["myinput3"])
+	})
+}
+
+func TestWorkflowDispatchPublicApiJSON(t *testing.T) {
+	onGiteaRun(t, func(t *testing.T, u *url.URL) {
+		user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+		session := loginUser(t, user2.Name)
+		token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
+
+		// create the repo
+		repo, err := repo_service.CreateRepository(db.DefaultContext, user2, user2, repo_service.CreateRepoOptions{
+			Name:          "workflow-dispatch-event",
+			Description:   "test workflow-dispatch ci event",
+			AutoInit:      true,
+			Gitignores:    "Go",
+			License:       "MIT",
+			Readme:        "Default",
+			DefaultBranch: "main",
+			IsPrivate:     false,
+		})
+		assert.NoError(t, err)
+		assert.NotEmpty(t, repo)
+
+		// add workflow file to the repo
+		addWorkflowToBaseResp, err := files_service.ChangeRepoFiles(git.DefaultContext, repo, user2, &files_service.ChangeRepoFilesOptions{
+			Files: []*files_service.ChangeRepoFile{
+				{
+					Operation:     "create",
+					TreePath:      ".gitea/workflows/dispatch.yml",
+					ContentReader: strings.NewReader("name: test\non:\n  workflow_dispatch: { inputs: { myinput: { default: def }, myinput2: { default: def2 }, myinput3: { type: boolean, default: false } } }\njobs:\n  test:\n    runs-on: ubuntu-latest\n    steps:\n      - run: echo helloworld\n"),
+				},
+			},
+			Message:   "add workflow",
+			OldBranch: "main",
+			NewBranch: "main",
+			Author: &files_service.IdentityOptions{
+				GitUserName:  user2.Name,
+				GitUserEmail: user2.Email,
+			},
+			Committer: &files_service.IdentityOptions{
+				GitUserName:  user2.Name,
+				GitUserEmail: user2.Email,
+			},
+			Dates: &files_service.CommitDateOptions{
+				Author:    time.Now(),
+				Committer: time.Now(),
+			},
+		})
+		assert.NoError(t, err)
+		assert.NotEmpty(t, addWorkflowToBaseResp)
+
+		// Get the commit ID of the default branch
+		gitRepo, err := gitrepo.OpenRepository(git.DefaultContext, repo)
+		assert.NoError(t, err)
+		defer gitRepo.Close()
+		branch, err := git_model.GetBranch(db.DefaultContext, repo.ID, repo.DefaultBranch)
+		assert.NoError(t, err)
+		inputs := &api.CreateActionWorkflowDispatch{
+			Ref: "main",
+			Inputs: map[string]any{
+				"myinput":  "val0",
+				"myinput3": "true",
+			},
+		}
+
+		req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/actions/workflows/dispatch.yml/dispatches", repo.FullName()), inputs).
+			AddTokenAuth(token)
+		_ = MakeRequest(t, req, http.StatusNoContent)
+
+		run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{
+			Title:      "add workflow",
+			RepoID:     repo.ID,
+			Event:      "workflow_dispatch",
+			Ref:        "refs/heads/main",
+			WorkflowID: "dispatch.yml",
+			CommitSHA:  branch.CommitID,
+		})
+		assert.NotNil(t, run)
+	})
+}
+
+func TestWorkflowDispatchPublicApiWithInputsJSON(t *testing.T) {
+	onGiteaRun(t, func(t *testing.T, u *url.URL) {
+		user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+		session := loginUser(t, user2.Name)
+		token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
+
+		// create the repo
+		repo, err := repo_service.CreateRepository(db.DefaultContext, user2, user2, repo_service.CreateRepoOptions{
+			Name:          "workflow-dispatch-event",
+			Description:   "test workflow-dispatch ci event",
+			AutoInit:      true,
+			Gitignores:    "Go",
+			License:       "MIT",
+			Readme:        "Default",
+			DefaultBranch: "main",
+			IsPrivate:     false,
+		})
+		assert.NoError(t, err)
+		assert.NotEmpty(t, repo)
+
+		// add workflow file to the repo
+		addWorkflowToBaseResp, err := files_service.ChangeRepoFiles(git.DefaultContext, repo, user2, &files_service.ChangeRepoFilesOptions{
+			Files: []*files_service.ChangeRepoFile{
+				{
+					Operation:     "create",
+					TreePath:      ".gitea/workflows/dispatch.yml",
+					ContentReader: strings.NewReader("name: test\non:\n  workflow_dispatch: { inputs: { myinput: { default: def }, myinput2: { default: def2 }, myinput3: { type: boolean, default: false } } }\njobs:\n  test:\n    runs-on: ubuntu-latest\n    steps:\n      - run: echo helloworld\n"),
+				},
+			},
+			Message:   "add workflow",
+			OldBranch: "main",
+			NewBranch: "main",
+			Author: &files_service.IdentityOptions{
+				GitUserName:  user2.Name,
+				GitUserEmail: user2.Email,
+			},
+			Committer: &files_service.IdentityOptions{
+				GitUserName:  user2.Name,
+				GitUserEmail: user2.Email,
+			},
+			Dates: &files_service.CommitDateOptions{
+				Author:    time.Now(),
+				Committer: time.Now(),
+			},
+		})
+		assert.NoError(t, err)
+		assert.NotEmpty(t, addWorkflowToBaseResp)
+
+		// Get the commit ID of the default branch
+		gitRepo, err := gitrepo.OpenRepository(git.DefaultContext, repo)
+		assert.NoError(t, err)
+		defer gitRepo.Close()
+		branch, err := git_model.GetBranch(db.DefaultContext, repo.ID, repo.DefaultBranch)
+		assert.NoError(t, err)
+		inputs := &api.CreateActionWorkflowDispatch{
+			Ref: "main",
+			Inputs: map[string]any{
+				"myinput":  "val0",
+				"myinput3": "true",
+			},
+		}
+		req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/actions/workflows/dispatch.yml/dispatches", repo.FullName()), inputs).
+			AddTokenAuth(token)
+		_ = MakeRequest(t, req, http.StatusNoContent)
+
+		run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{
+			Title:      "add workflow",
+			RepoID:     repo.ID,
+			Event:      "workflow_dispatch",
+			Ref:        "refs/heads/main",
+			WorkflowID: "dispatch.yml",
+			CommitSHA:  branch.CommitID,
+		})
+		assert.NotNil(t, run)
+		dispatchPayload := &api.WorkflowDispatchPayload{}
+		err = json.Unmarshal([]byte(run.EventPayload), dispatchPayload)
+		assert.NoError(t, err)
+		assert.Contains(t, dispatchPayload.Inputs, "myinput")
+		assert.Contains(t, dispatchPayload.Inputs, "myinput2")
+		assert.Contains(t, dispatchPayload.Inputs, "myinput3")
+		assert.Equal(t, "val0", dispatchPayload.Inputs["myinput"])
+		assert.Equal(t, "def2", dispatchPayload.Inputs["myinput2"])
+		assert.Equal(t, "true", dispatchPayload.Inputs["myinput3"])
+	})
+}
+
+func TestWorkflowDispatchPublicApiWithInputsNonDefaultBranchJSON(t *testing.T) {
+	onGiteaRun(t, func(t *testing.T, u *url.URL) {
+		user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+		session := loginUser(t, user2.Name)
+		token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
+
+		// create the repo
+		repo, err := repo_service.CreateRepository(db.DefaultContext, user2, user2, repo_service.CreateRepoOptions{
+			Name:          "workflow-dispatch-event",
+			Description:   "test workflow-dispatch ci event",
+			AutoInit:      true,
+			Gitignores:    "Go",
+			License:       "MIT",
+			Readme:        "Default",
+			DefaultBranch: "main",
+			IsPrivate:     false,
+		})
+		assert.NoError(t, err)
+		assert.NotEmpty(t, repo)
+
+		// add workflow file to the repo
+		addWorkflowToBaseResp, err := files_service.ChangeRepoFiles(git.DefaultContext, repo, user2, &files_service.ChangeRepoFilesOptions{
+			Files: []*files_service.ChangeRepoFile{
+				{
+					Operation:     "create",
+					TreePath:      ".gitea/workflows/dispatch.yml",
+					ContentReader: strings.NewReader("name: test\non:\n  workflow_dispatch\njobs:\n  test:\n    runs-on: ubuntu-latest\n    steps:\n      - run: echo helloworld\n"),
+				},
+			},
+			Message:   "add workflow",
+			OldBranch: "main",
+			NewBranch: "main",
+			Author: &files_service.IdentityOptions{
+				GitUserName:  user2.Name,
+				GitUserEmail: user2.Email,
+			},
+			Committer: &files_service.IdentityOptions{
+				GitUserName:  user2.Name,
+				GitUserEmail: user2.Email,
+			},
+			Dates: &files_service.CommitDateOptions{
+				Author:    time.Now(),
+				Committer: time.Now(),
+			},
+		})
+		assert.NoError(t, err)
+		assert.NotEmpty(t, addWorkflowToBaseResp)
+
+		// add workflow file to the repo
+		addWorkflowToBaseResp, err = files_service.ChangeRepoFiles(git.DefaultContext, repo, user2, &files_service.ChangeRepoFilesOptions{
+			Files: []*files_service.ChangeRepoFile{
+				{
+					Operation:     "update",
+					TreePath:      ".gitea/workflows/dispatch.yml",
+					ContentReader: strings.NewReader("name: test\non:\n  workflow_dispatch: { inputs: { myinput: { default: def }, myinput2: { default: def2 }, myinput3: { type: boolean, default: false } } }\njobs:\n  test:\n    runs-on: ubuntu-latest\n    steps:\n      - run: echo helloworld\n"),
+				},
+			},
+			Message:   "add workflow",
+			OldBranch: "main",
+			NewBranch: "dispatch",
+			Author: &files_service.IdentityOptions{
+				GitUserName:  user2.Name,
+				GitUserEmail: user2.Email,
+			},
+			Committer: &files_service.IdentityOptions{
+				GitUserName:  user2.Name,
+				GitUserEmail: user2.Email,
+			},
+			Dates: &files_service.CommitDateOptions{
+				Author:    time.Now(),
+				Committer: time.Now(),
+			},
+		})
+		assert.NoError(t, err)
+		assert.NotEmpty(t, addWorkflowToBaseResp)
+
+		// Get the commit ID of the dispatch branch
+		gitRepo, err := gitrepo.OpenRepository(git.DefaultContext, repo)
+		assert.NoError(t, err)
+		defer gitRepo.Close()
+		commit, err := gitRepo.GetBranchCommit("dispatch")
+		assert.NoError(t, err)
+		inputs := &api.CreateActionWorkflowDispatch{
+			Ref: "refs/heads/dispatch",
+			Inputs: map[string]any{
+				"myinput":  "val0",
+				"myinput3": "true",
+			},
+		}
+		req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/actions/workflows/dispatch.yml/dispatches", repo.FullName()), inputs).
+			AddTokenAuth(token)
+		_ = MakeRequest(t, req, http.StatusNoContent)
+
+		run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{
+			Title:      "add workflow",
+			RepoID:     repo.ID,
+			Event:      "workflow_dispatch",
+			Ref:        "refs/heads/dispatch",
+			WorkflowID: "dispatch.yml",
+			CommitSHA:  commit.ID.String(),
+		})
+		assert.NotNil(t, run)
+		dispatchPayload := &api.WorkflowDispatchPayload{}
+		err = json.Unmarshal([]byte(run.EventPayload), dispatchPayload)
+		assert.NoError(t, err)
+		assert.Contains(t, dispatchPayload.Inputs, "myinput")
+		assert.Contains(t, dispatchPayload.Inputs, "myinput2")
+		assert.Contains(t, dispatchPayload.Inputs, "myinput3")
+		assert.Equal(t, "val0", dispatchPayload.Inputs["myinput"])
+		assert.Equal(t, "def2", dispatchPayload.Inputs["myinput2"])
+		assert.Equal(t, "true", dispatchPayload.Inputs["myinput3"])
+	})
+}
+
+func TestWorkflowApi(t *testing.T) {
+	onGiteaRun(t, func(t *testing.T, u *url.URL) {
+		user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+		session := loginUser(t, user2.Name)
+		token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
+
+		// create the repo
+		repo, err := repo_service.CreateRepository(db.DefaultContext, user2, user2, repo_service.CreateRepoOptions{
+			Name:          "workflow-api",
+			Description:   "test workflow apis",
+			AutoInit:      true,
+			Gitignores:    "Go",
+			License:       "MIT",
+			Readme:        "Default",
+			DefaultBranch: "main",
+			IsPrivate:     false,
+		})
+		assert.NoError(t, err)
+		assert.NotEmpty(t, repo)
+
+		req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/actions/workflows", repo.FullName())).
+			AddTokenAuth(token)
+		resp := MakeRequest(t, req, http.StatusOK)
+		workflows := &api.ActionWorkflowResponse{}
+		json.NewDecoder(resp.Body).Decode(workflows)
+		assert.Empty(t, workflows.Workflows)
+
+		// add workflow file to the repo
+		addWorkflowToBaseResp, err := files_service.ChangeRepoFiles(git.DefaultContext, repo, user2, &files_service.ChangeRepoFilesOptions{
+			Files: []*files_service.ChangeRepoFile{
+				{
+					Operation:     "create",
+					TreePath:      ".gitea/workflows/dispatch.yml",
+					ContentReader: strings.NewReader("name: test\non:\n  workflow_dispatch: { inputs: { myinput: { default: def }, myinput2: { default: def2 }, myinput3: { type: boolean, default: false } } }\njobs:\n  test:\n    runs-on: ubuntu-latest\n    steps:\n      - run: echo helloworld\n"),
+				},
+			},
+			Message:   "add workflow",
+			OldBranch: "main",
+			NewBranch: "main",
+			Author: &files_service.IdentityOptions{
+				GitUserName:  user2.Name,
+				GitUserEmail: user2.Email,
+			},
+			Committer: &files_service.IdentityOptions{
+				GitUserName:  user2.Name,
+				GitUserEmail: user2.Email,
+			},
+			Dates: &files_service.CommitDateOptions{
+				Author:    time.Now(),
+				Committer: time.Now(),
+			},
+		})
+		assert.NoError(t, err)
+		assert.NotEmpty(t, addWorkflowToBaseResp)
+
+		req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/actions/workflows", repo.FullName())).
+			AddTokenAuth(token)
+		resp = MakeRequest(t, req, http.StatusOK)
+		json.NewDecoder(resp.Body).Decode(workflows)
+		assert.Len(t, workflows.Workflows, 1)
+		assert.Equal(t, "dispatch.yml", workflows.Workflows[0].Name)
+		assert.Equal(t, ".gitea/workflows/dispatch.yml", workflows.Workflows[0].Path)
+		assert.Equal(t, ".gitea/workflows/dispatch.yml", workflows.Workflows[0].Path)
+		assert.Equal(t, "active", workflows.Workflows[0].State)
+
+		// Use a hardcoded api path
+		req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/actions/workflows/%s", repo.FullName(), workflows.Workflows[0].ID)).
+			AddTokenAuth(token)
+		resp = MakeRequest(t, req, http.StatusOK)
+		workflow := &api.ActionWorkflow{}
+		json.NewDecoder(resp.Body).Decode(workflow)
+		assert.Equal(t, workflows.Workflows[0].ID, workflow.ID)
+		assert.Equal(t, workflows.Workflows[0].Path, workflow.Path)
+		assert.Equal(t, workflows.Workflows[0].URL, workflow.URL)
+		assert.Equal(t, workflows.Workflows[0].HTMLURL, workflow.HTMLURL)
+		assert.Equal(t, workflows.Workflows[0].Name, workflow.Name)
+		assert.Equal(t, workflows.Workflows[0].State, workflow.State)
+
+		// Use the provided url instead of the hardcoded one
+		req = NewRequest(t, "GET", workflows.Workflows[0].URL).
+			AddTokenAuth(token)
+		resp = MakeRequest(t, req, http.StatusOK)
+		workflow = &api.ActionWorkflow{}
+		json.NewDecoder(resp.Body).Decode(workflow)
+		assert.Equal(t, workflows.Workflows[0].ID, workflow.ID)
+		assert.Equal(t, workflows.Workflows[0].Path, workflow.Path)
+		assert.Equal(t, workflows.Workflows[0].URL, workflow.URL)
+		assert.Equal(t, workflows.Workflows[0].HTMLURL, workflow.HTMLURL)
+		assert.Equal(t, workflows.Workflows[0].Name, workflow.Name)
+		assert.Equal(t, workflows.Workflows[0].State, workflow.State)
+
+		// Disable the workflow
+		req = NewRequest(t, "PUT", workflows.Workflows[0].URL+"/disable").
+			AddTokenAuth(token)
+		_ = MakeRequest(t, req, http.StatusNoContent)
+
+		// Use the provided url instead of the hardcoded one
+		req = NewRequest(t, "GET", workflows.Workflows[0].URL).
+			AddTokenAuth(token)
+		resp = MakeRequest(t, req, http.StatusOK)
+		workflow = &api.ActionWorkflow{}
+		json.NewDecoder(resp.Body).Decode(workflow)
+		assert.Equal(t, workflows.Workflows[0].ID, workflow.ID)
+		assert.Equal(t, workflows.Workflows[0].Path, workflow.Path)
+		assert.Equal(t, workflows.Workflows[0].URL, workflow.URL)
+		assert.Equal(t, workflows.Workflows[0].HTMLURL, workflow.HTMLURL)
+		assert.Equal(t, workflows.Workflows[0].Name, workflow.Name)
+		assert.Equal(t, "disabled_manually", workflow.State)
+
+		inputs := &api.CreateActionWorkflowDispatch{
+			Ref: "main",
+			Inputs: map[string]any{
+				"myinput":  "val0",
+				"myinput3": "true",
+			},
+		}
+		req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/actions/workflows/dispatch.yml/dispatches", repo.FullName()), inputs).
+			AddTokenAuth(token)
+		// TODO which http code is expected here?
+		_ = MakeRequest(t, req, http.StatusInternalServerError)
+
+		// Enable the workflow again
+		req = NewRequest(t, "PUT", workflows.Workflows[0].URL+"/enable").
+			AddTokenAuth(token)
+		_ = MakeRequest(t, req, http.StatusNoContent)
+
+		// Use the provided url instead of the hardcoded one
+		req = NewRequest(t, "GET", workflows.Workflows[0].URL).
+			AddTokenAuth(token)
+		resp = MakeRequest(t, req, http.StatusOK)
+		workflow = &api.ActionWorkflow{}
+		json.NewDecoder(resp.Body).Decode(workflow)
+		assert.Equal(t, workflows.Workflows[0].ID, workflow.ID)
+		assert.Equal(t, workflows.Workflows[0].Path, workflow.Path)
+		assert.Equal(t, workflows.Workflows[0].URL, workflow.URL)
+		assert.Equal(t, workflows.Workflows[0].HTMLURL, workflow.HTMLURL)
+		assert.Equal(t, workflows.Workflows[0].Name, workflow.Name)
+		assert.Equal(t, workflows.Workflows[0].State, workflow.State)
+
+		req = NewRequest(t, "GET", workflows.Workflows[0].URL).
+			AddTokenAuth(token)
+		resp = MakeRequest(t, req, http.StatusOK)
+		workflow = &api.ActionWorkflow{}
+		json.NewDecoder(resp.Body).Decode(workflow)
+		assert.Equal(t, workflows.Workflows[0].ID, workflow.ID)
+		assert.Equal(t, workflows.Workflows[0].Path, workflow.Path)
+		assert.Equal(t, workflows.Workflows[0].URL, workflow.URL)
+		assert.Equal(t, workflows.Workflows[0].HTMLURL, workflow.HTMLURL)
+		assert.Equal(t, workflows.Workflows[0].Name, workflow.Name)
+		assert.Equal(t, workflows.Workflows[0].State, workflow.State)
+
+		// Get the commit ID of the default branch
+		gitRepo, err := gitrepo.OpenRepository(git.DefaultContext, repo)
+		assert.NoError(t, err)
+		defer gitRepo.Close()
+		branch, err := git_model.GetBranch(db.DefaultContext, repo.ID, repo.DefaultBranch)
+		assert.NoError(t, err)
+		inputs = &api.CreateActionWorkflowDispatch{
+			Ref: "main",
+			Inputs: map[string]any{
+				"myinput":  "val0",
+				"myinput3": "true",
+			},
+		}
+		req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/actions/workflows/dispatch.yml/dispatches", repo.FullName()), inputs).
+			AddTokenAuth(token)
+		_ = MakeRequest(t, req, http.StatusNoContent)
+
+		run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{
+			Title:      "add workflow",
+			RepoID:     repo.ID,
+			Event:      "workflow_dispatch",
+			Ref:        "refs/heads/main",
+			WorkflowID: "dispatch.yml",
+			CommitSHA:  branch.CommitID,
+		})
+		assert.NotNil(t, run)
+		dispatchPayload := &api.WorkflowDispatchPayload{}
+		err = json.Unmarshal([]byte(run.EventPayload), dispatchPayload)
+		assert.NoError(t, err)
+		assert.Contains(t, dispatchPayload.Inputs, "myinput")
+		assert.Contains(t, dispatchPayload.Inputs, "myinput2")
+		assert.Contains(t, dispatchPayload.Inputs, "myinput3")
+		assert.Equal(t, "val0", dispatchPayload.Inputs["myinput"])
+		assert.Equal(t, "def2", dispatchPayload.Inputs["myinput2"])
+		assert.Equal(t, "true", dispatchPayload.Inputs["myinput3"])
+	})
+}