mirror of
				https://github.com/go-gitea/gitea.git
				synced 2025-10-31 02:46:04 +01:00 
			
		
		
		
	Actions support workflow dispatch event (#28163)
fix #23668 My plan: * In the `actions.list` method, if workflow is selected and IsAdmin, check whether the on event contains `workflow_dispatch`. If so, display a `Run workflow` button to allow the user to manually trigger the run. * Providing a form that allows users to select target brach or tag, and these parameters can be configured in yaml * Simple form validation, `required` input cannot be empty * Add a route `/actions/run`, and an `actions.Run` method to handle * Add `WorkflowDispatchPayload` struct to pass the Webhook event payload to the runner when triggered, this payload carries the `inputs` values and other fields, doc: [workflow_dispatch payload](https://docs.github.com/en/webhooks/webhook-events-and-payloads#workflow_dispatch) Other PRs * the `Workflow.WorkflowDispatchConfig()` method still return non-nil when workflow_dispatch is not defined. I submitted a PR https://gitea.com/gitea/act/pulls/85 to fix it. Still waiting for them to process. Behavior should be same with github, but may cause confusion. Here's a quick reminder. * [Doc](https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#workflow_dispatch) Said: This event will `only` trigger a workflow run if the workflow file is `on the default branch`. * If the workflow yaml file only exists in a non-default branch, it cannot be triggered. (It will not even show up in the workflow list) * If the same workflow yaml file exists in each branch at the same time, the version of the default branch is used. Even if `Use workflow from` selects another branch  ```yaml name: Docker Image CI on: workflow_dispatch: inputs: logLevel: description: 'Log level' required: true default: 'warning' type: choice options: - info - warning - debug tags: description: 'Test scenario tags' required: false type: boolean boolean_default_true: description: 'Test scenario tags' required: true type: boolean default: true boolean_default_false: description: 'Test scenario tags' required: false type: boolean default: false environment: description: 'Environment to run tests against' type: environment required: true default: 'environment values' number_required_1: description: 'number ' type: number required: true default: '100' number_required_2: description: 'number' type: number required: true default: '100' number_required_3: description: 'number' type: number required: true default: '100' number_1: description: 'number' type: number required: false number_2: description: 'number' type: number required: false number_3: description: 'number' type: number required: false env: inputs_logLevel: ${{ inputs.logLevel }} inputs_tags: ${{ inputs.tags }} inputs_boolean_default_true: ${{ inputs.boolean_default_true }} inputs_boolean_default_false: ${{ inputs.boolean_default_false }} inputs_environment: ${{ inputs.environment }} inputs_number_1: ${{ inputs.number_1 }} inputs_number_2: ${{ inputs.number_2 }} inputs_number_3: ${{ inputs.number_3 }} inputs_number_required_1: ${{ inputs.number_required_1 }} inputs_number_required_2: ${{ inputs.number_required_2 }} inputs_number_required_3: ${{ inputs.number_required_3 }} jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - run: ls -la - run: env | grep inputs - run: echo ${{ inputs.logLevel }} - run: echo ${{ inputs.boolean_default_false }} ```   --------- Co-authored-by: TKaxv_7S <954067342@qq.com> Co-authored-by: silverwind <me@silverwind.io> Co-authored-by: Denys Konovalov <kontakt@denyskon.de> Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com>
This commit is contained in:
		| @@ -494,3 +494,17 @@ type PackagePayload struct { | ||||
| func (p *PackagePayload) JSONPayload() ([]byte, error) { | ||||
| 	return json.MarshalIndent(p, "", "  ") | ||||
| } | ||||
|  | ||||
| // WorkflowDispatchPayload represents a workflow dispatch payload | ||||
| type WorkflowDispatchPayload struct { | ||||
| 	Workflow   string         `json:"workflow"` | ||||
| 	Ref        string         `json:"ref"` | ||||
| 	Inputs     map[string]any `json:"inputs"` | ||||
| 	Repository *Repository    `json:"repository"` | ||||
| 	Sender     *User          `json:"sender"` | ||||
| } | ||||
|  | ||||
| // JSONPayload implements Payload | ||||
| func (p *WorkflowDispatchPayload) JSONPayload() ([]byte, error) { | ||||
| 	return json.MarshalIndent(p, "", "  ") | ||||
| } | ||||
|   | ||||
| @@ -628,6 +628,7 @@ org_still_own_repo = "This organization still owns one or more repositories, del | ||||
| org_still_own_packages = "This organization still owns one or more packages, delete them first." | ||||
|  | ||||
| target_branch_not_exist = Target branch does not exist. | ||||
| target_ref_not_exist = Target ref does not exist %s | ||||
|  | ||||
| admin_cannot_delete_self = You cannot delete yourself when you are an admin. Please remove your admin privileges first. | ||||
|  | ||||
| @@ -3701,6 +3702,11 @@ workflow.disable_success = Workflow '%s' disabled successfully. | ||||
| workflow.enable = Enable Workflow | ||||
| workflow.enable_success = Workflow '%s' enabled successfully. | ||||
| workflow.disabled = Workflow is disabled. | ||||
| workflow.run = Run Workflow | ||||
| workflow.not_found = Workflow '%s' not found. | ||||
| workflow.run_success = Workflow '%s' run successfully. | ||||
| workflow.from_ref = Use workflow from | ||||
| workflow.has_workflow_dispatch = This workflow has a workflow_dispatch event trigger. | ||||
|  | ||||
| need_approval_desc = Need approval to run workflows for fork pull request. | ||||
|  | ||||
|   | ||||
| @@ -7,22 +7,28 @@ import ( | ||||
| 	"bytes" | ||||
| 	"fmt" | ||||
| 	"net/http" | ||||
| 	"slices" | ||||
| 	"strings" | ||||
|  | ||||
| 	actions_model "code.gitea.io/gitea/models/actions" | ||||
| 	"code.gitea.io/gitea/models/db" | ||||
| 	git_model "code.gitea.io/gitea/models/git" | ||||
| 	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/base" | ||||
| 	"code.gitea.io/gitea/modules/container" | ||||
| 	"code.gitea.io/gitea/modules/git" | ||||
| 	"code.gitea.io/gitea/modules/log" | ||||
| 	"code.gitea.io/gitea/modules/optional" | ||||
| 	"code.gitea.io/gitea/modules/setting" | ||||
| 	"code.gitea.io/gitea/modules/util" | ||||
| 	"code.gitea.io/gitea/routers/web/repo" | ||||
| 	"code.gitea.io/gitea/services/context" | ||||
| 	"code.gitea.io/gitea/services/convert" | ||||
|  | ||||
| 	"github.com/nektos/act/pkg/model" | ||||
| 	"gopkg.in/yaml.v3" | ||||
| ) | ||||
|  | ||||
| const ( | ||||
| @@ -58,8 +64,13 @@ func MustEnableActions(ctx *context.Context) { | ||||
| func List(ctx *context.Context) { | ||||
| 	ctx.Data["Title"] = ctx.Tr("actions.actions") | ||||
| 	ctx.Data["PageIsActions"] = true | ||||
| 	workflowID := ctx.FormString("workflow") | ||||
| 	actorID := ctx.FormInt64("actor") | ||||
| 	status := ctx.FormInt("status") | ||||
| 	ctx.Data["CurWorkflow"] = workflowID | ||||
|  | ||||
| 	var workflows []Workflow | ||||
| 	var curWorkflow *model.Workflow | ||||
| 	if empty, err := ctx.Repo.GitRepo.IsEmpty(); err != nil { | ||||
| 		ctx.ServerError("IsEmpty", err) | ||||
| 		return | ||||
| @@ -140,6 +151,10 @@ func List(ctx *context.Context) { | ||||
| 				workflow.ErrMsg = ctx.Locale.TrString("actions.runs.no_job") | ||||
| 			} | ||||
| 			workflows = append(workflows, workflow) | ||||
|  | ||||
| 			if workflow.Entry.Name() == workflowID { | ||||
| 				curWorkflow = wf | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
| 	ctx.Data["workflows"] = workflows | ||||
| @@ -150,17 +165,46 @@ func List(ctx *context.Context) { | ||||
| 		page = 1 | ||||
| 	} | ||||
|  | ||||
| 	workflow := ctx.FormString("workflow") | ||||
| 	actorID := ctx.FormInt64("actor") | ||||
| 	status := ctx.FormInt("status") | ||||
| 	ctx.Data["CurWorkflow"] = workflow | ||||
|  | ||||
| 	actionsConfig := ctx.Repo.Repository.MustGetUnit(ctx, unit.TypeActions).ActionsConfig() | ||||
| 	ctx.Data["ActionsConfig"] = actionsConfig | ||||
|  | ||||
| 	if len(workflow) > 0 && ctx.Repo.IsAdmin() { | ||||
| 	if len(workflowID) > 0 && ctx.Repo.IsAdmin() { | ||||
| 		ctx.Data["AllowDisableOrEnableWorkflow"] = true | ||||
| 		ctx.Data["CurWorkflowDisabled"] = actionsConfig.IsWorkflowDisabled(workflow) | ||||
| 		isWorkflowDisabled := actionsConfig.IsWorkflowDisabled(workflowID) | ||||
| 		ctx.Data["CurWorkflowDisabled"] = isWorkflowDisabled | ||||
|  | ||||
| 		if !isWorkflowDisabled && curWorkflow != nil { | ||||
| 			workflowDispatchConfig := workflowDispatchConfig(curWorkflow) | ||||
| 			if workflowDispatchConfig != nil { | ||||
| 				ctx.Data["WorkflowDispatchConfig"] = workflowDispatchConfig | ||||
|  | ||||
| 				branchOpts := git_model.FindBranchOptions{ | ||||
| 					RepoID:          ctx.Repo.Repository.ID, | ||||
| 					IsDeletedBranch: optional.Some(false), | ||||
| 					ListOptions: db.ListOptions{ | ||||
| 						ListAll: true, | ||||
| 					}, | ||||
| 				} | ||||
| 				branches, err := git_model.FindBranchNames(ctx, branchOpts) | ||||
| 				if err != nil { | ||||
| 					ctx.ServerError("FindBranchNames", err) | ||||
| 					return | ||||
| 				} | ||||
| 				// always put default branch on the top if it exists | ||||
| 				if slices.Contains(branches, ctx.Repo.Repository.DefaultBranch) { | ||||
| 					branches = util.SliceRemoveAll(branches, ctx.Repo.Repository.DefaultBranch) | ||||
| 					branches = append([]string{ctx.Repo.Repository.DefaultBranch}, branches...) | ||||
| 				} | ||||
| 				ctx.Data["Branches"] = branches | ||||
|  | ||||
| 				tags, err := repo_model.GetTagNamesByRepoID(ctx, ctx.Repo.Repository.ID) | ||||
| 				if err != nil { | ||||
| 					ctx.ServerError("GetTagNamesByRepoID", err) | ||||
| 					return | ||||
| 				} | ||||
| 				ctx.Data["Tags"] = tags | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	// if status or actor query param is not given to frontend href, (href="/<repoLink>/actions") | ||||
| @@ -177,7 +221,7 @@ func List(ctx *context.Context) { | ||||
| 			PageSize: convert.ToCorrectPageSize(ctx.FormInt("limit")), | ||||
| 		}, | ||||
| 		RepoID:        ctx.Repo.Repository.ID, | ||||
| 		WorkflowID:    workflow, | ||||
| 		WorkflowID:    workflowID, | ||||
| 		TriggerUserID: actorID, | ||||
| 	} | ||||
|  | ||||
| @@ -214,7 +258,7 @@ func List(ctx *context.Context) { | ||||
|  | ||||
| 	pager := context.NewPagination(int(total), opts.PageSize, opts.Page, 5) | ||||
| 	pager.SetDefaultParams(ctx) | ||||
| 	pager.AddParamString("workflow", workflow) | ||||
| 	pager.AddParamString("workflow", workflowID) | ||||
| 	pager.AddParamString("actor", fmt.Sprint(actorID)) | ||||
| 	pager.AddParamString("status", fmt.Sprint(status)) | ||||
| 	ctx.Data["Page"] = pager | ||||
| @@ -222,3 +266,86 @@ func List(ctx *context.Context) { | ||||
|  | ||||
| 	ctx.HTML(http.StatusOK, tplListActions) | ||||
| } | ||||
|  | ||||
| type WorkflowDispatchInput struct { | ||||
| 	Name        string   `yaml:"name"` | ||||
| 	Description string   `yaml:"description"` | ||||
| 	Required    bool     `yaml:"required"` | ||||
| 	Default     string   `yaml:"default"` | ||||
| 	Type        string   `yaml:"type"` | ||||
| 	Options     []string `yaml:"options"` | ||||
| } | ||||
|  | ||||
| type WorkflowDispatch struct { | ||||
| 	Inputs []WorkflowDispatchInput | ||||
| } | ||||
|  | ||||
| func workflowDispatchConfig(w *model.Workflow) *WorkflowDispatch { | ||||
| 	switch w.RawOn.Kind { | ||||
| 	case yaml.ScalarNode: | ||||
| 		var val string | ||||
| 		if !decodeNode(w.RawOn, &val) { | ||||
| 			return nil | ||||
| 		} | ||||
| 		if val == "workflow_dispatch" { | ||||
| 			return &WorkflowDispatch{} | ||||
| 		} | ||||
| 	case yaml.SequenceNode: | ||||
| 		var val []string | ||||
| 		if !decodeNode(w.RawOn, &val) { | ||||
| 			return nil | ||||
| 		} | ||||
| 		for _, v := range val { | ||||
| 			if v == "workflow_dispatch" { | ||||
| 				return &WorkflowDispatch{} | ||||
| 			} | ||||
| 		} | ||||
| 	case yaml.MappingNode: | ||||
| 		var val map[string]yaml.Node | ||||
| 		if !decodeNode(w.RawOn, &val) { | ||||
| 			return nil | ||||
| 		} | ||||
|  | ||||
| 		workflowDispatchNode, found := val["workflow_dispatch"] | ||||
| 		if !found { | ||||
| 			return nil | ||||
| 		} | ||||
|  | ||||
| 		var workflowDispatch WorkflowDispatch | ||||
| 		var workflowDispatchVal map[string]yaml.Node | ||||
| 		if !decodeNode(workflowDispatchNode, &workflowDispatchVal) { | ||||
| 			return &workflowDispatch | ||||
| 		} | ||||
|  | ||||
| 		inputsNode, found := workflowDispatchVal["inputs"] | ||||
| 		if !found || inputsNode.Kind != yaml.MappingNode { | ||||
| 			return &workflowDispatch | ||||
| 		} | ||||
|  | ||||
| 		i := 0 | ||||
| 		for { | ||||
| 			if i+1 >= len(inputsNode.Content) { | ||||
| 				break | ||||
| 			} | ||||
| 			var input WorkflowDispatchInput | ||||
| 			if decodeNode(*inputsNode.Content[i+1], &input) { | ||||
| 				input.Name = inputsNode.Content[i].Value | ||||
| 				workflowDispatch.Inputs = append(workflowDispatch.Inputs, input) | ||||
| 			} | ||||
| 			i += 2 | ||||
| 		} | ||||
| 		return &workflowDispatch | ||||
|  | ||||
| 	default: | ||||
| 		return nil | ||||
| 	} | ||||
| 	return nil | ||||
| } | ||||
|  | ||||
| func decodeNode(node yaml.Node, out any) bool { | ||||
| 	if err := node.Decode(out); err != nil { | ||||
| 		log.Warn("Failed to decode node %v into %T: %v", node, out, err) | ||||
| 		return false | ||||
| 	} | ||||
| 	return true | ||||
| } | ||||
|   | ||||
							
								
								
									
										156
									
								
								routers/web/repo/actions/actions_test.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										156
									
								
								routers/web/repo/actions/actions_test.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,156 @@ | ||||
| // Copyright 2022 The Gitea Authors. All rights reserved. | ||||
| // SPDX-License-Identifier: MIT | ||||
|  | ||||
| package actions | ||||
|  | ||||
| import ( | ||||
| 	"strings" | ||||
| 	"testing" | ||||
|  | ||||
| 	act_model "github.com/nektos/act/pkg/model" | ||||
| 	"github.com/stretchr/testify/assert" | ||||
| ) | ||||
|  | ||||
| func TestReadWorkflow_WorkflowDispatchConfig(t *testing.T) { | ||||
| 	yaml := ` | ||||
|     name: local-action-docker-url | ||||
|     ` | ||||
| 	workflow, err := act_model.ReadWorkflow(strings.NewReader(yaml)) | ||||
| 	assert.NoError(t, err, "read workflow should succeed") | ||||
| 	workflowDispatch := workflowDispatchConfig(workflow) | ||||
| 	assert.Nil(t, workflowDispatch) | ||||
|  | ||||
| 	yaml = ` | ||||
|     name: local-action-docker-url | ||||
|     on: push | ||||
|     ` | ||||
| 	workflow, err = act_model.ReadWorkflow(strings.NewReader(yaml)) | ||||
| 	assert.NoError(t, err, "read workflow should succeed") | ||||
| 	workflowDispatch = workflowDispatchConfig(workflow) | ||||
| 	assert.Nil(t, workflowDispatch) | ||||
|  | ||||
| 	yaml = ` | ||||
|     name: local-action-docker-url | ||||
|     on: workflow_dispatch | ||||
|     ` | ||||
| 	workflow, err = act_model.ReadWorkflow(strings.NewReader(yaml)) | ||||
| 	assert.NoError(t, err, "read workflow should succeed") | ||||
| 	workflowDispatch = workflowDispatchConfig(workflow) | ||||
| 	assert.NotNil(t, workflowDispatch) | ||||
| 	assert.Nil(t, workflowDispatch.Inputs) | ||||
|  | ||||
| 	yaml = ` | ||||
|     name: local-action-docker-url | ||||
|     on: [push, pull_request] | ||||
|     ` | ||||
| 	workflow, err = act_model.ReadWorkflow(strings.NewReader(yaml)) | ||||
| 	assert.NoError(t, err, "read workflow should succeed") | ||||
| 	workflowDispatch = workflowDispatchConfig(workflow) | ||||
| 	assert.Nil(t, workflowDispatch) | ||||
|  | ||||
| 	yaml = ` | ||||
|     name: local-action-docker-url | ||||
|     on: | ||||
|         push: | ||||
|         pull_request: | ||||
|     ` | ||||
| 	workflow, err = act_model.ReadWorkflow(strings.NewReader(yaml)) | ||||
| 	assert.NoError(t, err, "read workflow should succeed") | ||||
| 	workflowDispatch = workflowDispatchConfig(workflow) | ||||
| 	assert.Nil(t, workflowDispatch) | ||||
|  | ||||
| 	yaml = ` | ||||
|     name: local-action-docker-url | ||||
|     on: [push, workflow_dispatch] | ||||
|     ` | ||||
| 	workflow, err = act_model.ReadWorkflow(strings.NewReader(yaml)) | ||||
| 	assert.NoError(t, err, "read workflow should succeed") | ||||
| 	workflowDispatch = workflowDispatchConfig(workflow) | ||||
| 	assert.NotNil(t, workflowDispatch) | ||||
| 	assert.Nil(t, workflowDispatch.Inputs) | ||||
|  | ||||
| 	yaml = ` | ||||
|     name: local-action-docker-url | ||||
|     on: | ||||
|         - push | ||||
|         - workflow_dispatch | ||||
|     ` | ||||
| 	workflow, err = act_model.ReadWorkflow(strings.NewReader(yaml)) | ||||
| 	assert.NoError(t, err, "read workflow should succeed") | ||||
| 	workflowDispatch = workflowDispatchConfig(workflow) | ||||
| 	assert.NotNil(t, workflowDispatch) | ||||
| 	assert.Nil(t, workflowDispatch.Inputs) | ||||
|  | ||||
| 	yaml = ` | ||||
|     name: local-action-docker-url | ||||
|     on: | ||||
|         push: | ||||
|         pull_request: | ||||
|         workflow_dispatch: | ||||
|             inputs: | ||||
|     ` | ||||
| 	workflow, err = act_model.ReadWorkflow(strings.NewReader(yaml)) | ||||
| 	assert.NoError(t, err, "read workflow should succeed") | ||||
| 	workflowDispatch = workflowDispatchConfig(workflow) | ||||
| 	assert.NotNil(t, workflowDispatch) | ||||
| 	assert.Nil(t, workflowDispatch.Inputs) | ||||
|  | ||||
| 	yaml = ` | ||||
|     name: local-action-docker-url | ||||
|     on: | ||||
|         push: | ||||
|         pull_request: | ||||
|         workflow_dispatch: | ||||
|             inputs: | ||||
|                 logLevel: | ||||
|                     description: 'Log level' | ||||
|                     required: true | ||||
|                     default: 'warning' | ||||
|                     type: choice | ||||
|                     options: | ||||
|                     - info | ||||
|                     - warning | ||||
|                     - debug | ||||
|                 boolean_default_true: | ||||
|                     description: 'Test scenario tags' | ||||
|                     required: true | ||||
|                     type: boolean | ||||
|                     default: true | ||||
|                 boolean_default_false: | ||||
|                     description: 'Test scenario tags' | ||||
|                     required: true | ||||
|                     type: boolean | ||||
|                     default: false | ||||
|     ` | ||||
|  | ||||
| 	workflow, err = act_model.ReadWorkflow(strings.NewReader(yaml)) | ||||
| 	assert.NoError(t, err, "read workflow should succeed") | ||||
| 	workflowDispatch = workflowDispatchConfig(workflow) | ||||
| 	assert.NotNil(t, workflowDispatch) | ||||
| 	assert.Equal(t, WorkflowDispatchInput{ | ||||
| 		Name:        "logLevel", | ||||
| 		Default:     "warning", | ||||
| 		Description: "Log level", | ||||
| 		Options: []string{ | ||||
| 			"info", | ||||
| 			"warning", | ||||
| 			"debug", | ||||
| 		}, | ||||
| 		Required: true, | ||||
| 		Type:     "choice", | ||||
| 	}, workflowDispatch.Inputs[0]) | ||||
| 	assert.Equal(t, WorkflowDispatchInput{ | ||||
| 		Name:        "boolean_default_true", | ||||
| 		Default:     "true", | ||||
| 		Description: "Test scenario tags", | ||||
| 		Required:    true, | ||||
| 		Type:        "boolean", | ||||
| 	}, workflowDispatch.Inputs[1]) | ||||
| 	assert.Equal(t, WorkflowDispatchInput{ | ||||
| 		Name:        "boolean_default_false", | ||||
| 		Default:     "false", | ||||
| 		Description: "Test scenario tags", | ||||
| 		Required:    true, | ||||
| 		Type:        "boolean", | ||||
| 	}, workflowDispatch.Inputs[2]) | ||||
| } | ||||
| @@ -18,18 +18,26 @@ import ( | ||||
|  | ||||
| 	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/base" | ||||
| 	"code.gitea.io/gitea/modules/git" | ||||
| 	"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/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" | ||||
| ) | ||||
|  | ||||
| @@ -745,3 +753,164 @@ func disableOrEnableWorkflowFile(ctx *context_module.Context, isEnable bool) { | ||||
| 		url.QueryEscape(ctx.FormString("actor")), url.QueryEscape(ctx.FormString("status"))) | ||||
| 	ctx.JSONRedirect(redirectURL) | ||||
| } | ||||
|  | ||||
| func Run(ctx *context_module.Context) { | ||||
| 	redirectURL := fmt.Sprintf("%s/actions?workflow=%s&actor=%s&status=%s", ctx.Repo.RepoLink, url.QueryEscape(ctx.FormString("workflow")), | ||||
| 		url.QueryEscape(ctx.FormString("actor")), url.QueryEscape(ctx.FormString("status"))) | ||||
|  | ||||
| 	workflowID := ctx.FormString("workflow") | ||||
| 	if len(workflowID) == 0 { | ||||
| 		ctx.ServerError("workflow", nil) | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	ref := ctx.FormString("ref") | ||||
| 	if len(ref) == 0 { | ||||
| 		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 default branch commit | ||||
| 	defaultBranchCommit, err := ctx.Repo.GitRepo.GetBranchCommit(ctx.Repo.Repository.DefaultBranch) | ||||
| 	if err != nil { | ||||
| 		ctx.Error(http.StatusInternalServerError, err.Error()) | ||||
| 		return | ||||
| 	} | ||||
| 	entries, err := actions.ListWorkflows(defaultBranchCommit) | ||||
| 	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.PostForm.Get(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}) | ||||
| 	if err != nil { | ||||
| 		log.Error("FindRunJobs: %v", err) | ||||
| 	} | ||||
| 	actions_service.CreateCommitStatus(ctx, alljobs...) | ||||
|  | ||||
| 	ctx.Flash.Success(ctx.Tr("actions.workflow.run_success", workflowID)) | ||||
| 	ctx.Redirect(redirectURL) | ||||
| } | ||||
|   | ||||
| @@ -1384,6 +1384,7 @@ func registerRoutes(m *web.Router) { | ||||
| 		m.Get("", actions.List) | ||||
| 		m.Post("/disable", reqRepoAdmin, actions.DisableWorkflowFile) | ||||
| 		m.Post("/enable", reqRepoAdmin, actions.EnableWorkflowFile) | ||||
| 		m.Post("/run", reqRepoAdmin, actions.Run) | ||||
|  | ||||
| 		m.Group("/runs/{run}", func() { | ||||
| 			m.Combo(""). | ||||
|   | ||||
| @@ -76,6 +76,11 @@ | ||||
| 						</button> | ||||
| 					{{end}} | ||||
| 				</div> | ||||
|  | ||||
| 				{{if .WorkflowDispatchConfig}} | ||||
| 					{{template "repo/actions/workflow_dispatch" .}} | ||||
| 				{{end}} | ||||
|  | ||||
| 				{{template "repo/actions/runs_list" .}} | ||||
| 			</div> | ||||
| 		</div> | ||||
|   | ||||
							
								
								
									
										78
									
								
								templates/repo/actions/workflow_dispatch.tmpl
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										78
									
								
								templates/repo/actions/workflow_dispatch.tmpl
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,78 @@ | ||||
| <div class="ui blue info message tw-flex tw-justify-between tw-items-center"> | ||||
| 	<span class="ui text middle">{{ctx.Locale.Tr "actions.workflow.has_workflow_dispatch"}}</span> | ||||
| 	<button class="ui mini button show-modal" data-modal="#runWorkflowDispatchModal">{{ctx.Locale.Tr "actions.workflow.run"}}{{svg "octicon-triangle-down" 14 "dropdown icon"}}</button> | ||||
| </div> | ||||
| <div id="runWorkflowDispatchModal" class="ui tiny modal"> | ||||
| 	<div class="content"> | ||||
| 		<form id="runWorkflowDispatchForm" class="ui form" action="{{$.Link}}/run?workflow={{$.CurWorkflow}}&actor={{$.CurActor}}&status={{.Status}}" method="post"> | ||||
| 			{{.CsrfTokenHtml}} | ||||
| 			<div class="ui inline field required tw-flex tw-items-center"> | ||||
| 				<span class="ui inline required field"> | ||||
| 					<label>{{ctx.Locale.Tr "actions.workflow.from_ref"}}:</label> | ||||
| 				</span> | ||||
| 				<div class="ui inline field dropdown button select-branch branch-selector-dropdown ellipsis-items-nowrap"> | ||||
| 					<input type="hidden" name="ref" value="refs/heads/{{index .Branches 0}}"> | ||||
| 					{{svg "octicon-git-branch" 14}} | ||||
| 					<div class="default text">{{index .Branches 0}}</div> | ||||
| 					{{svg "octicon-triangle-down" 14 "dropdown icon"}} | ||||
| 					<div class="menu transition"> | ||||
| 						<div class="ui icon search input"> | ||||
| 							<i class="icon">{{svg "octicon-filter" 16}}</i> | ||||
| 							<input name="search" type="text" placeholder="{{ctx.Locale.Tr "repo.filter_branch_and_tag"}}..."> | ||||
| 						</div> | ||||
| 						<div class="branch-tag-tab"> | ||||
| 							<a class="branch-tag-item reference column muted active" href="#" data-target="#branch-list"> | ||||
| 								{{svg "octicon-git-branch" 16 "tw-mr-1"}} {{ctx.Locale.Tr "repo.branches"}} | ||||
| 							</a> | ||||
| 							<a class="branch-tag-item reference column muted" href="#" data-target="#tag-list"> | ||||
| 								{{svg "octicon-tag" 16 "tw-mr-1"}} {{ctx.Locale.Tr "repo.tags"}} | ||||
| 							</a> | ||||
| 						</div> | ||||
| 						<div class="branch-tag-divider"></div> | ||||
| 						<div id="branch-list" class="scrolling menu reference-list-menu"> | ||||
| 							{{range .Branches}} | ||||
| 								<div class="item" data-value="refs/heads/{{.}}" title="{{.}}">{{.}}</div> | ||||
| 							{{else}} | ||||
| 								<div class="item">{{ctx.Locale.Tr "no_results_found"}}</div> | ||||
| 							{{end}} | ||||
| 						</div> | ||||
| 						<div id="tag-list" class="scrolling menu reference-list-menu tw-hidden"> | ||||
| 							{{range .Tags}} | ||||
| 								<div class="item" data-value="refs/tags/{{.}}" title="{{.}}">{{.}}</div> | ||||
| 							{{else}} | ||||
| 								<div class="item">{{ctx.Locale.Tr "no_results_found"}}</div> | ||||
| 							{{end}} | ||||
| 						</div> | ||||
| 					</div> | ||||
| 				</div> | ||||
| 			</div> | ||||
|  | ||||
| 			<div class="divider"></div> | ||||
|  | ||||
| 			{{range $item := .WorkflowDispatchConfig.Inputs}} | ||||
| 			<div class="ui field {{if .Required}}required{{end}}"> | ||||
| 				{{if eq .Type "choice"}} | ||||
| 					<label>{{.Description}}:</label> | ||||
| 					<select class="ui selection type dropdown" name="{{.Name}}"> | ||||
| 						{{range .Options}} | ||||
| 						<option value="{{.}}" {{if eq $item.Default .}}selected{{end}} >{{.}}</option> | ||||
| 						{{end}} | ||||
| 					</select> | ||||
| 				{{else if eq .Type "boolean"}} | ||||
| 					<div class="ui inline checkbox"> | ||||
| 						<label>{{.Description}}</label> | ||||
| 						<input type="checkbox" name="{{.Name}}" {{if eq .Default "true"}}checked{{end}}> | ||||
| 					</div> | ||||
| 				{{else if eq .Type "number"}} | ||||
| 					<label>{{.Description}}:</label> | ||||
| 					<input name="{{.Name}}" value="{{.Default}}" {{if .Required}}required{{end}}> | ||||
| 				{{else}} | ||||
| 					<label>{{.Description}}:</label> | ||||
| 					<input name="{{.Name}}" value="{{.Default}}" {{if .Required}}required{{end}}> | ||||
| 				{{end}} | ||||
| 			</div> | ||||
| 			{{end}} | ||||
| 			<button class="ui tiny primary button" type="submit">Submit</button> | ||||
| 		</form> | ||||
| 	</div> | ||||
| </div> | ||||
| @@ -43,6 +43,19 @@ function reloadConfirmDraftComment() { | ||||
|   window.location.reload(); | ||||
| } | ||||
|  | ||||
| export function initBranchSelectorTabs() { | ||||
|   const elSelectBranch = document.querySelector('.ui.dropdown.select-branch'); | ||||
|   if (!elSelectBranch) return; | ||||
|  | ||||
|   $(elSelectBranch).find('.reference.column').on('click', function () { | ||||
|     hideElem($(elSelectBranch).find('.scrolling.reference-list-menu')); | ||||
|     showElem(this.getAttribute('data-target')); | ||||
|     queryElemChildren(this.parentNode, '.branch-tag-item', (el) => el.classList.remove('active')); | ||||
|     this.classList.add('active'); | ||||
|     return false; | ||||
|   }); | ||||
| } | ||||
|  | ||||
| export function initRepoCommentForm() { | ||||
|   const $commentForm = $('.comment.form'); | ||||
|   if (!$commentForm.length) return; | ||||
| @@ -81,13 +94,6 @@ export function initRepoCommentForm() { | ||||
|         elSelectBranch.querySelector('.text-branch-name').textContent = selectedText; | ||||
|       } | ||||
|     }); | ||||
|     $selectBranch.find('.reference.column').on('click', function () { | ||||
|       hideElem($selectBranch.find('.scrolling.reference-list-menu')); | ||||
|       showElem(this.getAttribute('data-target')); | ||||
|       queryElemChildren(this.parentNode, '.branch-tag-item', (el) => el.classList.remove('active')); | ||||
|       this.classList.add('active'); | ||||
|       return false; | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   initBranchSelector(); | ||||
|   | ||||
| @@ -60,7 +60,7 @@ import {initCompWebHookEditor} from './features/comp/WebHookEditor.ts'; | ||||
| import {initRepoBranchButton} from './features/repo-branch.ts'; | ||||
| import {initCommonOrganization} from './features/common-organization.ts'; | ||||
| import {initRepoWikiForm} from './features/repo-wiki.ts'; | ||||
| import {initRepoCommentForm, initRepository} from './features/repo-legacy.ts'; | ||||
| import {initRepoCommentForm, initRepository, initBranchSelectorTabs} from './features/repo-legacy.ts'; | ||||
| import {initCopyContent} from './features/copycontent.ts'; | ||||
| import {initCaptcha} from './features/captcha.ts'; | ||||
| import {initRepositoryActionView} from './components/RepoActionView.vue'; | ||||
| @@ -182,6 +182,7 @@ onDomReady(() => { | ||||
|     initRepoBranchButton, | ||||
|     initRepoCodeView, | ||||
|     initRepoCommentForm, | ||||
|     initBranchSelectorTabs, | ||||
|     initRepoEllipsisButton, | ||||
|     initRepoDiffCommitBranchesAndTags, | ||||
|     initRepoEditor, | ||||
|   | ||||
		Reference in New Issue
	
	Block a user