feat: execute post run cleanup when workflow is cancelled (#37275)

## Fixes #36983

## Summary
1. Add transitional `Cancelling` status (between `Running` and
`Cancelled`); cancel flow marks active tasks `Cancelling`, runner
finalizes to `Cancelled` on terminal result.
2. Taskless jobs cancel directly (no runner to finalize).
3. Runner-protocol responses map `Cancelling` → `RESULT_CANCELLED`.
4. Run/job aggregation treats `Cancelling` as active.
5. Status mapping/aggregation tests + en-US locale added.

**Problem**
When a workflow was cancelled from the UI, jobs were marked cancelled
immediately, which could skip post-run cleanup behavior.

## Solution
Use a transitional status path:
Running → Cancelling → Cancelled
This allows runner finalization and cleanup path execution before final
terminal state.

**Testing**

> 1. go test -tags "sqlite sqlite_unlock_notify" ./models/actions -run
"TestAggregateJobStatus|TestStatusAsResult|TestStatusFromResult"
> 2. go run
github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.11.4 run
./models/actions/... ./routers/api/actions/runner/...

## Related
- act_runner: https://gitea.com/gitea/act_runner/pulls/825 —
independent; this PR's capability gate keeps legacy runners on the
immediate-cancel path. The new flow activates only for runners that
advertise the `cancelling` capability.

Co-authored-by: Nicolas <bircni@icloud.com>
Co-authored-by: silverwind <me@silverwind.io>
Co-authored-by: Claude (Opus 4.7) <noreply@anthropic.com>
Co-authored-by: Zettat123 <zettat123@gmail.com>
Co-authored-by: Giteabot <teabot@gitea.io>
This commit is contained in:
Kalash Thakare ☯︎
2026-05-17 12:11:39 +05:30
committed by GitHub
parent ae9b34897f
commit e7af84df72
31 changed files with 786 additions and 111 deletions

View File

@@ -7,6 +7,7 @@ import (
"context"
"errors"
"net/http"
"slices"
actions_model "code.gitea.io/gitea/models/actions"
repo_model "code.gitea.io/gitea/models/repo"
@@ -22,6 +23,7 @@ import (
gouuid "github.com/google/uuid"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/proto"
)
func NewRunnerServiceHandler() (string, http.Handler) {
@@ -67,17 +69,19 @@ func (s *Service) Register(
}
labels := req.Msg.Labels
hasCancellingSupport, _ := runnerRequestHasCancellingCapability(req.Msg)
// create new runner
name := util.EllipsisDisplayString(req.Msg.Name, 255)
runner := &actions_model.ActionRunner{
UUID: gouuid.New().String(),
Name: name,
OwnerID: runnerToken.OwnerID,
RepoID: runnerToken.RepoID,
Version: req.Msg.Version,
AgentLabels: labels,
Ephemeral: req.Msg.Ephemeral,
UUID: gouuid.New().String(),
Name: name,
OwnerID: runnerToken.OwnerID,
RepoID: runnerToken.RepoID,
Version: req.Msg.Version,
AgentLabels: labels,
Ephemeral: req.Msg.Ephemeral,
HasCancellingSupport: hasCancellingSupport,
}
runner.GenerateAndFillToken()
@@ -107,14 +111,53 @@ func (s *Service) Register(
return res, nil
}
// runnerCapabilityCancelling is the wire string the runner advertises in its
// capabilities list to indicate it understands the transitional cancelling
// state and will run post-step cleanup before finalizing the task.
const runnerCapabilityCancelling = "cancelling"
type capabilityGetter interface {
GetCapabilities() []string
}
type declareRequest interface {
proto.Message
GetVersion() string
GetLabels() []string
}
func runnerRequestHasCancellingCapability(req proto.Message) (bool, bool) {
if req == nil {
return false, false
}
if typedReq, ok := any(req).(capabilityGetter); ok {
return slices.Contains(typedReq.GetCapabilities(), runnerCapabilityCancelling), true
}
return false, false
}
func applyDeclareRequestToRunner(runner *actions_model.ActionRunner, req declareRequest) []string {
runner.AgentLabels = req.GetLabels()
runner.Version = req.GetVersion()
cols := []string{"agent_labels", "version"}
hasCancellingSupport, capabilityStateKnown := runnerRequestHasCancellingCapability(req)
if capabilityStateKnown && runner.HasCancellingSupport != hasCancellingSupport {
runner.HasCancellingSupport = hasCancellingSupport
cols = append(cols, "has_cancelling_support")
}
return cols
}
func (s *Service) Declare(
ctx context.Context,
req *connect.Request[runnerv1.DeclareRequest],
) (*connect.Response[runnerv1.DeclareResponse], error) {
runner := GetRunner(ctx)
runner.AgentLabels = req.Msg.Labels
runner.Version = req.Msg.Version
if err := actions_model.UpdateRunner(ctx, runner, "agent_labels", "version"); err != nil {
if err := actions_model.UpdateRunner(ctx, runner, applyDeclareRequestToRunner(runner, req.Msg)...); err != nil {
return nil, status.Errorf(codes.Internal, "update runner: %v", err)
}

View File

@@ -0,0 +1,86 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package runner
import (
"testing"
actions_model "code.gitea.io/gitea/models/actions"
runnerv1 "code.gitea.io/actions-proto-go/runner/v1"
"github.com/stretchr/testify/assert"
)
type capabilityRegisterRequest struct {
*runnerv1.RegisterRequest
capabilities []string
}
func (r *capabilityRegisterRequest) GetCapabilities() []string {
return r.capabilities
}
type capabilityDeclareRequest struct {
*runnerv1.DeclareRequest
capabilities []string
}
func (r *capabilityDeclareRequest) GetCapabilities() []string {
return r.capabilities
}
func TestRunnerRequestHasCancellingCapabilityTypedAccessor(t *testing.T) {
registerReq := &capabilityRegisterRequest{
RegisterRequest: &runnerv1.RegisterRequest{},
capabilities: []string{runnerCapabilityCancelling, "other"},
}
hasCapability, known := runnerRequestHasCancellingCapability(registerReq)
assert.True(t, hasCapability)
assert.True(t, known)
declareReq := &capabilityDeclareRequest{
DeclareRequest: &runnerv1.DeclareRequest{},
capabilities: nil,
}
hasCapability, known = runnerRequestHasCancellingCapability(declareReq)
assert.False(t, hasCapability)
assert.True(t, known)
hasCapability, known = runnerRequestHasCancellingCapability(nil)
assert.False(t, hasCapability)
assert.False(t, known)
}
func TestApplyDeclareRequestToRunnerPreservesUnknownCapabilityState(t *testing.T) {
runner := &actions_model.ActionRunner{
HasCancellingSupport: true,
}
req := &runnerv1.DeclareRequest{
Version: "1.2.3",
Labels: []string{"linux"},
}
cols := applyDeclareRequestToRunner(runner, req)
assert.Equal(t, []string{"agent_labels", "version"}, cols)
assert.True(t, runner.HasCancellingSupport)
assert.Equal(t, "1.2.3", runner.Version)
assert.Equal(t, []string{"linux"}, runner.AgentLabels)
}
func TestApplyDeclareRequestToRunnerUpdatesTypedCapabilityState(t *testing.T) {
runner := &actions_model.ActionRunner{
HasCancellingSupport: true,
}
req := &capabilityDeclareRequest{
DeclareRequest: &runnerv1.DeclareRequest{
Version: "1.2.3",
Labels: []string{"linux"},
},
capabilities: []string{},
}
cols := applyDeclareRequestToRunner(runner, req)
assert.Equal(t, []string{"agent_labels", "version", "has_cancelling_support"}, cols)
assert.False(t, runner.HasCancellingSupport)
}