diff --git a/models/actions/runner.go b/models/actions/runner.go
index 84398b143b..f5d40ca7d6 100644
--- a/models/actions/runner.go
+++ b/models/actions/runner.go
@@ -62,6 +62,8 @@ type ActionRunner struct {
AgentLabels []string `xorm:"TEXT"`
// Store if this is a runner that only ever get one single job assigned
Ephemeral bool `xorm:"ephemeral NOT NULL DEFAULT false"`
+ // Store if this runner is disabled and should not pick up new jobs
+ IsDisabled bool `xorm:"is_disabled NOT NULL DEFAULT false"`
Created timeutil.TimeStamp `xorm:"created"`
Updated timeutil.TimeStamp `xorm:"updated"`
@@ -199,6 +201,7 @@ type FindRunnerOptions struct {
Sort string
Filter string
IsOnline optional.Option[bool]
+ IsDisabled optional.Option[bool]
WithAvailable bool // not only runners belong to, but also runners can be used
}
@@ -239,6 +242,10 @@ func (opts FindRunnerOptions) ToConds() builder.Cond {
cond = cond.And(builder.Lte{"last_online": time.Now().Add(-RunnerOfflineTime).Unix()})
}
}
+
+ if opts.IsDisabled.Has() {
+ cond = cond.And(builder.Eq{"is_disabled": opts.IsDisabled.Value()})
+ }
return cond
}
@@ -297,6 +304,20 @@ func UpdateRunner(ctx context.Context, r *ActionRunner, cols ...string) error {
return err
}
+func SetRunnerDisabled(ctx context.Context, runner *ActionRunner, isDisabled bool) error {
+ if runner.IsDisabled == isDisabled {
+ return nil
+ }
+
+ return db.WithTx(ctx, func(ctx context.Context) error {
+ runner.IsDisabled = isDisabled
+ if err := UpdateRunner(ctx, runner, "is_disabled"); err != nil {
+ return err
+ }
+ return IncreaseTaskVersion(ctx, runner.OwnerID, runner.RepoID)
+ })
+}
+
// DeleteRunner deletes a runner by given ID.
func DeleteRunner(ctx context.Context, id int64) error {
if _, err := GetRunnerByID(ctx, id); err != nil {
diff --git a/models/migrations/migrations.go b/models/migrations/migrations.go
index ab14e8a8e7..c1d448577c 100644
--- a/models/migrations/migrations.go
+++ b/models/migrations/migrations.go
@@ -401,6 +401,7 @@ func prepareMigrationTasks() []*migration {
newMigration(324, "Fix closed milestone completeness for milestones with no issues", v1_26.FixClosedMilestoneCompleteness),
newMigration(325, "Fix missed repo_id when migrate attachments", v1_26.FixMissedRepoIDWhenMigrateAttachments),
newMigration(326, "Migrate commit status target URL to use run ID and job ID", v1_26.FixCommitStatusTargetURLToUseRunAndJobID),
+ newMigration(327, "Add disabled state to action runners", v1_26.AddDisabledToActionRunner),
}
return preparedMigrations
}
diff --git a/models/migrations/v1_26/v327.go b/models/migrations/v1_26/v327.go
new file mode 100644
index 0000000000..51af567650
--- /dev/null
+++ b/models/migrations/v1_26/v327.go
@@ -0,0 +1,17 @@
+// Copyright 2026 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package v1_26
+
+import "xorm.io/xorm"
+
+func AddDisabledToActionRunner(x *xorm.Engine) error {
+ type ActionRunner struct {
+ IsDisabled bool `xorm:"is_disabled NOT NULL DEFAULT false"`
+ }
+
+ _, err := x.SyncWithOptions(xorm.SyncOptions{
+ IgnoreDropIndices: true,
+ }, new(ActionRunner))
+ return err
+}
diff --git a/models/migrations/v1_26/v327_test.go b/models/migrations/v1_26/v327_test.go
new file mode 100644
index 0000000000..971707be4f
--- /dev/null
+++ b/models/migrations/v1_26/v327_test.go
@@ -0,0 +1,33 @@
+// Copyright 2026 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package v1_26
+
+import (
+ "testing"
+
+ "code.gitea.io/gitea/models/migrations/base"
+
+ "github.com/stretchr/testify/require"
+)
+
+func Test_AddDisabledToActionRunner(t *testing.T) {
+ type ActionRunner struct {
+ ID int64 `xorm:"pk autoincr"`
+ Name string
+ }
+
+ x, deferable := base.PrepareTestEnv(t, 0, new(ActionRunner))
+ defer deferable()
+
+ _, err := x.Insert(&ActionRunner{Name: "runner"})
+ require.NoError(t, err)
+
+ require.NoError(t, AddDisabledToActionRunner(x))
+
+ var isDisabled bool
+ has, err := x.SQL("SELECT is_disabled FROM action_runner WHERE id = ?", 1).Get(&isDisabled)
+ require.NoError(t, err)
+ require.True(t, has)
+ require.False(t, isDisabled)
+}
diff --git a/modules/structs/repo_actions.go b/modules/structs/repo_actions.go
index 86bf4959d1..92ca9bccce 100644
--- a/modules/structs/repo_actions.go
+++ b/modules/structs/repo_actions.go
@@ -196,10 +196,18 @@ type ActionRunner struct {
Name string `json:"name"`
Status string `json:"status"`
Busy bool `json:"busy"`
+ Disabled bool `json:"disabled"`
Ephemeral bool `json:"ephemeral"`
Labels []*ActionRunnerLabel `json:"labels"`
}
+// EditActionRunnerOption represents the editable fields for a runner.
+// swagger:model
+type EditActionRunnerOption struct {
+ // required: true
+ Disabled *bool `json:"disabled"`
+}
+
// ActionRunnersResponse returns Runners
type ActionRunnersResponse struct {
Entries []*ActionRunner `json:"runners"`
diff --git a/options/locale/locale_en-US.json b/options/locale/locale_en-US.json
index 27695a8f5a..5a5148a146 100644
--- a/options/locale/locale_en-US.json
+++ b/options/locale/locale_en-US.json
@@ -3644,6 +3644,7 @@
"actions.runners.id": "ID",
"actions.runners.name": "Name",
"actions.runners.owner_type": "Type",
+ "actions.runners.availability": "Availability",
"actions.runners.description": "Description",
"actions.runners.labels": "Labels",
"actions.runners.last_online": "Last Online Time",
@@ -3659,6 +3660,12 @@
"actions.runners.update_runner": "Update Changes",
"actions.runners.update_runner_success": "Runner updated successfully",
"actions.runners.update_runner_failed": "Failed to update runner",
+ "actions.runners.enable_runner": "Enable this runner",
+ "actions.runners.enable_runner_success": "Runner enabled successfully",
+ "actions.runners.enable_runner_failed": "Failed to enable runner",
+ "actions.runners.disable_runner": "Disable this runner",
+ "actions.runners.disable_runner_success": "Runner disabled successfully",
+ "actions.runners.disable_runner_failed": "Failed to disable runner",
"actions.runners.delete_runner": "Delete this runner",
"actions.runners.delete_runner_success": "Runner deleted successfully",
"actions.runners.delete_runner_failed": "Failed to delete runner",
diff --git a/routers/api/actions/runner/runner.go b/routers/api/actions/runner/runner.go
index 49d1b13262..190dc69744 100644
--- a/routers/api/actions/runner/runner.go
+++ b/routers/api/actions/runner/runner.go
@@ -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 {
diff --git a/routers/api/v1/admin/runners.go b/routers/api/v1/admin/runners.go
index f5aaea2c32..93983f6c7e 100644
--- a/routers/api/v1/admin/runners.go
+++ b/routers/api/v1/admin/runners.go
@@ -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"))
+}
diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go
index e95e597932..560894b798 100644
--- a/routers/api/v1/api.go
+++ b/routers/api/v1/api.go
@@ -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)
diff --git a/routers/api/v1/org/action.go b/routers/api/v1/org/action.go
index 687e9fcbfb..18ed602ddb 100644
--- a/routers/api/v1/org/action.go
+++ b/routers/api/v1/org/action.go
@@ -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
// ---
diff --git a/routers/api/v1/repo/action.go b/routers/api/v1/repo/action.go
index 2d28e7ae86..d7e51b8046 100644
--- a/routers/api/v1/repo/action.go
+++ b/routers/api/v1/repo/action.go
@@ -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
diff --git a/routers/api/v1/shared/runners.go b/routers/api/v1/shared/runners.go
index e9834aff9f..b2e07cf218 100644
--- a/routers/api/v1/shared/runners.go
+++ b/routers/api/v1/shared/runners.go
@@ -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)
+}
diff --git a/routers/api/v1/swagger/options.go b/routers/api/v1/swagger/options.go
index 310839374b..f66cef61df 100644
--- a/routers/api/v1/swagger/options.go
+++ b/routers/api/v1/swagger/options.go
@@ -225,6 +225,9 @@ type swaggerParameterBodies struct {
// in:body
UpdateVariableOption api.UpdateVariableOption
+ // in:body
+ EditActionRunnerOption api.EditActionRunnerOption
+
// in:body
LockIssueOption api.LockIssueOption
}
diff --git a/routers/api/v1/user/runners.go b/routers/api/v1/user/runners.go
index 77b0f59c1e..667bdb36fe 100644
--- a/routers/api/v1/user/runners.go
+++ b/routers/api/v1/user/runners.go
@@ -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"))
+}
diff --git a/routers/web/repo/actions/actions.go b/routers/web/repo/actions/actions.go
index 8e8557305b..988d2d0a99 100644
--- a/routers/web/repo/actions/actions.go
+++ b/routers/web/repo/actions/actions.go
@@ -317,7 +317,7 @@ func prepareWorkflowList(ctx *context.Context, workflows []WorkflowInfo) {
}
hasOnlineRunner := false
for _, runner := range runners {
- if runner.CanMatchLabels(job.RunsOn) {
+ if !runner.IsDisabled && runner.CanMatchLabels(job.RunsOn) {
hasOnlineRunner = true
break
}
diff --git a/routers/web/shared/actions/runners.go b/routers/web/shared/actions/runners.go
index 577dad822c..8a4e93fe82 100644
--- a/routers/web/shared/actions/runners.go
+++ b/routers/web/shared/actions/runners.go
@@ -321,6 +321,47 @@ func RunnerDeletePost(ctx *context.Context) {
ctx.JSONRedirect(successRedirectTo)
}
+func RunnerUpdatePost(ctx *context.Context) {
+ rCtx, err := getRunnersCtx(ctx)
+ if err != nil {
+ ctx.ServerError("getRunnersCtx", err)
+ return
+ }
+
+ runner := findActionsRunner(ctx, rCtx)
+ if ctx.Written() {
+ return
+ }
+
+ if !runner.EditableInContext(rCtx.OwnerID, rCtx.RepoID) {
+ ctx.NotFound(util.NewPermissionDeniedErrorf("no permission to edit this runner"))
+ return
+ }
+
+ isDisabled := ctx.FormOptionalBool("disabled")
+ if !isDisabled.Has() {
+ ctx.HTTPError(http.StatusBadRequest, "missing 'disabled' parameter")
+ return
+ }
+
+ successKey := "actions.runners.enable_runner_success"
+ failedKey := "actions.runners.enable_runner_failed"
+ if isDisabled.Value() {
+ successKey = "actions.runners.disable_runner_success"
+ failedKey = "actions.runners.disable_runner_failed"
+ }
+
+ if err := actions_model.SetRunnerDisabled(ctx, runner, isDisabled.Value()); err != nil {
+ log.Warn("RunnerUpdatePost.SetRunnerDisabled failed: %v, url: %s", err, ctx.Req.URL)
+ ctx.Flash.Error(ctx.Tr(failedKey))
+ ctx.JSONRedirect("")
+ return
+ }
+
+ ctx.Flash.Success(ctx.Tr(successKey))
+ ctx.JSONRedirect("")
+}
+
func RedirectToDefaultSetting(ctx *context.Context) {
ctx.Redirect(ctx.Repo.RepoLink + "/settings/actions/runners")
}
diff --git a/routers/web/web.go b/routers/web/web.go
index 95a54b5244..8da7609994 100644
--- a/routers/web/web.go
+++ b/routers/web/web.go
@@ -493,6 +493,7 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) {
m.Get("", shared_actions.Runners)
m.Combo("/{runnerid}").Get(shared_actions.RunnersEdit).
Post(web.Bind(forms.EditRunnerForm{}), shared_actions.RunnersEditPost)
+ m.Post("/{runnerid}/update-runner", shared_actions.RunnerUpdatePost)
m.Post("/{runnerid}/delete", shared_actions.RunnerDeletePost)
m.Post("/reset_registration_token", shared_actions.ResetRunnerRegistrationToken)
})
diff --git a/services/actions/interface.go b/services/actions/interface.go
index b1725550e1..15e6381052 100644
--- a/services/actions/interface.go
+++ b/services/actions/interface.go
@@ -31,6 +31,8 @@ type API interface {
GetRunner(*context.APIContext)
// DeleteRunner delete runner
DeleteRunner(*context.APIContext)
+ // UpdateRunner update runner
+ UpdateRunner(*context.APIContext)
// ListWorkflowJobs list jobs
ListWorkflowJobs(*context.APIContext)
// ListWorkflowRuns list runs
diff --git a/services/actions/task.go b/services/actions/task.go
index cf2164f456..a21b600998 100644
--- a/services/actions/task.go
+++ b/services/actions/task.go
@@ -24,6 +24,10 @@ func PickTask(ctx context.Context, runner *actions_model.ActionRunner) (*runnerv
actionTask *actions_model.ActionTask
)
+ if runner.IsDisabled {
+ return nil, false, nil
+ }
+
if runner.Ephemeral {
var task actions_model.ActionTask
has, err := db.GetEngine(ctx).Where("runner_id = ?", runner.ID).Get(&task)
diff --git a/services/convert/convert.go b/services/convert/convert.go
index 8bef63f7cd..d9aea7d7fa 100644
--- a/services/convert/convert.go
+++ b/services/convert/convert.go
@@ -521,6 +521,7 @@ func ToActionRunner(ctx context.Context, runner *actions_model.ActionRunner) *ap
Name: runner.Name,
Status: apiStatus,
Busy: status == runnerv1.RunnerStatus_RUNNER_STATUS_ACTIVE,
+ Disabled: runner.IsDisabled,
Ephemeral: runner.Ephemeral,
Labels: labels,
}
diff --git a/templates/shared/actions/runner_edit.tmpl b/templates/shared/actions/runner_edit.tmpl
index dbf4104fe5..4753711c2f 100644
--- a/templates/shared/actions/runner_edit.tmpl
+++ b/templates/shared/actions/runner_edit.tmpl
@@ -10,6 +10,16 @@
{{.Runner.StatusLocaleName ctx.Locale}}
+
+
+
+ {{if .Runner.IsDisabled}}
+ {{ctx.Locale.Tr "disabled"}}
+ {{else}}
+ {{ctx.Locale.Tr "enabled"}}
+ {{end}}
+
+
{{if .Runner.LastOnline}}{{DateUtils.TimeSince .Runner.LastOnline}}{{else}}{{ctx.Locale.Tr "never"}}{{end}}
@@ -39,6 +49,9 @@
+
diff --git a/templates/shared/actions/runner_list.tmpl b/templates/shared/actions/runner_list.tmpl
index 9c541c95ee..90eb4591d7 100644
--- a/templates/shared/actions/runner_list.tmpl
+++ b/templates/shared/actions/runner_list.tmpl
@@ -66,7 +66,10 @@
{{range .Runners}}
- | {{.StatusLocaleName ctx.Locale}} |
+
+ {{.StatusLocaleName ctx.Locale}}
+ {{if .IsDisabled}}{{ctx.Locale.Tr "actions.runners.disabled"}}{{end}}
+ |
{{.ID}} |
{{.Name}} |
{{if .Version}}{{.Version}}{{else}}{{ctx.Locale.Tr "unknown"}}{{end}} |
diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl
index c9f2e96858..fab51203d1 100644
--- a/templates/swagger/v1_json.tmpl
+++ b/templates/swagger/v1_json.tmpl
@@ -76,6 +76,14 @@
],
"summary": "Get all runners",
"operationId": "getAdminRunners",
+ "parameters": [
+ {
+ "type": "boolean",
+ "description": "filter by disabled status (true or false)",
+ "name": "disabled",
+ "in": "query"
+ }
+ ],
"responses": {
"200": {
"$ref": "#/definitions/ActionRunnersResponse"
@@ -166,6 +174,49 @@
"$ref": "#/responses/notFound"
}
}
+ },
+ "patch": {
+ "consumes": [
+ "application/json"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "admin"
+ ],
+ "summary": "Update a global runner",
+ "operationId": "updateAdminRunner",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "id of the runner",
+ "name": "runner_id",
+ "in": "path",
+ "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"
+ }
+ }
}
},
"/admin/actions/runs": {
@@ -1947,6 +1998,12 @@
"name": "org",
"in": "path",
"required": true
+ },
+ {
+ "type": "boolean",
+ "description": "filter by disabled status (true or false)",
+ "name": "disabled",
+ "in": "query"
}
],
"responses": {
@@ -2062,6 +2119,56 @@
"$ref": "#/responses/notFound"
}
}
+ },
+ "patch": {
+ "consumes": [
+ "application/json"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "organization"
+ ],
+ "summary": "Update an org-level runner",
+ "operationId": "updateOrgRunner",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "name of the organization",
+ "name": "org",
+ "in": "path",
+ "required": true
+ },
+ {
+ "type": "string",
+ "description": "id of the runner",
+ "name": "runner_id",
+ "in": "path",
+ "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"
+ }
+ }
}
},
"/orgs/{org}/actions/runs": {
@@ -4872,6 +4979,12 @@
"name": "repo",
"in": "path",
"required": true
+ },
+ {
+ "type": "boolean",
+ "description": "filter by disabled status (true or false)",
+ "name": "disabled",
+ "in": "query"
}
],
"responses": {
@@ -4928,7 +5041,7 @@
"tags": [
"repository"
],
- "summary": "Get an repo-level runner",
+ "summary": "Get a repo-level runner",
"operationId": "getRepoRunner",
"parameters": [
{
@@ -4972,7 +5085,7 @@
"tags": [
"repository"
],
- "summary": "Delete an repo-level runner",
+ "summary": "Delete a repo-level runner",
"operationId": "deleteRepoRunner",
"parameters": [
{
@@ -5008,6 +5121,63 @@
"$ref": "#/responses/notFound"
}
}
+ },
+ "patch": {
+ "consumes": [
+ "application/json"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "repository"
+ ],
+ "summary": "Update a repo-level runner",
+ "operationId": "updateRepoRunner",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "owner of the repo",
+ "name": "owner",
+ "in": "path",
+ "required": true
+ },
+ {
+ "type": "string",
+ "description": "name of the repo",
+ "name": "repo",
+ "in": "path",
+ "required": true
+ },
+ {
+ "type": "string",
+ "description": "id of the runner",
+ "name": "runner_id",
+ "in": "path",
+ "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"
+ }
+ }
}
},
"/repos/{owner}/{repo}/actions/runs": {
@@ -18441,6 +18611,14 @@
],
"summary": "Get user-level runners",
"operationId": "getUserRunners",
+ "parameters": [
+ {
+ "type": "boolean",
+ "description": "filter by disabled status (true or false)",
+ "name": "disabled",
+ "in": "query"
+ }
+ ],
"responses": {
"200": {
"$ref": "#/definitions/ActionRunnersResponse"
@@ -18479,7 +18657,7 @@
"tags": [
"user"
],
- "summary": "Get an user-level runner",
+ "summary": "Get a user-level runner",
"operationId": "getUserRunner",
"parameters": [
{
@@ -18509,7 +18687,7 @@
"tags": [
"user"
],
- "summary": "Delete an user-level runner",
+ "summary": "Delete a user-level runner",
"operationId": "deleteUserRunner",
"parameters": [
{
@@ -18531,6 +18709,49 @@
"$ref": "#/responses/notFound"
}
}
+ },
+ "patch": {
+ "consumes": [
+ "application/json"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "user"
+ ],
+ "summary": "Update a user-level runner",
+ "operationId": "updateUserRunner",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "id of the runner",
+ "name": "runner_id",
+ "in": "path",
+ "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"
+ }
+ }
}
},
"/user/actions/runs": {
@@ -21115,6 +21336,10 @@
"type": "boolean",
"x-go-name": "Busy"
},
+ "disabled": {
+ "type": "boolean",
+ "x-go-name": "Disabled"
+ },
"ephemeral": {
"type": "boolean",
"x-go-name": "Ephemeral"
@@ -24205,6 +24430,20 @@
},
"x-go-package": "code.gitea.io/gitea/modules/structs"
},
+ "EditActionRunnerOption": {
+ "type": "object",
+ "title": "EditActionRunnerOption represents the editable fields for a runner.",
+ "required": [
+ "disabled"
+ ],
+ "properties": {
+ "disabled": {
+ "type": "boolean",
+ "x-go-name": "Disabled"
+ }
+ },
+ "x-go-package": "code.gitea.io/gitea/modules/structs"
+ },
"EditAttachmentOptions": {
"description": "EditAttachmentOptions options for editing attachments",
"type": "object",
diff --git a/tests/integration/actions_job_test.go b/tests/integration/actions_job_test.go
index 4f4456a4e5..3da290f1d3 100644
--- a/tests/integration/actions_job_test.go
+++ b/tests/integration/actions_job_test.go
@@ -27,6 +27,7 @@ import (
runnerv1 "code.gitea.io/actions-proto-go/runner/v1"
"connectrpc.com/connect"
"github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
)
func TestJobWithNeeds(t *testing.T) {
@@ -349,6 +350,122 @@ jobs:
})
}
+func TestRunnerDisableEnable(t *testing.T) {
+ onGiteaRun(t, func(t *testing.T, _ *url.URL) {
+ user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+ session := loginUser(t, user2.Name)
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser)
+
+ t.Run("BasicDisableEnable", func(t *testing.T) {
+ testData := prepareRunnerDisableEnableTest(t, user2, token, "actions-runner-disable-enable", "mock-runner", "runner-disable-enable")
+
+ task1 := testData.runner.fetchTask(t)
+ require.NotNil(t, task1)
+
+ triggerRunnerDisableEnableRun(t, user2, token, testData.repo, "second-push.txt")
+
+ req := newRunnerUpdateRequest(t, fmt.Sprintf("/api/v1/repos/%s/%s/actions/runners/%d", user2.Name, testData.repo.Name, testData.runnerID), true).AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusOK)
+
+ testData.runner.execTask(t, task1, &mockTaskOutcome{result: runnerv1.Result_RESULT_SUCCESS})
+ testData.runner.fetchNoTask(t, 2*time.Second)
+
+ req = newRunnerUpdateRequest(t, fmt.Sprintf("/api/v1/repos/%s/%s/actions/runners/%d", user2.Name, testData.repo.Name, testData.runnerID), false).AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusOK)
+
+ task2 := testData.runner.fetchTask(t, 5*time.Second)
+ require.NotNil(t, task2)
+ testData.runner.execTask(t, task2, &mockTaskOutcome{result: runnerv1.Result_RESULT_SUCCESS})
+ })
+
+ t.Run("TasksVersionPath", func(t *testing.T) {
+ testData := prepareRunnerDisableEnableTest(t, user2, token, "actions-runner-version-path", "mock-runner-version-path", "runner-version-path")
+
+ var firstVersion int64
+ var task1 *runnerv1.Task
+ ddl := time.Now().Add(5 * time.Second)
+ for time.Now().Before(ddl) {
+ task1, firstVersion = testData.runner.fetchTaskOnce(t, 0)
+ if task1 != nil {
+ break
+ }
+ time.Sleep(200 * time.Millisecond)
+ }
+ require.NotNil(t, task1, "expected to receive first task")
+ require.NotZero(t, firstVersion, "response TasksVersion should be set")
+
+ // Trigger a second run so there is a pending job after we re-enable the runner
+ triggerRunnerDisableEnableRun(t, user2, token, testData.repo, "second-push.txt")
+ time.Sleep(500 * time.Millisecond) // allow workflow run to be created
+
+ req := newRunnerUpdateRequest(t, fmt.Sprintf("/api/v1/repos/%s/%s/actions/runners/%d", user2.Name, testData.repo.Name, testData.runnerID), true).AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusOK)
+
+ testData.runner.execTask(t, task1, &mockTaskOutcome{result: runnerv1.Result_RESULT_SUCCESS})
+
+ // Fetch with the version we had before disable. Server has bumped version on disable,
+ // so we enter PickTask with a re-loaded runner (disabled) and get no task.
+ taskAfterDisable, _ := testData.runner.fetchTaskOnce(t, firstVersion)
+ assert.Nil(t, taskAfterDisable, "disabled runner must not receive a task when sending previous TasksVersion")
+
+ req = newRunnerUpdateRequest(t, fmt.Sprintf("/api/v1/repos/%s/%s/actions/runners/%d", user2.Name, testData.repo.Name, testData.runnerID), false).AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusOK)
+
+ task2 := testData.runner.fetchTask(t, 5*time.Second)
+ require.NotNil(t, task2, "after re-enable runner should receive tasks again")
+ testData.runner.execTask(t, task2, &mockTaskOutcome{result: runnerv1.Result_RESULT_SUCCESS})
+ })
+ })
+}
+
+type runnerDisableEnableTestData struct {
+ repo *api.Repository
+ runner *mockRunner
+ runnerID int64
+}
+
+func prepareRunnerDisableEnableTest(t *testing.T, user *user_model.User, authToken, repoName, runnerName, workflowName string) *runnerDisableEnableTestData {
+ t.Helper()
+
+ apiRepo := createActionsTestRepo(t, authToken, repoName, false)
+ runner := newMockRunner()
+ runner.registerAsRepoRunner(t, user.Name, apiRepo.Name, runnerName, []string{"ubuntu-latest"}, false)
+
+ wfTreePath := fmt.Sprintf(".gitea/workflows/%s.yml", workflowName)
+ wfContent := fmt.Sprintf(`name: %s
+on: [push]
+jobs:
+ build:
+ runs-on: ubuntu-latest
+ steps:
+ - run: echo %s
+`, workflowName, workflowName)
+ opts := getWorkflowCreateFileOptions(user, apiRepo.DefaultBranch, "create workflow", wfContent)
+ createWorkflowFile(t, authToken, user.Name, apiRepo.Name, wfTreePath, opts)
+
+ return &runnerDisableEnableTestData{
+ repo: apiRepo,
+ runner: runner,
+ runnerID: getRepoRunnerID(t, authToken, user.Name, apiRepo.Name),
+ }
+}
+
+func triggerRunnerDisableEnableRun(t *testing.T, user *user_model.User, authToken string, repo *api.Repository, treePath string) {
+ t.Helper()
+ opts := getWorkflowCreateFileOptions(user, repo.DefaultBranch, "second push", "second run")
+ createWorkflowFile(t, authToken, user.Name, repo.Name, treePath, opts)
+}
+
+func getRepoRunnerID(t *testing.T, authToken, ownerName, repoName string) int64 {
+ t.Helper()
+ req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/%s/actions/runners", ownerName, repoName)).AddTokenAuth(authToken)
+ resp := MakeRequest(t, req, http.StatusOK)
+ runnerList := api.ActionRunnersResponse{}
+ DecodeJSON(t, resp, &runnerList)
+ require.Len(t, runnerList.Entries, 1)
+ return runnerList.Entries[0].ID
+}
+
func TestActionsGiteaContext(t *testing.T) {
onGiteaRun(t, func(t *testing.T, u *url.URL) {
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
diff --git a/tests/integration/actions_runner_modify_test.go b/tests/integration/actions_runner_modify_test.go
index 8a65dd625f..eb5206f73d 100644
--- a/tests/integration/actions_runner_modify_test.go
+++ b/tests/integration/actions_runner_modify_test.go
@@ -60,17 +60,36 @@ func TestActionsRunnerModify(t *testing.T) {
sess.MakeRequest(t, req, expectedStatus)
}
+ doDisable := func(t *testing.T, sess *TestSession, baseURL string, id int64, expectedStatus int) {
+ req := NewRequest(t, "POST", fmt.Sprintf("%s/%d/update-runner?disabled=true", baseURL, id))
+ sess.MakeRequest(t, req, expectedStatus)
+ }
+
+ doEnable := func(t *testing.T, sess *TestSession, baseURL string, id int64, expectedStatus int) {
+ req := NewRequest(t, "POST", fmt.Sprintf("%s/%d/update-runner?disabled=false", baseURL, id))
+ sess.MakeRequest(t, req, expectedStatus)
+ }
+
assertDenied := func(t *testing.T, sess *TestSession, baseURL string, id int64) {
doUpdate(t, sess, baseURL, id, "ChangedDescription", http.StatusNotFound)
+ doDisable(t, sess, baseURL, id, http.StatusNotFound)
+ doEnable(t, sess, baseURL, id, http.StatusNotFound)
doDelete(t, sess, baseURL, id, http.StatusNotFound)
v := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunner{ID: id})
assert.Empty(t, v.Description)
+ assert.False(t, v.IsDisabled)
}
assertSuccess := func(t *testing.T, sess *TestSession, baseURL string, id int64) {
doUpdate(t, sess, baseURL, id, "ChangedDescription", http.StatusSeeOther)
v := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunner{ID: id})
assert.Equal(t, "ChangedDescription", v.Description)
+ doDisable(t, sess, baseURL, id, http.StatusOK)
+ v = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunner{ID: id})
+ assert.True(t, v.IsDisabled)
+ doEnable(t, sess, baseURL, id, http.StatusOK)
+ v = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunner{ID: id})
+ assert.False(t, v.IsDisabled)
doDelete(t, sess, baseURL, id, http.StatusOK)
unittest.AssertNotExistsBean(t, &actions_model.ActionRunner{ID: id})
}
diff --git a/tests/integration/actions_runner_test.go b/tests/integration/actions_runner_test.go
index 3c48ce2409..9ac2fb6e13 100644
--- a/tests/integration/actions_runner_test.go
+++ b/tests/integration/actions_runner_test.go
@@ -19,6 +19,7 @@ import (
"code.gitea.io/actions-proto-go/runner/v1/runnerv1connect"
"connectrpc.com/connect"
"github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
"google.golang.org/protobuf/types/known/timestamppb"
)
@@ -113,12 +114,8 @@ func (r *mockRunner) tryFetchTask(t *testing.T, timeout ...time.Duration) *runne
ddl := time.Now().Add(fetchTimeout)
var task *runnerv1.Task
for time.Now().Before(ddl) {
- resp, err := r.client.runnerServiceClient.FetchTask(t.Context(), connect.NewRequest(&runnerv1.FetchTaskRequest{
- TasksVersion: 0,
- }))
- assert.NoError(t, err)
- if resp.Msg.Task != nil {
- task = resp.Msg.Task
+ task, _ = r.fetchTaskOnce(t, 0)
+ if task != nil {
break
}
time.Sleep(200 * time.Millisecond)
@@ -127,6 +124,17 @@ func (r *mockRunner) tryFetchTask(t *testing.T, timeout ...time.Duration) *runne
return task
}
+// fetchTaskOnce performs a single FetchTask request with the given TasksVersion
+// and returns the task (if any) and the TasksVersion from the response.
+// Used to verify the production path where the runner sends the current version.
+func (r *mockRunner) fetchTaskOnce(t *testing.T, tasksVersion int64) (*runnerv1.Task, int64) {
+ resp, err := r.client.runnerServiceClient.FetchTask(t.Context(), connect.NewRequest(&runnerv1.FetchTaskRequest{
+ TasksVersion: tasksVersion,
+ }))
+ require.NoError(t, err)
+ return resp.Msg.Task, resp.Msg.TasksVersion
+}
+
type mockTaskOutcome struct {
result runnerv1.Result
outputs map[string]string
diff --git a/tests/integration/api_actions_runner_test.go b/tests/integration/api_actions_runner_test.go
index fb9ba5b0c2..74788ec32e 100644
--- a/tests/integration/api_actions_runner_test.go
+++ b/tests/integration/api_actions_runner_test.go
@@ -24,6 +24,12 @@ func TestAPIActionsRunner(t *testing.T) {
t.Run("RepoRunner", testActionsRunnerRepo)
}
+func newRunnerUpdateRequest(t *testing.T, url string, disabled bool) *RequestWrapper {
+ return NewRequestWithJSON(t, http.MethodPatch, url, api.EditActionRunnerOption{
+ Disabled: &disabled,
+ })
+}
+
func testActionsRunnerAdmin(t *testing.T) {
defer tests.PrepareTestEnv(t)()
adminUsername := "user1"
@@ -45,22 +51,39 @@ func testActionsRunnerAdmin(t *testing.T) {
require.NotEqual(t, -1, idx)
expectedRunner := runnerList.Entries[idx]
assert.Equal(t, "runner_to_be_deleted", expectedRunner.Name)
+ assert.False(t, expectedRunner.Disabled)
assert.False(t, expectedRunner.Ephemeral)
assert.Len(t, expectedRunner.Labels, 2)
assert.Equal(t, "runner_to_be_deleted", expectedRunner.Labels[0].Name)
assert.Equal(t, "linux", expectedRunner.Labels[1].Name)
+ req = newRunnerUpdateRequest(t, fmt.Sprintf("/api/v1/admin/actions/runners/%d", expectedRunner.ID), true).AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusOK)
+ req = newRunnerUpdateRequest(t, fmt.Sprintf("/api/v1/admin/actions/runners/%d", expectedRunner.ID), true).AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusOK)
+ req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/admin/actions/runners/%d", expectedRunner.ID)).AddTokenAuth(token)
+ runnerResp := MakeRequest(t, req, http.StatusOK)
+ disabledRunner := api.ActionRunner{}
+ DecodeJSON(t, runnerResp, &disabledRunner)
+ assert.True(t, disabledRunner.Disabled)
+
+ req = newRunnerUpdateRequest(t, fmt.Sprintf("/api/v1/admin/actions/runners/%d", expectedRunner.ID), false).AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusOK)
+ req = newRunnerUpdateRequest(t, fmt.Sprintf("/api/v1/admin/actions/runners/%d", expectedRunner.ID), false).AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusOK)
+
// Verify all returned runners can be requested and deleted
for _, runnerEntry := range runnerList.Entries {
// Verify get the runner by id
req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/admin/actions/runners/%d", runnerEntry.ID)).AddTokenAuth(token)
- runnerResp := MakeRequest(t, req, http.StatusOK)
+ runnerResp = MakeRequest(t, req, http.StatusOK)
runner := api.ActionRunner{}
DecodeJSON(t, runnerResp, &runner)
assert.Equal(t, runnerEntry.Name, runner.Name)
assert.Equal(t, runnerEntry.ID, runner.ID)
+ assert.Equal(t, runnerEntry.Disabled, runner.Disabled)
assert.Equal(t, runnerEntry.Ephemeral, runner.Ephemeral)
assert.ElementsMatch(t, runnerEntry.Labels, runner.Labels)
@@ -93,6 +116,7 @@ func testActionsRunnerUser(t *testing.T) {
assert.Len(t, runnerList.Entries, 1)
assert.Equal(t, "runner_to_be_deleted-user", runnerList.Entries[0].Name)
assert.Equal(t, int64(34346), runnerList.Entries[0].ID)
+ assert.False(t, runnerList.Entries[0].Disabled)
assert.False(t, runnerList.Entries[0].Ephemeral)
assert.Len(t, runnerList.Entries[0].Labels, 2)
assert.Equal(t, "runner_to_be_deleted", runnerList.Entries[0].Labels[0].Name)
@@ -107,11 +131,26 @@ func testActionsRunnerUser(t *testing.T) {
assert.Equal(t, "runner_to_be_deleted-user", runner.Name)
assert.Equal(t, int64(34346), runner.ID)
+ assert.False(t, runner.Disabled)
assert.False(t, runner.Ephemeral)
assert.Len(t, runner.Labels, 2)
assert.Equal(t, "runner_to_be_deleted", runner.Labels[0].Name)
assert.Equal(t, "linux", runner.Labels[1].Name)
+ req = newRunnerUpdateRequest(t, fmt.Sprintf("/api/v1/user/actions/runners/%d", runnerList.Entries[0].ID), true).AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusOK)
+ req = newRunnerUpdateRequest(t, fmt.Sprintf("/api/v1/user/actions/runners/%d", runnerList.Entries[0].ID), true).AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusOK)
+ req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/user/actions/runners/%d", runnerList.Entries[0].ID)).AddTokenAuth(token)
+ runnerResp = MakeRequest(t, req, http.StatusOK)
+ DecodeJSON(t, runnerResp, &runner)
+ assert.True(t, runner.Disabled)
+
+ req = newRunnerUpdateRequest(t, fmt.Sprintf("/api/v1/user/actions/runners/%d", runnerList.Entries[0].ID), false).AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusOK)
+ req = newRunnerUpdateRequest(t, fmt.Sprintf("/api/v1/user/actions/runners/%d", runnerList.Entries[0].ID), false).AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusOK)
+
// Verify delete the runner by id
req = NewRequest(t, "DELETE", fmt.Sprintf("/api/v1/user/actions/runners/%d", runnerList.Entries[0].ID)).AddTokenAuth(token)
MakeRequest(t, req, http.StatusNoContent)
@@ -136,6 +175,7 @@ func testActionsRunnerOwner(t *testing.T) {
assert.Equal(t, "runner_to_be_deleted-org", runner.Name)
assert.Equal(t, int64(34347), runner.ID)
+ assert.False(t, runner.Disabled)
assert.False(t, runner.Ephemeral)
assert.Len(t, runner.Labels, 2)
assert.Equal(t, "runner_to_be_deleted", runner.Labels[0].Name)
@@ -165,6 +205,7 @@ func testActionsRunnerOwner(t *testing.T) {
require.NotNil(t, expectedRunner)
assert.Equal(t, "runner_to_be_deleted-org", expectedRunner.Name)
assert.Equal(t, int64(34347), expectedRunner.ID)
+ assert.False(t, expectedRunner.Disabled)
assert.False(t, expectedRunner.Ephemeral)
assert.Len(t, expectedRunner.Labels, 2)
assert.Equal(t, "runner_to_be_deleted", expectedRunner.Labels[0].Name)
@@ -179,11 +220,26 @@ func testActionsRunnerOwner(t *testing.T) {
assert.Equal(t, "runner_to_be_deleted-org", runner.Name)
assert.Equal(t, int64(34347), runner.ID)
+ assert.False(t, runner.Disabled)
assert.False(t, runner.Ephemeral)
assert.Len(t, runner.Labels, 2)
assert.Equal(t, "runner_to_be_deleted", runner.Labels[0].Name)
assert.Equal(t, "linux", runner.Labels[1].Name)
+ req = newRunnerUpdateRequest(t, fmt.Sprintf("/api/v1/orgs/org3/actions/runners/%d", expectedRunner.ID), true).AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusOK)
+ req = newRunnerUpdateRequest(t, fmt.Sprintf("/api/v1/orgs/org3/actions/runners/%d", expectedRunner.ID), true).AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusOK)
+ req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/orgs/org3/actions/runners/%d", expectedRunner.ID)).AddTokenAuth(token)
+ runnerResp = MakeRequest(t, req, http.StatusOK)
+ DecodeJSON(t, runnerResp, &runner)
+ assert.True(t, runner.Disabled)
+
+ req = newRunnerUpdateRequest(t, fmt.Sprintf("/api/v1/orgs/org3/actions/runners/%d", expectedRunner.ID), false).AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusOK)
+ req = newRunnerUpdateRequest(t, fmt.Sprintf("/api/v1/orgs/org3/actions/runners/%d", expectedRunner.ID), false).AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusOK)
+
// Verify delete the runner by id
req = NewRequest(t, "DELETE", fmt.Sprintf("/api/v1/orgs/org3/actions/runners/%d", expectedRunner.ID)).AddTokenAuth(token)
MakeRequest(t, req, http.StatusNoContent)
@@ -202,6 +258,22 @@ func testActionsRunnerOwner(t *testing.T) {
MakeRequest(t, req, http.StatusForbidden)
})
+ t.Run("DisableReadScopeForbidden", func(t *testing.T) {
+ userUsername := "user2"
+ token := getUserToken(t, userUsername, auth_model.AccessTokenScopeReadOrganization)
+
+ req := newRunnerUpdateRequest(t, fmt.Sprintf("/api/v1/orgs/org3/actions/runners/%d", 34347), true).AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusForbidden)
+ })
+
+ t.Run("UpdateReadScopeForbidden", func(t *testing.T) {
+ userUsername := "user2"
+ token := getUserToken(t, userUsername, auth_model.AccessTokenScopeReadOrganization)
+
+ req := newRunnerUpdateRequest(t, fmt.Sprintf("/api/v1/orgs/org3/actions/runners/%d", 34347), false).AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusForbidden)
+ })
+
t.Run("GetRepoScopeForbidden", func(t *testing.T) {
userUsername := "user2"
token := getUserToken(t, userUsername, auth_model.AccessTokenScopeReadRepository)
@@ -244,6 +316,7 @@ func testActionsRunnerRepo(t *testing.T) {
assert.Equal(t, "runner_to_be_deleted-repo1", runner.Name)
assert.Equal(t, int64(34348), runner.ID)
+ assert.False(t, runner.Disabled)
assert.False(t, runner.Ephemeral)
assert.Len(t, runner.Labels, 2)
assert.Equal(t, "runner_to_be_deleted", runner.Labels[0].Name)
@@ -269,6 +342,7 @@ func testActionsRunnerRepo(t *testing.T) {
assert.Len(t, runnerList.Entries, 1)
assert.Equal(t, "runner_to_be_deleted-repo1", runnerList.Entries[0].Name)
assert.Equal(t, int64(34348), runnerList.Entries[0].ID)
+ assert.False(t, runnerList.Entries[0].Disabled)
assert.False(t, runnerList.Entries[0].Ephemeral)
assert.Len(t, runnerList.Entries[0].Labels, 2)
assert.Equal(t, "runner_to_be_deleted", runnerList.Entries[0].Labels[0].Name)
@@ -283,11 +357,26 @@ func testActionsRunnerRepo(t *testing.T) {
assert.Equal(t, "runner_to_be_deleted-repo1", runner.Name)
assert.Equal(t, int64(34348), runner.ID)
+ assert.False(t, runner.Disabled)
assert.False(t, runner.Ephemeral)
assert.Len(t, runner.Labels, 2)
assert.Equal(t, "runner_to_be_deleted", runner.Labels[0].Name)
assert.Equal(t, "linux", runner.Labels[1].Name)
+ req = newRunnerUpdateRequest(t, fmt.Sprintf("/api/v1/repos/user2/repo1/actions/runners/%d", runnerList.Entries[0].ID), true).AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusOK)
+ req = newRunnerUpdateRequest(t, fmt.Sprintf("/api/v1/repos/user2/repo1/actions/runners/%d", runnerList.Entries[0].ID), true).AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusOK)
+ req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/user2/repo1/actions/runners/%d", runnerList.Entries[0].ID)).AddTokenAuth(token)
+ runnerResp = MakeRequest(t, req, http.StatusOK)
+ DecodeJSON(t, runnerResp, &runner)
+ assert.True(t, runner.Disabled)
+
+ req = newRunnerUpdateRequest(t, fmt.Sprintf("/api/v1/repos/user2/repo1/actions/runners/%d", runnerList.Entries[0].ID), false).AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusOK)
+ req = newRunnerUpdateRequest(t, fmt.Sprintf("/api/v1/repos/user2/repo1/actions/runners/%d", runnerList.Entries[0].ID), false).AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusOK)
+
// Verify delete the runner by id
req = NewRequest(t, "DELETE", fmt.Sprintf("/api/v1/repos/user2/repo1/actions/runners/%d", runnerList.Entries[0].ID)).AddTokenAuth(token)
MakeRequest(t, req, http.StatusNoContent)
@@ -306,6 +395,22 @@ func testActionsRunnerRepo(t *testing.T) {
MakeRequest(t, req, http.StatusForbidden)
})
+ t.Run("DisableReadScopeForbidden", func(t *testing.T) {
+ userUsername := "user2"
+ token := getUserToken(t, userUsername, auth_model.AccessTokenScopeReadRepository)
+
+ req := newRunnerUpdateRequest(t, fmt.Sprintf("/api/v1/repos/user2/repo1/actions/runners/%d", 34348), true).AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusForbidden)
+ })
+
+ t.Run("UpdateReadScopeForbidden", func(t *testing.T) {
+ userUsername := "user2"
+ token := getUserToken(t, userUsername, auth_model.AccessTokenScopeReadRepository)
+
+ req := newRunnerUpdateRequest(t, fmt.Sprintf("/api/v1/repos/user2/repo1/actions/runners/%d", 34348), false).AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusForbidden)
+ })
+
t.Run("GetOrganizationScopeForbidden", func(t *testing.T) {
userUsername := "user2"
token := getUserToken(t, userUsername, auth_model.AccessTokenScopeReadOrganization)