diff --git a/cmd/actions.go b/cmd/actions.go
new file mode 100644
index 0000000000..acb525aea8
--- /dev/null
+++ b/cmd/actions.go
@@ -0,0 +1,71 @@
+// SPDX-License-Identifier: MIT
+
+package cmd
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"log"
+
+	actions_model "code.gitea.io/gitea/models/actions"
+	"code.gitea.io/gitea/modules/setting"
+	"code.gitea.io/gitea/modules/util"
+
+	"github.com/urfave/cli"
+)
+
+var (
+	// CmdActions represents the available actions sub-commands.
+	CmdActions = cli.Command{
+		Name:        "actions",
+		Usage:       "",
+		Description: "Commands for managing Gitea Actions",
+		Subcommands: []cli.Command{
+			subcmdActionsGenRunnerToken,
+		},
+	}
+
+	subcmdActionsGenRunnerToken = cli.Command{
+		Name:    "generate-runner-token",
+		Usage:   "Generate a new token for a runner to use to register with the server",
+		Action:  runGenerateActionsRunnerToken,
+		Aliases: []string{"grt"},
+		Flags:   []cli.Flag{},
+	}
+)
+
+func maybeInitDB(stdCtx context.Context) error {
+	if setting.Database.Type == "" {
+		if err := initDB(stdCtx); err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
+func runGenerateActionsRunnerToken(ctx *cli.Context) error {
+	stdCtx := context.Background()
+
+	if err := maybeInitDB(stdCtx); err != nil {
+		log.Fatalf("maybeInitDB %v", err)
+	}
+
+	// ownid=0,repo_id=0,means this token is used for global
+	return runActionsRegistrationToken(stdCtx, 0, 0)
+}
+
+func runActionsRegistrationToken(stdCtx context.Context, ownerID, repoID int64) error {
+	var token *actions_model.ActionRunnerToken
+	token, err := actions_model.GetUnactivatedRunnerToken(stdCtx, ownerID, repoID)
+	if errors.Is(err, util.ErrNotExist) {
+		token, err = actions_model.NewRunnerToken(stdCtx, ownerID, repoID)
+		if err != nil {
+			log.Fatalf("CreateRunnerToken %v", err)
+		}
+	} else if err != nil {
+		log.Fatalf("GetUnactivatedRunnerToken %v", err)
+	}
+	fmt.Print(token.Token)
+	return nil
+}
diff --git a/main.go b/main.go
index eeedf54c27..f004d58bea 100644
--- a/main.go
+++ b/main.go
@@ -58,6 +58,7 @@ func main() {
 arguments - which can alternatively be run by running the subcommand web.`
 	app.Version = Version + formatBuiltWith()
 	app.Commands = []cli.Command{
+		cmd.CmdActions,
 		cmd.CmdWeb,
 		cmd.CmdServ,
 		cmd.CmdHook,
diff --git a/tests/integration/cmd_actions_test.go b/tests/integration/cmd_actions_test.go
new file mode 100644
index 0000000000..b373e77c8e
--- /dev/null
+++ b/tests/integration/cmd_actions_test.go
@@ -0,0 +1,50 @@
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+	"bytes"
+	"flag"
+	"io"
+	"net/url"
+	"os"
+	"testing"
+
+	"code.gitea.io/gitea/cmd"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/urfave/cli"
+)
+
+func Test_CmdActions(t *testing.T) {
+	onGiteaRun(t, func(*testing.T, *url.URL) {
+		tests := []struct {
+			name           string
+			args           []string
+			wantErr        bool
+			expectedOutput func(string)
+		}{
+			{"test_registration-token-admin", []string{"actions", "generate-runner-token"}, false, func(output string) { assert.EqualValues(t, 40, len(output), output) }},
+		}
+		for _, tt := range tests {
+			t.Run(tt.name, func(t *testing.T) {
+				realStdout := os.Stdout
+				r, w, _ := os.Pipe()
+				os.Stdout = w
+
+				set := flag.NewFlagSet("actions", 0)
+				_ = set.Parse(tt.args)
+				context := cli.NewContext(&cli.App{Writer: os.Stdout}, set, nil)
+				err := cmd.CmdActions.Run(context)
+				if (err != nil) != tt.wantErr {
+					t.Errorf("CmdActions.Run() error = %v, wantErr %v", err, tt.wantErr)
+				}
+				w.Close()
+				var buf bytes.Buffer
+				io.Copy(&buf, r)
+				tt.expectedOutput(buf.String())
+				os.Stdout = realStdout
+			})
+		}
+	})
+}