diff --git a/models/actions/runner.go b/models/actions/runner.go
index 0d5464a5be..798a647180 100644
--- a/models/actions/runner.go
+++ b/models/actions/runner.go
@@ -167,6 +167,7 @@ func init() {
 
 type FindRunnerOptions struct {
 	db.ListOptions
+	IDs           []int64
 	RepoID        int64
 	OwnerID       int64 // it will be ignored if RepoID is set
 	Sort          string
@@ -178,6 +179,14 @@ type FindRunnerOptions struct {
 func (opts FindRunnerOptions) ToConds() builder.Cond {
 	cond := builder.NewCond()
 
+	if len(opts.IDs) > 0 {
+		if len(opts.IDs) == 1 {
+			cond = cond.And(builder.Eq{"id": opts.IDs[0]})
+		} else {
+			cond = cond.And(builder.In("id", opts.IDs))
+		}
+	}
+
 	if opts.RepoID > 0 {
 		c := builder.NewCond().And(builder.Eq{"repo_id": opts.RepoID})
 		if opts.WithAvailable {
diff --git a/routers/web/repo/setting/runners.go b/routers/web/repo/setting/runners.go
deleted file mode 100644
index 94f2ae7a0c..0000000000
--- a/routers/web/repo/setting/runners.go
+++ /dev/null
@@ -1,187 +0,0 @@
-// Copyright 2022 The Gitea Authors. All rights reserved.
-// SPDX-License-Identifier: MIT
-
-package setting
-
-import (
-	"errors"
-	"net/http"
-	"net/url"
-
-	actions_model "code.gitea.io/gitea/models/actions"
-	"code.gitea.io/gitea/models/db"
-	"code.gitea.io/gitea/modules/setting"
-	"code.gitea.io/gitea/modules/templates"
-	actions_shared "code.gitea.io/gitea/routers/web/shared/actions"
-	shared_user "code.gitea.io/gitea/routers/web/shared/user"
-	"code.gitea.io/gitea/services/context"
-)
-
-const (
-	// TODO: Separate secrets from runners when layout is ready
-	tplRepoRunners     templates.TplName = "repo/settings/actions"
-	tplOrgRunners      templates.TplName = "org/settings/actions"
-	tplAdminRunners    templates.TplName = "admin/actions"
-	tplUserRunners     templates.TplName = "user/settings/actions"
-	tplRepoRunnerEdit  templates.TplName = "repo/settings/runner_edit"
-	tplOrgRunnerEdit   templates.TplName = "org/settings/runners_edit"
-	tplAdminRunnerEdit templates.TplName = "admin/runners/edit"
-	tplUserRunnerEdit  templates.TplName = "user/settings/runner_edit"
-)
-
-type runnersCtx struct {
-	OwnerID            int64
-	RepoID             int64
-	IsRepo             bool
-	IsOrg              bool
-	IsAdmin            bool
-	IsUser             bool
-	RunnersTemplate    templates.TplName
-	RunnerEditTemplate templates.TplName
-	RedirectLink       string
-}
-
-func getRunnersCtx(ctx *context.Context) (*runnersCtx, error) {
-	if ctx.Data["PageIsRepoSettings"] == true {
-		return &runnersCtx{
-			RepoID:             ctx.Repo.Repository.ID,
-			OwnerID:            0,
-			IsRepo:             true,
-			RunnersTemplate:    tplRepoRunners,
-			RunnerEditTemplate: tplRepoRunnerEdit,
-			RedirectLink:       ctx.Repo.RepoLink + "/settings/actions/runners/",
-		}, nil
-	}
-
-	if ctx.Data["PageIsOrgSettings"] == true {
-		err := shared_user.LoadHeaderCount(ctx)
-		if err != nil {
-			ctx.ServerError("LoadHeaderCount", err)
-			return nil, nil
-		}
-		return &runnersCtx{
-			RepoID:             0,
-			OwnerID:            ctx.Org.Organization.ID,
-			IsOrg:              true,
-			RunnersTemplate:    tplOrgRunners,
-			RunnerEditTemplate: tplOrgRunnerEdit,
-			RedirectLink:       ctx.Org.OrgLink + "/settings/actions/runners/",
-		}, nil
-	}
-
-	if ctx.Data["PageIsAdmin"] == true {
-		return &runnersCtx{
-			RepoID:             0,
-			OwnerID:            0,
-			IsAdmin:            true,
-			RunnersTemplate:    tplAdminRunners,
-			RunnerEditTemplate: tplAdminRunnerEdit,
-			RedirectLink:       setting.AppSubURL + "/-/admin/actions/runners/",
-		}, nil
-	}
-
-	if ctx.Data["PageIsUserSettings"] == true {
-		return &runnersCtx{
-			OwnerID:            ctx.Doer.ID,
-			RepoID:             0,
-			IsUser:             true,
-			RunnersTemplate:    tplUserRunners,
-			RunnerEditTemplate: tplUserRunnerEdit,
-			RedirectLink:       setting.AppSubURL + "/user/settings/actions/runners/",
-		}, nil
-	}
-
-	return nil, errors.New("unable to set Runners context")
-}
-
-// Runners render settings/actions/runners page for repo level
-func Runners(ctx *context.Context) {
-	ctx.Data["PageIsSharedSettingsRunners"] = true
-	ctx.Data["Title"] = ctx.Tr("actions.actions")
-	ctx.Data["PageType"] = "runners"
-
-	rCtx, err := getRunnersCtx(ctx)
-	if err != nil {
-		ctx.ServerError("getRunnersCtx", err)
-		return
-	}
-
-	page := ctx.FormInt("page")
-	if page <= 1 {
-		page = 1
-	}
-
-	opts := actions_model.FindRunnerOptions{
-		ListOptions: db.ListOptions{
-			Page:     page,
-			PageSize: 100,
-		},
-		Sort:   ctx.Req.URL.Query().Get("sort"),
-		Filter: ctx.Req.URL.Query().Get("q"),
-	}
-	if rCtx.IsRepo {
-		opts.RepoID = rCtx.RepoID
-		opts.WithAvailable = true
-	} else if rCtx.IsOrg || rCtx.IsUser {
-		opts.OwnerID = rCtx.OwnerID
-		opts.WithAvailable = true
-	}
-	actions_shared.RunnersList(ctx, opts)
-
-	ctx.HTML(http.StatusOK, rCtx.RunnersTemplate)
-}
-
-// RunnersEdit renders runner edit page for repository level
-func RunnersEdit(ctx *context.Context) {
-	ctx.Data["PageIsSharedSettingsRunners"] = true
-	ctx.Data["Title"] = ctx.Tr("actions.runners.edit_runner")
-	rCtx, err := getRunnersCtx(ctx)
-	if err != nil {
-		ctx.ServerError("getRunnersCtx", err)
-		return
-	}
-
-	page := ctx.FormInt("page")
-	if page <= 1 {
-		page = 1
-	}
-
-	actions_shared.RunnerDetails(ctx, page,
-		ctx.PathParamInt64("runnerid"), rCtx.OwnerID, rCtx.RepoID,
-	)
-	ctx.HTML(http.StatusOK, rCtx.RunnerEditTemplate)
-}
-
-func RunnersEditPost(ctx *context.Context) {
-	rCtx, err := getRunnersCtx(ctx)
-	if err != nil {
-		ctx.ServerError("getRunnersCtx", err)
-		return
-	}
-	actions_shared.RunnerDetailsEditPost(ctx, ctx.PathParamInt64("runnerid"),
-		rCtx.OwnerID, rCtx.RepoID,
-		rCtx.RedirectLink+url.PathEscape(ctx.PathParam("runnerid")))
-}
-
-func ResetRunnerRegistrationToken(ctx *context.Context) {
-	rCtx, err := getRunnersCtx(ctx)
-	if err != nil {
-		ctx.ServerError("getRunnersCtx", err)
-		return
-	}
-	actions_shared.RunnerResetRegistrationToken(ctx, rCtx.OwnerID, rCtx.RepoID, rCtx.RedirectLink)
-}
-
-// RunnerDeletePost response for deleting runner
-func RunnerDeletePost(ctx *context.Context) {
-	rCtx, err := getRunnersCtx(ctx)
-	if err != nil {
-		ctx.ServerError("getRunnersCtx", err)
-		return
-	}
-	actions_shared.RunnerDeletePost(ctx, ctx.PathParamInt64("runnerid"), rCtx.RedirectLink, rCtx.RedirectLink+url.PathEscape(ctx.PathParam("runnerid")))
-}
-
-func RedirectToDefaultSetting(ctx *context.Context) {
-	ctx.Redirect(ctx.Repo.RepoLink + "/settings/actions/runners")
-}
diff --git a/routers/web/shared/actions/runners.go b/routers/web/shared/actions/runners.go
index 6d77bdd2fa..41aac4976b 100644
--- a/routers/web/shared/actions/runners.go
+++ b/routers/web/shared/actions/runners.go
@@ -5,18 +5,131 @@ package actions
 
 import (
 	"errors"
+	"net/http"
+	"net/url"
 
 	actions_model "code.gitea.io/gitea/models/actions"
 	"code.gitea.io/gitea/models/db"
 	"code.gitea.io/gitea/modules/log"
+	"code.gitea.io/gitea/modules/setting"
+	"code.gitea.io/gitea/modules/templates"
 	"code.gitea.io/gitea/modules/util"
 	"code.gitea.io/gitea/modules/web"
+	shared_user "code.gitea.io/gitea/routers/web/shared/user"
 	"code.gitea.io/gitea/services/context"
 	"code.gitea.io/gitea/services/forms"
 )
 
-// RunnersList prepares data for runners list
-func RunnersList(ctx *context.Context, opts actions_model.FindRunnerOptions) {
+const (
+	// TODO: Separate secrets from runners when layout is ready
+	tplRepoRunners     templates.TplName = "repo/settings/actions"
+	tplOrgRunners      templates.TplName = "org/settings/actions"
+	tplAdminRunners    templates.TplName = "admin/actions"
+	tplUserRunners     templates.TplName = "user/settings/actions"
+	tplRepoRunnerEdit  templates.TplName = "repo/settings/runner_edit"
+	tplOrgRunnerEdit   templates.TplName = "org/settings/runners_edit"
+	tplAdminRunnerEdit templates.TplName = "admin/runners/edit"
+	tplUserRunnerEdit  templates.TplName = "user/settings/runner_edit"
+)
+
+type runnersCtx struct {
+	OwnerID            int64
+	RepoID             int64
+	IsRepo             bool
+	IsOrg              bool
+	IsAdmin            bool
+	IsUser             bool
+	RunnersTemplate    templates.TplName
+	RunnerEditTemplate templates.TplName
+	RedirectLink       string
+}
+
+func getRunnersCtx(ctx *context.Context) (*runnersCtx, error) {
+	if ctx.Data["PageIsRepoSettings"] == true {
+		return &runnersCtx{
+			RepoID:             ctx.Repo.Repository.ID,
+			OwnerID:            0,
+			IsRepo:             true,
+			RunnersTemplate:    tplRepoRunners,
+			RunnerEditTemplate: tplRepoRunnerEdit,
+			RedirectLink:       ctx.Repo.RepoLink + "/settings/actions/runners/",
+		}, nil
+	}
+
+	if ctx.Data["PageIsOrgSettings"] == true {
+		err := shared_user.LoadHeaderCount(ctx)
+		if err != nil {
+			ctx.ServerError("LoadHeaderCount", err)
+			return nil, nil
+		}
+		return &runnersCtx{
+			RepoID:             0,
+			OwnerID:            ctx.Org.Organization.ID,
+			IsOrg:              true,
+			RunnersTemplate:    tplOrgRunners,
+			RunnerEditTemplate: tplOrgRunnerEdit,
+			RedirectLink:       ctx.Org.OrgLink + "/settings/actions/runners/",
+		}, nil
+	}
+
+	if ctx.Data["PageIsAdmin"] == true {
+		return &runnersCtx{
+			RepoID:             0,
+			OwnerID:            0,
+			IsAdmin:            true,
+			RunnersTemplate:    tplAdminRunners,
+			RunnerEditTemplate: tplAdminRunnerEdit,
+			RedirectLink:       setting.AppSubURL + "/-/admin/actions/runners/",
+		}, nil
+	}
+
+	if ctx.Data["PageIsUserSettings"] == true {
+		return &runnersCtx{
+			OwnerID:            ctx.Doer.ID,
+			RepoID:             0,
+			IsUser:             true,
+			RunnersTemplate:    tplUserRunners,
+			RunnerEditTemplate: tplUserRunnerEdit,
+			RedirectLink:       setting.AppSubURL + "/user/settings/actions/runners/",
+		}, nil
+	}
+
+	return nil, errors.New("unable to set Runners context")
+}
+
+// Runners render settings/actions/runners page for repo level
+func Runners(ctx *context.Context) {
+	ctx.Data["PageIsSharedSettingsRunners"] = true
+	ctx.Data["Title"] = ctx.Tr("actions.actions")
+	ctx.Data["PageType"] = "runners"
+
+	rCtx, err := getRunnersCtx(ctx)
+	if err != nil {
+		ctx.ServerError("getRunnersCtx", err)
+		return
+	}
+
+	page := ctx.FormInt("page")
+	if page <= 1 {
+		page = 1
+	}
+
+	opts := actions_model.FindRunnerOptions{
+		ListOptions: db.ListOptions{
+			Page:     page,
+			PageSize: 100,
+		},
+		Sort:   ctx.Req.URL.Query().Get("sort"),
+		Filter: ctx.Req.URL.Query().Get("q"),
+	}
+	if rCtx.IsRepo {
+		opts.RepoID = rCtx.RepoID
+		opts.WithAvailable = true
+	} else if rCtx.IsOrg || rCtx.IsUser {
+		opts.OwnerID = rCtx.OwnerID
+		opts.WithAvailable = true
+	}
+
 	runners, count, err := db.FindAndCount[actions_model.ActionRunner](ctx, opts)
 	if err != nil {
 		ctx.ServerError("CountRunners", err)
@@ -53,10 +166,29 @@ func RunnersList(ctx *context.Context, opts actions_model.FindRunnerOptions) {
 	pager := context.NewPagination(int(count), opts.PageSize, opts.Page, 5)
 
 	ctx.Data["Page"] = pager
+
+	ctx.HTML(http.StatusOK, rCtx.RunnersTemplate)
 }
 
-// RunnerDetails prepares data for runners edit page
-func RunnerDetails(ctx *context.Context, page int, runnerID, ownerID, repoID int64) {
+// RunnersEdit renders runner edit page for repository level
+func RunnersEdit(ctx *context.Context) {
+	ctx.Data["PageIsSharedSettingsRunners"] = true
+	ctx.Data["Title"] = ctx.Tr("actions.runners.edit_runner")
+	rCtx, err := getRunnersCtx(ctx)
+	if err != nil {
+		ctx.ServerError("getRunnersCtx", err)
+		return
+	}
+
+	page := ctx.FormInt("page")
+	if page <= 1 {
+		page = 1
+	}
+
+	runnerID := ctx.PathParamInt64("runnerid")
+	ownerID := rCtx.OwnerID
+	repoID := rCtx.RepoID
+
 	runner, err := actions_model.GetRunnerByID(ctx, runnerID)
 	if err != nil {
 		ctx.ServerError("GetRunnerByID", err)
@@ -97,10 +229,22 @@ func RunnerDetails(ctx *context.Context, page int, runnerID, ownerID, repoID int
 	ctx.Data["Tasks"] = tasks
 	pager := context.NewPagination(int(count), opts.PageSize, opts.Page, 5)
 	ctx.Data["Page"] = pager
+
+	ctx.HTML(http.StatusOK, rCtx.RunnerEditTemplate)
 }
 
-// RunnerDetailsEditPost response for edit runner details
-func RunnerDetailsEditPost(ctx *context.Context, runnerID, ownerID, repoID int64, redirectTo string) {
+func RunnersEditPost(ctx *context.Context) {
+	rCtx, err := getRunnersCtx(ctx)
+	if err != nil {
+		ctx.ServerError("getRunnersCtx", err)
+		return
+	}
+
+	runnerID := ctx.PathParamInt64("runnerid")
+	ownerID := rCtx.OwnerID
+	repoID := rCtx.RepoID
+	redirectTo := rCtx.RedirectLink
+
 	runner, err := actions_model.GetRunnerByID(ctx, runnerID)
 	if err != nil {
 		log.Warn("RunnerDetailsEditPost.GetRunnerByID failed: %v, url: %s", err, ctx.Req.URL)
@@ -129,10 +273,18 @@ func RunnerDetailsEditPost(ctx *context.Context, runnerID, ownerID, repoID int64
 	ctx.Redirect(redirectTo)
 }
 
-// RunnerResetRegistrationToken reset registration token
-func RunnerResetRegistrationToken(ctx *context.Context, ownerID, repoID int64, redirectTo string) {
-	_, err := actions_model.NewRunnerToken(ctx, ownerID, repoID)
+func ResetRunnerRegistrationToken(ctx *context.Context) {
+	rCtx, err := getRunnersCtx(ctx)
 	if err != nil {
+		ctx.ServerError("getRunnersCtx", err)
+		return
+	}
+
+	ownerID := rCtx.OwnerID
+	repoID := rCtx.RepoID
+	redirectTo := rCtx.RedirectLink
+
+	if _, err := actions_model.NewRunnerToken(ctx, ownerID, repoID); err != nil {
 		ctx.ServerError("ResetRunnerRegistrationToken", err)
 		return
 	}
@@ -140,11 +292,28 @@ func RunnerResetRegistrationToken(ctx *context.Context, ownerID, repoID int64, r
 	ctx.JSONRedirect(redirectTo)
 }
 
-// RunnerDeletePost response for deleting a runner
-func RunnerDeletePost(ctx *context.Context, runnerID int64,
-	successRedirectTo, failedRedirectTo string,
-) {
-	if err := actions_model.DeleteRunner(ctx, runnerID); err != nil {
+// RunnerDeletePost response for deleting runner
+func RunnerDeletePost(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.Editable(rCtx.OwnerID, rCtx.RepoID) {
+		ctx.NotFound("RunnerDeletePost", util.NewPermissionDeniedErrorf("no permission to delete this runner"))
+		return
+	}
+
+	successRedirectTo := rCtx.RedirectLink
+	failedRedirectTo := rCtx.RedirectLink + url.PathEscape(ctx.PathParam("runnerid"))
+
+	if err := actions_model.DeleteRunner(ctx, runner.ID); err != nil {
 		log.Warn("DeleteRunnerPost.UpdateRunner failed: %v, url: %s", err, ctx.Req.URL)
 		ctx.Flash.Warning(ctx.Tr("actions.runners.delete_runner_failed"))
 
@@ -158,3 +327,41 @@ func RunnerDeletePost(ctx *context.Context, runnerID int64,
 
 	ctx.JSONRedirect(successRedirectTo)
 }
+
+func RedirectToDefaultSetting(ctx *context.Context) {
+	ctx.Redirect(ctx.Repo.RepoLink + "/settings/actions/runners")
+}
+
+func findActionsRunner(ctx *context.Context, rCtx *runnersCtx) *actions_model.ActionRunner {
+	runnerID := ctx.PathParamInt64("runnerid")
+	opts := &actions_model.FindRunnerOptions{
+		IDs: []int64{runnerID},
+	}
+	switch {
+	case rCtx.IsRepo:
+		opts.RepoID = rCtx.RepoID
+		if opts.RepoID == 0 {
+			panic("repoID is 0")
+		}
+	case rCtx.IsOrg, rCtx.IsUser:
+		opts.OwnerID = rCtx.OwnerID
+		if opts.OwnerID == 0 {
+			panic("ownerID is 0")
+		}
+	case rCtx.IsAdmin:
+		// do nothing
+	default:
+		panic("invalid actions runner context")
+	}
+
+	got, err := db.Find[actions_model.ActionRunner](ctx, opts)
+	if err != nil {
+		ctx.ServerError("FindRunner", err)
+		return nil
+	} else if len(got) == 0 {
+		ctx.NotFound("FindRunner", errors.New("runner not found"))
+		return nil
+	}
+
+	return got[0]
+}
diff --git a/routers/web/web.go b/routers/web/web.go
index 2745f7df41..65548073d2 100644
--- a/routers/web/web.go
+++ b/routers/web/web.go
@@ -467,11 +467,11 @@ func registerRoutes(m *web.Router) {
 
 	addSettingsRunnersRoutes := func() {
 		m.Group("/runners", func() {
-			m.Get("", repo_setting.Runners)
-			m.Combo("/{runnerid}").Get(repo_setting.RunnersEdit).
-				Post(web.Bind(forms.EditRunnerForm{}), repo_setting.RunnersEditPost)
-			m.Post("/{runnerid}/delete", repo_setting.RunnerDeletePost)
-			m.Post("/reset_registration_token", repo_setting.ResetRunnerRegistrationToken)
+			m.Get("", shared_actions.Runners)
+			m.Combo("/{runnerid}").Get(shared_actions.RunnersEdit).
+				Post(web.Bind(forms.EditRunnerForm{}), shared_actions.RunnersEditPost)
+			m.Post("/{runnerid}/delete", shared_actions.RunnerDeletePost)
+			m.Post("/reset_registration_token", shared_actions.ResetRunnerRegistrationToken)
 		})
 	}
 
@@ -1147,7 +1147,7 @@ func registerRoutes(m *web.Router) {
 			})
 		})
 		m.Group("/actions", func() {
-			m.Get("", repo_setting.RedirectToDefaultSetting)
+			m.Get("", shared_actions.RedirectToDefaultSetting)
 			addSettingsRunnersRoutes()
 			addSettingsSecretsRoutes()
 			addSettingsVariablesRoutes()
diff --git a/tests/integration/actions_runner_modify_test.go b/tests/integration/actions_runner_modify_test.go
new file mode 100644
index 0000000000..feb3bc0893
--- /dev/null
+++ b/tests/integration/actions_runner_modify_test.go
@@ -0,0 +1,151 @@
+// Copyright 2025 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+	"context"
+	"fmt"
+	"net/http"
+	"testing"
+
+	actions_model "code.gitea.io/gitea/models/actions"
+	"code.gitea.io/gitea/models/db"
+	repo_model "code.gitea.io/gitea/models/repo"
+	"code.gitea.io/gitea/models/unittest"
+	user_model "code.gitea.io/gitea/models/user"
+	"code.gitea.io/gitea/tests"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func TestActionsRunnerModify(t *testing.T) {
+	defer tests.PrepareTestEnv(t)()
+
+	ctx := context.Background()
+
+	require.NoError(t, db.DeleteAllRecords("action_runner"))
+
+	user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+	_ = actions_model.CreateRunner(ctx, &actions_model.ActionRunner{OwnerID: user2.ID, Name: "user2-runner", TokenHash: "a", UUID: "a"})
+	user2Runner := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunner{OwnerID: user2.ID, Name: "user2-runner"})
+	userWebURL := "/user/settings/actions/runners"
+
+	org3 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 3, Type: user_model.UserTypeOrganization})
+	require.NoError(t, actions_model.CreateRunner(ctx, &actions_model.ActionRunner{OwnerID: org3.ID, Name: "org3-runner", TokenHash: "b", UUID: "b"}))
+	org3Runner := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunner{OwnerID: org3.ID, Name: "org3-runner"})
+	orgWebURL := "/org/org3/settings/actions/runners"
+
+	repo1 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
+	_ = actions_model.CreateRunner(ctx, &actions_model.ActionRunner{RepoID: repo1.ID, Name: "repo1-runner", TokenHash: "c", UUID: "c"})
+	repo1Runner := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunner{RepoID: repo1.ID, Name: "repo1-runner"})
+	repoWebURL := "/user2/repo1/settings/actions/runners"
+
+	_ = actions_model.CreateRunner(ctx, &actions_model.ActionRunner{Name: "global-runner", TokenHash: "d", UUID: "d"})
+	globalRunner := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunner{Name: "global-runner"})
+	adminWebURL := "/-/admin/actions/runners"
+
+	sessionAdmin := loginUser(t, "user1")
+	sessionUser2 := loginUser(t, user2.Name)
+
+	doUpdate := func(t *testing.T, sess *TestSession, baseURL string, id int64, description string, expectedStatus int) {
+		req := NewRequestWithValues(t, "POST", fmt.Sprintf("%s/%d", baseURL, id), map[string]string{
+			"_csrf":       GetUserCSRFToken(t, sess),
+			"description": description,
+		})
+		sess.MakeRequest(t, req, expectedStatus)
+	}
+
+	doDelete := func(t *testing.T, sess *TestSession, baseURL string, id int64, expectedStatus int) {
+		req := NewRequestWithValues(t, "POST", fmt.Sprintf("%s/%d/delete", baseURL, id), map[string]string{
+			"_csrf": GetUserCSRFToken(t, sess),
+		})
+		sess.MakeRequest(t, req, expectedStatus)
+	}
+
+	assertDenied := func(t *testing.T, sess *TestSession, baseURL string, id int64) {
+		doUpdate(t, sess, baseURL, id, "ChangedDescription", http.StatusNotFound)
+		doDelete(t, sess, baseURL, id, http.StatusNotFound)
+		v := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunner{ID: id})
+		assert.Empty(t, v.Description)
+	}
+
+	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)
+		doDelete(t, sess, baseURL, id, http.StatusOK)
+		unittest.AssertNotExistsBean(t, &actions_model.ActionRunner{ID: id})
+	}
+
+	t.Run("UpdateUserRunner", func(t *testing.T) {
+		theRunner := user2Runner
+		t.Run("FromOrg", func(t *testing.T) {
+			assertDenied(t, sessionAdmin, orgWebURL, theRunner.ID)
+		})
+		t.Run("FromRepo", func(t *testing.T) {
+			assertDenied(t, sessionAdmin, repoWebURL, theRunner.ID)
+		})
+		t.Run("FromAdmin", func(t *testing.T) {
+			t.Skip("Admin can update any runner (not right but not too bad)")
+			assertDenied(t, sessionAdmin, adminWebURL, theRunner.ID)
+		})
+	})
+
+	t.Run("UpdateOrgRunner", func(t *testing.T) {
+		theRunner := org3Runner
+		t.Run("FromRepo", func(t *testing.T) {
+			assertDenied(t, sessionAdmin, repoWebURL, theRunner.ID)
+		})
+		t.Run("FromUser", func(t *testing.T) {
+			assertDenied(t, sessionAdmin, userWebURL, theRunner.ID)
+		})
+		t.Run("FromAdmin", func(t *testing.T) {
+			t.Skip("Admin can update any runner (not right but not too bad)")
+			assertDenied(t, sessionAdmin, adminWebURL, theRunner.ID)
+		})
+	})
+
+	t.Run("UpdateRepoRunner", func(t *testing.T) {
+		theRunner := repo1Runner
+		t.Run("FromOrg", func(t *testing.T) {
+			assertDenied(t, sessionAdmin, orgWebURL, theRunner.ID)
+		})
+		t.Run("FromUser", func(t *testing.T) {
+			assertDenied(t, sessionAdmin, userWebURL, theRunner.ID)
+		})
+		t.Run("FromAdmin", func(t *testing.T) {
+			t.Skip("Admin can update any runner (not right but not too bad)")
+			assertDenied(t, sessionAdmin, adminWebURL, theRunner.ID)
+		})
+	})
+
+	t.Run("UpdateGlobalRunner", func(t *testing.T) {
+		theRunner := globalRunner
+		t.Run("FromOrg", func(t *testing.T) {
+			assertDenied(t, sessionAdmin, orgWebURL, theRunner.ID)
+		})
+		t.Run("FromUser", func(t *testing.T) {
+			assertDenied(t, sessionAdmin, userWebURL, theRunner.ID)
+		})
+		t.Run("FromRepo", func(t *testing.T) {
+			assertDenied(t, sessionAdmin, repoWebURL, theRunner.ID)
+		})
+	})
+
+	t.Run("UpdateSuccess", func(t *testing.T) {
+		t.Run("User", func(t *testing.T) {
+			assertSuccess(t, sessionUser2, userWebURL, user2Runner.ID)
+		})
+		t.Run("Org", func(t *testing.T) {
+			assertSuccess(t, sessionAdmin, orgWebURL, org3Runner.ID)
+		})
+		t.Run("Repo", func(t *testing.T) {
+			assertSuccess(t, sessionUser2, repoWebURL, repo1Runner.ID)
+		})
+		t.Run("Admin", func(t *testing.T) {
+			assertSuccess(t, sessionAdmin, adminWebURL, globalRunner.ID)
+		})
+	})
+}