Feature: Add per-runner “Disable/Pause” (#36776)

This PR adds per-runner disable/enable support for Gitea Actions so a
registered runner can be paused from picking up new jobs without
unregistering.

Disabled runners stay registered and online but are excluded from new
task assignment; running tasks are allowed to finish. Re-enabling
restores pickup, and runner list/get responses now expose disabled
state.

Also added an endpoint for testing
http://localhost:3000/devtest/runner-edit/enable

<img width="1509" height="701" alt="Bildschirmfoto 2026-02-27 um 22 13
24"
src="https://github.com/user-attachments/assets/5328eda9-e59c-46b6-b398-f436e50ee3da"
/>


Fixes: https://github.com/go-gitea/gitea/issues/36767
This commit is contained in:
Nicolas
2026-03-16 18:24:36 +01:00
committed by GitHub
parent 6372cd7c7d
commit b3b2d111da
27 changed files with 860 additions and 24 deletions
+7 -1
View File
@@ -156,10 +156,16 @@ func (s *Service) FetchTask(
}
if tasksVersion != latestVersion {
// Re-load runner from DB so task assignment uses current IsDisabled state
// (avoids race where disable commits while this request still has stale runner).
freshRunner, err := actions_model.GetRunnerByUUID(ctx, runner.UUID)
if err != nil {
return nil, status.Errorf(codes.Internal, "get runner: %v", err)
}
// if the task version in request is not equal to the version in db,
// it means there may still be some tasks that haven't been assigned.
// try to pick a task for the runner that send the request.
if t, ok, err := actions_service.PickTask(ctx, runner); err != nil {
if t, ok, err := actions_service.PickTask(ctx, freshRunner); err != nil {
log.Error("pick task failed: %v", err)
return nil, status.Errorf(codes.Internal, "pick task: %v", err)
} else if ok {
+37
View File
@@ -32,6 +32,12 @@ func ListRunners(ctx *context.APIContext) {
// summary: Get all runners
// produces:
// - application/json
// parameters:
// - name: disabled
// in: query
// description: filter by disabled status (true or false)
// type: boolean
// required: false
// responses:
// "200":
// "$ref": "#/definitions/ActionRunnersResponse"
@@ -87,3 +93,34 @@ func DeleteRunner(ctx *context.APIContext) {
// "$ref": "#/responses/notFound"
shared.DeleteRunner(ctx, 0, 0, ctx.PathParamInt64("runner_id"))
}
// UpdateRunner update a global runner
func UpdateRunner(ctx *context.APIContext) {
// swagger:operation PATCH /admin/actions/runners/{runner_id} admin updateAdminRunner
// ---
// summary: Update a global runner
// consumes:
// - application/json
// produces:
// - application/json
// parameters:
// - name: runner_id
// in: path
// description: id of the runner
// type: string
// required: true
// - name: body
// in: body
// schema:
// "$ref": "#/definitions/EditActionRunnerOption"
// responses:
// "200":
// "$ref": "#/definitions/ActionRunner"
// "400":
// "$ref": "#/responses/error"
// "404":
// "$ref": "#/responses/notFound"
// "422":
// "$ref": "#/responses/validationError"
shared.UpdateRunner(ctx, 0, 0, ctx.PathParamInt64("runner_id"))
}
+3
View File
@@ -916,6 +916,7 @@ func Routes() *web.Router {
m.Post("/registration-token", reqToken(), reqOwnerCheck, act.CreateRegistrationToken)
m.Get("/{runner_id}", reqToken(), reqOwnerCheck, act.GetRunner)
m.Delete("/{runner_id}", reqToken(), reqOwnerCheck, act.DeleteRunner)
m.Patch("/{runner_id}", reqToken(), reqOwnerCheck, bind(api.EditActionRunnerOption{}), act.UpdateRunner)
})
m.Get("/runs", reqToken(), reqReaderCheck, act.ListWorkflowRuns)
m.Get("/jobs", reqToken(), reqReaderCheck, act.ListWorkflowJobs)
@@ -1043,6 +1044,7 @@ func Routes() *web.Router {
m.Post("/registration-token", reqToken(), user.CreateRegistrationToken)
m.Get("/{runner_id}", reqToken(), user.GetRunner)
m.Delete("/{runner_id}", reqToken(), user.DeleteRunner)
m.Patch("/{runner_id}", reqToken(), bind(api.EditActionRunnerOption{}), user.UpdateRunner)
})
m.Get("/runs", reqToken(), user.ListWorkflowRuns)
@@ -1728,6 +1730,7 @@ func Routes() *web.Router {
m.Post("/registration-token", admin.CreateRegistrationToken)
m.Get("/{runner_id}", admin.GetRunner)
m.Delete("/{runner_id}", admin.DeleteRunner)
m.Patch("/{runner_id}", bind(api.EditActionRunnerOption{}), admin.UpdateRunner)
})
m.Get("/runs", admin.ListWorkflowRuns)
m.Get("/jobs", admin.ListWorkflowJobs)
+41
View File
@@ -485,6 +485,11 @@ func (Action) ListRunners(ctx *context.APIContext) {
// description: name of the organization
// type: string
// required: true
// - name: disabled
// in: query
// description: filter by disabled status (true or false)
// type: boolean
// required: false
// responses:
// "200":
// "$ref": "#/definitions/ActionRunnersResponse"
@@ -551,6 +556,42 @@ func (Action) DeleteRunner(ctx *context.APIContext) {
shared.DeleteRunner(ctx, ctx.Org.Organization.ID, 0, ctx.PathParamInt64("runner_id"))
}
// UpdateRunner update an org-level runner
func (Action) UpdateRunner(ctx *context.APIContext) {
// swagger:operation PATCH /orgs/{org}/actions/runners/{runner_id} organization updateOrgRunner
// ---
// summary: Update an org-level runner
// consumes:
// - application/json
// produces:
// - application/json
// parameters:
// - name: org
// in: path
// description: name of the organization
// type: string
// required: true
// - name: runner_id
// in: path
// description: id of the runner
// type: string
// required: true
// - name: body
// in: body
// schema:
// "$ref": "#/definitions/EditActionRunnerOption"
// responses:
// "200":
// "$ref": "#/definitions/ActionRunner"
// "400":
// "$ref": "#/responses/error"
// "404":
// "$ref": "#/responses/notFound"
// "422":
// "$ref": "#/responses/validationError"
shared.UpdateRunner(ctx, ctx.Org.Organization.ID, 0, ctx.PathParamInt64("runner_id"))
}
func (Action) ListWorkflowJobs(ctx *context.APIContext) {
// swagger:operation GET /orgs/{org}/actions/jobs organization getOrgWorkflowJobs
// ---
+50 -4
View File
@@ -554,6 +554,11 @@ func (Action) ListRunners(ctx *context.APIContext) {
// description: name of the repo
// type: string
// required: true
// - name: disabled
// in: query
// description: filter by disabled status (true or false)
// type: boolean
// required: false
// responses:
// "200":
// "$ref": "#/definitions/ActionRunnersResponse"
@@ -564,11 +569,11 @@ func (Action) ListRunners(ctx *context.APIContext) {
shared.ListRunners(ctx, 0, ctx.Repo.Repository.ID)
}
// GetRunner get an repo-level runner
// GetRunner get a repo-level runner
func (Action) GetRunner(ctx *context.APIContext) {
// swagger:operation GET /repos/{owner}/{repo}/actions/runners/{runner_id} repository getRepoRunner
// ---
// summary: Get an repo-level runner
// summary: Get a repo-level runner
// produces:
// - application/json
// parameters:
@@ -597,11 +602,11 @@ func (Action) GetRunner(ctx *context.APIContext) {
shared.GetRunner(ctx, 0, ctx.Repo.Repository.ID, ctx.PathParamInt64("runner_id"))
}
// DeleteRunner delete an repo-level runner
// DeleteRunner delete a repo-level runner
func (Action) DeleteRunner(ctx *context.APIContext) {
// swagger:operation DELETE /repos/{owner}/{repo}/actions/runners/{runner_id} repository deleteRepoRunner
// ---
// summary: Delete an repo-level runner
// summary: Delete a repo-level runner
// produces:
// - application/json
// parameters:
@@ -630,6 +635,47 @@ func (Action) DeleteRunner(ctx *context.APIContext) {
shared.DeleteRunner(ctx, 0, ctx.Repo.Repository.ID, ctx.PathParamInt64("runner_id"))
}
// UpdateRunner update a repo-level runner
func (Action) UpdateRunner(ctx *context.APIContext) {
// swagger:operation PATCH /repos/{owner}/{repo}/actions/runners/{runner_id} repository updateRepoRunner
// ---
// summary: Update a repo-level runner
// consumes:
// - application/json
// 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: runner_id
// in: path
// description: id of the runner
// type: string
// required: true
// - name: body
// in: body
// schema:
// "$ref": "#/definitions/EditActionRunnerOption"
// responses:
// "200":
// "$ref": "#/definitions/ActionRunner"
// "400":
// "$ref": "#/responses/error"
// "404":
// "$ref": "#/responses/notFound"
// "422":
// "$ref": "#/responses/validationError"
shared.UpdateRunner(ctx, 0, ctx.Repo.Repository.ID, ctx.PathParamInt64("runner_id"))
}
// GetWorkflowRunJobs Lists all jobs for a workflow run.
func (Action) ListWorkflowJobs(ctx *context.APIContext) {
// swagger:operation GET /repos/{owner}/{repo}/actions/jobs repository listWorkflowJobs
+25 -2
View File
@@ -12,6 +12,7 @@ import (
"code.gitea.io/gitea/modules/setting"
api "code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/modules/util"
"code.gitea.io/gitea/modules/web"
"code.gitea.io/gitea/routers/api/v1/utils"
"code.gitea.io/gitea/services/context"
"code.gitea.io/gitea/services/convert"
@@ -46,11 +47,13 @@ func ListRunners(ctx *context.APIContext, ownerID, repoID int64) {
if ownerID != 0 && repoID != 0 {
setting.PanicInDevOrTesting("ownerID and repoID should not be both set")
}
runners, total, err := db.FindAndCount[actions_model.ActionRunner](ctx, &actions_model.FindRunnerOptions{
opts := &actions_model.FindRunnerOptions{
OwnerID: ownerID,
RepoID: repoID,
ListOptions: utils.GetListOptions(ctx),
})
}
opts.IsDisabled = ctx.FormOptionalBool("disabled")
runners, total, err := db.FindAndCount[actions_model.ActionRunner](ctx, opts)
if err != nil {
ctx.APIErrorInternal(err)
return
@@ -125,3 +128,23 @@ func DeleteRunner(ctx *context.APIContext, ownerID, repoID, runnerID int64) {
}
ctx.Status(http.StatusNoContent)
}
func UpdateRunner(ctx *context.APIContext, ownerID, repoID, runnerID int64) {
runner, ok := getRunnerByID(ctx, ownerID, repoID, runnerID)
if !ok {
return
}
form := web.GetForm(ctx).(*api.EditActionRunnerOption)
if form.Disabled == nil {
ctx.APIError(http.StatusUnprocessableEntity, "[Disabled]: Required")
return
}
if err := actions_model.SetRunnerDisabled(ctx, runner, *form.Disabled); err != nil {
ctx.APIErrorInternal(err)
return
}
GetRunner(ctx, ownerID, repoID, runnerID)
}
+3
View File
@@ -225,6 +225,9 @@ type swaggerParameterBodies struct {
// in:body
UpdateVariableOption api.UpdateVariableOption
// in:body
EditActionRunnerOption api.EditActionRunnerOption
// in:body
LockIssueOption api.LockIssueOption
}
+41 -4
View File
@@ -32,6 +32,12 @@ func ListRunners(ctx *context.APIContext) {
// summary: Get user-level runners
// produces:
// - application/json
// parameters:
// - name: disabled
// in: query
// description: filter by disabled status (true or false)
// type: boolean
// required: false
// responses:
// "200":
// "$ref": "#/definitions/ActionRunnersResponse"
@@ -42,11 +48,11 @@ func ListRunners(ctx *context.APIContext) {
shared.ListRunners(ctx, ctx.Doer.ID, 0)
}
// GetRunner get an user-level runner
// GetRunner get a user-level runner
func GetRunner(ctx *context.APIContext) {
// swagger:operation GET /user/actions/runners/{runner_id} user getUserRunner
// ---
// summary: Get an user-level runner
// summary: Get a user-level runner
// produces:
// - application/json
// parameters:
@@ -65,11 +71,11 @@ func GetRunner(ctx *context.APIContext) {
shared.GetRunner(ctx, ctx.Doer.ID, 0, ctx.PathParamInt64("runner_id"))
}
// DeleteRunner delete an user-level runner
// DeleteRunner delete a user-level runner
func DeleteRunner(ctx *context.APIContext) {
// swagger:operation DELETE /user/actions/runners/{runner_id} user deleteUserRunner
// ---
// summary: Delete an user-level runner
// summary: Delete a user-level runner
// produces:
// - application/json
// parameters:
@@ -87,3 +93,34 @@ func DeleteRunner(ctx *context.APIContext) {
// "$ref": "#/responses/notFound"
shared.DeleteRunner(ctx, ctx.Doer.ID, 0, ctx.PathParamInt64("runner_id"))
}
// UpdateRunner update a user-level runner
func UpdateRunner(ctx *context.APIContext) {
// swagger:operation PATCH /user/actions/runners/{runner_id} user updateUserRunner
// ---
// summary: Update a user-level runner
// consumes:
// - application/json
// produces:
// - application/json
// parameters:
// - name: runner_id
// in: path
// description: id of the runner
// type: string
// required: true
// - name: body
// in: body
// schema:
// "$ref": "#/definitions/EditActionRunnerOption"
// responses:
// "200":
// "$ref": "#/definitions/ActionRunner"
// "400":
// "$ref": "#/responses/error"
// "404":
// "$ref": "#/responses/notFound"
// "422":
// "$ref": "#/responses/validationError"
shared.UpdateRunner(ctx, ctx.Doer.ID, 0, ctx.PathParamInt64("runner_id"))
}