1
1
mirror of https://github.com/go-gitea/gitea synced 2025-07-22 18:28:37 +00: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


![image](https://github.com/go-gitea/gitea/assets/3114995/4bf596f3-426b-48e8-9b8f-0f6d18defd79)
```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 }}
```

![image](https://github.com/go-gitea/gitea/assets/3114995/a58a842d-a0ff-4618-bc6d-83a9596d07c8)

![image](https://github.com/go-gitea/gitea/assets/3114995/44a7cca5-7bd4-42a9-8723-91751a501c88)

---------

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:
胖梁
2024-08-19 10:38:40 +08:00
committed by GitHub
parent 561b5c504f
commit 36232b69db
10 changed files with 580 additions and 17 deletions

View File

@@ -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
}

View 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])
}

View File

@@ -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)
}