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

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

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

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

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


Fixes: https://github.com/go-gitea/gitea/issues/36767
This commit is contained in:
Nicolas
2026-03-16 18:24:36 +01:00
committed by GitHub
parent 6372cd7c7d
commit b3b2d111da
27 changed files with 860 additions and 24 deletions
+106 -1
View File
@@ -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)