From 21b43fce083f8645b03ab8d52c9e4e552db69317 Mon Sep 17 00:00:00 2001
From: ChristopherHX <christopher.homberger@web.de>
Date: Fri, 18 Apr 2025 17:22:41 +0200
Subject: [PATCH] Actions Runner rest api (#33873)

Implements runner apis based on
https://docs.github.com/en/rest/actions/self-hosted-runners?apiVersion=2022-11-28#list-self-hosted-runners-for-an-organization

- Add Post endpoints for registration-token, google/go-github revealed
this as problem
  - We should deprecate Get Endpoints, leaving them for compatibility
- Get endpoint of admin has api path /admin/runners/registration-token
that feels wrong, /admin/actions/runners/registration-token seems more
consistent with user/org/repo api
- Get Runner Api
- List Runner Api
- Delete Runner Api

- Tests admin / user / org / repo level endpoints

Related to #33750 (implements point 1 and 2)
Via needs discovered in #32461, this runner api is needed to allow
cleanup of runners that are deallocated without user interaction.

---------

Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
---
 models/actions/runner.go                     |  18 +-
 models/fixtures/action_runner.yml            |  40 ++
 modules/structs/repo_actions.go              |  23 +
 routers/api/v1/admin/runners.go              |  78 +++
 routers/api/v1/api.go                        |  14 +
 routers/api/v1/org/action.go                 | 100 ++++
 routers/api/v1/repo/action.go                | 121 +++-
 routers/api/v1/shared/runners.go             |  86 +++
 routers/api/v1/swagger/repo.go               |  14 +
 routers/api/v1/user/runners.go               |  78 +++
 routers/web/shared/actions/runners.go        |   6 +-
 services/actions/interface.go                |   8 +
 services/convert/convert.go                  |  26 +
 templates/swagger/v1_json.tmpl               | 582 ++++++++++++++++++-
 tests/integration/api_actions_runner_test.go | 332 +++++++++++
 15 files changed, 1519 insertions(+), 7 deletions(-)
 create mode 100644 models/fixtures/action_runner.yml
 create mode 100644 tests/integration/api_actions_runner_test.go

diff --git a/models/actions/runner.go b/models/actions/runner.go
index 0411a48393..b55723efa0 100644
--- a/models/actions/runner.go
+++ b/models/actions/runner.go
@@ -14,6 +14,7 @@ import (
 	"code.gitea.io/gitea/models/shared/types"
 	user_model "code.gitea.io/gitea/models/user"
 	"code.gitea.io/gitea/modules/optional"
+	"code.gitea.io/gitea/modules/setting"
 	"code.gitea.io/gitea/modules/timeutil"
 	"code.gitea.io/gitea/modules/translation"
 	"code.gitea.io/gitea/modules/util"
@@ -123,8 +124,15 @@ func (r *ActionRunner) IsOnline() bool {
 	return false
 }
 
-// Editable checks if the runner is editable by the user
-func (r *ActionRunner) Editable(ownerID, repoID int64) bool {
+// EditableInContext checks if the runner is editable by the "context" owner/repo
+// ownerID == 0 and repoID == 0 means "admin" context, any runner including global runners could be edited
+// ownerID == 0 and repoID != 0 means "repo" context, any runner belonging to the given repo could be edited
+// ownerID != 0 and repoID == 0 means "owner(org/user)" context, any runner belonging to the given user/org could be edited
+// ownerID != 0 and repoID != 0 means "owner" OR "repo" context, legacy behavior, but we should forbid using it
+func (r *ActionRunner) EditableInContext(ownerID, repoID int64) bool {
+	if ownerID != 0 && repoID != 0 {
+		setting.PanicInDevOrTesting("ownerID and repoID should not be both set")
+	}
 	if ownerID == 0 && repoID == 0 {
 		return true
 	}
@@ -168,6 +176,12 @@ func init() {
 	db.RegisterModel(&ActionRunner{})
 }
 
+// FindRunnerOptions
+// ownerID == 0 and repoID == 0 means any runner including global runners
+// repoID != 0 and WithAvailable == false means any runner for the given repo
+// repoID != 0 and WithAvailable == true means any runner for the given repo, parent user/org, and global runners
+// ownerID != 0 and repoID == 0 and WithAvailable == false means any runner for the given user/org
+// ownerID != 0 and repoID == 0 and WithAvailable == true means any runner for the given user/org and global runners
 type FindRunnerOptions struct {
 	db.ListOptions
 	IDs           []int64
diff --git a/models/fixtures/action_runner.yml b/models/fixtures/action_runner.yml
new file mode 100644
index 0000000000..dce2d41cfb
--- /dev/null
+++ b/models/fixtures/action_runner.yml
@@ -0,0 +1,40 @@
+-
+  id: 34346
+  name: runner_to_be_deleted-user
+  uuid: 3EF231BD-FBB7-4E4B-9602-E6F28363EF18
+  token_hash: 3EF231BD-FBB7-4E4B-9602-E6F28363EF18
+  version: "1.0.0"
+  owner_id: 1
+  repo_id: 0
+  description: "This runner is going to be deleted"
+  agent_labels: '["runner_to_be_deleted","linux"]'
+-
+  id: 34347
+  name: runner_to_be_deleted-org
+  uuid: 3EF231BD-FBB7-4E4B-9602-E6F28363EF19
+  token_hash: 3EF231BD-FBB7-4E4B-9602-E6F28363EF19
+  version: "1.0.0"
+  owner_id: 3
+  repo_id: 0
+  description: "This runner is going to be deleted"
+  agent_labels: '["runner_to_be_deleted","linux"]'
+-
+  id: 34348
+  name: runner_to_be_deleted-repo1
+  uuid: 3EF231BD-FBB7-4E4B-9602-E6F28363EF20
+  token_hash: 3EF231BD-FBB7-4E4B-9602-E6F28363EF20
+  version: "1.0.0"
+  owner_id: 0
+  repo_id: 1
+  description: "This runner is going to be deleted"
+  agent_labels: '["runner_to_be_deleted","linux"]'
+-
+  id: 34349
+  name: runner_to_be_deleted
+  uuid: 3EF231BD-FBB7-4E4B-9602-E6F28363EF17
+  token_hash: 3EF231BD-FBB7-4E4B-9602-E6F28363EF17
+  version: "1.0.0"
+  owner_id: 0
+  repo_id: 0
+  description: "This runner is going to be deleted"
+  agent_labels: '["runner_to_be_deleted","linux"]'
diff --git a/modules/structs/repo_actions.go b/modules/structs/repo_actions.go
index 22409b4aff..75f8e188dd 100644
--- a/modules/structs/repo_actions.go
+++ b/modules/structs/repo_actions.go
@@ -133,3 +133,26 @@ type ActionWorkflowJob struct {
 	// swagger:strfmt date-time
 	CompletedAt time.Time `json:"completed_at,omitempty"`
 }
+
+// ActionRunnerLabel represents a Runner Label
+type ActionRunnerLabel struct {
+	ID   int64  `json:"id"`
+	Name string `json:"name"`
+	Type string `json:"type"`
+}
+
+// ActionRunner represents a Runner
+type ActionRunner struct {
+	ID        int64                `json:"id"`
+	Name      string               `json:"name"`
+	Status    string               `json:"status"`
+	Busy      bool                 `json:"busy"`
+	Ephemeral bool                 `json:"ephemeral"`
+	Labels    []*ActionRunnerLabel `json:"labels"`
+}
+
+// ActionRunnersResponse returns Runners
+type ActionRunnersResponse struct {
+	Entries    []*ActionRunner `json:"runners"`
+	TotalCount int64           `json:"total_count"`
+}
diff --git a/routers/api/v1/admin/runners.go b/routers/api/v1/admin/runners.go
index 329242d9f6..736c421229 100644
--- a/routers/api/v1/admin/runners.go
+++ b/routers/api/v1/admin/runners.go
@@ -24,3 +24,81 @@ func GetRegistrationToken(ctx *context.APIContext) {
 
 	shared.GetRegistrationToken(ctx, 0, 0)
 }
+
+// CreateRegistrationToken returns the token to register global runners
+func CreateRegistrationToken(ctx *context.APIContext) {
+	// swagger:operation POST /admin/actions/runners/registration-token admin adminCreateRunnerRegistrationToken
+	// ---
+	// summary: Get an global actions runner registration token
+	// produces:
+	// - application/json
+	// parameters:
+	// responses:
+	//   "200":
+	//     "$ref": "#/responses/RegistrationToken"
+
+	shared.GetRegistrationToken(ctx, 0, 0)
+}
+
+// ListRunners get all runners
+func ListRunners(ctx *context.APIContext) {
+	// swagger:operation GET /admin/actions/runners admin getAdminRunners
+	// ---
+	// summary: Get all runners
+	// produces:
+	// - application/json
+	// responses:
+	//   "200":
+	//     "$ref": "#/definitions/ActionRunnersResponse"
+	//   "400":
+	//     "$ref": "#/responses/error"
+	//   "404":
+	//     "$ref": "#/responses/notFound"
+	shared.ListRunners(ctx, 0, 0)
+}
+
+// GetRunner get an global runner
+func GetRunner(ctx *context.APIContext) {
+	// swagger:operation GET /admin/actions/runners/{runner_id} admin getAdminRunner
+	// ---
+	// summary: Get an global runner
+	// produces:
+	// - application/json
+	// parameters:
+	// - name: runner_id
+	//   in: path
+	//   description: id of the runner
+	//   type: string
+	//   required: true
+	// responses:
+	//   "200":
+	//     "$ref": "#/definitions/ActionRunner"
+	//   "400":
+	//     "$ref": "#/responses/error"
+	//   "404":
+	//     "$ref": "#/responses/notFound"
+	shared.GetRunner(ctx, 0, 0, ctx.PathParamInt64("runner_id"))
+}
+
+// DeleteRunner delete an global runner
+func DeleteRunner(ctx *context.APIContext) {
+	// swagger:operation DELETE /admin/actions/runners/{runner_id} admin deleteAdminRunner
+	// ---
+	// summary: Delete an global runner
+	// produces:
+	// - application/json
+	// parameters:
+	// - name: runner_id
+	//   in: path
+	//   description: id of the runner
+	//   type: string
+	//   required: true
+	// responses:
+	//   "204":
+	//     description: runner has been deleted
+	//   "400":
+	//     "$ref": "#/responses/error"
+	//   "404":
+	//     "$ref": "#/responses/notFound"
+	shared.DeleteRunner(ctx, 0, 0, ctx.PathParamInt64("runner_id"))
+}
diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go
index b9b590725b..e77118f4ff 100644
--- a/routers/api/v1/api.go
+++ b/routers/api/v1/api.go
@@ -912,7 +912,11 @@ func Routes() *web.Router {
 			})
 
 			m.Group("/runners", func() {
+				m.Get("", reqToken(), reqChecker, act.ListRunners)
 				m.Get("/registration-token", reqToken(), reqChecker, act.GetRegistrationToken)
+				m.Post("/registration-token", reqToken(), reqChecker, act.CreateRegistrationToken)
+				m.Get("/{runner_id}", reqToken(), reqChecker, act.GetRunner)
+				m.Delete("/{runner_id}", reqToken(), reqChecker, act.DeleteRunner)
 			})
 		})
 	}
@@ -1043,7 +1047,11 @@ func Routes() *web.Router {
 				})
 
 				m.Group("/runners", func() {
+					m.Get("", reqToken(), user.ListRunners)
 					m.Get("/registration-token", reqToken(), user.GetRegistrationToken)
+					m.Post("/registration-token", reqToken(), user.CreateRegistrationToken)
+					m.Get("/{runner_id}", reqToken(), user.GetRunner)
+					m.Delete("/{runner_id}", reqToken(), user.DeleteRunner)
 				})
 			})
 
@@ -1689,6 +1697,12 @@ func Routes() *web.Router {
 					Patch(bind(api.EditHookOption{}), admin.EditHook).
 					Delete(admin.DeleteHook)
 			})
+			m.Group("/actions/runners", func() {
+				m.Get("", admin.ListRunners)
+				m.Post("/registration-token", admin.CreateRegistrationToken)
+				m.Get("/{runner_id}", admin.GetRunner)
+				m.Delete("/{runner_id}", admin.DeleteRunner)
+			})
 			m.Group("/runners", func() {
 				m.Get("/registration-token", admin.GetRegistrationToken)
 			})
diff --git a/routers/api/v1/org/action.go b/routers/api/v1/org/action.go
index b1cd2f0c3c..700a5ef8ea 100644
--- a/routers/api/v1/org/action.go
+++ b/routers/api/v1/org/action.go
@@ -190,6 +190,27 @@ func (Action) GetRegistrationToken(ctx *context.APIContext) {
 	shared.GetRegistrationToken(ctx, ctx.Org.Organization.ID, 0)
 }
 
+// https://docs.github.com/en/rest/actions/self-hosted-runners?apiVersion=2022-11-28#create-a-registration-token-for-an-organization
+// CreateRegistrationToken returns the token to register org runners
+func (Action) CreateRegistrationToken(ctx *context.APIContext) {
+	// swagger:operation POST /orgs/{org}/actions/runners/registration-token organization orgCreateRunnerRegistrationToken
+	// ---
+	// summary: Get an organization's actions runner registration token
+	// produces:
+	// - application/json
+	// parameters:
+	// - name: org
+	//   in: path
+	//   description: name of the organization
+	//   type: string
+	//   required: true
+	// responses:
+	//   "200":
+	//     "$ref": "#/responses/RegistrationToken"
+
+	shared.GetRegistrationToken(ctx, ctx.Org.Organization.ID, 0)
+}
+
 // ListVariables list org-level variables
 func (Action) ListVariables(ctx *context.APIContext) {
 	// swagger:operation GET /orgs/{org}/actions/variables organization getOrgVariablesList
@@ -470,6 +491,85 @@ func (Action) UpdateVariable(ctx *context.APIContext) {
 	ctx.Status(http.StatusNoContent)
 }
 
+// ListRunners get org-level runners
+func (Action) ListRunners(ctx *context.APIContext) {
+	// swagger:operation GET /orgs/{org}/actions/runners organization getOrgRunners
+	// ---
+	// summary: Get org-level runners
+	// produces:
+	// - application/json
+	// parameters:
+	// - name: org
+	//   in: path
+	//   description: name of the organization
+	//   type: string
+	//   required: true
+	// responses:
+	//   "200":
+	//     "$ref": "#/definitions/ActionRunnersResponse"
+	//   "400":
+	//     "$ref": "#/responses/error"
+	//   "404":
+	//     "$ref": "#/responses/notFound"
+	shared.ListRunners(ctx, ctx.Org.Organization.ID, 0)
+}
+
+// GetRunner get an org-level runner
+func (Action) GetRunner(ctx *context.APIContext) {
+	// swagger:operation GET /orgs/{org}/actions/runners/{runner_id} organization getOrgRunner
+	// ---
+	// summary: Get an org-level runner
+	// 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
+	// responses:
+	//   "200":
+	//     "$ref": "#/definitions/ActionRunner"
+	//   "400":
+	//     "$ref": "#/responses/error"
+	//   "404":
+	//     "$ref": "#/responses/notFound"
+	shared.GetRunner(ctx, ctx.Org.Organization.ID, 0, ctx.PathParamInt64("runner_id"))
+}
+
+// DeleteRunner delete an org-level runner
+func (Action) DeleteRunner(ctx *context.APIContext) {
+	// swagger:operation DELETE /orgs/{org}/actions/runners/{runner_id} organization deleteOrgRunner
+	// ---
+	// summary: Delete an org-level runner
+	// 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
+	// responses:
+	//   "204":
+	//     description: runner has been deleted
+	//   "400":
+	//     "$ref": "#/responses/error"
+	//   "404":
+	//     "$ref": "#/responses/notFound"
+	shared.DeleteRunner(ctx, ctx.Org.Organization.ID, 0, ctx.PathParamInt64("runner_id"))
+}
+
 var _ actions_service.API = new(Action)
 
 // Action implements actions_service.API
diff --git a/routers/api/v1/repo/action.go b/routers/api/v1/repo/action.go
index ed2017a372..6aef529f98 100644
--- a/routers/api/v1/repo/action.go
+++ b/routers/api/v1/repo/action.go
@@ -183,7 +183,7 @@ func (Action) DeleteSecret(ctx *context.APIContext) {
 	//   required: true
 	// responses:
 	//   "204":
-	//     description: delete one secret of the organization
+	//     description: delete one secret of the repository
 	//   "400":
 	//     "$ref": "#/responses/error"
 	//   "404":
@@ -531,6 +531,125 @@ func (Action) GetRegistrationToken(ctx *context.APIContext) {
 	shared.GetRegistrationToken(ctx, 0, ctx.Repo.Repository.ID)
 }
 
+// CreateRegistrationToken returns the token to register repo runners
+func (Action) CreateRegistrationToken(ctx *context.APIContext) {
+	// swagger:operation POST /repos/{owner}/{repo}/actions/runners/registration-token repository repoCreateRunnerRegistrationToken
+	// ---
+	// summary: Get a repository's actions runner registration token
+	// 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
+	// responses:
+	//   "200":
+	//     "$ref": "#/responses/RegistrationToken"
+
+	shared.GetRegistrationToken(ctx, 0, ctx.Repo.Repository.ID)
+}
+
+// ListRunners get repo-level runners
+func (Action) ListRunners(ctx *context.APIContext) {
+	// swagger:operation GET /repos/{owner}/{repo}/actions/runners repository getRepoRunners
+	// ---
+	// summary: Get repo-level runners
+	// 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
+	// responses:
+	//   "200":
+	//     "$ref": "#/definitions/ActionRunnersResponse"
+	//   "400":
+	//     "$ref": "#/responses/error"
+	//   "404":
+	//     "$ref": "#/responses/notFound"
+	shared.ListRunners(ctx, 0, ctx.Repo.Repository.ID)
+}
+
+// GetRunner get an 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
+	// 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
+	// responses:
+	//   "200":
+	//     "$ref": "#/definitions/ActionRunner"
+	//   "400":
+	//     "$ref": "#/responses/error"
+	//   "404":
+	//     "$ref": "#/responses/notFound"
+	shared.GetRunner(ctx, 0, ctx.Repo.Repository.ID, ctx.PathParamInt64("runner_id"))
+}
+
+// DeleteRunner delete an 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
+	// 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
+	// responses:
+	//   "204":
+	//     description: runner has been deleted
+	//   "400":
+	//     "$ref": "#/responses/error"
+	//   "404":
+	//     "$ref": "#/responses/notFound"
+	shared.DeleteRunner(ctx, 0, ctx.Repo.Repository.ID, ctx.PathParamInt64("runner_id"))
+}
+
 var _ actions_service.API = new(Action)
 
 // Action implements actions_service.API
diff --git a/routers/api/v1/shared/runners.go b/routers/api/v1/shared/runners.go
index f31d9e5d0b..d42f330d1c 100644
--- a/routers/api/v1/shared/runners.go
+++ b/routers/api/v1/shared/runners.go
@@ -8,8 +8,13 @@ import (
 	"net/http"
 
 	actions_model "code.gitea.io/gitea/models/actions"
+	"code.gitea.io/gitea/models/db"
+	"code.gitea.io/gitea/modules/setting"
+	api "code.gitea.io/gitea/modules/structs"
 	"code.gitea.io/gitea/modules/util"
+	"code.gitea.io/gitea/routers/api/v1/utils"
 	"code.gitea.io/gitea/services/context"
+	"code.gitea.io/gitea/services/convert"
 )
 
 // RegistrationToken is response related to registration token
@@ -30,3 +35,84 @@ func GetRegistrationToken(ctx *context.APIContext, ownerID, repoID int64) {
 
 	ctx.JSON(http.StatusOK, RegistrationToken{Token: token.Token})
 }
+
+// ListRunners lists runners for api route validated ownerID and repoID
+// ownerID == 0 and repoID == 0 means all runners including global runners, does not appear in sql where clause
+// ownerID == 0 and repoID != 0 means all runners for the given repo
+// ownerID != 0 and repoID == 0 means all runners for the given user/org
+// ownerID != 0 and repoID != 0 undefined behavior
+// Access rights are checked at the API route level
+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{
+		OwnerID:     ownerID,
+		RepoID:      repoID,
+		ListOptions: utils.GetListOptions(ctx),
+	})
+	if err != nil {
+		ctx.APIErrorInternal(err)
+		return
+	}
+
+	res := new(api.ActionRunnersResponse)
+	res.TotalCount = total
+
+	res.Entries = make([]*api.ActionRunner, len(runners))
+	for i, runner := range runners {
+		res.Entries[i] = convert.ToActionRunner(ctx, runner)
+	}
+
+	ctx.JSON(http.StatusOK, &res)
+}
+
+// GetRunner get the runner for api route validated ownerID and repoID
+// ownerID == 0 and repoID == 0 means any runner including global runners
+// ownerID == 0 and repoID != 0 means any runner for the given repo
+// ownerID != 0 and repoID == 0 means any runner for the given user/org
+// ownerID != 0 and repoID != 0 undefined behavior
+// Access rights are checked at the API route level
+func GetRunner(ctx *context.APIContext, ownerID, repoID, runnerID int64) {
+	if ownerID != 0 && repoID != 0 {
+		setting.PanicInDevOrTesting("ownerID and repoID should not be both set")
+	}
+	runner, err := actions_model.GetRunnerByID(ctx, runnerID)
+	if err != nil {
+		ctx.APIErrorNotFound(err)
+		return
+	}
+	if !runner.EditableInContext(ownerID, repoID) {
+		ctx.APIErrorNotFound("No permission to get this runner")
+		return
+	}
+	ctx.JSON(http.StatusOK, convert.ToActionRunner(ctx, runner))
+}
+
+// DeleteRunner deletes the runner for api route validated ownerID and repoID
+// ownerID == 0 and repoID == 0 means any runner including global runners
+// ownerID == 0 and repoID != 0 means any runner for the given repo
+// ownerID != 0 and repoID == 0 means any runner for the given user/org
+// ownerID != 0 and repoID != 0 undefined behavior
+// Access rights are checked at the API route level
+func DeleteRunner(ctx *context.APIContext, ownerID, repoID, runnerID int64) {
+	if ownerID != 0 && repoID != 0 {
+		setting.PanicInDevOrTesting("ownerID and repoID should not be both set")
+	}
+	runner, err := actions_model.GetRunnerByID(ctx, runnerID)
+	if err != nil {
+		ctx.APIErrorInternal(err)
+		return
+	}
+	if !runner.EditableInContext(ownerID, repoID) {
+		ctx.APIErrorNotFound("No permission to delete this runner")
+		return
+	}
+
+	err = actions_model.DeleteRunner(ctx, runner.ID)
+	if err != nil {
+		ctx.APIErrorInternal(err)
+		return
+	}
+	ctx.Status(http.StatusNoContent)
+}
diff --git a/routers/api/v1/swagger/repo.go b/routers/api/v1/swagger/repo.go
index 25f137f3bf..df0c8a805a 100644
--- a/routers/api/v1/swagger/repo.go
+++ b/routers/api/v1/swagger/repo.go
@@ -457,6 +457,20 @@ type swaggerRepoArtifact struct {
 	Body api.ActionArtifact `json:"body"`
 }
 
+// RunnerList
+// swagger:response RunnerList
+type swaggerRunnerList struct {
+	// in:body
+	Body api.ActionRunnersResponse `json:"body"`
+}
+
+// Runner
+// swagger:response Runner
+type swaggerRunner struct {
+	// in:body
+	Body api.ActionRunner `json:"body"`
+}
+
 // swagger:response Compare
 type swaggerCompare struct {
 	// in:body
diff --git a/routers/api/v1/user/runners.go b/routers/api/v1/user/runners.go
index 899218473e..be3f63cc5e 100644
--- a/routers/api/v1/user/runners.go
+++ b/routers/api/v1/user/runners.go
@@ -24,3 +24,81 @@ func GetRegistrationToken(ctx *context.APIContext) {
 
 	shared.GetRegistrationToken(ctx, ctx.Doer.ID, 0)
 }
+
+// CreateRegistrationToken returns the token to register user runners
+func CreateRegistrationToken(ctx *context.APIContext) {
+	// swagger:operation POST /user/actions/runners/registration-token user userCreateRunnerRegistrationToken
+	// ---
+	// summary: Get an user's actions runner registration token
+	// produces:
+	// - application/json
+	// parameters:
+	// responses:
+	//   "200":
+	//     "$ref": "#/responses/RegistrationToken"
+
+	shared.GetRegistrationToken(ctx, ctx.Doer.ID, 0)
+}
+
+// ListRunners get user-level runners
+func ListRunners(ctx *context.APIContext) {
+	// swagger:operation GET /user/actions/runners user getUserRunners
+	// ---
+	// summary: Get user-level runners
+	// produces:
+	// - application/json
+	// responses:
+	//   "200":
+	//     "$ref": "#/definitions/ActionRunnersResponse"
+	//   "400":
+	//     "$ref": "#/responses/error"
+	//   "404":
+	//     "$ref": "#/responses/notFound"
+	shared.ListRunners(ctx, ctx.Doer.ID, 0)
+}
+
+// GetRunner get an user-level runner
+func GetRunner(ctx *context.APIContext) {
+	// swagger:operation GET /user/actions/runners/{runner_id} user getUserRunner
+	// ---
+	// summary: Get an user-level runner
+	// produces:
+	// - application/json
+	// parameters:
+	// - name: runner_id
+	//   in: path
+	//   description: id of the runner
+	//   type: string
+	//   required: true
+	// responses:
+	//   "200":
+	//     "$ref": "#/definitions/ActionRunner"
+	//   "400":
+	//     "$ref": "#/responses/error"
+	//   "404":
+	//     "$ref": "#/responses/notFound"
+	shared.GetRunner(ctx, ctx.Doer.ID, 0, ctx.PathParamInt64("runner_id"))
+}
+
+// DeleteRunner delete an user-level runner
+func DeleteRunner(ctx *context.APIContext) {
+	// swagger:operation DELETE /user/actions/runners/{runner_id} user deleteUserRunner
+	// ---
+	// summary: Delete an user-level runner
+	// produces:
+	// - application/json
+	// parameters:
+	// - name: runner_id
+	//   in: path
+	//   description: id of the runner
+	//   type: string
+	//   required: true
+	// responses:
+	//   "204":
+	//     description: runner has been deleted
+	//   "400":
+	//     "$ref": "#/responses/error"
+	//   "404":
+	//     "$ref": "#/responses/notFound"
+	shared.DeleteRunner(ctx, ctx.Doer.ID, 0, ctx.PathParamInt64("runner_id"))
+}
diff --git a/routers/web/shared/actions/runners.go b/routers/web/shared/actions/runners.go
index a87f6ce4dc..a642cfd66d 100644
--- a/routers/web/shared/actions/runners.go
+++ b/routers/web/shared/actions/runners.go
@@ -197,7 +197,7 @@ func RunnersEdit(ctx *context.Context) {
 		ctx.ServerError("LoadAttributes", err)
 		return
 	}
-	if !runner.Editable(ownerID, repoID) {
+	if !runner.EditableInContext(ownerID, repoID) {
 		err = errors.New("no permission to edit this runner")
 		ctx.NotFound(err)
 		return
@@ -250,7 +250,7 @@ func RunnersEditPost(ctx *context.Context) {
 		ctx.ServerError("RunnerDetailsEditPost.GetRunnerByID", err)
 		return
 	}
-	if !runner.Editable(ownerID, repoID) {
+	if !runner.EditableInContext(ownerID, repoID) {
 		ctx.NotFound(util.NewPermissionDeniedErrorf("no permission to edit this runner"))
 		return
 	}
@@ -304,7 +304,7 @@ func RunnerDeletePost(ctx *context.Context) {
 		return
 	}
 
-	if !runner.Editable(rCtx.OwnerID, rCtx.RepoID) {
+	if !runner.EditableInContext(rCtx.OwnerID, rCtx.RepoID) {
 		ctx.NotFound(util.NewPermissionDeniedErrorf("no permission to delete this runner"))
 		return
 	}
diff --git a/services/actions/interface.go b/services/actions/interface.go
index d4fa782fec..b407f5c6c8 100644
--- a/services/actions/interface.go
+++ b/services/actions/interface.go
@@ -25,4 +25,12 @@ type API interface {
 	UpdateVariable(*context.APIContext)
 	// GetRegistrationToken get registration token
 	GetRegistrationToken(*context.APIContext)
+	// CreateRegistrationToken get registration token
+	CreateRegistrationToken(*context.APIContext)
+	// ListRunners list runners
+	ListRunners(*context.APIContext)
+	// GetRunner get a runner
+	GetRunner(*context.APIContext)
+	// DeleteRunner delete runner
+	DeleteRunner(*context.APIContext)
 }
diff --git a/services/convert/convert.go b/services/convert/convert.go
index ac2680766c..9d2afdea30 100644
--- a/services/convert/convert.go
+++ b/services/convert/convert.go
@@ -30,6 +30,8 @@ import (
 	"code.gitea.io/gitea/modules/util"
 	asymkey_service "code.gitea.io/gitea/services/asymkey"
 	"code.gitea.io/gitea/services/gitdiff"
+
+	runnerv1 "code.gitea.io/actions-proto-go/runner/v1"
 )
 
 // ToEmail convert models.EmailAddress to api.Email
@@ -252,6 +254,30 @@ func ToActionArtifact(repo *repo_model.Repository, art *actions_model.ActionArti
 	}, nil
 }
 
+func ToActionRunner(ctx context.Context, runner *actions_model.ActionRunner) *api.ActionRunner {
+	status := runner.Status()
+	apiStatus := "offline"
+	if runner.IsOnline() {
+		apiStatus = "online"
+	}
+	labels := make([]*api.ActionRunnerLabel, len(runner.AgentLabels))
+	for i, label := range runner.AgentLabels {
+		labels[i] = &api.ActionRunnerLabel{
+			ID:   int64(i),
+			Name: label,
+			Type: "custom",
+		}
+	}
+	return &api.ActionRunner{
+		ID:        runner.ID,
+		Name:      runner.Name,
+		Status:    apiStatus,
+		Busy:      status == runnerv1.RunnerStatus_RUNNER_STATUS_ACTIVE,
+		Ephemeral: runner.Ephemeral,
+		Labels:    labels,
+	}
+}
+
 // ToVerification convert a git.Commit.Signature to an api.PayloadCommitVerification
 func ToVerification(ctx context.Context, c *git.Commit) *api.PayloadCommitVerification {
 	verif := asymkey_service.ParseCommitWithSignature(ctx, c)
diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl
index 244bc9f9c0..97438aced9 100644
--- a/templates/swagger/v1_json.tmpl
+++ b/templates/swagger/v1_json.tmpl
@@ -75,6 +75,108 @@
         }
       }
     },
+    "/admin/actions/runners": {
+      "get": {
+        "produces": [
+          "application/json"
+        ],
+        "tags": [
+          "admin"
+        ],
+        "summary": "Get all runners",
+        "operationId": "getAdminRunners",
+        "responses": {
+          "200": {
+            "$ref": "#/definitions/ActionRunnersResponse"
+          },
+          "400": {
+            "$ref": "#/responses/error"
+          },
+          "404": {
+            "$ref": "#/responses/notFound"
+          }
+        }
+      }
+    },
+    "/admin/actions/runners/registration-token": {
+      "post": {
+        "produces": [
+          "application/json"
+        ],
+        "tags": [
+          "admin"
+        ],
+        "summary": "Get an global actions runner registration token",
+        "operationId": "adminCreateRunnerRegistrationToken",
+        "responses": {
+          "200": {
+            "$ref": "#/responses/RegistrationToken"
+          }
+        }
+      }
+    },
+    "/admin/actions/runners/{runner_id}": {
+      "get": {
+        "produces": [
+          "application/json"
+        ],
+        "tags": [
+          "admin"
+        ],
+        "summary": "Get an global runner",
+        "operationId": "getAdminRunner",
+        "parameters": [
+          {
+            "type": "string",
+            "description": "id of the runner",
+            "name": "runner_id",
+            "in": "path",
+            "required": true
+          }
+        ],
+        "responses": {
+          "200": {
+            "$ref": "#/definitions/ActionRunner"
+          },
+          "400": {
+            "$ref": "#/responses/error"
+          },
+          "404": {
+            "$ref": "#/responses/notFound"
+          }
+        }
+      },
+      "delete": {
+        "produces": [
+          "application/json"
+        ],
+        "tags": [
+          "admin"
+        ],
+        "summary": "Delete an global runner",
+        "operationId": "deleteAdminRunner",
+        "parameters": [
+          {
+            "type": "string",
+            "description": "id of the runner",
+            "name": "runner_id",
+            "in": "path",
+            "required": true
+          }
+        ],
+        "responses": {
+          "204": {
+            "description": "runner has been deleted"
+          },
+          "400": {
+            "$ref": "#/responses/error"
+          },
+          "404": {
+            "$ref": "#/responses/notFound"
+          }
+        }
+      }
+    },
     "/admin/cron": {
       "get": {
         "produces": [
@@ -1697,6 +1799,38 @@
         }
       }
     },
+    "/orgs/{org}/actions/runners": {
+      "get": {
+        "produces": [
+          "application/json"
+        ],
+        "tags": [
+          "organization"
+        ],
+        "summary": "Get org-level runners",
+        "operationId": "getOrgRunners",
+        "parameters": [
+          {
+            "type": "string",
+            "description": "name of the organization",
+            "name": "org",
+            "in": "path",
+            "required": true
+          }
+        ],
+        "responses": {
+          "200": {
+            "$ref": "#/definitions/ActionRunnersResponse"
+          },
+          "400": {
+            "$ref": "#/responses/error"
+          },
+          "404": {
+            "$ref": "#/responses/notFound"
+          }
+        }
+      }
+    },
     "/orgs/{org}/actions/runners/registration-token": {
       "get": {
         "produces": [
@@ -1721,6 +1855,106 @@
             "$ref": "#/responses/RegistrationToken"
           }
         }
+      },
+      "post": {
+        "produces": [
+          "application/json"
+        ],
+        "tags": [
+          "organization"
+        ],
+        "summary": "Get an organization's actions runner registration token",
+        "operationId": "orgCreateRunnerRegistrationToken",
+        "parameters": [
+          {
+            "type": "string",
+            "description": "name of the organization",
+            "name": "org",
+            "in": "path",
+            "required": true
+          }
+        ],
+        "responses": {
+          "200": {
+            "$ref": "#/responses/RegistrationToken"
+          }
+        }
+      }
+    },
+    "/orgs/{org}/actions/runners/{runner_id}": {
+      "get": {
+        "produces": [
+          "application/json"
+        ],
+        "tags": [
+          "organization"
+        ],
+        "summary": "Get an org-level runner",
+        "operationId": "getOrgRunner",
+        "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
+          }
+        ],
+        "responses": {
+          "200": {
+            "$ref": "#/definitions/ActionRunner"
+          },
+          "400": {
+            "$ref": "#/responses/error"
+          },
+          "404": {
+            "$ref": "#/responses/notFound"
+          }
+        }
+      },
+      "delete": {
+        "produces": [
+          "application/json"
+        ],
+        "tags": [
+          "organization"
+        ],
+        "summary": "Delete an org-level runner",
+        "operationId": "deleteOrgRunner",
+        "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
+          }
+        ],
+        "responses": {
+          "204": {
+            "description": "runner has been deleted"
+          },
+          "400": {
+            "$ref": "#/responses/error"
+          },
+          "404": {
+            "$ref": "#/responses/notFound"
+          }
+        }
       }
     },
     "/orgs/{org}/actions/secrets": {
@@ -4331,6 +4565,45 @@
         }
       }
     },
+    "/repos/{owner}/{repo}/actions/runners": {
+      "get": {
+        "produces": [
+          "application/json"
+        ],
+        "tags": [
+          "repository"
+        ],
+        "summary": "Get repo-level runners",
+        "operationId": "getRepoRunners",
+        "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
+          }
+        ],
+        "responses": {
+          "200": {
+            "$ref": "#/definitions/ActionRunnersResponse"
+          },
+          "400": {
+            "$ref": "#/responses/error"
+          },
+          "404": {
+            "$ref": "#/responses/notFound"
+          }
+        }
+      }
+    },
     "/repos/{owner}/{repo}/actions/runners/registration-token": {
       "get": {
         "produces": [
@@ -4362,6 +4635,127 @@
             "$ref": "#/responses/RegistrationToken"
           }
         }
+      },
+      "post": {
+        "produces": [
+          "application/json"
+        ],
+        "tags": [
+          "repository"
+        ],
+        "summary": "Get a repository's actions runner registration token",
+        "operationId": "repoCreateRunnerRegistrationToken",
+        "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
+          }
+        ],
+        "responses": {
+          "200": {
+            "$ref": "#/responses/RegistrationToken"
+          }
+        }
+      }
+    },
+    "/repos/{owner}/{repo}/actions/runners/{runner_id}": {
+      "get": {
+        "produces": [
+          "application/json"
+        ],
+        "tags": [
+          "repository"
+        ],
+        "summary": "Get an repo-level runner",
+        "operationId": "getRepoRunner",
+        "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
+          }
+        ],
+        "responses": {
+          "200": {
+            "$ref": "#/definitions/ActionRunner"
+          },
+          "400": {
+            "$ref": "#/responses/error"
+          },
+          "404": {
+            "$ref": "#/responses/notFound"
+          }
+        }
+      },
+      "delete": {
+        "produces": [
+          "application/json"
+        ],
+        "tags": [
+          "repository"
+        ],
+        "summary": "Delete an repo-level runner",
+        "operationId": "deleteRepoRunner",
+        "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
+          }
+        ],
+        "responses": {
+          "204": {
+            "description": "runner has been deleted"
+          },
+          "400": {
+            "$ref": "#/responses/error"
+          },
+          "404": {
+            "$ref": "#/responses/notFound"
+          }
+        }
       }
     },
     "/repos/{owner}/{repo}/actions/runs/{run}/artifacts": {
@@ -4559,7 +4953,7 @@
         ],
         "responses": {
           "204": {
-            "description": "delete one secret of the organization"
+            "description": "delete one secret of the repository"
           },
           "400": {
             "$ref": "#/responses/error"
@@ -16869,6 +17263,29 @@
         }
       }
     },
+    "/user/actions/runners": {
+      "get": {
+        "produces": [
+          "application/json"
+        ],
+        "tags": [
+          "user"
+        ],
+        "summary": "Get user-level runners",
+        "operationId": "getUserRunners",
+        "responses": {
+          "200": {
+            "$ref": "#/definitions/ActionRunnersResponse"
+          },
+          "400": {
+            "$ref": "#/responses/error"
+          },
+          "404": {
+            "$ref": "#/responses/notFound"
+          }
+        }
+      }
+    },
     "/user/actions/runners/registration-token": {
       "get": {
         "produces": [
@@ -16884,6 +17301,83 @@
             "$ref": "#/responses/RegistrationToken"
           }
         }
+      },
+      "post": {
+        "produces": [
+          "application/json"
+        ],
+        "tags": [
+          "user"
+        ],
+        "summary": "Get an user's actions runner registration token",
+        "operationId": "userCreateRunnerRegistrationToken",
+        "responses": {
+          "200": {
+            "$ref": "#/responses/RegistrationToken"
+          }
+        }
+      }
+    },
+    "/user/actions/runners/{runner_id}": {
+      "get": {
+        "produces": [
+          "application/json"
+        ],
+        "tags": [
+          "user"
+        ],
+        "summary": "Get an user-level runner",
+        "operationId": "getUserRunner",
+        "parameters": [
+          {
+            "type": "string",
+            "description": "id of the runner",
+            "name": "runner_id",
+            "in": "path",
+            "required": true
+          }
+        ],
+        "responses": {
+          "200": {
+            "$ref": "#/definitions/ActionRunner"
+          },
+          "400": {
+            "$ref": "#/responses/error"
+          },
+          "404": {
+            "$ref": "#/responses/notFound"
+          }
+        }
+      },
+      "delete": {
+        "produces": [
+          "application/json"
+        ],
+        "tags": [
+          "user"
+        ],
+        "summary": "Delete an user-level runner",
+        "operationId": "deleteUserRunner",
+        "parameters": [
+          {
+            "type": "string",
+            "description": "id of the runner",
+            "name": "runner_id",
+            "in": "path",
+            "required": true
+          }
+        ],
+        "responses": {
+          "204": {
+            "description": "runner has been deleted"
+          },
+          "400": {
+            "$ref": "#/responses/error"
+          },
+          "404": {
+            "$ref": "#/responses/notFound"
+          }
+        }
       }
     },
     "/user/actions/secrets/{secretname}": {
@@ -19377,6 +19871,80 @@
       },
       "x-go-package": "code.gitea.io/gitea/modules/structs"
     },
+    "ActionRunner": {
+      "description": "ActionRunner represents a Runner",
+      "type": "object",
+      "properties": {
+        "busy": {
+          "type": "boolean",
+          "x-go-name": "Busy"
+        },
+        "ephemeral": {
+          "type": "boolean",
+          "x-go-name": "Ephemeral"
+        },
+        "id": {
+          "type": "integer",
+          "format": "int64",
+          "x-go-name": "ID"
+        },
+        "labels": {
+          "type": "array",
+          "items": {
+            "$ref": "#/definitions/ActionRunnerLabel"
+          },
+          "x-go-name": "Labels"
+        },
+        "name": {
+          "type": "string",
+          "x-go-name": "Name"
+        },
+        "status": {
+          "type": "string",
+          "x-go-name": "Status"
+        }
+      },
+      "x-go-package": "code.gitea.io/gitea/modules/structs"
+    },
+    "ActionRunnerLabel": {
+      "description": "ActionRunnerLabel represents a Runner Label",
+      "type": "object",
+      "properties": {
+        "id": {
+          "type": "integer",
+          "format": "int64",
+          "x-go-name": "ID"
+        },
+        "name": {
+          "type": "string",
+          "x-go-name": "Name"
+        },
+        "type": {
+          "type": "string",
+          "x-go-name": "Type"
+        }
+      },
+      "x-go-package": "code.gitea.io/gitea/modules/structs"
+    },
+    "ActionRunnersResponse": {
+      "description": "ActionRunnersResponse returns Runners",
+      "type": "object",
+      "properties": {
+        "runners": {
+          "type": "array",
+          "items": {
+            "$ref": "#/definitions/ActionRunner"
+          },
+          "x-go-name": "Entries"
+        },
+        "total_count": {
+          "type": "integer",
+          "format": "int64",
+          "x-go-name": "TotalCount"
+        }
+      },
+      "x-go-package": "code.gitea.io/gitea/modules/structs"
+    },
     "ActionTask": {
       "description": "ActionTask represents a ActionTask",
       "type": "object",
@@ -27409,6 +27977,18 @@
         }
       }
     },
+    "Runner": {
+      "description": "Runner",
+      "schema": {
+        "$ref": "#/definitions/ActionRunner"
+      }
+    },
+    "RunnerList": {
+      "description": "RunnerList",
+      "schema": {
+        "$ref": "#/definitions/ActionRunnersResponse"
+      }
+    },
     "SearchResults": {
       "description": "SearchResults",
       "schema": {
diff --git a/tests/integration/api_actions_runner_test.go b/tests/integration/api_actions_runner_test.go
new file mode 100644
index 0000000000..ace7aa381a
--- /dev/null
+++ b/tests/integration/api_actions_runner_test.go
@@ -0,0 +1,332 @@
+// Copyright 2025 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+	"fmt"
+	"net/http"
+	"slices"
+	"testing"
+
+	auth_model "code.gitea.io/gitea/models/auth"
+	api "code.gitea.io/gitea/modules/structs"
+	"code.gitea.io/gitea/tests"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func TestAPIActionsRunner(t *testing.T) {
+	t.Run("AdminRunner", testActionsRunnerAdmin)
+	t.Run("UserRunner", testActionsRunnerUser)
+	t.Run("OwnerRunner", testActionsRunnerOwner)
+	t.Run("RepoRunner", testActionsRunnerRepo)
+}
+
+func testActionsRunnerAdmin(t *testing.T) {
+	defer tests.PrepareTestEnv(t)()
+	adminUsername := "user1"
+	token := getUserToken(t, adminUsername, auth_model.AccessTokenScopeWriteAdmin)
+	req := NewRequest(t, "POST", "/api/v1/admin/actions/runners/registration-token").AddTokenAuth(token)
+	tokenResp := MakeRequest(t, req, http.StatusOK)
+	var registrationToken struct {
+		Token string `json:"token"`
+	}
+	DecodeJSON(t, tokenResp, &registrationToken)
+	assert.NotEmpty(t, registrationToken.Token)
+
+	req = NewRequest(t, "GET", "/api/v1/admin/actions/runners").AddTokenAuth(token)
+	runnerListResp := MakeRequest(t, req, http.StatusOK)
+	runnerList := api.ActionRunnersResponse{}
+	DecodeJSON(t, runnerListResp, &runnerList)
+
+	assert.Len(t, runnerList.Entries, 4)
+
+	idx := slices.IndexFunc(runnerList.Entries, func(e *api.ActionRunner) bool { return e.ID == 34349 })
+	require.NotEqual(t, -1, idx)
+	expectedRunner := runnerList.Entries[idx]
+	assert.Equal(t, "runner_to_be_deleted", expectedRunner.Name)
+	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)
+
+	// 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)
+
+		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.Ephemeral, runner.Ephemeral)
+		assert.ElementsMatch(t, runnerEntry.Labels, runner.Labels)
+
+		req = NewRequest(t, "DELETE", fmt.Sprintf("/api/v1/admin/actions/runners/%d", runnerEntry.ID)).AddTokenAuth(token)
+		MakeRequest(t, req, http.StatusNoContent)
+
+		// Verify runner deletion
+		req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/admin/actions/runners/%d", runnerEntry.ID)).AddTokenAuth(token)
+		MakeRequest(t, req, http.StatusNotFound)
+	}
+}
+
+func testActionsRunnerUser(t *testing.T) {
+	defer tests.PrepareTestEnv(t)()
+	userUsername := "user1"
+	token := getUserToken(t, userUsername, auth_model.AccessTokenScopeWriteUser)
+	req := NewRequest(t, "POST", "/api/v1/user/actions/runners/registration-token").AddTokenAuth(token)
+	tokenResp := MakeRequest(t, req, http.StatusOK)
+	var registrationToken struct {
+		Token string `json:"token"`
+	}
+	DecodeJSON(t, tokenResp, &registrationToken)
+	assert.NotEmpty(t, registrationToken.Token)
+
+	req = NewRequest(t, "GET", "/api/v1/user/actions/runners").AddTokenAuth(token)
+	runnerListResp := MakeRequest(t, req, http.StatusOK)
+	runnerList := api.ActionRunnersResponse{}
+	DecodeJSON(t, runnerListResp, &runnerList)
+
+	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].Ephemeral)
+	assert.Len(t, runnerList.Entries[0].Labels, 2)
+	assert.Equal(t, "runner_to_be_deleted", runnerList.Entries[0].Labels[0].Name)
+	assert.Equal(t, "linux", runnerList.Entries[0].Labels[1].Name)
+
+	// Verify get the runner by id
+	req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/user/actions/runners/%d", runnerList.Entries[0].ID)).AddTokenAuth(token)
+	runnerResp := MakeRequest(t, req, http.StatusOK)
+
+	runner := api.ActionRunner{}
+	DecodeJSON(t, runnerResp, &runner)
+
+	assert.Equal(t, "runner_to_be_deleted-user", runner.Name)
+	assert.Equal(t, int64(34346), runner.ID)
+	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)
+
+	// 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)
+
+	// Verify runner deletion
+	req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/user/actions/runners/%d", runnerList.Entries[0].ID)).AddTokenAuth(token)
+	MakeRequest(t, req, http.StatusNotFound)
+}
+
+func testActionsRunnerOwner(t *testing.T) {
+	defer tests.PrepareTestEnv(t)()
+
+	t.Run("GetRunner", func(t *testing.T) {
+		userUsername := "user2"
+		token := getUserToken(t, userUsername, auth_model.AccessTokenScopeReadOrganization)
+		// Verify get the runner by id with read scope
+		req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/orgs/org3/actions/runners/%d", 34347)).AddTokenAuth(token)
+		runnerResp := MakeRequest(t, req, http.StatusOK)
+
+		runner := api.ActionRunner{}
+		DecodeJSON(t, runnerResp, &runner)
+
+		assert.Equal(t, "runner_to_be_deleted-org", runner.Name)
+		assert.Equal(t, int64(34347), runner.ID)
+		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)
+	})
+
+	t.Run("Access", func(t *testing.T) {
+		userUsername := "user2"
+		token := getUserToken(t, userUsername, auth_model.AccessTokenScopeWriteOrganization)
+		req := NewRequest(t, "POST", "/api/v1/orgs/org3/actions/runners/registration-token").AddTokenAuth(token)
+		tokenResp := MakeRequest(t, req, http.StatusOK)
+		var registrationToken struct {
+			Token string `json:"token"`
+		}
+		DecodeJSON(t, tokenResp, &registrationToken)
+		assert.NotEmpty(t, registrationToken.Token)
+
+		req = NewRequest(t, "GET", "/api/v1/orgs/org3/actions/runners").AddTokenAuth(token)
+		runnerListResp := MakeRequest(t, req, http.StatusOK)
+		runnerList := api.ActionRunnersResponse{}
+		DecodeJSON(t, runnerListResp, &runnerList)
+
+		assert.Len(t, runnerList.Entries, 1)
+		assert.Equal(t, "runner_to_be_deleted-org", runnerList.Entries[0].Name)
+		assert.Equal(t, int64(34347), runnerList.Entries[0].ID)
+		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)
+		assert.Equal(t, "linux", runnerList.Entries[0].Labels[1].Name)
+
+		// Verify get the runner by id
+		req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/orgs/org3/actions/runners/%d", runnerList.Entries[0].ID)).AddTokenAuth(token)
+		runnerResp := MakeRequest(t, req, http.StatusOK)
+
+		runner := api.ActionRunner{}
+		DecodeJSON(t, runnerResp, &runner)
+
+		assert.Equal(t, "runner_to_be_deleted-org", runner.Name)
+		assert.Equal(t, int64(34347), runner.ID)
+		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)
+
+		// Verify delete the runner by id
+		req = NewRequest(t, "DELETE", fmt.Sprintf("/api/v1/orgs/org3/actions/runners/%d", runnerList.Entries[0].ID)).AddTokenAuth(token)
+		MakeRequest(t, req, http.StatusNoContent)
+
+		// Verify runner deletion
+		req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/orgs/org3/actions/runners/%d", runnerList.Entries[0].ID)).AddTokenAuth(token)
+		MakeRequest(t, req, http.StatusNotFound)
+	})
+
+	t.Run("DeleteReadScopeForbidden", func(t *testing.T) {
+		userUsername := "user2"
+		token := getUserToken(t, userUsername, auth_model.AccessTokenScopeReadOrganization)
+
+		// Verify delete the runner by id is forbidden with read scope
+		req := NewRequest(t, "DELETE", fmt.Sprintf("/api/v1/orgs/org3/actions/runners/%d", 34347)).AddTokenAuth(token)
+		MakeRequest(t, req, http.StatusForbidden)
+	})
+
+	t.Run("GetRepoScopeForbidden", func(t *testing.T) {
+		userUsername := "user2"
+		token := getUserToken(t, userUsername, auth_model.AccessTokenScopeReadRepository)
+		// Verify get the runner by id with read scope
+		req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/orgs/org3/actions/runners/%d", 34347)).AddTokenAuth(token)
+		MakeRequest(t, req, http.StatusForbidden)
+	})
+
+	t.Run("GetAdminRunner", func(t *testing.T) {
+		userUsername := "user2"
+		token := getUserToken(t, userUsername, auth_model.AccessTokenScopeReadOrganization)
+		// Verify get a runner by id of different entity is not found
+		// runner.EditableInContext(ownerID, repoID) false
+		req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/orgs/org3/actions/runners/%d", 34349)).AddTokenAuth(token)
+		MakeRequest(t, req, http.StatusNotFound)
+	})
+
+	t.Run("DeleteAdminRunner", func(t *testing.T) {
+		userUsername := "user2"
+		token := getUserToken(t, userUsername, auth_model.AccessTokenScopeWriteOrganization)
+		// Verify delete a runner by id of different entity is not found
+		// runner.EditableInContext(ownerID, repoID) false
+		req := NewRequest(t, "DELETE", fmt.Sprintf("/api/v1/orgs/org3/actions/runners/%d", 34349)).AddTokenAuth(token)
+		MakeRequest(t, req, http.StatusNotFound)
+	})
+}
+
+func testActionsRunnerRepo(t *testing.T) {
+	defer tests.PrepareTestEnv(t)()
+
+	t.Run("GetRunner", func(t *testing.T) {
+		userUsername := "user2"
+		token := getUserToken(t, userUsername, auth_model.AccessTokenScopeReadRepository)
+		// Verify get the runner by id with read scope
+		req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/user2/repo1/actions/runners/%d", 34348)).AddTokenAuth(token)
+		runnerResp := MakeRequest(t, req, http.StatusOK)
+
+		runner := api.ActionRunner{}
+		DecodeJSON(t, runnerResp, &runner)
+
+		assert.Equal(t, "runner_to_be_deleted-repo1", runner.Name)
+		assert.Equal(t, int64(34348), runner.ID)
+		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)
+	})
+
+	t.Run("Access", func(t *testing.T) {
+		userUsername := "user2"
+		token := getUserToken(t, userUsername, auth_model.AccessTokenScopeWriteRepository)
+		req := NewRequest(t, "POST", "/api/v1/repos/user2/repo1/actions/runners/registration-token").AddTokenAuth(token)
+		tokenResp := MakeRequest(t, req, http.StatusOK)
+		var registrationToken struct {
+			Token string `json:"token"`
+		}
+		DecodeJSON(t, tokenResp, &registrationToken)
+		assert.NotEmpty(t, registrationToken.Token)
+
+		req = NewRequest(t, "GET", "/api/v1/repos/user2/repo1/actions/runners").AddTokenAuth(token)
+		runnerListResp := MakeRequest(t, req, http.StatusOK)
+		runnerList := api.ActionRunnersResponse{}
+		DecodeJSON(t, runnerListResp, &runnerList)
+
+		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].Ephemeral)
+		assert.Len(t, runnerList.Entries[0].Labels, 2)
+		assert.Equal(t, "runner_to_be_deleted", runnerList.Entries[0].Labels[0].Name)
+		assert.Equal(t, "linux", runnerList.Entries[0].Labels[1].Name)
+
+		// Verify get the runner by id
+		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)
+
+		runner := api.ActionRunner{}
+		DecodeJSON(t, runnerResp, &runner)
+
+		assert.Equal(t, "runner_to_be_deleted-repo1", runner.Name)
+		assert.Equal(t, int64(34348), runner.ID)
+		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)
+
+		// 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)
+
+		// Verify runner deletion
+		req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/user2/repo1/actions/runners/%d", runnerList.Entries[0].ID)).AddTokenAuth(token)
+		MakeRequest(t, req, http.StatusNotFound)
+	})
+
+	t.Run("DeleteReadScopeForbidden", func(t *testing.T) {
+		userUsername := "user2"
+		token := getUserToken(t, userUsername, auth_model.AccessTokenScopeReadRepository)
+
+		// Verify delete the runner by id is forbidden with read scope
+		req := NewRequest(t, "DELETE", fmt.Sprintf("/api/v1/repos/user2/repo1/actions/runners/%d", 34348)).AddTokenAuth(token)
+		MakeRequest(t, req, http.StatusForbidden)
+	})
+
+	t.Run("GetOrganizationScopeForbidden", func(t *testing.T) {
+		userUsername := "user2"
+		token := getUserToken(t, userUsername, auth_model.AccessTokenScopeReadOrganization)
+		// Verify get the runner by id with read scope
+		req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/user2/repo1/actions/runners/%d", 34348)).AddTokenAuth(token)
+		MakeRequest(t, req, http.StatusForbidden)
+	})
+
+	t.Run("GetAdminRunnerNotFound", func(t *testing.T) {
+		userUsername := "user2"
+		token := getUserToken(t, userUsername, auth_model.AccessTokenScopeReadRepository)
+		// Verify get a runner by id of different entity is not found
+		// runner.EditableInContext(ownerID, repoID) false
+		req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/user2/repo1/actions/runners/%d", 34349)).AddTokenAuth(token)
+		MakeRequest(t, req, http.StatusNotFound)
+	})
+
+	t.Run("DeleteAdminRunnerNotFound", func(t *testing.T) {
+		userUsername := "user2"
+		token := getUserToken(t, userUsername, auth_model.AccessTokenScopeWriteRepository)
+		// Verify delete a runner by id of different entity is not found
+		// runner.EditableInContext(ownerID, repoID) false
+		req := NewRequest(t, "DELETE", fmt.Sprintf("/api/v1/repos/user2/repo1/actions/runners/%d", 34349)).AddTokenAuth(token)
+		MakeRequest(t, req, http.StatusNotFound)
+	})
+}