jojo/services/actions/workflows.go
Mathieu Fenniak 5f757d9e83 [v13.0/forgejo] feat: allow workflows to control cancellation of existing jobs (#9797)
Partial backport of #9434.

Fixes a bug: it was intended that pushes to a branch and pushes to a pull request would cancel any previous running job from the same workflow, however this functionality only worked on a `on: push` workflow.  This PR fixes it for an `on: pull_request` workflow.

Adds a feature: `concurrency.cancel-in-progress` can be used to override the automatic cancellation behaviour and disable it.

It can be disabled unconditionally in a workflow:
```yaml
concurrency:
    cancel-in-progress: false
```

Or it can be disabled with some logic; for example, keeping the cancel behaviour on PRs but disabling it otherwise:
```yaml
concurrency:
  cancel-in-progress: ${{ github.event_name == 'pull_request' }}
```

Only a small subset of automated tests were applicable for backport, so I supplemented with manual testing in these cases:

|                                                                    | Expected      | Actual        |
| ------------------------------------------------------------------ | ------------- | ------------- |
| Default behaviour:                                                 |               |               |
| on: pull_request -- push to a PR when it's already running         | Cancelled     | Cancelled     |
| on: push -- push to a branch (eg. main) when it's already running  | Cancelled     | Cancelled     |
| on: workflow_dispatch -- run again when it is already running      | Multiple Runs | Multiple Runs |
|                                                                    |               |               |
| Set cancel-in-progress: true                                       |               |               |
| on: pull_request -- push to a PR when it's already running         | Cancelled     | Cancelled     |
| on: push -- push to a branch (eg. main) when it's already running  | Cancelled     | Cancelled     |
| on: workflow_dispatch -- run again when it is already running      | Multiple Runs | Multiple Runs |
|                                                                    |               |               |
| Set cancel-in-progress: false                                      |               |               |
| on: pull_request -- push to a PR when it's already running         | Multiple Runs | Multiple Runs |
| on: push -- push to a branch (eg. main) when it's already running  | Multiple Runs | Multiple Runs |
| on: workflow_dispatch -- run again when it is already running      | Multiple Runs | Multiple Runs |
|                                                                    |               |               |
| Set cancel-in-progress: ${{ github.event_name == 'pull_request' }} |               |               |
| on: pull_request -- push to a PR when it's already running         | Cancelled     | Cancelled     |
| on: push -- push to a branch (eg. main) when it's already running  | Multiple Runs | Multiple Runs |
| on: workflow_dispatch -- run again when it is already running      | Multiple Runs | Multiple Runs |

## Checklist

The [contributor guide](https://forgejo.org/docs/next/contributor/) contains information that will be helpful to first time contributors. There also are a few [conditions for merging Pull Requests in Forgejo repositories](https://codeberg.org/forgejo/governance/src/branch/main/PullRequestsAgreement.md). You are also welcome to join the [Forgejo development chatroom](https://matrix.to/#/#forgejo-development:matrix.org).

### Tests

- I added test coverage for Go changes...
  - [x] in their respective `*_test.go` for unit tests.
  - [ ] in the `tests/integration` directory if it involves interactions with a live Forgejo server.
- I added test coverage for JavaScript changes...
  - [ ] in `web_src/js/*.test.js` if it can be unit tested.
  - [ ] in `tests/e2e/*.test.e2e.js` if it requires interactions with a live Forgejo server (see also the [developer guide for JavaScript testing](https://codeberg.org/forgejo/forgejo/src/branch/forgejo/tests/e2e/README.md#end-to-end-tests)).

### Documentation

- [ ] I created a pull request [to the documentation](https://codeberg.org/forgejo/docs) to explain to Forgejo users how to use this change.
  - **TODO:**  Will backport the relevant documentation.
- [ ] I did not document these changes and I do not expect someone else to do it.

### Release notes

- [ ] I do not want this change to show in the release notes.
- [x] I want the title to show in the release notes with a link to this pull request.
- [ ] I want the content of the `release-notes/<pull request number>.md` to be be used for the release notes instead of the title.

<!--start release-notes-assistant-->

## Release notes
<!--URL:https://codeberg.org/forgejo/forgejo-->
- Features
  - [PR](https://codeberg.org/forgejo/forgejo/pulls/9797): <!--number 9797 --><!--line 0 --><!--description ZmVhdDogYWxsb3cgd29ya2Zsb3dzIHRvIGNvbnRyb2wgY2FuY2VsbGF0aW9uIG9mIGV4aXN0aW5nIGpvYnM=-->feat: allow workflows to control cancellation of existing jobs<!--description-->
<!--end release-notes-assistant-->

Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/9797
Reviewed-by: Earl Warren <earl-warren@noreply.codeberg.org>
Reviewed-by: Michael Kriese <michael.kriese@gmx.de>
Co-authored-by: Mathieu Fenniak <mathieu@fenniak.net>
Co-committed-by: Mathieu Fenniak <mathieu@fenniak.net>
2025-11-23 23:01:09 +01:00

208 lines
5.5 KiB
Go

// Copyright The Forgejo Authors.
// SPDX-License-Identifier: MIT
package actions
import (
"bytes"
"context"
"errors"
"fmt"
"strconv"
actions_model "forgejo.org/models/actions"
"forgejo.org/models/perm"
"forgejo.org/models/perm/access"
repo_model "forgejo.org/models/repo"
"forgejo.org/models/user"
"forgejo.org/modules/actions"
"forgejo.org/modules/git"
"forgejo.org/modules/json"
"forgejo.org/modules/setting"
"forgejo.org/modules/structs"
"forgejo.org/modules/util"
"forgejo.org/modules/webhook"
"forgejo.org/services/convert"
"code.forgejo.org/forgejo/runner/v11/act/jobparser"
act_model "code.forgejo.org/forgejo/runner/v11/act/model"
)
type InputRequiredErr struct {
Name string
}
func (err InputRequiredErr) Error() string {
return fmt.Sprintf("input required for '%s'", err.Name)
}
func IsInputRequiredErr(err error) bool {
_, ok := err.(InputRequiredErr)
return ok
}
type Workflow struct {
WorkflowID string
Ref string
Commit *git.Commit
GitEntry *git.TreeEntry
}
type InputValueGetter func(key string) string
func (entry *Workflow) Dispatch(ctx context.Context, inputGetter InputValueGetter, repo *repo_model.Repository, doer *user.User) (r *actions_model.ActionRun, j []string, err error) {
content, err := actions.GetContentFromEntry(entry.GitEntry)
if err != nil {
return nil, nil, err
}
wf, err := act_model.ReadWorkflow(bytes.NewReader(content), false)
if err != nil {
return nil, nil, err
}
fullWorkflowID := ".forgejo/workflows/" + entry.WorkflowID
title := wf.Name
if len(title) < 1 {
title = fullWorkflowID
}
inputs := make(map[string]string)
if workflowDispatch := wf.WorkflowDispatchConfig(); workflowDispatch != nil {
for key, input := range workflowDispatch.Inputs {
val := inputGetter(key)
if len(val) == 0 {
val = input.Default
if len(val) == 0 {
if input.Required {
name := input.Description
if len(name) == 0 {
name = key
}
return nil, nil, InputRequiredErr{Name: name}
}
continue
}
} else if input.Type == "boolean" {
// Since "boolean" inputs are rendered as a checkbox in html, the value inside the form is "on"
val = strconv.FormatBool(val == "on")
}
inputs[key] = val
}
}
if int64(len(inputs)) > setting.Actions.LimitDispatchInputs {
return nil, nil, errors.New("to many inputs")
}
jobNames := util.KeysOfMap(wf.Jobs)
payload := &structs.WorkflowDispatchPayload{
Inputs: inputs,
Ref: entry.Ref,
Repository: convert.ToRepo(ctx, repo, access.Permission{AccessMode: perm.AccessModeNone}),
Sender: convert.ToUser(ctx, doer, nil),
Workflow: fullWorkflowID,
}
p, err := json.Marshal(payload)
if err != nil {
return nil, nil, err
}
notifications, err := wf.Notifications()
if err != nil {
return nil, nil, err
}
run := &actions_model.ActionRun{
Title: title,
RepoID: repo.ID,
Repo: repo,
OwnerID: repo.OwnerID,
WorkflowID: entry.WorkflowID,
TriggerUserID: doer.ID,
TriggerUser: doer,
Ref: entry.Ref,
CommitSHA: entry.Commit.ID.String(),
Event: webhook.HookEventWorkflowDispatch,
EventPayload: string(p),
TriggerEvent: string(webhook.HookEventWorkflowDispatch),
Status: actions_model.StatusWaiting,
NotifyEmail: notifications,
}
vars, err := actions_model.GetVariablesOfRun(ctx, run)
if err != nil {
return nil, nil, err
}
jobs, err := jobParser(content, jobparser.WithVars(vars))
if err != nil {
return nil, nil, err
}
return run, jobNames, actions_model.InsertRun(ctx, run, jobs)
}
func GetWorkflowFromCommit(gitRepo *git.Repository, ref, workflowID string) (*Workflow, error) {
ref, err := gitRepo.ExpandRef(ref)
if err != nil {
return nil, err
}
commit, err := gitRepo.GetCommit(ref)
if err != nil {
return nil, err
}
entries, err := actions.ListWorkflows(commit)
if err != nil {
return nil, err
}
var workflowEntry *git.TreeEntry
for _, entry := range entries {
if entry.Name() == workflowID {
workflowEntry = entry
break
}
}
if workflowEntry == nil {
return nil, errors.New("workflow not found")
}
return &Workflow{
WorkflowID: workflowID,
Ref: ref,
Commit: commit,
GitEntry: workflowEntry,
}, nil
}
// Sets the ConcurrencyGroup & ConcurrencyType on the provided ActionRun based upon the Workflow's `concurrency` data,
// or appropriate defaults if not present.
func ConfigureActionRunConcurrency(workflow *act_model.Workflow, run *actions_model.ActionRun, vars map[string]string, inputs map[string]any) error {
_, cancelInProgress, err := jobparser.EvaluateWorkflowConcurrency(
workflow.RawConcurrency, generateGiteaContextForRun(run), vars, inputs)
if err != nil {
return fmt.Errorf("unable to evaluate workflow `concurrency` block: %w", err)
}
if cancelInProgress == nil {
// Maintain compatible behavior from before concurrency groups were implemented -- if `cancel-in-progress`
// isn't defined in the workflow, cancel on push & PR sync events.
if run.Event == webhook.HookEventPush || run.Event == webhook.HookEventPullRequestSync {
run.ConcurrencyType = actions_model.CancelInProgress
} else {
run.ConcurrencyType = actions_model.UnlimitedConcurrency
}
} else if *cancelInProgress {
run.ConcurrencyType = actions_model.CancelInProgress
} else {
// A workflow has explicitly listed `cancel-in-progress: false`, and we don't support concurrency groups
// (queue-behind style behaviour, to be added in Forgejo v14).
run.ConcurrencyType = actions_model.UnlimitedConcurrency
}
return nil
}