├── .gitignore ├── runtime ├── task_meta.go ├── task_meta_windows.go ├── runtime.go └── task.go ├── .goreleaser.checksum.sh ├── client ├── client.go └── http.go ├── poller ├── thread.go ├── metric.go └── poller.go ├── core └── runner.go ├── main.go ├── .vscode └── launch.json ├── runners ├── external_runner_test.go └── external_runner.go ├── .gitea └── workflows │ └── build_release.yml ├── .github ├── workflows │ ├── build_release.yml │ ├── build_container.yml │ └── test.yml └── actions │ └── setup-gitea │ └── action.yml ├── LICENSE ├── examples ├── docker-compose-dind │ └── docker-compose.yml └── docker-compose-dind-rootless │ └── docker-compose.yml ├── register └── register.go ├── .goreleaser.yaml ├── actions-runner-worker.js ├── go.mod ├── util ├── actions-runner-worker.py ├── setup.go ├── actions-runner-worker.ps1 └── extract.go ├── actions-runner-worker.py ├── start.sh ├── actions └── server │ ├── main.go │ └── server.go ├── Dockerfile.medium ├── cmd ├── daemon.go ├── register.go └── cmd.go ├── exec └── exec.go ├── config └── config.go ├── actions-runner-worker.ps1 ├── Dockerfile ├── .golangci.yml ├── Makefile ├── README.md └── actions-runner-worker-v2.js /.gitignore: -------------------------------------------------------------------------------- 1 | act_runner* 2 | gitea-actions-runner* 3 | .env 4 | .runner 5 | actions-runner* 6 | __debug* 7 | -------------------------------------------------------------------------------- /runtime/task_meta.go: -------------------------------------------------------------------------------- 1 | //go:build !windows 2 | 3 | package runtime 4 | 5 | import ( 6 | "syscall" 7 | ) 8 | 9 | func getSysProcAttr() *syscall.SysProcAttr { 10 | return &syscall.SysProcAttr{Setpgid: true} 11 | } 12 | -------------------------------------------------------------------------------- /runtime/task_meta_windows.go: -------------------------------------------------------------------------------- 1 | package runtime 2 | 3 | import ( 4 | "syscall" 5 | ) 6 | 7 | func getSysProcAttr() *syscall.SysProcAttr { 8 | return &syscall.SysProcAttr{CreationFlags: syscall.CREATE_NEW_PROCESS_GROUP, HideWindow: true} 9 | } 10 | -------------------------------------------------------------------------------- /.goreleaser.checksum.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | if [ -z "$1" ]; then 6 | echo "usage: $0 " 7 | exit 1 8 | fi 9 | 10 | SUM=$(shasum -a 256 "$1" | cut -d' ' -f1) 11 | BASENAME=$(basename "$1") 12 | echo -n "${SUM} ${BASENAME}" > "$1".sha256 -------------------------------------------------------------------------------- /client/client.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import ( 4 | "code.gitea.io/actions-proto-go/ping/v1/pingv1connect" 5 | "code.gitea.io/actions-proto-go/runner/v1/runnerv1connect" 6 | ) 7 | 8 | // A Client manages communication with the runner. 9 | type Client interface { 10 | pingv1connect.PingServiceClient 11 | runnerv1connect.RunnerServiceClient 12 | Address() string 13 | } 14 | -------------------------------------------------------------------------------- /poller/thread.go: -------------------------------------------------------------------------------- 1 | package poller 2 | 3 | import "sync" 4 | 5 | type routineGroup struct { 6 | waitGroup sync.WaitGroup 7 | } 8 | 9 | func newRoutineGroup() *routineGroup { 10 | return new(routineGroup) 11 | } 12 | 13 | func (g *routineGroup) Run(fn func()) { 14 | g.waitGroup.Add(1) 15 | 16 | go func() { 17 | defer g.waitGroup.Done() 18 | fn() 19 | }() 20 | } 21 | 22 | func (g *routineGroup) Wait() { 23 | g.waitGroup.Wait() 24 | } 25 | -------------------------------------------------------------------------------- /core/runner.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | const ( 4 | UUIDHeader = "x-runner-uuid" 5 | TokenHeader = "x-runner-token" 6 | ) 7 | 8 | // Runner struct 9 | type Runner struct { 10 | ID int64 `json:"id"` 11 | UUID string `json:"uuid"` 12 | Name string `json:"name"` 13 | Token string `json:"token"` 14 | Address string `json:"address"` 15 | Labels []string `json:"labels"` 16 | RunnerWorker []string `json:"runner_worker"` 17 | Capacity int `json:"capacity"` 18 | Ephemeral bool `json:"ephemeral"` 19 | } 20 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "os" 6 | "os/signal" 7 | "syscall" 8 | 9 | "github.com/ChristopherHX/gitea-actions-runner/cmd" 10 | ) 11 | 12 | func withContextFunc(ctx context.Context, f func()) context.Context { 13 | ctx, cancel := context.WithCancel(ctx) 14 | go func() { 15 | c := make(chan os.Signal, 1) 16 | signal.Notify(c, syscall.SIGINT, syscall.SIGTERM) 17 | defer signal.Stop(c) 18 | 19 | select { 20 | case <-ctx.Done(): 21 | case <-c: 22 | cancel() 23 | f() 24 | } 25 | }() 26 | 27 | return ctx 28 | } 29 | 30 | func main() { 31 | ctx := withContextFunc(context.Background(), func() {}) 32 | // run the command 33 | cmd.Execute(ctx) 34 | } 35 | -------------------------------------------------------------------------------- /poller/metric.go: -------------------------------------------------------------------------------- 1 | package poller 2 | 3 | import "sync/atomic" 4 | 5 | // Metric interface 6 | type Metric interface { 7 | IncBusyWorker() int64 8 | DecBusyWorker() int64 9 | BusyWorkers() int64 10 | } 11 | 12 | var _ Metric = (*metric)(nil) 13 | 14 | type metric struct { 15 | busyWorkers int64 16 | } 17 | 18 | // NewMetric for default metric structure 19 | func NewMetric() Metric { 20 | return &metric{} 21 | } 22 | 23 | func (m *metric) IncBusyWorker() int64 { 24 | return atomic.AddInt64(&m.busyWorkers, 1) 25 | } 26 | 27 | func (m *metric) DecBusyWorker() int64 { 28 | return atomic.AddInt64(&m.busyWorkers, -1) 29 | } 30 | 31 | func (m *metric) BusyWorkers() int64 { 32 | return atomic.LoadInt64(&m.busyWorkers) 33 | } 34 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "Launch Register", 6 | "type": "go", 7 | "request": "launch", 8 | "mode": "auto", 9 | "program": "${workspaceFolder}", 10 | "args": [ 11 | "register" 12 | ], 13 | "console": "integratedTerminal" 14 | }, 15 | { 16 | "name": "Launch Daemon", 17 | "type": "go", 18 | "request": "launch", 19 | "mode": "auto", 20 | "program": "${workspaceFolder}", 21 | "args": [ 22 | "daemon" 23 | ], 24 | "console": "integratedTerminal" 25 | } 26 | ] 27 | } -------------------------------------------------------------------------------- /runtime/runtime.go: -------------------------------------------------------------------------------- 1 | package runtime 2 | 3 | import ( 4 | "context" 5 | 6 | runnerv1 "code.gitea.io/actions-proto-go/runner/v1" 7 | "github.com/ChristopherHX/gitea-actions-runner/client" 8 | ) 9 | 10 | // Runner runs the pipeline. 11 | type Runner struct { 12 | Machine string 13 | ForgeInstance string 14 | Environ map[string]string 15 | Client client.Client 16 | Labels []string 17 | RunnerWorker []string 18 | } 19 | 20 | // Run runs the pipeline stage. 21 | func (s *Runner) Run(ctx context.Context, task *runnerv1.Task) error { 22 | return NewTask(s.ForgeInstance, task.Id, s.Client, s.Environ, s.platformPicker).Run(ctx, task, s.RunnerWorker) 23 | } 24 | 25 | func (s *Runner) platformPicker(labels []string) string { 26 | return "" 27 | } 28 | -------------------------------------------------------------------------------- /runners/external_runner_test.go: -------------------------------------------------------------------------------- 1 | package runners 2 | 3 | import ( 4 | "os" 5 | "path" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func TestCloneExternalRunner(t *testing.T) { 12 | aure, prefix, ext, agentname, tmpdir, err := CreateExternalRunnerDirectory(Parameters{ 13 | RunnerPath: "/Users/christopher/Documents/ActionsAndPipelines/gitea-actions-runner/actions-runner-3.12.1", 14 | RunnerDirectory: "runners", 15 | }) 16 | defer os.RemoveAll(tmpdir) 17 | assert.NoError(t, err) 18 | assert.Equal(t, false, aure) 19 | assert.Equal(t, "Runner", prefix) 20 | assert.Equal(t, "", ext) 21 | assert.NotEmpty(t, agentname) 22 | assert.Equal(t, "/Users/christopher/Documents/ActionsAndPipelines/gitea-actions-runner/runners/runners", path.Dir(tmpdir)) 23 | } 24 | -------------------------------------------------------------------------------- /.gitea/workflows/build_release.yml: -------------------------------------------------------------------------------- 1 | name: build/release 2 | 3 | on: 4 | push: 5 | 6 | jobs: 7 | goreleaser: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v3 11 | with: 12 | fetch-depth: 0 # all history for all branches and tags 13 | - uses: actions/setup-go@v3 14 | with: 15 | go-version: '>=1.20.1' 16 | 17 | - name: Build 18 | if: "!startsWith(github.ref, 'refs/tags/')" 19 | run: | 20 | go build 21 | 22 | - name: Run GoReleaser 23 | uses: https://github.com/goreleaser/goreleaser-action@v4 24 | if: startsWith(github.ref, 'refs/tags/') 25 | with: 26 | version: latest 27 | args: release --clean 28 | env: 29 | GORELEASER_FORCE_TOKEN: 'gitea' 30 | GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }} 31 | 32 | -------------------------------------------------------------------------------- /.github/workflows/build_release.yml: -------------------------------------------------------------------------------- 1 | name: build/release 2 | 3 | on: 4 | push: 5 | 6 | jobs: 7 | goreleaser: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v4 11 | with: 12 | fetch-depth: 0 # all history for all branches and tags 13 | - uses: actions/setup-go@v4 14 | with: 15 | go-version: '>=1.20.1' 16 | 17 | - name: Build 18 | if: "!startsWith(github.ref, 'refs/tags/')" 19 | run: | 20 | go build 21 | 22 | - name: Run GoReleaser 23 | uses: goreleaser/goreleaser-action@v4 24 | if: startsWith(github.ref, 'refs/tags/') 25 | with: 26 | version: latest 27 | args: release --clean 28 | env: 29 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 30 | 31 | container: 32 | if: startsWith(github.ref, 'refs/tags/') 33 | uses: ./.github/workflows/build_container.yml 34 | permissions: 35 | contents: read 36 | packages: write 37 | with: 38 | gitea-runner-tag: ${{ github.ref_name }} 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2022 The Gitea Authors 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /examples/docker-compose-dind/docker-compose.yml: -------------------------------------------------------------------------------- 1 | # docker-compose.yml 2 | # Known issues 3 | # - exposed ports of docker container are bound inside of the `docker` DinD container, localhost: doesn't work 4 | networks: 5 | runner: 6 | external: false 7 | volumes: 8 | runner: 9 | driver: local 10 | runner-externals: 11 | driver: local 12 | gitea-runner-data: 13 | driver: local 14 | socket: 15 | driver: local 16 | services: 17 | runner: 18 | image: ghcr.io/christopherhx/gitea-actions-runner:ubuntu-focal-latest 19 | environment: 20 | - GITEA_INSTANCE_URL=https://gitea.com/ # Your Gitea Instance to register to 21 | - GITEA_RUNNER_REGISTRATION_TOKEN=XXXXXXXXXXXXXXXXXXXXXXX # The Gitea registration token 22 | - GITEA_RUNNER_LABELS=self-hosted # The labels of your runner (comma separated) 23 | restart: always 24 | user: root 25 | networks: 26 | - runner 27 | volumes: 28 | - gitea-runner-data:/data # Persist runner registration across updates 29 | - runner:/home/runner/_work # DIND 30 | - runner-externals:/home/runner/externals # DIND 31 | - socket:/var/run 32 | depends_on: 33 | - docker 34 | docker: 35 | image: docker:dind 36 | restart: always 37 | privileged: true 38 | networks: 39 | - runner 40 | volumes: 41 | - runner:/home/runner/_work 42 | - runner-externals:/home/runner/externals 43 | - socket:/var/run -------------------------------------------------------------------------------- /client/http.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | "strings" 7 | 8 | "code.gitea.io/actions-proto-go/ping/v1/pingv1connect" 9 | "code.gitea.io/actions-proto-go/runner/v1/runnerv1connect" 10 | "connectrpc.com/connect" 11 | "github.com/ChristopherHX/gitea-actions-runner/core" 12 | ) 13 | 14 | // New returns a new runner client. 15 | func New(endpoint string, uuid, token string, opts ...connect.ClientOption) *HTTPClient { 16 | baseURL := strings.TrimRight(endpoint, "/") + "/api/actions" 17 | 18 | opts = append(opts, connect.WithInterceptors(connect.UnaryInterceptorFunc(func(next connect.UnaryFunc) connect.UnaryFunc { 19 | return func(ctx context.Context, req connect.AnyRequest) (connect.AnyResponse, error) { 20 | if uuid != "" { 21 | req.Header().Set(core.UUIDHeader, uuid) 22 | } 23 | if token != "" { 24 | req.Header().Set(core.TokenHeader, token) 25 | } 26 | return next(ctx, req) 27 | } 28 | }))) 29 | 30 | return &HTTPClient{ 31 | PingServiceClient: pingv1connect.NewPingServiceClient( 32 | http.DefaultClient, 33 | baseURL, 34 | opts..., 35 | ), 36 | RunnerServiceClient: runnerv1connect.NewRunnerServiceClient( 37 | http.DefaultClient, 38 | baseURL, 39 | opts..., 40 | ), 41 | endpoint: endpoint, 42 | } 43 | } 44 | 45 | func (c *HTTPClient) Address() string { 46 | return c.endpoint 47 | } 48 | 49 | var _ Client = (*HTTPClient)(nil) 50 | 51 | // An HTTPClient manages communication with the runner API. 52 | type HTTPClient struct { 53 | pingv1connect.PingServiceClient 54 | runnerv1connect.RunnerServiceClient 55 | endpoint string 56 | } 57 | -------------------------------------------------------------------------------- /examples/docker-compose-dind-rootless/docker-compose.yml: -------------------------------------------------------------------------------- 1 | # docker-compose.yml 2 | # Known issues 3 | # - alpine images may be unable to resolve dns 4 | # - exposed ports of docker container are bound inside of the `docker` DinD container, localhost: doesn't work 5 | networks: 6 | runner: 7 | external: false 8 | volumes: 9 | runner: 10 | driver: local 11 | runner-externals: 12 | driver: local 13 | gitea-runner-data: 14 | driver: local 15 | docker-certs: 16 | driver: local 17 | services: 18 | runner: 19 | image: ghcr.io/christopherhx/gitea-actions-runner:latest 20 | environment: 21 | - GITEA_INSTANCE_URL=https://gitea.com # Your Gitea Instance to register to 22 | - GITEA_RUNNER_REGISTRATION_TOKEN=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX # The Gitea registration token 23 | - GITEA_RUNNER_LABELS=self-hosted # The labels of your runner (comma separated) 24 | - DOCKER_TLS_CERTDIR=/certs # DIND 25 | - DOCKER_CERT_PATH=/certs/client # DIND 26 | - DOCKER_TLS_VERIFY=1 # DIND 27 | - DOCKER_HOST=tcp://docker:2376 # DIND 28 | restart: always 29 | networks: 30 | - runner 31 | volumes: 32 | - gitea-runner-data:/data # Persist runner registration across updates 33 | - runner:/home/runner/_work # DIND 34 | - runner-externals:/home/runner/externals # DIND 35 | - docker-certs:/certs # DIND 36 | depends_on: 37 | - docker 38 | docker: 39 | image: docker:dind-rootless 40 | restart: always 41 | privileged: true 42 | environment: 43 | - DOCKER_TLS_CERTDIR=/certs 44 | networks: 45 | - runner 46 | volumes: 47 | - runner:/home/runner/_work 48 | - runner-externals:/home/runner/externals 49 | - docker-certs:/certs 50 | - ./var-lib-docker:/var/lib/docker 51 | -------------------------------------------------------------------------------- /register/register.go: -------------------------------------------------------------------------------- 1 | package register 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "os" 7 | "strings" 8 | 9 | runnerv1 "code.gitea.io/actions-proto-go/runner/v1" 10 | "github.com/ChristopherHX/gitea-actions-runner/client" 11 | "github.com/ChristopherHX/gitea-actions-runner/config" 12 | "github.com/ChristopherHX/gitea-actions-runner/core" 13 | 14 | "connectrpc.com/connect" 15 | log "github.com/sirupsen/logrus" 16 | ) 17 | 18 | func New(cli client.Client) *Register { 19 | return &Register{ 20 | Client: cli, 21 | } 22 | } 23 | 24 | type Register struct { 25 | Client client.Client 26 | } 27 | 28 | func (p *Register) Register(ctx context.Context, cfg config.Runner) (*core.Runner, error) { 29 | labels := make([]string, len(cfg.Labels)) 30 | for i, v := range cfg.Labels { 31 | labels[i] = strings.SplitN(v, ":", 2)[0] 32 | } 33 | // register new runner. 34 | resp, err := p.Client.Register(ctx, connect.NewRequest(&runnerv1.RegisterRequest{ 35 | Name: cfg.Name, 36 | Token: cfg.Token, 37 | AgentLabels: labels, 38 | Ephemeral: cfg.Ephemeral, 39 | })) 40 | if err != nil { 41 | log.WithError(err).Error("poller: cannot register new runner") 42 | return nil, err 43 | } 44 | 45 | data := &core.Runner{ 46 | ID: resp.Msg.Runner.Id, 47 | UUID: resp.Msg.Runner.Uuid, 48 | Name: resp.Msg.Runner.Name, 49 | Token: resp.Msg.Runner.Token, 50 | Address: p.Client.Address(), 51 | RunnerWorker: cfg.RunnerWorker, 52 | Labels: cfg.Labels, 53 | Ephemeral: resp.Msg.Runner.Ephemeral, 54 | } 55 | 56 | file, err := json.MarshalIndent(data, "", " ") 57 | if err != nil { 58 | log.WithError(err).Error("poller: cannot marshal the json input") 59 | return data, err 60 | } 61 | 62 | if cfg.Ephemeral != resp.Msg.Runner.Ephemeral { 63 | // TODO we cannot remove the configuration via runner api, if we return an error here we just fill the database 64 | log.Error("poller: cannot register new runner as ephemeral upgrade Gitea to gain security, run-once will be used automatically") 65 | } 66 | 67 | // store runner config in .runner file 68 | return data, os.WriteFile(cfg.File, file, 0o640) 69 | } 70 | -------------------------------------------------------------------------------- /runners/external_runner.go: -------------------------------------------------------------------------------- 1 | package runners 2 | 3 | import ( 4 | "io" 5 | "os" 6 | "path" 7 | "path/filepath" 8 | "runtime" 9 | "strings" 10 | ) 11 | 12 | func CreateExternalRunnerDirectory(parameters Parameters) (azure bool, prefix, ext, agentname, tmpdir string, err error) { 13 | azure = parameters.AzurePipelines 14 | prefix = "Runner" 15 | if azure { 16 | prefix = "Agent" 17 | } 18 | ext = "" 19 | if runtime.GOOS == "windows" { 20 | ext = ".exe" // adjust this based on the target OS 21 | } 22 | root, err := filepath.Abs(parameters.RunnerPath) 23 | if err != nil { 24 | return 25 | } 26 | absPath, _ := filepath.Abs(parameters.RunnerDirectory) 27 | os.MkdirAll(absPath, 0755) 28 | tmpdir, _ = os.MkdirTemp(absPath, "runner-*") 29 | agentname = path.Base(tmpdir) 30 | os.MkdirAll(filepath.Join(tmpdir, "bin"), 0755) 31 | bindir := filepath.Join(root, "bin") 32 | err = filepath.Walk(bindir, func(bfile string, info os.FileInfo, err error) error { 33 | if err != nil { 34 | return err 35 | } 36 | fname := strings.TrimPrefix(bfile, bindir+string(os.PathSeparator)) 37 | destfile := filepath.Join(tmpdir, "bin", fname) 38 | if strings.HasPrefix(fname, prefix+".") && (strings.HasSuffix(fname, ".exe") || strings.HasSuffix(fname, ".dll") || !strings.Contains(fname[len(prefix)+1:], ".")) { 39 | copyFile(bfile, destfile) 40 | } else { 41 | if info.IsDir() { 42 | os.Symlink(bfile, destfile) 43 | } else { 44 | os.Symlink(bfile, destfile) 45 | } 46 | } 47 | return nil 48 | }) 49 | if err != nil { 50 | return 51 | } 52 | os.MkdirAll(filepath.Join(root, "externals"), 0755) 53 | os.Symlink(filepath.Join(root, "externals"), filepath.Join(tmpdir, "externals")) 54 | os.Symlink(filepath.Join(root, "license.html"), filepath.Join(tmpdir, "license.html")) 55 | return 56 | } 57 | 58 | func copyFile(src, dst string) error { 59 | sourceFile, err := os.Open(src) 60 | if err != nil { 61 | return err 62 | } 63 | info, _ := sourceFile.Stat() 64 | defer sourceFile.Close() 65 | 66 | destFile, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, info.Mode()) 67 | if err != nil { 68 | return err 69 | } 70 | defer destFile.Close() 71 | 72 | _, err = io.Copy(destFile, sourceFile) 73 | if err != nil { 74 | return err 75 | } 76 | 77 | return destFile.Sync() 78 | } 79 | 80 | type Parameters struct { 81 | AzurePipelines bool 82 | RunnerPath string 83 | RunnerDirectory string 84 | } 85 | -------------------------------------------------------------------------------- /.github/workflows/build_container.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | branches: 4 | - main 5 | workflow_dispatch: 6 | inputs: 7 | gitea-runner-tag: 8 | type: string 9 | workflow_call: 10 | inputs: 11 | gitea-runner-tag: 12 | type: string 13 | 14 | jobs: 15 | docker: 16 | runs-on: ubuntu-latest 17 | strategy: 18 | matrix: 19 | include: 20 | - file: Dockerfile 21 | build-args: '' 22 | tag-prefix: '' 23 | - file: Dockerfile.medium 24 | build-args: | 25 | BASE_IMAGE=catthehacker/ubuntu:act-20.04 26 | tag-prefix: ubuntu-focal- 27 | - file: Dockerfile.medium 28 | build-args: | 29 | BASE_IMAGE=catthehacker/ubuntu:act-22.04 30 | tag-prefix: ubuntu-jammy- 31 | - file: Dockerfile.medium 32 | build-args: | 33 | BASE_IMAGE=catthehacker/ubuntu:act-24.04 34 | tag-prefix: ubuntu-noble- 35 | - file: Dockerfile.medium 36 | build-args: | 37 | BASE_IMAGE=catthehacker/ubuntu:act-latest 38 | tag-prefix: ubuntu- 39 | permissions: 40 | contents: read 41 | packages: write 42 | steps: 43 | - uses: actions/checkout@v3 44 | - 45 | name: Set up QEMU 46 | uses: docker/setup-qemu-action@v2 47 | - 48 | name: Set up Docker Buildx 49 | uses: docker/setup-buildx-action@v2 50 | - 51 | name: Login to DockerHub 52 | uses: docker/login-action@v2 53 | with: 54 | registry: ghcr.io 55 | username: ${{ github.actor }} 56 | password: ${{ secrets.GITHUB_TOKEN }} 57 | - run: | 58 | echo "LOWNER<> $GITHUB_ENV 59 | echo $(echo "$OWNER" | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV 60 | echo "EOF23" >> $GITHUB_ENV 61 | shell: bash 62 | env: 63 | OWNER: ${{github.repository_owner}} 64 | - 65 | name: Build and push 66 | uses: docker/build-push-action@v5 67 | with: 68 | context: . 69 | file: ${{ matrix.Dockerfile || 'Dockerfile' }} 70 | platforms: linux/amd64,linux/arm64 71 | push: true 72 | build-args: ${{ matrix.build-args || '' }} 73 | tags: ghcr.io/${{env.LOWNER}}/gitea-actions-runner:${{ matrix.tag-prefix }}${{ inputs.gitea-runner-tag || 'nightly' }},ghcr.io/${{env.LOWNER}}/gitea-actions-runner:${{ matrix.tag-prefix }}${{ inputs.gitea-runner-tag && 'latest' || 'nightly' }} 74 | -------------------------------------------------------------------------------- /.goreleaser.yaml: -------------------------------------------------------------------------------- 1 | before: 2 | hooks: 3 | - go mod tidy 4 | 5 | builds: 6 | - env: 7 | - CGO_ENABLED=0 8 | goos: 9 | - darwin 10 | - linux 11 | - windows 12 | goarch: 13 | - amd64 14 | - arm 15 | - arm64 16 | goarm: 17 | - "7" 18 | ignore: 19 | - goos: darwin 20 | goarch: arm 21 | - goos: darwin 22 | goarch: ppc64le 23 | - goos: darwin 24 | goarch: s390x 25 | - goos: windows 26 | goarch: ppc64le 27 | - goos: windows 28 | goarch: s390x 29 | - goos: windows 30 | goarch: arm 31 | goarm: "5" 32 | - goos: windows 33 | goarch: arm 34 | goarm: "6" 35 | - goos: windows 36 | goarch: arm 37 | goarm: "7" 38 | - goos: windows 39 | goarch: arm64 40 | - goos: freebsd 41 | goarch: ppc64le 42 | - goos: freebsd 43 | goarch: s390x 44 | - goos: freebsd 45 | goarch: arm 46 | goarm: "5" 47 | - goos: freebsd 48 | goarch: arm 49 | goarm: "6" 50 | - goos: freebsd 51 | goarch: arm 52 | goarm: "7" 53 | - goos: freebsd 54 | goarch: arm64 55 | flags: 56 | - -trimpath 57 | ldflags: 58 | - -s -w -X github.com/ChristopherHX/gitea-actions-runner/cmd.version={{ .Summary }} 59 | binary: >- 60 | {{ .ProjectName }}- 61 | {{- .Version }}- 62 | {{- .Os }}- 63 | {{- if eq .Arch "amd64" }}amd64 64 | {{- else if eq .Arch "amd64_v1" }}amd64 65 | {{- else if eq .Arch "386" }}386 66 | {{- else }}{{ .Arch }}{{ end }} 67 | {{- if .Arm }}-{{ .Arm }}{{ end }} 68 | no_unique_dist_dir: true 69 | hooks: 70 | post: 71 | - cmd: xz -k -9 {{ .Path }} 72 | dir: ./dist/ 73 | - cmd: sh .goreleaser.checksum.sh {{ .Path }} 74 | - cmd: sh .goreleaser.checksum.sh {{ .Path }}.xz 75 | 76 | archives: 77 | - format: binary 78 | name_template: "{{ .Binary }}" 79 | allow_different_binary_count: true 80 | 81 | checksum: 82 | name_template: 'checksums.txt' 83 | extra_files: 84 | - glob: ./**.xz 85 | 86 | snapshot: 87 | name_template: "{{ .Branch }}-devel" 88 | 89 | gitea_urls: 90 | api: https://gitea.com/api/v1 91 | download: https://gitea.com 92 | 93 | release: 94 | extra_files: 95 | - glob: ./**.xz 96 | - glob: ./**.xz.sha256 97 | 98 | # yaml-language-server: $schema=https://goreleaser.com/static/schema-pro.json 99 | # vim: set ts=2 sw=2 tw=0 fo=cnqoj 100 | -------------------------------------------------------------------------------- /.github/actions/setup-gitea/action.yml: -------------------------------------------------------------------------------- 1 | inputs: 2 | GITEA_BIN: 3 | description: 'Path to the Gitea binary' 4 | GITEA_PLATFORM: 5 | description: 'Port for Gitea to run on' 6 | default: 'darwin-10.12-arm64' 7 | GITEA_PORT: 8 | description: 'Port for Gitea to run on' 9 | default: 3005 10 | runs: 11 | using: composite 12 | steps: 13 | - name: Download Gitea 14 | if: ${{ !inputs.GITEA_BIN }} 15 | shell: bash 16 | run: | 17 | GITEA_BIN="$RUNNER_TEMP/gitea-main" 18 | echo "GITEA_BIN=$GITEA_BIN" >> $GITHUB_ENV 19 | if [ ! -f "$GITEA_BIN" ]; then 20 | echo "Downloading Gitea binary..." 21 | curl -L https://dl.gitea.io/gitea/main-nightly/gitea-main-nightly-${{ inputs.GITEA_PLATFORM }} -o "$GITEA_BIN" 22 | chmod +x "$GITEA_BIN" 23 | fi 24 | - name: Setup Gitea 25 | shell: bash 26 | run: | 27 | mkdir -p "$RUNNER_TEMP/conf/" 28 | mkdir -p "$RUNNER_TEMP/data/" 29 | echo "I_AM_BEING_UNSAFE_RUNNING_AS_ROOT = true" > "$RUNNER_TEMP/conf/app.ini" 30 | echo "[security]" >> "$RUNNER_TEMP/conf/app.ini" 31 | echo "INTERNAL_TOKEN = eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYmYiOjE1NTg4MzY4ODB9.LoKQyK5TN_0kMJFVHWUW0uDAyoGjDP6Mkup4ps2VJN4" >> "$RUNNER_TEMP/conf/app.ini" 32 | echo "INSTALL_LOCK = true" >> "$RUNNER_TEMP/conf/app.ini" 33 | echo "SECRET_KEY = 2crAW4UANgvLipDS6U5obRcFosjSJHQANll6MNfX7P0G3se3fKcCwwK3szPyGcbo" >> "$RUNNER_TEMP/conf/app.ini" 34 | echo "PASSWORD_COMPLEXITY = off" >> "$RUNNER_TEMP/conf/app.ini" 35 | echo "[database]" >> "$RUNNER_TEMP/conf/app.ini" 36 | echo "DB_TYPE = sqlite3" >> "$RUNNER_TEMP/conf/app.ini" 37 | echo "PATH = "$RUNNER_TEMP/data/gitea.db"" >> "$RUNNER_TEMP/conf/app.ini" 38 | echo "[repository]" >> "$RUNNER_TEMP/conf/app.ini" 39 | echo "ROOT = "$RUNNER_TEMP/data/"" >> "$RUNNER_TEMP/conf/app.ini" 40 | echo "[server]" >> "$RUNNER_TEMP/conf/app.ini" 41 | echo "ROOT_URL = http://localhost:$GITEA_PORT" >> "$RUNNER_TEMP/conf/app.ini" 42 | "$GITEA_BIN" migrate -c "$RUNNER_TEMP/conf/app.ini" 43 | "$GITEA_BIN" admin user create --username=test01 --password=test01 --email=test01@gitea.io --admin=true --must-change-password=false --access-token -c "$RUNNER_TEMP/conf/app.ini" 44 | "$GITEA_BIN" web -c "$RUNNER_TEMP/conf/app.ini" -p "$GITEA_PORT" & 45 | echo "waiting for gitea to start" 46 | while ! curl -s -o /dev/null -w "%{http_code}" "http://localhost:$GITEA_PORT" > /dev/null; do 47 | sleep 1 48 | done 49 | echo "TOKEN=$("$GITEA_BIN" admin user generate-access-token -u test01 -t random --raw -c "$RUNNER_TEMP/conf/app.ini")" >> $GITHUB_ENV 50 | echo "RUNNER_TOKEN=$("$GITEA_BIN" actions generate-runner-token -c "$RUNNER_TEMP/conf/app.ini")" >> $GITHUB_ENV 51 | env: 52 | GITEA_BIN: ${{ inputs.GITEA_BIN || env.GITEA_BIN }} 53 | GITEA_PORT: ${{ inputs.GITEA_PORT }} 54 | -------------------------------------------------------------------------------- /actions-runner-worker.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const path = require('path'); 3 | const child_process = require('child_process'); 4 | 5 | // Get the worker path from the command line. 6 | const worker = process.argv[2]; 7 | 8 | // Compute the runner file path (like os.path.abspath(os.path.join(worker, '../../.runner'))) 9 | const runnerFile = path.resolve(worker, '../../.runner'); 10 | if (!fs.existsSync(runnerFile)) { 11 | // Create default JSON data 12 | const data = { 13 | isHostedServer: false, 14 | agentName: 'my-runner', 15 | workFolder: '_work' 16 | }; 17 | fs.writeFileSync(runnerFile, JSON.stringify(data)); 18 | } 19 | 20 | const interpreter = worker.endsWith('.dll') ? ['dotnet'] : []; 21 | 22 | var spawnArgs = interpreter.concat([worker, "spawnclient", "3", "4"]); 23 | const exe = spawnArgs.shift(); 24 | 25 | const child = child_process.spawn( 26 | exe, spawnArgs, 27 | { 28 | stdio: [process.stdin, process.stdout, process.stderr, 'pipe', 'pipe'] 29 | } 30 | ); 31 | 32 | const childPipeWrite = child.stdio[3]; 33 | const childPipeRead = child.stdio[4]; 34 | 35 | let inputBuffer = Buffer.alloc(0); 36 | 37 | // Listen for incoming data on standard input. 38 | process.stdin.on('data', chunk => { 39 | inputBuffer = Buffer.concat([inputBuffer, chunk]); 40 | processMessages(); 41 | }); 42 | 43 | function processMessages() { 44 | // We need at least 8 bytes to get message type (4 bytes) and length (4 bytes) 45 | while (inputBuffer.length >= 8) { 46 | // Read the first 4 bytes as a big‑endian unsigned integer (message type) 47 | const messageType = inputBuffer.readUInt32BE(0); 48 | // Next 4 bytes give the message length 49 | const messageLength = inputBuffer.readUInt32BE(4); 50 | 51 | // If we don’t yet have the full payload, wait 52 | if (inputBuffer.length < 8 + messageLength) break; 53 | 54 | const rawMessage = inputBuffer.subarray(8, 8 + messageLength); 55 | inputBuffer = inputBuffer.subarray(8 + messageLength); 56 | 57 | const message = rawMessage.toString('utf8'); 58 | 59 | // For debugging, if the environment variable is set: 60 | if (process.env.ACTIONS_RUNNER_WORKER_DEBUG === '1') { 61 | console.log("Message Received"); 62 | console.log("Type:", messageType); 63 | console.log("================"); 64 | console.log(message); 65 | console.log("================"); 66 | } 67 | 68 | let encoded = Buffer.from(message, 'utf16le'); 69 | 70 | const typeBuffer = Buffer.alloc(4); 71 | typeBuffer.writeUint32LE(messageType, 0); 72 | const lengthBuffer = Buffer.alloc(4); 73 | lengthBuffer.writeUint32LE(encoded.length, 0); 74 | 75 | childPipeWrite.write(typeBuffer); 76 | childPipeWrite.write(lengthBuffer); 77 | childPipeWrite.write(encoded); 78 | } 79 | } 80 | 81 | child.on('exit', (code) => { 82 | console.log(`Child exited with code ${code}`); 83 | if (code >= 100 && code <= 105) { 84 | process.exit(0); 85 | } else { 86 | process.exit(1); 87 | } 88 | }); 89 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/ChristopherHX/gitea-actions-runner 2 | 3 | go 1.24 4 | 5 | toolchain go1.24.2 6 | 7 | require ( 8 | code.gitea.io/actions-proto-go v0.4.1 9 | connectrpc.com/connect v1.18.1 10 | github.com/ChristopherHX/github-act-runner v0.10.0 11 | github.com/actions-oss/act-cli v0.3.3-0.20250426145830-bb13ab4f84d5 12 | github.com/avast/retry-go/v4 v4.6.1 13 | github.com/google/uuid v1.6.0 14 | github.com/joho/godotenv v1.5.1 15 | github.com/kardianos/service v1.2.2 16 | github.com/kelseyhightower/envconfig v1.4.0 17 | github.com/mattn/go-isatty v0.0.20 18 | github.com/nektos/act v0.2.76 19 | github.com/rhysd/actionlint v1.7.7 20 | github.com/sirupsen/logrus v1.9.3 21 | github.com/spf13/cobra v1.9.1 22 | github.com/stretchr/testify v1.10.0 23 | golang.org/x/net v0.39.0 24 | golang.org/x/sync v0.13.0 25 | google.golang.org/protobuf v1.36.6 26 | gopkg.in/yaml.v3 v3.0.1 27 | ) 28 | 29 | require ( 30 | dario.cat/mergo v1.0.1 // indirect 31 | github.com/Microsoft/go-winio v0.6.2 // indirect 32 | github.com/ProtonMail/go-crypto v1.2.0 // indirect 33 | github.com/bmatcuk/doublestar/v4 v4.8.1 // indirect 34 | github.com/cloudflare/circl v1.6.1 // indirect 35 | github.com/cyphar/filepath-securejoin v0.4.1 // indirect 36 | github.com/davecgh/go-spew v1.1.1 // indirect 37 | github.com/emirpasic/gods v1.18.1 // indirect 38 | github.com/fatih/color v1.18.0 // indirect 39 | github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect 40 | github.com/go-git/go-billy/v5 v5.6.2 // indirect 41 | github.com/go-git/go-git/v5 v5.16.0 // indirect 42 | github.com/golang-jwt/jwt v3.2.2+incompatible // indirect 43 | github.com/golang-jwt/jwt/v5 v5.2.2 // indirect 44 | github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect 45 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 46 | github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect 47 | github.com/julienschmidt/httprouter v1.3.0 // indirect 48 | github.com/kevinburke/ssh_config v1.2.0 // indirect 49 | github.com/mattn/go-colorable v0.1.14 // indirect 50 | github.com/mattn/go-runewidth v0.0.16 // indirect 51 | github.com/mattn/go-shellwords v1.0.12 // indirect 52 | github.com/pjbgf/sha1cd v0.3.2 // indirect 53 | github.com/pmezard/go-difflib v1.0.0 // indirect 54 | github.com/rivo/uniseg v0.4.7 // indirect 55 | github.com/robfig/cron/v3 v3.0.1 // indirect 56 | github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect 57 | github.com/skeema/knownhosts v1.3.1 // indirect 58 | github.com/spf13/pflag v1.0.6 // indirect 59 | github.com/timshannon/bolthold v0.0.0-20240314194003-30aac6950928 // indirect 60 | github.com/xanzy/ssh-agent v0.3.3 // indirect 61 | go.etcd.io/bbolt v1.4.0 // indirect 62 | golang.org/x/crypto v0.37.0 // indirect 63 | golang.org/x/sys v0.32.0 // indirect 64 | golang.org/x/text v0.24.0 // indirect 65 | gopkg.in/warnings.v0 v0.1.2 // indirect 66 | ) 67 | 68 | replace github.com/nektos/act => gitea.com/gitea/act v0.261.3 69 | 70 | replace github.com/ChristopherHX/github-act-runner => gitea.com/ChristopherHX/github-act-runner v0.0.0-20250101191334-47a23853e4fa 71 | -------------------------------------------------------------------------------- /util/actions-runner-worker.py: -------------------------------------------------------------------------------- 1 | # This script can be used to call Runner.Worker as github-act-runner worker on unix like systems 2 | # You just have to create simple .runner file in the root folder with the following Content 3 | # {"isHostedServer": false, "agentName": "my-runner", "workFolder": "_work"} 4 | # Then use `python3 path/to/this/script.py path/to/actions/runner/bin/Runner.Worker` as the worker args 5 | 6 | import sys 7 | import subprocess 8 | import os 9 | import threading 10 | import codecs 11 | import json 12 | 13 | worker = sys.argv[1] 14 | 15 | runner_file = os.path.abspath(os.path.join(worker, '../../.runner')) 16 | if not os.path.exists(runner_file): 17 | data = { 18 | 'isHostedServer': False, 19 | 'agentName': 'my-runner', 20 | 'workFolder': '_work' 21 | } 22 | with open(runner_file, 'w') as file: 23 | json.dump(data, file) 24 | 25 | wdr, wdw = os.pipe() 26 | rdr, rdw = os.pipe() 27 | 28 | def readfull(fd: int, l: int): 29 | b = bytes() 30 | while len(b) < l: 31 | r = os.read(fd, l - len(b)) 32 | if len(r) <= 0: 33 | raise RuntimeError("unexpected read len: {}".format(len(r))) 34 | b += r 35 | if len(b) != l: 36 | raise RuntimeError("read {} bytes expected {} bytes".format(len(b), l)) 37 | return b 38 | 39 | def writefull(fd: int, buf: bytes): 40 | written: int = 0 41 | while written < len(buf): 42 | w = os.write(fd, buf[written:]) 43 | if w <= 0: 44 | raise RuntimeError("unexpected write result: {}".format(w)) 45 | written += w 46 | if written != len(buf): 47 | raise RuntimeError("written {} bytes expected {}".format(written, len(buf))) 48 | return written 49 | 50 | def redirectio(): 51 | while(True): 52 | stdin = sys.stdin.fileno() 53 | messageType = int.from_bytes(readfull(stdin, 4), "big", signed=False) 54 | writefull(rdw, messageType.to_bytes(4, sys.byteorder, signed=False)) 55 | messageLength = int.from_bytes(readfull(stdin, 4), "big", signed=False) 56 | rawmessage = readfull(stdin, messageLength) 57 | message = codecs.decode(rawmessage, "utf-8") 58 | if os.getenv("ACTIONS_RUNNER_WORKER_DEBUG", "0") != "0": 59 | print("Message Received") 60 | print("Type:", messageType) 61 | print("================") 62 | print(message) 63 | print("================") 64 | encoded = message.encode("utf_16")[2:] 65 | writefull(rdw, len(encoded).to_bytes(4, sys.byteorder, signed=False)) 66 | writefull(rdw, encoded) 67 | 68 | threading.Thread(target=redirectio, daemon=True).start() 69 | 70 | interpreter = [] 71 | if worker.endswith(".dll"): 72 | interpreter = [ "dotnet" ] 73 | 74 | code = subprocess.call(interpreter + [worker, "spawnclient", format(rdr), format(wdw)], pass_fds=(rdr, wdw)) 75 | print(code) 76 | # https://github.com/actions/runner/blob/af6ed41bcb47019cce2a7035bad76c97ac97b92a/src/Runner.Common/Util/TaskResultUtil.cs#L13-L14 77 | if code >= 100 and code <= 105: 78 | sys.exit(0) 79 | else: 80 | sys.exit(1) 81 | -------------------------------------------------------------------------------- /actions-runner-worker.py: -------------------------------------------------------------------------------- 1 | # This script can be used to call Runner.Worker as github-act-runner worker on unix like systems 2 | # You just have to create simple .runner file in the root folder with the following Content 3 | # {"isHostedServer": false, "agentName": "my-runner", "workFolder": "_work"} 4 | # Then use `python3 path/to/this/script.py path/to/actions/runner/bin/Runner.Worker` as the worker args 5 | 6 | import sys 7 | import subprocess 8 | import os 9 | import threading 10 | import codecs 11 | import json 12 | 13 | worker = sys.argv[1] 14 | 15 | # Fallback if not existing 16 | runner_file = os.path.abspath(os.path.join(worker, '../../.runner')) 17 | if not os.path.exists(runner_file): 18 | data = { 19 | 'isHostedServer': False, 20 | 'agentName': 'my-runner', 21 | 'workFolder': '_work' 22 | } 23 | with open(runner_file, 'w') as file: 24 | json.dump(data, file) 25 | 26 | wdr, wdw = os.pipe() 27 | rdr, rdw = os.pipe() 28 | 29 | def readfull(fd: int, l: int): 30 | b = bytes() 31 | while len(b) < l: 32 | r = os.read(fd, l - len(b)) 33 | if len(r) <= 0: 34 | raise RuntimeError("unexpected read len: {}".format(len(r))) 35 | b += r 36 | if len(b) != l: 37 | raise RuntimeError("read {} bytes expected {} bytes".format(len(b), l)) 38 | return b 39 | 40 | def writefull(fd: int, buf: bytes): 41 | written: int = 0 42 | while written < len(buf): 43 | w = os.write(fd, buf[written:]) 44 | if w <= 0: 45 | raise RuntimeError("unexpected write result: {}".format(w)) 46 | written += w 47 | if written != len(buf): 48 | raise RuntimeError("written {} bytes expected {}".format(written, len(buf))) 49 | return written 50 | 51 | def redirectio(): 52 | while(True): 53 | stdin = sys.stdin.fileno() 54 | messageType = int.from_bytes(readfull(stdin, 4), "big", signed=False) 55 | writefull(rdw, messageType.to_bytes(4, sys.byteorder, signed=False)) 56 | messageLength = int.from_bytes(readfull(stdin, 4), "big", signed=False) 57 | rawmessage = readfull(stdin, messageLength) 58 | message = codecs.decode(rawmessage, "utf-8") 59 | if os.getenv("ACTIONS_RUNNER_WORKER_DEBUG", "0") != "0": 60 | print("Message Received") 61 | print("Type:", messageType) 62 | print("================") 63 | print(message) 64 | print("================") 65 | encoded = message.encode("utf_16")[2:] 66 | writefull(rdw, len(encoded).to_bytes(4, sys.byteorder, signed=False)) 67 | writefull(rdw, encoded) 68 | 69 | threading.Thread(target=redirectio, daemon=True).start() 70 | 71 | interpreter = [] 72 | if worker.endswith(".dll"): 73 | interpreter = [ "dotnet" ] 74 | 75 | code = subprocess.call(interpreter + [worker, "spawnclient", format(rdr), format(wdw)], pass_fds=(rdr, wdw)) 76 | print(code) 77 | # https://github.com/actions/runner/blob/af6ed41bcb47019cce2a7035bad76c97ac97b92a/src/Runner.Common/Util/TaskResultUtil.cs#L13-L14 78 | if code >= 100 and code <= 105: 79 | sys.exit(0) 80 | else: 81 | sys.exit(1) 82 | -------------------------------------------------------------------------------- /start.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | if [[ ! -d /data ]]; then 4 | mkdir -p /data 5 | fi 6 | 7 | cd /data 8 | 9 | RUNNER_STATE_FILE=${RUNNER_STATE_FILE:-'.runner'} 10 | 11 | EXTRA_ARGS="" 12 | if [[ ! -z "${GITEA_RUNNER_LABELS}" ]]; then 13 | EXTRA_ARGS="${EXTRA_ARGS} --labels ${GITEA_RUNNER_LABELS}" 14 | fi 15 | if [[ ! -z "${GITEA_RUNNER_EPHEMERAL}" ]]; then 16 | EXTRA_ARGS="${EXTRA_ARGS} --ephemeral" 17 | fi 18 | 19 | # In case no token is set, it's possible to read the token from a file, i.e. a Docker Secret 20 | if [[ -z "${GITEA_RUNNER_REGISTRATION_TOKEN}" ]] && [[ -f "${GITEA_RUNNER_REGISTRATION_TOKEN_FILE}" ]]; then 21 | GITEA_RUNNER_REGISTRATION_TOKEN=$(cat "${GITEA_RUNNER_REGISTRATION_TOKEN_FILE}") 22 | fi 23 | 24 | if [[ ! -z "${GITEA_RUNNER_PAT}" ]]; then 25 | GITEA_RUNNER_REGISTRATION_TOKEN="$(curl -s -L -X POST -H "Accept: application/vnd.github+json" -H "Authorization: Bearer ${GITEA_RUNNER_PAT}" ${GITEA_INSTANCE_URL}/api/v1/repos/${GITEA_RUNNER_OWNER}/${GITEA_RUNNER_REPO}/actions/runners/registration-token | jq .token -r)" 26 | unset GITEA_RUNNER_PAT 27 | fi 28 | 29 | # Use the same ENV variable names as https://github.com/vegardit/docker-gitea-act-runner 30 | test -f "$RUNNER_STATE_FILE" || echo "$RUNNER_STATE_FILE is missing or not a regular file" 31 | 32 | if [[ ! -s "$RUNNER_STATE_FILE" ]]; then 33 | try=1 34 | success=0 35 | 36 | # The point of this loop is to make it simple, when running both act_runner and gitea in docker, 37 | # for the act_runner to wait a moment for gitea to become available before erroring out. Within 38 | # the context of a single docker-compose, something similar could be done via healthchecks, but 39 | # this is more flexible. 40 | while [[ $success -eq 0 ]] && [[ $try -lt ${GITEA_MAX_REG_ATTEMPTS:-10} ]]; do 41 | /runner/gitea-actions-runner register \ 42 | --instance "${GITEA_INSTANCE_URL}" \ 43 | --token "${GITEA_RUNNER_REGISTRATION_TOKEN}" \ 44 | --name "${GITEA_RUNNER_NAME:-`hostname`}" \ 45 | --worker python3,/runner/actions-runner-worker.py,/home/runner/bin/Runner.Worker \ 46 | ${EXTRA_ARGS} --no-interactive 2>&1 | tee /tmp/reg.log 47 | 48 | cat /tmp/reg.log | grep 'Runner registered successfully' > /dev/null 49 | if [[ $? -eq 0 ]]; then 50 | echo "SUCCESS" 51 | success=1 52 | 53 | jq --null-input \ 54 | --arg agentName "${GITEA_RUNNER_NAME:-`hostname`}" \ 55 | --arg workFolder "/home/runner/_work" \ 56 | '{ "isHostedServer": false, "agentName": $agentName, "workFolder": $workFolder }' > /data/.actions_runner 57 | else 58 | echo "Waiting to retry ..." 59 | try=$(($try + 1)) 60 | sleep 5 61 | fi 62 | done 63 | fi 64 | 65 | if [[ ! -s "/data/.actions_runner" ]]; then 66 | jq --null-input \ 67 | --arg agentName "${GITEA_RUNNER_NAME:-`hostname`}" \ 68 | --arg workFolder "/home/runner/_work" \ 69 | '{ "isHostedServer": false, "agentName": $agentName, "workFolder": $workFolder }' > /data/.actions_runner 70 | fi 71 | 72 | # Prevent reading the token from the act_runner process 73 | unset GITEA_RUNNER_REGISTRATION_TOKEN 74 | unset GITEA_RUNNER_REGISTRATION_TOKEN_FILE 75 | 76 | /runner/gitea-actions-runner daemon "$@" ${GITEA_RUNNER_ONCE+"--once"} 77 | -------------------------------------------------------------------------------- /util/setup.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "context" 5 | _ "embed" 6 | "fmt" 7 | "os" 8 | "os/exec" 9 | "path/filepath" 10 | "runtime" 11 | 12 | log "github.com/sirupsen/logrus" 13 | ) 14 | 15 | // https://github.com/actions/runner/releases 16 | const ActionsRunnerVersion string = "2.329.0" 17 | 18 | // https://github.com/christopherHX/runner.server/releases 19 | const RunnerServerRunnerVersion string = "3.13.7" 20 | 21 | //go:embed actions-runner-worker.py 22 | var pythonWorkerScript string 23 | 24 | //go:embed actions-runner-worker.ps1 25 | var pwshWorkerScript string 26 | 27 | func SetupRunner(runnerType int32, runnerVersion string) []string { 28 | d := DownloadRunner 29 | if runnerType == 2 { 30 | d = DownloadRunnerServer 31 | } 32 | if runnerVersion == "" { 33 | if runnerType == 1 { 34 | runnerVersion = ActionsRunnerVersion 35 | } else { 36 | runnerVersion = RunnerServerRunnerVersion 37 | } 38 | } 39 | wd, _ := os.Getwd() 40 | p := filepath.Join(wd, "actions-runner-"+runnerVersion) 41 | if fi, err := os.Stat(p); err == nil && fi.IsDir() { 42 | log.Infof("Runner %s already exists, skip downloading.", runnerVersion) 43 | } else { 44 | if err := d(context.Background(), log.StandardLogger(), runtime.GOOS+"/"+runtime.GOARCH, p, runnerVersion); err != nil { 45 | log.Infoln("Something went wrong: %s" + err.Error()) 46 | return nil 47 | } 48 | } 49 | 50 | return SetupWorker(wd, p, runnerType, runnerVersion) 51 | } 52 | 53 | func SetupWorker(wd string, p string, runnerType int32, runnerVersion string) []string { 54 | flags := []string{"--runner-dir=" + p, "--runner-type=" + fmt.Sprint(runnerType), "--runner-version=" + runnerVersion, "--allow-clone"} 55 | pwshScript := filepath.Join(p, "actions-runner-worker.ps1") 56 | _ = os.WriteFile(pwshScript, []byte(pwshWorkerScript), 0755) 57 | pythonScript := filepath.Join(p, "actions-runner-worker.py") 58 | _ = os.WriteFile(pythonScript, []byte(pythonWorkerScript), 0755) 59 | 60 | var pythonPath string 61 | var err error 62 | ext := "" 63 | if runtime.GOOS != "windows" { 64 | pythonPath, err = exec.LookPath("python3") 65 | if err != nil { 66 | pythonPath, _ = exec.LookPath("python") 67 | } 68 | } else { 69 | ext = ".exe" 70 | } 71 | if pythonPath == "" { 72 | pwshPath, err := exec.LookPath("pwsh") 73 | if err != nil { 74 | pwshVersion := "7.4.7" 75 | pwshPath = filepath.Join(wd, "pwsh-"+pwshVersion) 76 | if fi, err := os.Stat(pwshPath); err == nil && fi.IsDir() { 77 | log.Infof("pwsh %s already exists, skip downloading.", pwshVersion) 78 | } else { 79 | log.Infoln("pwsh not found, downloading pwsh...") 80 | err = DownloadPwsh(context.Background(), log.StandardLogger(), runtime.GOOS+"/"+runtime.GOARCH, pwshPath, pwshVersion) 81 | if err != nil { 82 | log.Infoln("Something went wrong: %s" + err.Error()) 83 | return nil 84 | } 85 | } 86 | pwshPath = filepath.Join(pwshPath, "pwsh"+ext) 87 | } else { 88 | log.Infoln("pwsh found, using pwsh...") 89 | } 90 | return append(flags, pwshPath, pwshScript, filepath.Join(p, "bin", "Runner.Worker"+ext)) 91 | } else { 92 | log.Infoln("python found, using python...") 93 | return append(flags, pythonPath, pythonScript, filepath.Join(p, "bin", "Runner.Worker"+ext)) 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /actions/server/main.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "io" 5 | "net" 6 | "net/http" 7 | "time" 8 | 9 | "golang.org/x/net/http2" 10 | ) 11 | 12 | // stdioConn wraps os.Stdin and os.Stdout to provide a net.Conn-like interface. 13 | type stdioConn struct { 14 | r io.Reader // typically os.Stdin 15 | w io.Writer // typically os.Stdout 16 | } 17 | 18 | // Read reads from the underlying reader. 19 | func (c *stdioConn) Read(b []byte) (int, error) { 20 | return c.r.Read(b) 21 | } 22 | 23 | // Write writes to the underlying writer. 24 | func (c *stdioConn) Write(b []byte) (int, error) { 25 | return c.w.Write(b) 26 | } 27 | 28 | // Close is a dummy close because os.Stdin/Stdout are typically managed by the OS. 29 | func (c *stdioConn) Close() error { 30 | return nil 31 | } 32 | 33 | // LocalAddr returns a dummy local address. 34 | func (c *stdioConn) LocalAddr() net.Addr { 35 | return dummyAddr("local") 36 | } 37 | 38 | // RemoteAddr returns a dummy remote address. 39 | func (c *stdioConn) RemoteAddr() net.Addr { 40 | return dummyAddr("remote") 41 | } 42 | 43 | // SetDeadline is a no-op. 44 | func (c *stdioConn) SetDeadline(t time.Time) error { 45 | return nil 46 | } 47 | 48 | // SetReadDeadline is a no-op. 49 | func (c *stdioConn) SetReadDeadline(t time.Time) error { 50 | return nil 51 | } 52 | 53 | // SetWriteDeadline is a no-op. 54 | func (c *stdioConn) SetWriteDeadline(t time.Time) error { 55 | return nil 56 | } 57 | 58 | // dummyAddr is a simple implementation of net.Addr. 59 | type dummyAddr string 60 | 61 | func (d dummyAddr) Network() string { return string(d) } 62 | func (d dummyAddr) String() string { return string(d) } 63 | 64 | func CreateStdioConn(r io.Reader, w io.Writer) net.Conn { 65 | return &stdioConn{ 66 | r: r, 67 | w: w, 68 | } 69 | } 70 | 71 | func Server(conn net.Conn, handler http.Handler) { 72 | // Create an HTTP/2 server instance with custom configuration (if needed). 73 | h2Server := &http2.Server{ 74 | MaxConcurrentStreams: 250, // for example, customize as needed 75 | } 76 | 77 | // // Define the HTTP handler to process requests. 78 | // handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 79 | // // This is a basic example that responds with a plain text message.x 80 | // w.Header().Set("Content-Type", "text/plain") 81 | // w.WriteHeader(http.StatusOK) 82 | // //w.(http.Flusher).Flush() 83 | // fmt.Fprintf(w, "Hello from the custom HTTP/2 transport over stdio!\n%s\n", r.RequestURI) 84 | // io.Copy(w, r.Body) 85 | // // for i := 0; i < 2; i++ { 86 | // // w.(http.Flusher).Flush() 87 | // // time.Sleep(time.Second) 88 | // // fmt.Fprintf(w, "ping %d / %s!\n", i, base64.StdEncoding.EncodeToString(rnd)) 89 | // // //fmt.Printf("ping %d / %s!\n", i, base64.StdEncoding.EncodeToString(rnd)) 90 | // // } 91 | // }) 92 | 93 | // Use http2.Server's ServeConn to serve HTTP/2 on our custom connection. 94 | // Note: ServeConn will perform the initial HTTP/2 connection preface and then 95 | // multiplex streams. This requires that the underlying connection support 96 | // full-duplex communication. 97 | h2Server.ServeConn(conn, &http2.ServeConnOpts{ 98 | Handler: handler, 99 | }) 100 | } 101 | -------------------------------------------------------------------------------- /Dockerfile.medium: -------------------------------------------------------------------------------- 1 | # Dockerfile 2 | # Source: https://github.com/dotnet/dotnet-docker 3 | FROM mcr.microsoft.com/dotnet/runtime-deps:6.0-jammy as build 4 | 5 | ARG TARGETOS 6 | ARG TARGETARCH 7 | # https://github.com/actions/runner/releases 8 | ARG RUNNER_VERSION=2.329.0 9 | # https://github.com/actions/runner-container-hooks/releases 10 | ARG RUNNER_CONTAINER_HOOKS_VERSION=0.7.0 11 | 12 | RUN apt update -y && apt install curl unzip -y 13 | 14 | WORKDIR /actions-runner 15 | RUN export RUNNER_ARCH=${TARGETARCH} \ 16 | && if [ "$RUNNER_ARCH" = "amd64" ]; then export RUNNER_ARCH=x64 ; fi \ 17 | && curl -f -L -o runner.tar.gz https://github.com/actions/runner/releases/download/v${RUNNER_VERSION}/actions-runner-${TARGETOS}-${RUNNER_ARCH}-${RUNNER_VERSION}.tar.gz \ 18 | && tar xzf ./runner.tar.gz \ 19 | && rm runner.tar.gz 20 | 21 | RUN curl -f -L -o runner-container-hooks.zip https://github.com/actions/runner-container-hooks/releases/download/v${RUNNER_CONTAINER_HOOKS_VERSION}/actions-runner-hooks-docker-${RUNNER_CONTAINER_HOOKS_VERSION}.zip \ 22 | && unzip ./runner-container-hooks.zip -d ./docker-hooks \ 23 | && rm runner-container-hooks.zip 24 | 25 | RUN curl -f -L -o runner-container-hooks.zip https://github.com/actions/runner-container-hooks/releases/download/v${RUNNER_CONTAINER_HOOKS_VERSION}/actions-runner-hooks-k8s-${RUNNER_CONTAINER_HOOKS_VERSION}.zip \ 26 | && unzip ./runner-container-hooks.zip -d ./k8s \ 27 | && rm runner-container-hooks.zip 28 | 29 | RUN curl -f -L -o runner-container-hooks.zip https://github.com/actions/runner-container-hooks/releases/download/v0.8.0/actions-runner-hooks-k8s-0.8.0.zip \ 30 | && unzip ./runner-container-hooks.zip -d ./k8s-novolume \ 31 | && rm runner-container-hooks.zip 32 | 33 | FROM golang:1.24-alpine as builder 34 | # Do not remove `git` here, it is required for getting runner version when executing `make build` 35 | RUN apk add --no-cache make git 36 | 37 | COPY . /opt/src/act_runner 38 | WORKDIR /opt/src/act_runner 39 | 40 | RUN make clean && make build 41 | 42 | ARG BASE_IMAGE=catthehacker/ubuntu:act-latest 43 | FROM $BASE_IMAGE 44 | 45 | ENV GITEA_RUNNER_LABELS=ubuntu-latest 46 | ENV RUNNER_MANUALLY_TRAP_SIG=1 47 | ENV ACTIONS_RUNNER_PRINT_LOG_TO_STDOUT=1 48 | ENV ACTIONS_RUNNER_CONTAINER_HOOKS=/home/runner/docker-hooks/index.js 49 | 50 | RUN adduser --disabled-password --gecos "" --uid 1000 runner \ 51 | && usermod -aG sudo runner \ 52 | && usermod -aG docker runner \ 53 | && echo "%sudo ALL=(ALL:ALL) NOPASSWD:ALL" > /etc/sudoers \ 54 | && echo "Defaults env_keep += \"DEBIAN_FRONTEND\"" >> /etc/sudoers \ 55 | && echo "root ALL=(ALL) ALL" >> /etc/sudoers 56 | 57 | WORKDIR /home/runner 58 | 59 | VOLUME /home/runner/externals 60 | VOLUME /home/runner/_work 61 | 62 | COPY --chown=runner:docker --from=build /actions-runner . 63 | 64 | RUN mkdir -p /runner && chown runner:docker -R /runner && ln -s /data/.actions_runner .runner 65 | 66 | WORKDIR /runner 67 | RUN chown runner:docker /runner && mkdir -p /home/runner/_work && chown -R runner:docker /home/runner/_work && mkdir -p /data && chown runner:docker /data 68 | 69 | USER 1000 70 | 71 | COPY --from=builder /opt/src/act_runner/gitea-actions-runner /runner/gitea-actions-runner 72 | 73 | COPY actions-runner-worker.py /runner 74 | COPY start.sh /runner 75 | 76 | ENTRYPOINT ["bash", "/runner/start.sh"] 77 | -------------------------------------------------------------------------------- /cmd/daemon.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | 8 | "github.com/ChristopherHX/gitea-actions-runner/client" 9 | "github.com/ChristopherHX/gitea-actions-runner/config" 10 | "github.com/ChristopherHX/gitea-actions-runner/poller" 11 | "github.com/ChristopherHX/gitea-actions-runner/runtime" 12 | 13 | runnerv1 "code.gitea.io/actions-proto-go/runner/v1" 14 | "connectrpc.com/connect" 15 | "github.com/joho/godotenv" 16 | "github.com/mattn/go-isatty" 17 | log "github.com/sirupsen/logrus" 18 | "github.com/spf13/cobra" 19 | "golang.org/x/sync/errgroup" 20 | ) 21 | 22 | func runDaemon(ctx context.Context, envFile string) func(cmd *cobra.Command, args []string) error { 23 | return func(cmd *cobra.Command, args []string) error { 24 | log.Infoln("Starting runner daemon") 25 | 26 | _ = godotenv.Load(envFile) 27 | cfg, err := config.FromEnviron() 28 | if err != nil { 29 | log.WithError(err). 30 | Fatalln("invalid configuration") 31 | } 32 | 33 | initLogging(cfg) 34 | 35 | var g errgroup.Group 36 | 37 | cli := client.New( 38 | cfg.Client.Address, 39 | cfg.Runner.UUID, 40 | cfg.Runner.Token, 41 | ) 42 | 43 | runner := &runtime.Runner{ 44 | Client: cli, 45 | Machine: cfg.Runner.Name, 46 | ForgeInstance: cfg.Client.Address, 47 | Environ: cfg.Runner.Environ, 48 | Labels: cfg.Runner.Labels, 49 | RunnerWorker: cfg.Runner.RunnerWorker, 50 | } 51 | flags := []string{fmt.Sprintf("--max-parallel=%d", cfg.Runner.Capacity)} 52 | 53 | runner.RunnerWorker = append(flags, runner.RunnerWorker...) 54 | 55 | resp, err := cli.Declare(cmd.Context(), &connect.Request[runnerv1.DeclareRequest]{ 56 | Msg: &runnerv1.DeclareRequest{ 57 | Version: cmd.Root().Version, 58 | Labels: runner.Labels, 59 | }, 60 | }) 61 | if err != nil && connect.CodeOf(err) == connect.CodeUnimplemented { 62 | // Gitea instance is older version. skip declare step. 63 | log.Info("Because the Gitea instance is an old version, labels can only be set during configure.") 64 | } else if err != nil { 65 | log.WithError(err).Error("fail to invoke Declare") 66 | return err 67 | } else { 68 | log.Infof("runner: %s, with version: %s, with labels: %v, declare successfully", 69 | resp.Msg.Runner.Name, resp.Msg.Runner.Version, resp.Msg.Runner.Labels) 70 | } 71 | 72 | once, _ := cmd.Flags().GetBool("once") 73 | once = once || cfg.Runner.Ephemeral 74 | if once { 75 | cfg.Runner.Capacity = 1 76 | } 77 | poller := poller.New( 78 | cli, 79 | runner.Run, 80 | cfg.Runner.Capacity, 81 | ) 82 | poller.Once = once 83 | 84 | g.Go(func() error { 85 | l := log.WithField("capacity", cfg.Runner.Capacity). 86 | WithField("endpoint", cfg.Client.Address). 87 | WithField("os", cfg.Platform.OS). 88 | WithField("arch", cfg.Platform.Arch) 89 | l.Infoln("polling the remote server") 90 | 91 | if err := poller.Poll(ctx); err != nil { 92 | l.Errorf("poller error: %v", err) 93 | } 94 | poller.Wait() 95 | return nil 96 | }) 97 | 98 | err = g.Wait() 99 | if err != nil { 100 | log.WithError(err). 101 | Errorln("shutting down the server") 102 | } 103 | return err 104 | } 105 | } 106 | 107 | // initLogging setup the global logrus logger. 108 | func initLogging(cfg config.Config) { 109 | isTerm := isatty.IsTerminal(os.Stdout.Fd()) 110 | log.SetFormatter(&log.TextFormatter{ 111 | DisableColors: !isTerm, 112 | FullTimestamp: true, 113 | }) 114 | 115 | if cfg.Debug { 116 | log.SetLevel(log.DebugLevel) 117 | } 118 | if cfg.Trace { 119 | log.SetLevel(log.TraceLevel) 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /exec/exec.go: -------------------------------------------------------------------------------- 1 | package exec 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | pingv1 "code.gitea.io/actions-proto-go/ping/v1" 8 | runnerv1 "code.gitea.io/actions-proto-go/runner/v1" 9 | "connectrpc.com/connect" 10 | "github.com/ChristopherHX/gitea-actions-runner/runtime" 11 | structpb "google.golang.org/protobuf/types/known/structpb" 12 | "gopkg.in/yaml.v3" 13 | ) 14 | 15 | type mockClient struct{} 16 | 17 | // Address implements client.Client. 18 | func (m *mockClient) Address() string { 19 | return "http://localhost:8080" 20 | } 21 | 22 | // Declare implements client.Client. 23 | func (m *mockClient) Declare(context.Context, *connect.Request[runnerv1.DeclareRequest]) (*connect.Response[runnerv1.DeclareResponse], error) { 24 | return connect.NewResponse(&runnerv1.DeclareResponse{ 25 | Runner: &runnerv1.Runner{ 26 | Id: 1, 27 | }, 28 | }), nil 29 | } 30 | 31 | // FetchTask implements client.Client. 32 | func (m *mockClient) FetchTask(context.Context, *connect.Request[runnerv1.FetchTaskRequest]) (*connect.Response[runnerv1.FetchTaskResponse], error) { 33 | return connect.NewResponse(&runnerv1.FetchTaskResponse{ 34 | Task: &runnerv1.Task{ 35 | Id: 1, 36 | }, 37 | }), nil 38 | } 39 | 40 | // Ping implements client.Client. 41 | func (m *mockClient) Ping(_ context.Context, req *connect.Request[pingv1.PingRequest]) (*connect.Response[pingv1.PingResponse], error) { 42 | return connect.NewResponse(&pingv1.PingResponse{ 43 | Data: req.Msg.Data, 44 | }), nil 45 | } 46 | 47 | // Register implements client.Client. 48 | func (m *mockClient) Register(context.Context, *connect.Request[runnerv1.RegisterRequest]) (*connect.Response[runnerv1.RegisterResponse], error) { 49 | return connect.NewResponse(&runnerv1.RegisterResponse{ 50 | Runner: &runnerv1.Runner{ 51 | Id: 1, 52 | }, 53 | }), nil 54 | } 55 | 56 | // UpdateLog implements client.Client. 57 | func (m *mockClient) UpdateLog(_ context.Context, req *connect.Request[runnerv1.UpdateLogRequest]) (*connect.Response[runnerv1.UpdateLogResponse], error) { 58 | for _, row := range req.Msg.Rows { 59 | fmt.Println(row.Content) 60 | } 61 | return connect.NewResponse(&runnerv1.UpdateLogResponse{ 62 | AckIndex: req.Msg.Index + int64(len(req.Msg.Rows)), 63 | }), nil 64 | } 65 | 66 | // UpdateTask implements client.Client. 67 | func (m *mockClient) UpdateTask(_ context.Context, req *connect.Request[runnerv1.UpdateTaskRequest]) (*connect.Response[runnerv1.UpdateTaskResponse], error) { 68 | if req.Msg.State.Result != runnerv1.Result_RESULT_UNSPECIFIED { 69 | fmt.Println("Task completed with result:", req.Msg.State.Result) 70 | } 71 | return connect.NewResponse(&runnerv1.UpdateTaskResponse{ 72 | State: req.Msg.State, 73 | }), nil 74 | } 75 | 76 | func Exec(ctx context.Context, content, contextData, varsData, secretsData string, args []string) error { 77 | mapData := make(map[string]any) 78 | yaml.Unmarshal([]byte(contextData), &mapData) 79 | if len(mapData) == 0 { 80 | mapData = map[string]any{} 81 | } 82 | if mapData["gitea_runtime_token"] == nil { 83 | mapData["gitea_runtime_token"] = "1234567890abcdef" 84 | } 85 | if mapData["repository"] == nil { 86 | mapData["repository"] = "test/test" 87 | } 88 | secrets := make(map[string]string) 89 | vars := make(map[string]string) 90 | yaml.Unmarshal([]byte(secretsData), &secrets) 91 | yaml.Unmarshal([]byte(varsData), &vars) 92 | 93 | pContext, _ := structpb.NewStruct(mapData) 94 | task := runtime.NewTask("gitea", 0, &mockClient{}, nil, nil) 95 | return task.Run(ctx, &runnerv1.Task{ 96 | Id: 1, 97 | WorkflowPayload: []byte(content), 98 | Context: pContext, 99 | Secrets: secrets, 100 | Vars: vars, 101 | }, args) 102 | } 103 | -------------------------------------------------------------------------------- /config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io" 7 | "os" 8 | "runtime" 9 | 10 | "github.com/ChristopherHX/gitea-actions-runner/core" 11 | 12 | "github.com/joho/godotenv" 13 | "github.com/kelseyhightower/envconfig" 14 | ) 15 | 16 | type ( 17 | // Config provides the system configuration. 18 | Config struct { 19 | Debug bool `envconfig:"GITEA_DEBUG"` 20 | Trace bool `envconfig:"GITEA_TRACE"` 21 | Client Client 22 | Runner Runner 23 | Platform Platform 24 | } 25 | 26 | Client struct { 27 | Address string `ignored:"true"` 28 | } 29 | 30 | Runner struct { 31 | UUID string `ignored:"true"` 32 | Name string `envconfig:"GITEA_RUNNER_NAME"` 33 | Token string `ignored:"true"` 34 | RunnerWorker []string `envconfig:"GITEA_RUNNER_WORKER"` 35 | Capacity int `envconfig:"GITEA_RUNNER_CAPACITY"` 36 | File string `envconfig:"GITEA_RUNNER_FILE" default:".runner"` 37 | Environ map[string]string `envconfig:"GITEA_RUNNER_ENVIRON"` 38 | EnvFile string `envconfig:"GITEA_RUNNER_ENV_FILE"` 39 | Labels []string `envconfig:"GITEA_RUNNER_LABELS"` 40 | Ephemeral bool `ignored:"true"` 41 | } 42 | 43 | Platform struct { 44 | OS string `envconfig:"GITEA_PLATFORM_OS"` 45 | Arch string `envconfig:"GITEA_PLATFORM_ARCH"` 46 | } 47 | ) 48 | 49 | // FromEnviron returns the settings from the environment. 50 | func FromEnviron() (Config, error) { 51 | cfg := Config{} 52 | if err := envconfig.Process("", &cfg); err != nil { 53 | return cfg, err 54 | } 55 | 56 | // check runner config exist 57 | if f, err := os.Stat(cfg.Runner.File); err == nil && !f.IsDir() { 58 | jsonFile, err := os.Open(cfg.Runner.File) 59 | if err != nil { 60 | return cfg, err 61 | } 62 | defer jsonFile.Close() 63 | byteValue, err := io.ReadAll(jsonFile) 64 | if err != nil { 65 | return cfg, err 66 | } 67 | var runner core.Runner 68 | if err := json.Unmarshal(byteValue, &runner); err != nil { 69 | return cfg, fmt.Errorf("%w: %s", err, string(byteValue)) 70 | } 71 | if cfg.Runner.UUID == "" { 72 | cfg.Runner.UUID = runner.UUID 73 | } 74 | if cfg.Runner.Token == "" { 75 | cfg.Runner.Token = runner.Token 76 | } 77 | if len(cfg.Runner.RunnerWorker) == 0 { 78 | cfg.Runner.RunnerWorker = runner.RunnerWorker 79 | } 80 | if len(cfg.Runner.Labels) == 0 { 81 | cfg.Runner.Labels = runner.Labels 82 | } 83 | if cfg.Client.Address == "" { 84 | cfg.Client.Address = runner.Address 85 | } 86 | if cfg.Runner.Name == "" { 87 | cfg.Runner.Name = runner.Name 88 | } 89 | if cfg.Runner.Capacity < 1 { 90 | cfg.Runner.Capacity = runner.Capacity 91 | } 92 | if cfg.Runner.Ephemeral == false { 93 | cfg.Runner.Ephemeral = runner.Ephemeral 94 | } 95 | } 96 | if cfg.Runner.Capacity < 1 { 97 | cfg.Runner.Capacity = 1 98 | } 99 | 100 | // runner config 101 | if cfg.Runner.Environ == nil { 102 | cfg.Runner.Environ = map[string]string{ 103 | "GITHUB_API_URL": cfg.Client.Address + "/api/v1", 104 | "GITHUB_SERVER_URL": cfg.Client.Address, 105 | } 106 | } 107 | if cfg.Runner.Name == "" { 108 | cfg.Runner.Name, _ = os.Hostname() 109 | } 110 | 111 | // platform config 112 | if cfg.Platform.OS == "" { 113 | cfg.Platform.OS = runtime.GOOS 114 | } 115 | if cfg.Platform.Arch == "" { 116 | cfg.Platform.Arch = runtime.GOARCH 117 | } 118 | 119 | if file := cfg.Runner.EnvFile; file != "" { 120 | envs, err := godotenv.Read(file) 121 | if err != nil { 122 | return cfg, err 123 | } 124 | for k, v := range envs { 125 | cfg.Runner.Environ[k] = v 126 | } 127 | } 128 | 129 | return cfg, nil 130 | } 131 | -------------------------------------------------------------------------------- /actions-runner-worker.ps1: -------------------------------------------------------------------------------- 1 | param ($Worker) 2 | # This script can be used to call Runner.Worker as github-act-runner worker 3 | # You just have to create simple .runner file in the root folder with the following Content 4 | # {"isHostedServer": false, "agentName": "my-runner", "workFolder": "_work"} 5 | # Then use `pwsh path/to/this/script.ps1 path/to/actions/runner/bin/Runner.Worker` as the worker args 6 | 7 | # Fallback if not existing 8 | $runnerFile = (Join-Path (Join-Path $Worker "../.." -Resolve) ".runner") 9 | if(-not (Test-Path $runnerFile)) { 10 | Write-Output '{"isHostedServer": false, "agentName": "my-runner", "workFolder": "_work"}' | Out-File $runnerFile 11 | } 12 | 13 | $stdin = [System.Console]::OpenStandardInput() 14 | $pipeOut = New-Object -TypeName System.IO.Pipes.AnonymousPipeServerStream -ArgumentList 'Out','Inheritable' 15 | $pipeIn = New-Object -TypeName System.IO.Pipes.AnonymousPipeServerStream -ArgumentList 'In','Inheritable' 16 | $startInfo = New-Object System.Diagnostics.ProcessStartInfo 17 | if($Worker.EndsWith(".dll")) { 18 | $startInfo.FileName = $Worker 19 | $startInfo.Arguments = "`"$Worker`" spawnclient $($pipeOut.GetClientHandleAsString()) $($pipeIn.GetClientHandleAsString())" 20 | } else { 21 | $startInfo.FileName = $Worker 22 | $startInfo.Arguments = "spawnclient $($pipeOut.GetClientHandleAsString()) $($pipeIn.GetClientHandleAsString())" 23 | } 24 | $startInfo.UseShellExecute = $false 25 | $startInfo.RedirectStandardInput = $true 26 | $startInfo.RedirectStandardOutput = $true 27 | $startInfo.RedirectStandardError = $true 28 | 29 | $process = New-Object System.Diagnostics.Process 30 | $process.StartInfo = $startInfo 31 | 32 | # Set the process creation flags to CREATE_NEW_PROCESS_GROUP (0x200) 33 | $processCreationFlags = 0x200 34 | $process.StartInfo.CreateNoWindow = $true 35 | $process.Start() 36 | $proc = $process 37 | $inputjob = Start-ThreadJob -ScriptBlock { 38 | $stdin = $using:stdin 39 | $pipeOut = $using:pipeOut 40 | $pipeIn = $using:pipeIn 41 | $proc = $using:proc 42 | $buf = New-Object byte[] 4 43 | function ReadStdin($buf, $offset, $len) { 44 | # We should read exactly, if available 45 | if($stdin.ReadExactly) { 46 | $stdin.ReadExactly($buf, $offset, $len) 47 | } else { 48 | # broken fallback 49 | $stdin.Read($buf, $offset, $len) 50 | } 51 | } 52 | while( -Not $proc.HasExited ) { 53 | ReadStdin $buf 0 4 54 | $messageType = [System.Buffers.Binary.BinaryPrimitives]::ReadInt32BigEndian($buf) 55 | if($proc.HasExited) { 56 | return 57 | } 58 | if($messageType -eq 0) { 59 | return 60 | } 61 | ReadStdin $buf 0 4 62 | $contentLength = [System.Buffers.Binary.BinaryPrimitives]::ReadInt32BigEndian($buf) 63 | $rawcontent = New-Object byte[] $contentLength 64 | ReadStdin $rawcontent 0 $contentLength 65 | $utf8Content = [System.Text.Encoding]::UTF8.GetString($rawcontent) 66 | $content = [System.Text.Encoding]::Unicode.GetBytes($utf8Content) 67 | $pipeOut.Write([BitConverter]::GetBytes($messageType), 0, 4) 68 | $pipeOut.Write([BitConverter]::GetBytes($content.Length), 0, 4) 69 | $pipeOut.Write($content, 0, $content.Length) 70 | $pipeOut.Flush() 71 | } 72 | } 73 | echo "Wait for exit" 74 | Wait-Process -InputObject $proc 75 | $exitCode = $proc.ExitCode 76 | # https://github.com/actions/runner/blob/af6ed41bcb47019cce2a7035bad76c97ac97b92a/src/Runner.Common/Util/TaskResultUtil.cs#L13-L14 77 | if(($exitCode -ge 100) -and ($exitCode -le 105)) { 78 | $conclusion = 0 79 | } else { 80 | $conclusion = 1 81 | } 82 | echo "Has exited with code $exitCode and conclusion $conclusion" 83 | # This is needed to shutdown the input thread, it seem to stall if we just do nothing or exit 84 | [System.Environment]::Exit($conclusion) -------------------------------------------------------------------------------- /util/actions-runner-worker.ps1: -------------------------------------------------------------------------------- 1 | param ($Worker) 2 | # This script can be used to call Runner.Worker as github-act-runner worker 3 | # You just have to create simple .runner file in the root folder with the following Content 4 | # {"isHostedServer": false, "agentName": "my-runner", "workFolder": "_work"} 5 | # Then use `pwsh path/to/this/script.ps1 path/to/actions/runner/bin/Runner.Worker` as the worker args 6 | 7 | # Fallback if not existing 8 | $runnerFile = (Join-Path (Join-Path $Worker "../.." -Resolve) ".runner") 9 | if(-not (Test-Path $runnerFile)) { 10 | Write-Output '{"isHostedServer": false, "agentName": "my-runner", "workFolder": "_work"}' | Out-File $runnerFile 11 | } 12 | 13 | $stdin = [System.Console]::OpenStandardInput() 14 | $pipeOut = New-Object -TypeName System.IO.Pipes.AnonymousPipeServerStream -ArgumentList 'Out','Inheritable' 15 | $pipeIn = New-Object -TypeName System.IO.Pipes.AnonymousPipeServerStream -ArgumentList 'In','Inheritable' 16 | $startInfo = New-Object System.Diagnostics.ProcessStartInfo 17 | if($Worker.EndsWith(".dll")) { 18 | $startInfo.FileName = $Worker 19 | $startInfo.Arguments = "`"$Worker`" spawnclient $($pipeOut.GetClientHandleAsString()) $($pipeIn.GetClientHandleAsString())" 20 | } else { 21 | $startInfo.FileName = $Worker 22 | $startInfo.Arguments = "spawnclient $($pipeOut.GetClientHandleAsString()) $($pipeIn.GetClientHandleAsString())" 23 | } 24 | $startInfo.UseShellExecute = $false 25 | $startInfo.RedirectStandardInput = $true 26 | $startInfo.RedirectStandardOutput = $true 27 | $startInfo.RedirectStandardError = $true 28 | 29 | $process = New-Object System.Diagnostics.Process 30 | $process.StartInfo = $startInfo 31 | 32 | # Set the process creation flags to CREATE_NEW_PROCESS_GROUP (0x200) 33 | $processCreationFlags = 0x200 34 | $process.StartInfo.CreateNoWindow = $true 35 | $process.Start() 36 | $proc = $process 37 | $inputjob = Start-ThreadJob -ScriptBlock { 38 | $stdin = $using:stdin 39 | $pipeOut = $using:pipeOut 40 | $pipeIn = $using:pipeIn 41 | $proc = $using:proc 42 | $buf = New-Object byte[] 4 43 | function ReadStdin($buf, $offset, $len) { 44 | # We should read exactly, if available 45 | if($stdin.ReadExactly) { 46 | $stdin.ReadExactly($buf, $offset, $len) 47 | } else { 48 | # broken fallback 49 | $stdin.Read($buf, $offset, $len) 50 | } 51 | } 52 | while( -Not $proc.HasExited ) { 53 | ReadStdin $buf 0 4 54 | $messageType = [System.Buffers.Binary.BinaryPrimitives]::ReadInt32BigEndian($buf) 55 | if($proc.HasExited) { 56 | return 57 | } 58 | if($messageType -eq 0) { 59 | return 60 | } 61 | ReadStdin $buf 0 4 62 | $contentLength = [System.Buffers.Binary.BinaryPrimitives]::ReadInt32BigEndian($buf) 63 | $rawcontent = New-Object byte[] $contentLength 64 | ReadStdin $rawcontent 0 $contentLength 65 | $utf8Content = [System.Text.Encoding]::UTF8.GetString($rawcontent) 66 | $content = [System.Text.Encoding]::Unicode.GetBytes($utf8Content) 67 | $pipeOut.Write([BitConverter]::GetBytes($messageType), 0, 4) 68 | $pipeOut.Write([BitConverter]::GetBytes($content.Length), 0, 4) 69 | $pipeOut.Write($content, 0, $content.Length) 70 | $pipeOut.Flush() 71 | } 72 | } 73 | echo "Wait for exit" 74 | Wait-Process -InputObject $proc 75 | $exitCode = $proc.ExitCode 76 | # https://github.com/actions/runner/blob/af6ed41bcb47019cce2a7035bad76c97ac97b92a/src/Runner.Common/Util/TaskResultUtil.cs#L13-L14 77 | if(($exitCode -ge 100) -and ($exitCode -le 105)) { 78 | $conclusion = 0 79 | } else { 80 | $conclusion = 1 81 | } 82 | echo "Has exited with code $exitCode and conclusion $conclusion" 83 | # This is needed to shutdown the input thread, it seem to stall if we just do nothing or exit 84 | [System.Environment]::Exit($conclusion) -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Dockerfile 2 | # Source: https://github.com/dotnet/dotnet-docker 3 | FROM mcr.microsoft.com/dotnet/runtime-deps:6.0-jammy as build 4 | 5 | ARG TARGETOS 6 | ARG TARGETARCH 7 | # https://github.com/actions/runner/releases 8 | ARG RUNNER_VERSION=2.329.0 9 | # https://github.com/actions/runner-container-hooks/releases 10 | ARG RUNNER_CONTAINER_HOOKS_VERSION=0.7.0 11 | # https://github.com/moby/moby/releases 12 | ARG DOCKER_VERSION=28.5.2 13 | # https://github.com/docker/buildx/releases 14 | ARG BUILDX_VERSION=0.29.1 15 | 16 | RUN apt update -y && apt install curl unzip -y 17 | 18 | WORKDIR /actions-runner 19 | RUN export RUNNER_ARCH=${TARGETARCH} \ 20 | && if [ "$RUNNER_ARCH" = "amd64" ]; then export RUNNER_ARCH=x64 ; fi \ 21 | && curl -f -L -o runner.tar.gz https://github.com/actions/runner/releases/download/v${RUNNER_VERSION}/actions-runner-${TARGETOS}-${RUNNER_ARCH}-${RUNNER_VERSION}.tar.gz \ 22 | && tar xzf ./runner.tar.gz \ 23 | && rm runner.tar.gz 24 | 25 | RUN curl -f -L -o runner-container-hooks.zip https://github.com/actions/runner-container-hooks/releases/download/v${RUNNER_CONTAINER_HOOKS_VERSION}/actions-runner-hooks-docker-${RUNNER_CONTAINER_HOOKS_VERSION}.zip \ 26 | && unzip ./runner-container-hooks.zip -d ./docker-hooks \ 27 | && rm runner-container-hooks.zip 28 | 29 | RUN curl -f -L -o runner-container-hooks.zip https://github.com/actions/runner-container-hooks/releases/download/v${RUNNER_CONTAINER_HOOKS_VERSION}/actions-runner-hooks-k8s-${RUNNER_CONTAINER_HOOKS_VERSION}.zip \ 30 | && unzip ./runner-container-hooks.zip -d ./k8s \ 31 | && rm runner-container-hooks.zip 32 | 33 | RUN curl -f -L -o runner-container-hooks.zip https://github.com/actions/runner-container-hooks/releases/download/v0.8.0/actions-runner-hooks-k8s-0.8.0.zip \ 34 | && unzip ./runner-container-hooks.zip -d ./k8s-novolume \ 35 | && rm runner-container-hooks.zip 36 | 37 | RUN export RUNNER_ARCH=${TARGETARCH} \ 38 | && if [ "$RUNNER_ARCH" = "amd64" ]; then export DOCKER_ARCH=x86_64 ; fi \ 39 | && if [ "$RUNNER_ARCH" = "arm64" ]; then export DOCKER_ARCH=aarch64 ; fi \ 40 | && curl -fLo docker.tgz https://download.docker.com/${TARGETOS}/static/stable/${DOCKER_ARCH}/docker-${DOCKER_VERSION}.tgz \ 41 | && tar zxvf docker.tgz \ 42 | && rm -rf docker.tgz \ 43 | && mkdir -p /usr/local/lib/docker/cli-plugins \ 44 | && curl -fLo /usr/local/lib/docker/cli-plugins/docker-buildx \ 45 | "https://github.com/docker/buildx/releases/download/v${BUILDX_VERSION}/buildx-v${BUILDX_VERSION}.linux-${TARGETARCH}" \ 46 | && chmod +x /usr/local/lib/docker/cli-plugins/docker-buildx 47 | 48 | FROM golang:1.24-alpine as builder 49 | # Do not remove `git` here, it is required for getting runner version when executing `make build` 50 | RUN apk add --no-cache make git 51 | 52 | COPY . /opt/src/gitea-actions-runner 53 | WORKDIR /opt/src/gitea-actions-runner 54 | 55 | RUN make clean && make build 56 | 57 | FROM mcr.microsoft.com/dotnet/runtime-deps:6.0-jammy 58 | 59 | ENV DEBIAN_FRONTEND=noninteractive 60 | ENV RUNNER_MANUALLY_TRAP_SIG=1 61 | ENV ACTIONS_RUNNER_PRINT_LOG_TO_STDOUT=1 62 | ENV ImageOS=ubuntu22 63 | ENV ACTIONS_RUNNER_CONTAINER_HOOKS=/home/runner/docker-hooks/index.js 64 | 65 | RUN apt-get update -y \ 66 | && apt-get install -y --no-install-recommends \ 67 | sudo \ 68 | jq \ 69 | curl \ 70 | git \ 71 | lsb-release \ 72 | && rm -rf /var/lib/apt/lists/* 73 | 74 | RUN adduser --disabled-password --gecos "" --uid 1000 runner \ 75 | && groupadd docker --gid 123 \ 76 | && usermod -aG sudo runner \ 77 | && usermod -aG docker runner \ 78 | && echo "%sudo ALL=(ALL:ALL) NOPASSWD:ALL" > /etc/sudoers \ 79 | && echo "Defaults env_keep += \"DEBIAN_FRONTEND\"" >> /etc/sudoers 80 | 81 | WORKDIR /home/runner 82 | 83 | VOLUME /home/runner/externals 84 | VOLUME /home/runner/_work 85 | 86 | COPY --chown=runner:docker --from=build /actions-runner . 87 | COPY --from=build /usr/local/lib/docker/cli-plugins/docker-buildx /usr/local/lib/docker/cli-plugins/docker-buildx 88 | 89 | RUN install -o root -g root -m 755 docker/* /usr/bin/ && rm -rf docker 90 | 91 | RUN mkdir -p /runner && chown runner:docker -R /runner && ln -s /data/.actions_runner .runner 92 | 93 | WORKDIR /runner 94 | RUN chown runner:docker /runner && mkdir -p /home/runner/_work && chown -R runner:docker /home/runner/_work && mkdir -p /data && chown runner:docker /data 95 | 96 | USER 1000 97 | 98 | COPY --from=builder /opt/src/gitea-actions-runner/gitea-actions-runner /runner/gitea-actions-runner 99 | 100 | COPY actions-runner-worker.py /runner 101 | COPY start.sh /runner 102 | 103 | ENTRYPOINT ["bash", "/runner/start.sh"] 104 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | linters: 2 | enable: 3 | - gosimple 4 | - deadcode 5 | - typecheck 6 | - govet 7 | - errcheck 8 | - staticcheck 9 | - unused 10 | - structcheck 11 | - varcheck 12 | - dupl 13 | #- gocyclo # The cyclomatic complexety of a lot of functions is too high, we should refactor those another time. 14 | - gofmt 15 | - misspell 16 | - gocritic 17 | - bidichk 18 | - ineffassign 19 | - revive 20 | - gofumpt 21 | - depguard 22 | - nakedret 23 | - unconvert 24 | - wastedassign 25 | - nolintlint 26 | - stylecheck 27 | enable-all: false 28 | disable-all: true 29 | fast: false 30 | 31 | run: 32 | go: 1.18 33 | timeout: 10m 34 | skip-dirs: 35 | - node_modules 36 | - public 37 | - web_src 38 | 39 | linters-settings: 40 | stylecheck: 41 | checks: ["all", "-ST1005", "-ST1003"] 42 | nakedret: 43 | max-func-lines: 0 44 | gocritic: 45 | disabled-checks: 46 | - ifElseChain 47 | - singleCaseSwitch # Every time this occurred in the code, there was no other way. 48 | revive: 49 | ignore-generated-header: false 50 | severity: warning 51 | confidence: 0.8 52 | errorCode: 1 53 | warningCode: 1 54 | rules: 55 | - name: blank-imports 56 | - name: context-as-argument 57 | - name: context-keys-type 58 | - name: dot-imports 59 | - name: error-return 60 | - name: error-strings 61 | - name: error-naming 62 | - name: exported 63 | - name: if-return 64 | - name: increment-decrement 65 | - name: var-naming 66 | - name: var-declaration 67 | - name: package-comments 68 | - name: range 69 | - name: receiver-naming 70 | - name: time-naming 71 | - name: unexported-return 72 | - name: indent-error-flow 73 | - name: errorf 74 | - name: duplicated-imports 75 | - name: modifies-value-receiver 76 | gofumpt: 77 | extra-rules: true 78 | lang-version: "1.18" 79 | depguard: 80 | # TODO: use depguard to replace import checks in gitea-vet 81 | list-type: denylist 82 | # Check the list against standard lib. 83 | include-go-root: true 84 | packages-with-error-message: 85 | - github.com/unknwon/com: "use gitea's util and replacements" 86 | 87 | issues: 88 | exclude-rules: 89 | # Exclude some linters from running on tests files. 90 | - path: _test\.go 91 | linters: 92 | - gocyclo 93 | - errcheck 94 | - dupl 95 | - gosec 96 | - unparam 97 | - staticcheck 98 | - path: models/migrations/v 99 | linters: 100 | - gocyclo 101 | - errcheck 102 | - dupl 103 | - gosec 104 | - linters: 105 | - dupl 106 | text: "webhook" 107 | - linters: 108 | - gocritic 109 | text: "`ID' should not be capitalized" 110 | - path: modules/templates/helper.go 111 | linters: 112 | - gocritic 113 | - linters: 114 | - unused 115 | - deadcode 116 | text: "swagger" 117 | - path: contrib/pr/checkout.go 118 | linters: 119 | - errcheck 120 | - path: models/issue.go 121 | linters: 122 | - errcheck 123 | - path: models/migrations/ 124 | linters: 125 | - errcheck 126 | - path: modules/log/ 127 | linters: 128 | - errcheck 129 | - path: routers/api/v1/repo/issue_subscription.go 130 | linters: 131 | - dupl 132 | - path: routers/repo/view.go 133 | linters: 134 | - dupl 135 | - path: models/migrations/ 136 | linters: 137 | - unused 138 | - linters: 139 | - staticcheck 140 | text: "argument x is overwritten before first use" 141 | - path: modules/httplib/httplib.go 142 | linters: 143 | - staticcheck 144 | # Enabling this would require refactoring the methods and how they are called. 145 | - path: models/issue_comment_list.go 146 | linters: 147 | - dupl 148 | - linters: 149 | - misspell 150 | text: '`Unknwon` is a misspelling of `Unknown`' 151 | - path: models/update.go 152 | linters: 153 | - unused 154 | - path: cmd/dump.go 155 | linters: 156 | - dupl 157 | - path: services/webhook/webhook.go 158 | linters: 159 | - structcheck 160 | - text: "commentFormatting: put a space between `//` and comment text" 161 | linters: 162 | - gocritic 163 | - text: "exitAfterDefer:" 164 | linters: 165 | - gocritic 166 | - path: modules/graceful/manager_windows.go 167 | linters: 168 | - staticcheck 169 | text: "svc.IsAnInteractiveSession is deprecated: Use IsWindowsService instead." 170 | - path: models/user/openid.go 171 | linters: 172 | - golint 173 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | DIST := dist 2 | EXECUTABLE := gitea-actions-runner 3 | GOFMT ?= gofumpt -l 4 | DIST := dist 5 | DIST_DIRS := $(DIST)/binaries $(DIST)/release 6 | GO ?= go 7 | SHASUM ?= shasum -a 256 8 | HAS_GO = $(shell hash $(GO) > /dev/null 2>&1 && echo "GO" || echo "NOGO" ) 9 | XGO_PACKAGE ?= src.techknowlogick.com/xgo@latest 10 | XGO_VERSION := go-1.18.x 11 | GXZ_PAGAGE ?= github.com/ulikunitz/xz/cmd/gxz@v0.5.10 12 | 13 | LINUX_ARCHS ?= linux/amd64,linux/arm64 14 | DARWIN_ARCHS ?= darwin-12/amd64,darwin-12/arm64 15 | WINDOWS_ARCHS ?= windows/amd64 16 | GOFILES := $(shell find . -type f -name "*.go" ! -name "generated.*") 17 | 18 | ifneq ($(shell uname), Darwin) 19 | EXTLDFLAGS = -extldflags "-static" $(null) 20 | else 21 | EXTLDFLAGS = 22 | endif 23 | 24 | ifeq ($(HAS_GO), GO) 25 | GOPATH ?= $(shell $(GO) env GOPATH) 26 | export PATH := $(GOPATH)/bin:$(PATH) 27 | 28 | CGO_EXTRA_CFLAGS := -DSQLITE_MAX_VARIABLE_NUMBER=32766 29 | CGO_CFLAGS ?= $(shell $(GO) env CGO_CFLAGS) $(CGO_EXTRA_CFLAGS) 30 | endif 31 | 32 | ifeq ($(OS), Windows_NT) 33 | GOFLAGS := -v -buildmode=exe 34 | EXECUTABLE ?= $(EXECUTABLE).exe 35 | else ifeq ($(OS), Windows) 36 | GOFLAGS := -v -buildmode=exe 37 | EXECUTABLE ?= $(EXECUTABLE).exe 38 | else 39 | GOFLAGS := -v 40 | EXECUTABLE ?= $(EXECUTABLE) 41 | endif 42 | 43 | STORED_VERSION_FILE := VERSION 44 | 45 | ifneq ($(DRONE_TAG),) 46 | VERSION ?= $(subst v,,$(DRONE_TAG)) 47 | RELEASE_VERSION ?= $(VERSION) 48 | else 49 | ifneq ($(DRONE_BRANCH),) 50 | VERSION ?= $(subst release/v,,$(DRONE_BRANCH)) 51 | else 52 | VERSION ?= master 53 | endif 54 | 55 | STORED_VERSION=$(shell cat $(STORED_VERSION_FILE) 2>/dev/null) 56 | ifneq ($(STORED_VERSION),) 57 | RELEASE_VERSION ?= $(STORED_VERSION) 58 | else 59 | RELEASE_VERSION ?= $(shell git describe --tags --always | sed 's/-/+/' | sed 's/^v//') 60 | endif 61 | endif 62 | 63 | TAGS ?= 64 | LDFLAGS ?= -X 'github.com/ChristopherHX/gitea-actions-runner/cmd.version=$(RELEASE_VERSION)' 65 | 66 | all: build 67 | 68 | fmt: 69 | @hash gofumpt > /dev/null 2>&1; if [ $$? -ne 0 ]; then \ 70 | $(GO) install -u mvdan.cc/gofumpt; \ 71 | fi 72 | $(GOFMT) -w $(GOFILES) 73 | 74 | vet: 75 | $(GO) vet ./... 76 | 77 | .PHONY: fmt-check 78 | fmt-check: 79 | @hash gofumpt > /dev/null 2>&1; if [ $$? -ne 0 ]; then \ 80 | $(GO) install -u mvdan.cc/gofumpt; \ 81 | fi 82 | @diff=$$($(GOFMT) -d $(GOFILES)); \ 83 | if [ -n "$$diff" ]; then \ 84 | echo "Please run 'make fmt' and commit the result:"; \ 85 | echo "$${diff}"; \ 86 | exit 1; \ 87 | fi; 88 | 89 | test: fmt-check 90 | @$(GO) test -v -cover -coverprofile coverage.txt ./... && echo "\n==>\033[32m Ok\033[m\n" || exit 1 91 | 92 | install: $(GOFILES) 93 | $(GO) install -v -tags '$(TAGS)' -ldflags '$(EXTLDFLAGS)-s -w $(LDFLAGS)' 94 | 95 | build: $(EXECUTABLE) 96 | 97 | $(EXECUTABLE): $(GOFILES) 98 | $(GO) build -v -tags '$(TAGS)' -ldflags '$(EXTLDFLAGS)-s -w $(LDFLAGS)' -o $@ 99 | 100 | .PHONY: deps-backend 101 | deps-backend: 102 | $(GO) mod download 103 | $(GO) install $(GXZ_PAGAGE) 104 | $(GO) install $(XGO_PACKAGE) 105 | 106 | .PHONY: release 107 | release: release-windows release-linux release-darwin release-copy release-compress release-check 108 | 109 | $(DIST_DIRS): 110 | mkdir -p $(DIST_DIRS) 111 | 112 | .PHONY: release-windows 113 | release-windows: | $(DIST_DIRS) 114 | CGO_CFLAGS="$(CGO_CFLAGS)" $(GO) run $(XGO_PACKAGE) -go $(XGO_VERSION) -buildmode exe -dest $(DIST)/binaries -tags 'netgo osusergo $(TAGS)' -ldflags '-linkmode external -extldflags "-static" $(LDFLAGS)' -targets '$(WINDOWS_ARCHS)' -out $(EXECUTABLE)-$(VERSION) . 115 | ifeq ($(CI),true) 116 | cp -r /build/* $(DIST)/binaries/ 117 | endif 118 | 119 | .PHONY: release-linux 120 | release-linux: | $(DIST_DIRS) 121 | CGO_CFLAGS="$(CGO_CFLAGS)" $(GO) run $(XGO_PACKAGE) -go $(XGO_VERSION) -dest $(DIST)/binaries -tags 'netgo osusergo $(TAGS)' -ldflags '-linkmode external -extldflags "-static" $(LDFLAGS)' -targets '$(LINUX_ARCHS)' -out $(EXECUTABLE)-$(VERSION) . 122 | ifeq ($(CI),true) 123 | cp -r /build/* $(DIST)/binaries/ 124 | endif 125 | 126 | .PHONY: release-darwin 127 | release-darwin: | $(DIST_DIRS) 128 | CGO_CFLAGS="$(CGO_CFLAGS)" $(GO) run $(XGO_PACKAGE) -go $(XGO_VERSION) -dest $(DIST)/binaries -tags 'netgo osusergo $(TAGS)' -ldflags '$(LDFLAGS)' -targets '$(DARWIN_ARCHS)' -out $(EXECUTABLE)-$(VERSION) . 129 | ifeq ($(CI),true) 130 | cp -r /build/* $(DIST)/binaries/ 131 | endif 132 | 133 | .PHONY: release-copy 134 | release-copy: | $(DIST_DIRS) 135 | cd $(DIST); for file in `find . -type f -name "*"`; do cp $${file} ./release/; done; 136 | 137 | .PHONY: release-check 138 | release-check: | $(DIST_DIRS) 139 | cd $(DIST)/release/; for file in `find . -type f -name "*"`; do echo "checksumming $${file}" && $(SHASUM) `echo $${file} | sed 's/^..//'` > $${file}.sha256; done; 140 | 141 | .PHONY: release-compress 142 | release-compress: | $(DIST_DIRS) 143 | cd $(DIST)/release/; for file in `find . -type f -name "*"`; do echo "compressing $${file}" && $(GO) run $(GXZ_PAGAGE) -k -9 $${file}; done; 144 | 145 | clean: 146 | $(GO) clean -x -i ./... 147 | rm -rf coverage.txt $(EXECUTABLE) $(DIST) 148 | 149 | version: 150 | @echo $(VERSION) 151 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # actions runner 2 | 3 | Actions runner is a runner for Gitea based on [actions/runner](https://github.com/actions/runner) and the workflow yaml parser of [act](https://gitea.com/gitea/act). 4 | 5 | - This runner doesn't download actions via the git http protocol, it downloads them via tar.gz and zip archives. 6 | - This runner doesn't support absolute actions in composite actions `https://github.com/actions/checkout@v3` will only work in workflow steps. 7 | - This runner doesn't support go actions, however you can create a wrapper composite action as a workaround 8 | - This runner doesn't support the gitea context including `GITEA_` env variables 9 | - This runner doesn't support expressions and secrets in uses 10 | - This runner does support service container 11 | - This runner does support https://github.com/actions/runner-container-hooks 12 | - This runner uses the official https://github.com/actions/runner runner to run your steps 13 | - This runner is a protocol proxy between Gitea Actions and GitHub Actions 14 | 15 | ## Known Issues 16 | 17 | - actions/runner's live logs are skipping lines, but it is not possible to upload the full log without breaking live logs after the steps are finished 18 | - ~~job outputs cannot be sent back https://gitea.com/gitea/actions-proto-def/issues/4~~ fixed 19 | - Not possible to update display name of steps from runner 20 | Pre and Post steps are part of setup and complete job, steps not in the job are ignored by the server 21 | - Not possible to send step updates between cancellation request and finishing the request 22 | 23 | ## Prerequisites 24 | 25 | - Install powershell 7 https://github.com/powershell/powershell (actions-runner-worker.ps1) 26 | - For linux and macOS you can also use python3 instead (actions-runner-worker.py) 27 | - Download and extract actions/runner https://github.com/actions/runner/releases 28 | - You have to create simple .runner file in the root folder of the actions/runner with the following Content 29 | ``` 30 | {"isHostedServer": false, "agentName": "my-runner", "workFolder": "_work"} 31 | ``` 32 | 33 | ## Quickstart 34 | 35 | ### Build 36 | 37 | ```bash 38 | make build 39 | ``` 40 | 41 | ### Register 42 | 43 | ```bash![C4_Elements](https://github.com/ChristopherHX/gitea-actions-runner/assets/44845461/76b0ded2-0f18-472a-862d-6550b5937252) 44 | 45 | ./gitea-actions-runner register 46 | ``` 47 | 48 | And you will be asked to input: 49 | 50 | 1. worker args for example `python3,actions-runner-worker.py,actions-runner/bin/Runner.Worker`, `pwsh,actions-runner-worker.ps1,actions-runner/bin/Runner.Worker` 51 | `actions-runner-worker`(`.ps1`/`.py`) are wrapper scripts to call the actions/runner via the platform specfic dotnet anonymous pipes 52 | 53 | `actions-runner-worker.py` doesn't work on windows 54 | 55 | On windows you might need to unblock the `actions-runner-worker.ps1` script via pwsh `Unblock-File actions-runner-worker.ps1` and `Runner.Worker` needs the `.exe` suffix. 56 | For example on windows use the following worker args `pwsh,actions-runner-worker.ps1,actions-runner/bin/Runner.Worker.exe` 57 | 2. Gitea instance URL, like `http://192.168.8.8:3000/`. You should use your gitea instance ROOT_URL as the instance argument 58 | and you should not use `localhost` or `127.0.0.1` as instance IP; 59 | 3. Runner token, you can get it from `http://192.168.8.8:3000/admin/runners`; 60 | 4. Runner name, you can just leave it blank; 61 | 5. Runner labels, you can just leave it blank. 62 | 63 | The process looks like: 64 | 65 | ```text 66 | INFO Registering runner, arch=amd64, os=darwin, version=0.1.5. 67 | INFO Enter the worker args for example pwsh,actions-runner-worker.ps1,actions-runner/bin/Runner.Worker: 68 | pwsh,actions-runner-worker.ps1,actions-runner/bin/Runner.Worker 69 | INFO Enter the Gitea instance URL (for example, https://gitea.com/): 70 | http://192.168.8.8:3000/ 71 | INFO Enter the runner token: 72 | fe884e8027dc292970d4e0303fe82b14xxxxxxxx 73 | INFO Enter the runner name (if set empty, use hostname:Test.local ): 74 | 75 | INFO Enter the runner labels, leave blank to use the default labels (comma-separated, for example, self-hosted,ubuntu-latest): 76 | 77 | INFO Registering runner, name=Test.local, instance=http://192.168.8.8:3000/, labels=[self-hosted ubuntu-latest]. 78 | DEBU Successfully pinged the Gitea instance server 79 | INFO Runner registered successfully. 80 | ``` 81 | 82 | You can also register with command line arguments. 83 | 84 | ```bash 85 | ./gitea-actions-runner register --instance http://192.168.8.8:3000 --token --worker pwsh,actions-runner-worker.ps1,actions-runner/bin/Runner.Worker --no-interactive 86 | ``` 87 | 88 | ```bash 89 | ./gitea-actions-runner register --instance http://192.168.8.8:3000 --token --worker python3,actions-runner-worker.py,actions-runner/bin/Runner.Worker --no-interactive 90 | ``` 91 | 92 | If the registry succeed, you could run the runner directly. 93 | 94 | ### Run 95 | 96 | ```bash 97 | ./gitea-actions-runner daemon 98 | ``` 99 | 100 | ### Hosted on both GitHub and Gitea 101 | - https://gitea.com/ChristopherHX/actions_runner 102 | - https://github.com/ChristopherHX/gitea-actions-runner 103 | 104 | ### System Overview 105 | 106 | A lot of my projects can communicate through various indirections. 107 | 108 | ![C4_Elements](https://github.com/ChristopherHX/gitea-actions-runner/assets/44845461/78ff5218-570c-4506-81a5-a1baa26f8016) 109 | 110 | ### Integrations based on this Project 111 | 112 | - [QEMU-KVM-Solution](https://github.com/CrimsonGiteaActions/QEMU-KVM-Solution) 113 | -------------------------------------------------------------------------------- /poller/poller.go: -------------------------------------------------------------------------------- 1 | package poller 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "os" 7 | "os/signal" 8 | "sync" 9 | "sync/atomic" 10 | "syscall" 11 | "time" 12 | 13 | runnerv1 "code.gitea.io/actions-proto-go/runner/v1" 14 | "github.com/ChristopherHX/gitea-actions-runner/client" 15 | 16 | "connectrpc.com/connect" 17 | log "github.com/sirupsen/logrus" 18 | ) 19 | 20 | var ErrDataLock = errors.New("Data Lock Error") 21 | 22 | func New(cli client.Client, dispatch func(context.Context, *runnerv1.Task) error, workerNum int) *Poller { 23 | return &Poller{ 24 | Client: cli, 25 | Dispatch: dispatch, 26 | routineGroup: newRoutineGroup(), 27 | metric: &metric{}, 28 | workerNum: workerNum, 29 | ready: make(chan struct{}, 1), 30 | } 31 | } 32 | 33 | type Poller struct { 34 | Client client.Client 35 | Dispatch func(context.Context, *runnerv1.Task) error 36 | 37 | sync.Mutex 38 | routineGroup *routineGroup 39 | metric *metric 40 | ready chan struct{} 41 | workerNum int 42 | tasksVersion atomic.Int64 // tasksVersion used to store the version of the last task fetched from the Gitea. 43 | Once bool 44 | } 45 | 46 | func (p *Poller) schedule() { 47 | p.Lock() 48 | defer p.Unlock() 49 | if int(p.metric.BusyWorkers()) >= p.workerNum { 50 | return 51 | } 52 | 53 | select { 54 | case p.ready <- struct{}{}: 55 | default: 56 | } 57 | } 58 | 59 | func (p *Poller) Wait() { 60 | p.routineGroup.Wait() 61 | defer log.Infof("wait: exit") 62 | } 63 | 64 | func (p *Poller) Poll(rootctx context.Context) error { 65 | defer log.Infof("Poll: exit %v", recover()) 66 | 67 | ctx, cancel := context.WithCancel(rootctx) 68 | defer cancel() 69 | 70 | // this is needed to not force cancel the job by parent context 71 | jobCtx, hardCancel := context.WithCancel(context.Background()) 72 | // trap Ctrl+C to control graceful exit of running jobs 73 | channel := make(chan os.Signal, 1) 74 | signal.Notify(channel, syscall.SIGTERM, os.Interrupt) 75 | 76 | defer func() { 77 | p.Wait() 78 | hardCancel() 79 | signal.Stop(channel) 80 | close(channel) 81 | }() 82 | 83 | go func() { 84 | sig, ok := <-channel 85 | // follow github-act-runner behavior 86 | // sigterm will cancel all running jobs 87 | if sig == syscall.SIGTERM || !ok { 88 | hardCancel() 89 | return 90 | } 91 | // now a second signal always terminates the job 92 | <-channel 93 | hardCancel() 94 | }() 95 | 96 | l := log.WithField("func", "Poll") 97 | 98 | for { 99 | // check worker number 100 | p.schedule() 101 | 102 | select { 103 | // wait worker ready 104 | case <-p.ready: 105 | case <-ctx.Done(): 106 | log.Infof("Poll: exit -1") 107 | return nil 108 | } 109 | LOOP: 110 | for { 111 | select { 112 | case <-ctx.Done(): 113 | break LOOP 114 | default: 115 | task, err := p.pollTask(ctx) 116 | if task == nil || err != nil { 117 | if err != nil { 118 | l.Errorf("can't find the task: %v", err.Error()) 119 | } 120 | select { 121 | case <-ctx.Done(): 122 | break LOOP 123 | case <-time.After(5 * time.Second): 124 | } 125 | break 126 | } 127 | 128 | p.metric.IncBusyWorker() 129 | p.routineGroup.Run(func() { 130 | defer p.schedule() 131 | defer p.metric.DecBusyWorker() 132 | if p.Once { 133 | defer l.Infof("execute task: once") 134 | defer cancel() 135 | } 136 | if err := p.dispatchTask(jobCtx, task); err != nil { 137 | l.Errorf("execute task: %v", err.Error()) 138 | } else { 139 | l.Infof("execute task: ok") 140 | } 141 | }) 142 | break LOOP 143 | } 144 | } 145 | } 146 | } 147 | 148 | func (p *Poller) pollTask(ctx context.Context) (*runnerv1.Task, error) { 149 | l := log.WithField("func", "pollTask") 150 | 151 | // Load the version value that was in the cache when the request was sent. 152 | v := p.tasksVersion.Load() 153 | 154 | //l.Infof("poller: request stage from remote server task version %d", v) 155 | 156 | reqCtx, cancel := context.WithTimeout(ctx, 50*time.Second) 157 | defer cancel() 158 | 159 | resp, err := p.Client.FetchTask(reqCtx, connect.NewRequest(&runnerv1.FetchTaskRequest{ 160 | TasksVersion: v, 161 | })) 162 | if err == context.Canceled || err == context.DeadlineExceeded { 163 | l.WithError(err).Trace("poller: no stage returned") 164 | return nil, nil 165 | } 166 | 167 | if err != nil && err == ErrDataLock { 168 | l.WithError(err).Info("task accepted by another runner") 169 | return nil, nil 170 | } 171 | 172 | if err != nil { 173 | l.WithError(err).Error("cannot accept task") 174 | return nil, err 175 | } 176 | 177 | // exit if a nil or empty stage is returned from the system 178 | // and allow the runner to retry. 179 | if resp == nil || resp.Msg == nil { 180 | return nil, nil 181 | } 182 | 183 | if resp.Msg.TasksVersion > v { 184 | p.tasksVersion.CompareAndSwap(v, resp.Msg.TasksVersion) 185 | } 186 | 187 | if resp.Msg.Task == nil || resp.Msg.Task.Id == 0 { 188 | l.Infof("poller: no job available task version %d new %d", v, p.tasksVersion.Load()) 189 | return nil, nil 190 | } 191 | 192 | // got a task, set `tasksVersion` to zero to force query db in next request. 193 | if !p.tasksVersion.CompareAndSwap(resp.Msg.TasksVersion, 0) { 194 | l.Warnf("poller: tasksVersion changed from %d to %d", resp.Msg.TasksVersion, p.tasksVersion.Load()) 195 | } 196 | 197 | return resp.Msg.Task, nil 198 | } 199 | 200 | func (p *Poller) dispatchTask(ctx context.Context, task *runnerv1.Task) error { 201 | l := log.WithField("func", "dispatchTask") 202 | defer func() { 203 | e := recover() 204 | if e != nil { 205 | l.Errorf("panic error: %v", e) 206 | } 207 | }() 208 | 209 | return p.Dispatch(ctx, task) 210 | } 211 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Integration Test 2 | on: 3 | push: 4 | branches: 5 | - main 6 | pull_request: 7 | workflow_dispatch: 8 | jobs: 9 | test: 10 | runs-on: ubuntu-latest 11 | env: 12 | GITEA_PORT: ${{ vars.GITEA_PORT || '3005' }} 13 | steps: 14 | - uses: actions/checkout@v4 15 | - name: Download Runner 16 | if: ${{ !vars.RUNNER_BIN }} 17 | shell: bash 18 | run: | 19 | go build -o "$RUNNER_TEMP/gitea-runner" 20 | echo "RUNNER_BIN=$RUNNER_TEMP/gitea-runner" >> $GITHUB_ENV 21 | - uses: ./.github/actions/setup-gitea 22 | with: 23 | gitea_bin: ${{ vars.GITEA_BIN }} 24 | gitea_port: ${{ env.GITEA_PORT }} 25 | gitea_platform: ${{ vars.GITEA_PLATFORM || 'linux-amd64' }} 26 | - name: Create test repository 27 | uses: actions/github-script@v7 28 | timeout-minutes: 5 29 | with: 30 | script: | 31 | await github.rest.repos.createForAuthenticatedUser({ 32 | name: 'action-repo', 33 | private: false, 34 | auto_init: true, 35 | default_branch: 'main', 36 | }) 37 | // Create new file in the repository 38 | await github.request("POST /repos/{owner}/{repo}/contents/{path}", { 39 | owner: "test01", 40 | repo: "action-repo", 41 | path: 'action.yml', 42 | message: 'Add action', 43 | branch: 'main', 44 | content: Buffer.from(`on: push 45 | runs: 46 | using: composite 47 | steps: 48 | - run: echo OK 49 | shell: bash`).toString('base64'), 50 | }); 51 | 52 | await github.rest.repos.createForAuthenticatedUser({ 53 | name: 'test-repo', 54 | private: true, 55 | auto_init: true, 56 | default_branch: 'main', 57 | }) 58 | // Create new file in the repository 59 | await github.request("POST /repos/{owner}/{repo}/contents/{path}", { 60 | owner: "test01", 61 | repo: "test-repo", 62 | path: 'action.yml', 63 | message: 'Add action', 64 | branch: 'main', 65 | content: Buffer.from(`on: push 66 | runs: 67 | using: composite 68 | steps: 69 | - run: echo OK 70 | shell: bash`).toString('base64'), 71 | }); 72 | // Create new file in the repository 73 | await github.request("POST /repos/{owner}/{repo}/contents/{path}", { 74 | owner: "test01", 75 | repo: "test-repo", 76 | path: '.github/workflows/test.yml', 77 | message: 'Add test workflow', 78 | branch: 'main', 79 | content: Buffer.from(`on: push 80 | jobs: 81 | test: 82 | runs-on: ubuntu-latest 83 | steps: 84 | - name: Checkout code 85 | uses: actions/checkout@v5 86 | - name: Run instance public local action 87 | uses: test01/action-repo@main 88 | - name: Run self private local action 89 | uses: test01/test-repo@main 90 | - name: Run tests 91 | run: echo "Running tests..."`).toString('base64'), 92 | }); 93 | await exec.exec(`${process.env.RUNNER_BIN} register --instance http://localhost:${{ env.GITEA_PORT }} --token ${process.env.RUNNER_TOKEN} --name test-runner --labels ubuntu-latest --no-interactive --ephemeral --type 2`); 94 | await exec.exec(`${process.env.RUNNER_BIN} daemon`); 95 | 96 | // List all workflow runs 97 | const runs = await github.rest.actions.listWorkflowRunsForRepo({ 98 | owner: "test01", 99 | repo: "test-repo", 100 | branch: "main", 101 | }); 102 | 103 | var hasFailures = false; 104 | 105 | for (const run of runs.data.workflow_runs) { 106 | console.log(`Workflow Run: ${run.id}`); 107 | 108 | // List all jobs for the workflow run 109 | const jobs = await github.rest.actions.listJobsForWorkflowRun({ 110 | owner: "test01", 111 | repo: "test-repo", 112 | run_id: run.id, 113 | }); 114 | hasFailures ||= jobs.data.jobs.some(job => job.status == 'completed' && job.conclusion !== 'success' && job.conclusion !== 'skipped'); 115 | for (const job of jobs.data.jobs) { 116 | console.log(`Job: ${job.id}, Name: ${job.name}, Status: ${job.status}, Conclusion: ${job.conclusion}`); 117 | if(job.status !== 'completed') { 118 | continue; 119 | } 120 | try { 121 | // Download logs for the job 122 | const logResponse = await github.rest.actions.downloadJobLogsForWorkflowRun({ 123 | owner: "test01", 124 | repo: "test-repo", 125 | job_id: job.id, 126 | }); 127 | 128 | console.log(`Logs for Job ${job.id}:`); 129 | console.log(logResponse.data); 130 | } catch (err) { 131 | console.error(`Failed to download logs for Job ${job.id}: ${err}`); 132 | } 133 | } 134 | } 135 | if (hasFailures) { 136 | throw new Error('Some jobs have failed.'); 137 | } 138 | github-token: ${{ env.TOKEN }} 139 | base-url: "http://localhost:${{ env.GITEA_PORT }}/api/v1" 140 | env: 141 | RUNNER_BIN: ${{ vars.RUNNER_BIN || env.RUNNER_BIN }} 142 | - name: Sleep 143 | if: vars.SLEEP 144 | run: | 145 | sleep ${{ vars.SLEEP }} 146 | -------------------------------------------------------------------------------- /util/extract.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "archive/tar" 5 | "archive/zip" 6 | "bytes" 7 | "compress/gzip" 8 | "context" 9 | "crypto/rand" 10 | "encoding/hex" 11 | "errors" 12 | "fmt" 13 | "io" 14 | "net/http" 15 | "os" 16 | "path/filepath" 17 | "strings" 18 | "time" 19 | 20 | "github.com/nektos/act/pkg/filecollector" 21 | ) 22 | 23 | type Logger interface { 24 | Infof(format string, args ...interface{}) 25 | } 26 | 27 | func ExtractTar(in io.Reader, dst string) error { 28 | os.RemoveAll(dst) 29 | tr := tar.NewReader(in) 30 | cp := &filecollector.CopyCollector{ 31 | DstDir: dst, 32 | } 33 | for { 34 | ti, err := tr.Next() 35 | if errors.Is(err, io.EOF) { 36 | return nil 37 | } else if err != nil { 38 | return err 39 | } 40 | pc := strings.SplitN(ti.Name, "/", 2) 41 | if ti.FileInfo().IsDir() || len(pc) < 2 { 42 | continue 43 | } 44 | _ = cp.WriteFile(pc[1], ti.FileInfo(), ti.Linkname, tr) 45 | } 46 | } 47 | 48 | func ExtractZip(in io.ReaderAt, size int64, dst string) error { 49 | os.RemoveAll(dst) 50 | tr, err := zip.NewReader(in, size) 51 | if err != nil { 52 | return err 53 | } 54 | cp := &filecollector.CopyCollector{ 55 | DstDir: dst, 56 | } 57 | for _, ti := range tr.File { 58 | if ti.FileInfo().IsDir() { 59 | continue 60 | } 61 | fs, _ := ti.Open() 62 | defer fs.Close() 63 | _ = cp.WriteFile(ti.Name, ti.FileInfo(), "", fs) 64 | } 65 | return nil 66 | } 67 | 68 | func ExtractTarGz(reader io.Reader, dir string) error { 69 | gzr, err := gzip.NewReader(reader) 70 | if err != nil { 71 | return err 72 | } 73 | defer gzr.Close() 74 | return ExtractTar(gzr, dir) 75 | } 76 | 77 | func DownloadTool(ctx context.Context, logger Logger, url, dest string) error { 78 | token := "" 79 | httpClient := http.DefaultClient 80 | randBytes := make([]byte, 16) 81 | _, _ = rand.Read(randBytes) 82 | os.MkdirAll(filepath.Dir(dest), 0755) 83 | cachedTar := filepath.Join(dest, "..", hex.EncodeToString(randBytes)+".tmp") 84 | defer os.Remove(cachedTar) 85 | var tarstream io.Reader 86 | if logger != nil { 87 | logger.Infof("Downloading %s to %s", url, dest) 88 | } 89 | req, err := http.NewRequestWithContext(ctx, "GET", url, nil) 90 | if err != nil { 91 | return err 92 | } 93 | if token != "" { 94 | req.Header.Add("Authorization", "token "+token) 95 | } 96 | req.Header.Add("User-Agent", "github-act-runner/1.0.0") 97 | req.Header.Add("Accept", "*/*") 98 | rsp, err := httpClient.Do(req) 99 | if err != nil { 100 | return err 101 | } 102 | defer rsp.Body.Close() 103 | if rsp.StatusCode != 200 { 104 | buf := &bytes.Buffer{} 105 | _, _ = io.Copy(buf, rsp.Body) 106 | return fmt.Errorf("failed to download action from %v response %v", url, buf.String()) 107 | } 108 | fo, err := os.Create(cachedTar) 109 | if err != nil { 110 | return err 111 | } 112 | defer fo.Close() 113 | ch := make(chan error) 114 | go func() { 115 | for { 116 | select { 117 | case <-ch: 118 | return 119 | case <-time.After(time.Second * 10): 120 | off, _ := fo.Seek(0, 1) 121 | percent := off * 100 / rsp.ContentLength 122 | logger.Infof("Downloading... %d%%\n", percent) 123 | } 124 | } 125 | }() 126 | l, err := io.Copy(fo, rsp.Body) 127 | close(ch) 128 | if err != nil { 129 | return err 130 | } 131 | if rsp.ContentLength >= 0 && l != rsp.ContentLength { 132 | return fmt.Errorf("failed to download tar expected %v, but copied %v", rsp.ContentLength, l) 133 | } 134 | tarstream = fo 135 | _, _ = fo.Seek(0, 0) 136 | if strings.HasSuffix(url, ".tar.gz") { 137 | if err := ExtractTarGz(tarstream, dest); err != nil { 138 | return err 139 | } 140 | } else { 141 | st, _ := fo.Stat() 142 | if err := ExtractZip(fo, st.Size(), dest); err != nil { 143 | return err 144 | } 145 | } 146 | return nil 147 | } 148 | 149 | // Official GitHub Actions Runner 150 | func DownloadRunner(ctx context.Context, logger Logger, plat string, dest string, version string) error { 151 | AURL := func(arch, ext string) string { 152 | return fmt.Sprintf("https://github.com/actions/runner/releases/download/v%s/actions-runner-%s-%s.%s", version, arch, version, ext) 153 | } 154 | download := map[string]string{ 155 | "windows/386": AURL("win-x86", "zip"), 156 | "windows/amd64": AURL("win-x64", "zip"), 157 | "windows/arm64": AURL("win-arm64", "zip"), 158 | "linux/amd64": AURL("linux-x64", "tar.gz"), 159 | "linux/arm": AURL("linux-arm", "tar.gz"), 160 | "linux/arm64": AURL("linux-arm64", "tar.gz"), 161 | "darwin/amd64": AURL("osx-x64", "tar.gz"), 162 | "darwin/arm64": AURL("osx-arm64", "tar.gz"), 163 | } 164 | // Includes the bin folder in the archive 165 | return DownloadTool(ctx, logger, download[plat], dest) 166 | } 167 | 168 | // Includes windows container support 169 | func DownloadRunnerServer(ctx context.Context, logger Logger, plat string, dest string, version string) error { 170 | AURL := func(arch, ext string) string { 171 | return fmt.Sprintf("https://github.com/ChristopherHX/runner.server/releases/download/v%s/runner.server-%s.%s", version, arch, ext) 172 | } 173 | download := map[string]string{ 174 | "windows/386": AURL("win-x86", "zip"), 175 | "windows/amd64": AURL("win-x64", "zip"), 176 | "windows/arm64": AURL("win-arm64", "zip"), 177 | "linux/amd64": AURL("linux-x64", "tar.gz"), 178 | "linux/arm": AURL("linux-arm", "tar.gz"), 179 | "linux/arm64": AURL("linux-arm64", "tar.gz"), 180 | "darwin/amd64": AURL("osx-x64", "tar.gz"), 181 | "darwin/arm64": AURL("osx-arm64", "tar.gz"), 182 | } 183 | downloadURL, ok := download[plat] 184 | if !ok { 185 | return fmt.Errorf("unsupported platform %s", plat) 186 | } 187 | // Contains only the bin folder content 188 | return DownloadTool(ctx, logger, downloadURL, filepath.Join(dest, "bin")) 189 | } 190 | 191 | // The windows version required pwsh to be able to send the job request, powershell 5 not supported 192 | func DownloadPwsh(ctx context.Context, logger Logger, plat string, dest string, version string) error { 193 | AURL := func(arch, ext string) string { 194 | return fmt.Sprintf("https://github.com/PowerShell/PowerShell/releases/download/v%s/powershell-%s-%s.%s", version, version, arch, ext) 195 | } 196 | download := map[string]string{ 197 | "windows/386": AURL("win-x86", "zip"), 198 | "windows/amd64": AURL("win-x64", "zip"), 199 | "windows/arm64": AURL("win-arm64", "zip"), 200 | "linux/amd64": AURL("linux-x64", "tar.gz"), 201 | "linux/arm": AURL("linux-arm", "tar.gz"), 202 | "linux/arm64": AURL("linux-arm64", "tar.gz"), 203 | "darwin/amd64": AURL("osx-x64", "tar.gz"), 204 | "darwin/arm64": AURL("osx-arm64", "tar.gz"), 205 | } 206 | downloadURL, ok := download[plat] 207 | if !ok { 208 | return fmt.Errorf("unsupported platform %s", plat) 209 | } 210 | // Contains only the bin folder content 211 | return DownloadTool(ctx, logger, downloadURL, dest) 212 | } 213 | -------------------------------------------------------------------------------- /actions-runner-worker-v2.js: -------------------------------------------------------------------------------- 1 | // Experimental --worker-v2 script 2 | // Use --worker-v2,node,actions-runner-worker-v2.js,/path/to/Runner.Worker 3 | 4 | const fs = require('fs'); 5 | const path = require('path'); 6 | const child_process = require('child_process'); 7 | const { Duplex } = require('stream'); 8 | const http2 = require('http2'); 9 | const http = require('http'); 10 | 11 | // Custom duplex stream that uses process.stdin for reading and process.stdout for writing. 12 | class StdioDuplex extends Duplex { 13 | constructor(options) { 14 | super(options); 15 | // Use process.stdin as the source of incoming data. 16 | this.stdin = process.stdin; 17 | this.stdout = process.stdout; 18 | 19 | // As data comes in on stdin, push it to the readable side. 20 | this.stdin.on('data', (chunk) => { 21 | // If push returns false, the internal buffer is full; pause stdin. 22 | if (!this.push(chunk)) { 23 | this.stdin.pause(); 24 | } 25 | }); 26 | this.stdin.on('end', () => this.push(null)); 27 | } 28 | 29 | // Called when the consumer is ready for more data. 30 | _read(size) { 31 | this.stdin.resume(); 32 | } 33 | 34 | // Called when data is written to the stream; forward it to stdout. 35 | _write(chunk, encoding, callback) { 36 | this.stdout.write(chunk, encoding, callback); 37 | } 38 | 39 | // Called when no more data will be written. 40 | _final(callback) { 41 | this.stdout.end(); 42 | callback(); 43 | } 44 | } 45 | 46 | // Create an instance of the custom duplex stream. 47 | const stdioDuplex = new StdioDuplex(); 48 | 49 | var client = null; 50 | 51 | const ACTIONS_RUNNER_WORKER_DEBUG = process.env.ACTIONS_RUNNER_WORKER_DEBUG === '1' 52 | 53 | const server = http.createServer((req, res) => { 54 | let headers = {} 55 | for (const name in req.headers) { 56 | if (!name.startsWith(':') && name !== "connection" && name !== "upgrade" && name !== "host" && name !== "transfer-encoding") { 57 | headers[name] = req.headers[name]; 58 | } 59 | } 60 | if (ACTIONS_RUNNER_WORKER_DEBUG) { 61 | console.error('Request headers:', headers); 62 | } 63 | const creq = client.request({ 64 | ':method': req.method, 65 | ':path': req.url, 66 | ...headers 67 | } 68 | ); 69 | 70 | req.pipe(creq) 71 | 72 | creq.on('response', (headers) => { 73 | if (ACTIONS_RUNNER_WORKER_DEBUG) { 74 | console.error('Response headers:', headers); 75 | } 76 | const status = headers[':status'] || 200; 77 | // Remove HTTP/2 pseudo headers before forwarding. 78 | const resHeaders = {}; 79 | for (const name in headers) { 80 | if (!name.startsWith(':')) { 81 | resHeaders[name] = headers[name]; 82 | } 83 | } 84 | res.writeHead(status, resHeaders); 85 | creq.pipe(res); 86 | }); 87 | 88 | creq.on('error', (err) => { 89 | console.error('Error with HTTP/2 request:', err); 90 | res.writeHead(500); 91 | res.end('Internal Server Error'); 92 | }); 93 | 94 | if (ACTIONS_RUNNER_WORKER_DEBUG) { 95 | creq.on('end', () => { 96 | console.error('Response ended.'); 97 | }); 98 | } 99 | }); 100 | 101 | const hostname = process.argv.length > 3 ? process.argv[3] : "localhost"; 102 | 103 | server.listen(0, hostname, () => { 104 | const port = server.address().port; 105 | console.error(`Server running at http://${hostname}:${port}/`); 106 | 107 | // Use Node's native HTTP/2 client with a custom connection. 108 | // The "authority" URL here is a placeholder. HTTP/2 requires a proper protocol negotiation, 109 | // so the underlying framing might need additional handling if you're bridging to a non-standard transport. 110 | client = http2.connect(`http://${hostname}:${port}/`, { 111 | createConnection: () => stdioDuplex, 112 | // Additional options might be required depending on your environment. 113 | sessionTimeout: 60 * 60 * 24 * 7, 114 | }); 115 | 116 | // Get the worker path from the command line. 117 | const worker = process.argv[2]; 118 | 119 | // Compute the runner file path (like os.path.abspath(os.path.join(worker, '../../.runner'))) 120 | const runnerFile = path.resolve(worker, '../../.runner'); 121 | if (!fs.existsSync(runnerFile)) { 122 | // Create default JSON data 123 | const data = { 124 | isHostedServer: false, 125 | agentName: 'my-runner', 126 | workFolder: '_work' 127 | }; 128 | fs.writeFileSync(runnerFile, JSON.stringify(data)); 129 | } 130 | 131 | const interpreter = worker.endsWith('.dll') ? ['dotnet'] : []; 132 | 133 | var spawnArgs = interpreter.concat([worker, "spawnclient", "3", "4"]); 134 | const exe = spawnArgs.shift(); 135 | 136 | const child = child_process.spawn( 137 | exe, spawnArgs, 138 | { 139 | stdio: [process.stdin, process.stdout, process.stderr, 'pipe', 'pipe'] 140 | } 141 | ); 142 | 143 | const childPipeWrite = child.stdio[3]; 144 | const childPipeRead = child.stdio[4]; 145 | 146 | const creq = client.request({ 147 | ':method': "GET", 148 | ':path': "/JobRequest?SYSTEMVSSCONNECTION=" + encodeURIComponent(`http://${hostname}:${port}/`), 149 | } 150 | ); 151 | var fchunk = Buffer.alloc(0); 152 | creq.on('data', (chunk) => { 153 | if (ACTIONS_RUNNER_WORKER_DEBUG) { 154 | console.error('Response data:', chunk.toString()); 155 | } 156 | fchunk = Buffer.concat([fchunk, chunk]); 157 | }); 158 | creq.on('end', () => { 159 | if (ACTIONS_RUNNER_WORKER_DEBUG) { 160 | console.error('Response ended.'); 161 | } 162 | let jobMessage = fchunk.toString(); 163 | if (ACTIONS_RUNNER_WORKER_DEBUG) { 164 | console.error('fchunk:', jobMessage); 165 | } 166 | let encoded = Buffer.from(jobMessage.toString(), 'utf16le'); 167 | 168 | // Prepare buffers for the type and encoded-length as 4-byte big-endian integers. 169 | const typeBuffer = Buffer.alloc(4); 170 | typeBuffer.writeUint32LE(1, 0); 171 | const lengthBuffer = Buffer.alloc(4); 172 | lengthBuffer.writeUint32LE(encoded.length, 0); 173 | 174 | childPipeWrite.write(typeBuffer); 175 | childPipeWrite.write(lengthBuffer); 176 | childPipeWrite.write(encoded); 177 | 178 | (() => { 179 | const creq = client.request({ 180 | ':method': "GET", 181 | ':path': "/WaitForCancellation" 182 | } 183 | ); 184 | var fchunk = Buffer.alloc(0); 185 | creq.on('data', (chunk) => { 186 | if (ACTIONS_RUNNER_WORKER_DEBUG) { 187 | console.error('Response data:', chunk.toString()); 188 | } 189 | fchunk = Buffer.concat([fchunk, chunk]); 190 | }); 191 | creq.on('end', () => { 192 | if (ACTIONS_RUNNER_WORKER_DEBUG) { 193 | console.error('Response ended.'); 194 | } 195 | let jobMessage = fchunk.toString(); 196 | if (ACTIONS_RUNNER_WORKER_DEBUG) { 197 | console.error('fchunk:', jobMessage); 198 | } 199 | let encoded = Buffer.from("", 'utf16le'); 200 | 201 | if (jobMessage.includes("cancelled")) { 202 | if (ACTIONS_RUNNER_WORKER_DEBUG) { 203 | console.error('Cancelled'); 204 | } 205 | 206 | // Prepare buffers for the type and encoded-length as 4-byte big-endian integers. 207 | const typeBuffer = Buffer.alloc(4); 208 | typeBuffer.writeUint32LE(2, 0); 209 | const lengthBuffer = Buffer.alloc(4); 210 | lengthBuffer.writeUint32LE(encoded.length, 0); 211 | 212 | childPipeWrite.write(typeBuffer); 213 | childPipeWrite.write(lengthBuffer); 214 | childPipeWrite.write(encoded); 215 | 216 | } else if (ACTIONS_RUNNER_WORKER_DEBUG) { 217 | console.error('Not Cancelled'); 218 | } 219 | }); 220 | })() 221 | }); 222 | 223 | child.on('exit', (code) => { 224 | console.error(`Child exited with code ${code}`); 225 | if (code >= 100 && code <= 105) { 226 | process.exit(0); 227 | } else { 228 | process.exit(1); 229 | } 230 | }); 231 | }); 232 | -------------------------------------------------------------------------------- /cmd/register.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "bufio" 5 | "context" 6 | "fmt" 7 | "os" 8 | "os/signal" 9 | "runtime" 10 | "strings" 11 | "time" 12 | 13 | pingv1 "code.gitea.io/actions-proto-go/ping/v1" 14 | "github.com/ChristopherHX/gitea-actions-runner/client" 15 | "github.com/ChristopherHX/gitea-actions-runner/config" 16 | "github.com/ChristopherHX/gitea-actions-runner/register" 17 | "github.com/ChristopherHX/gitea-actions-runner/util" 18 | 19 | "connectrpc.com/connect" 20 | "github.com/joho/godotenv" 21 | "github.com/mattn/go-isatty" 22 | log "github.com/sirupsen/logrus" 23 | "github.com/spf13/cobra" 24 | ) 25 | 26 | // runRegister registers a runner to the server 27 | func runRegister(ctx context.Context, regArgs *registerArgs, envFile string) func(*cobra.Command, []string) error { 28 | return func(cmd *cobra.Command, args []string) error { 29 | log.SetReportCaller(false) 30 | isTerm := isatty.IsTerminal(os.Stdout.Fd()) 31 | log.SetFormatter(&log.TextFormatter{ 32 | DisableColors: !isTerm, 33 | DisableTimestamp: true, 34 | }) 35 | log.SetLevel(log.DebugLevel) 36 | 37 | log.Infof("Registering runner, arch=%s, os=%s, version=%s.", 38 | runtime.GOARCH, runtime.GOOS, version) 39 | 40 | if regArgs.NoInteractive { 41 | if err := registerNoInteractive(envFile, regArgs); err != nil { 42 | return err 43 | } 44 | } else { 45 | go func() { 46 | if err := registerInteractive(envFile, regArgs); err != nil { 47 | // log.Errorln(err) 48 | os.Exit(2) 49 | return 50 | } 51 | os.Exit(0) 52 | }() 53 | 54 | c := make(chan os.Signal, 1) 55 | signal.Notify(c, os.Interrupt) 56 | <-c 57 | } 58 | 59 | return nil 60 | } 61 | } 62 | 63 | // registerArgs represents the arguments for register command 64 | type registerArgs struct { 65 | NoInteractive bool 66 | RunnerWorker []string 67 | InstanceAddr string 68 | Token string 69 | RunnerName string 70 | Labels string 71 | RunnerType int32 72 | RunnerVersion string 73 | Ephemeral bool 74 | } 75 | 76 | type registerStage int8 77 | 78 | const ( 79 | StageUnknown registerStage = -1 80 | StageOverwriteLocalConfig registerStage = iota + 1 81 | StageInputRunnerChoice 82 | StageInputRunnerVersion 83 | StageInputRunnerWorker 84 | StageInputInstance 85 | StageInputToken 86 | StageInputRunnerName 87 | StageInputCustomLabels 88 | StageWaitingForRegistration 89 | StageExit 90 | ) 91 | 92 | var ( 93 | defaultLabels = []string{ 94 | "self-hosted", 95 | } 96 | ) 97 | 98 | type registerInputs struct { 99 | RunnerWorker []string 100 | InstanceAddr string 101 | Token string 102 | RunnerName string 103 | CustomLabels []string 104 | RunnerType int32 105 | RunnerVersion string 106 | Ephemeral bool 107 | } 108 | 109 | func (r *registerInputs) validate() error { 110 | if r.InstanceAddr == "" { 111 | return fmt.Errorf("instance address is empty") 112 | } 113 | if r.Token == "" { 114 | return fmt.Errorf("token is empty") 115 | } 116 | if r.RunnerType != 0 { 117 | if r.setupRunner() != StageInputInstance { 118 | return fmt.Errorf("runner setup failed") 119 | } 120 | } 121 | if len(r.RunnerWorker) == 0 { 122 | return fmt.Errorf("Runner.Worker Path is Empty, otherwise add --type 1 or --type 2") 123 | } 124 | if len(r.CustomLabels) > 0 { 125 | return validateLabels(r.CustomLabels) 126 | } 127 | return nil 128 | } 129 | 130 | func validateLabels(labels []string) error { 131 | return nil 132 | } 133 | 134 | func (r *registerInputs) stageValue(stage registerStage) string { 135 | switch stage { 136 | case StageInputRunnerChoice: 137 | if r.RunnerType != 0 || len(r.RunnerWorker) != 0 { 138 | return fmt.Sprint(r.RunnerType) 139 | } 140 | case StageInputRunnerVersion: 141 | return r.RunnerVersion 142 | case StageInputRunnerWorker: 143 | if len(r.RunnerWorker) > 0 { 144 | return strings.Join(r.RunnerWorker, ",") 145 | } 146 | case StageInputInstance: 147 | return r.InstanceAddr 148 | case StageInputToken: 149 | return r.Token 150 | case StageInputRunnerName: 151 | return r.RunnerName 152 | case StageInputCustomLabels: 153 | if len(r.CustomLabels) > 0 { 154 | return strings.Join(r.CustomLabels, ",") 155 | } 156 | } 157 | return "" 158 | } 159 | 160 | func (r *registerInputs) assignToNext(stage registerStage, value string) registerStage { 161 | // must set instance address and token. 162 | // if empty, keep current stage. 163 | if stage == StageInputInstance || stage == StageInputToken || stage == StageInputRunnerChoice { 164 | if value == "" { 165 | return stage 166 | } 167 | } 168 | 169 | // set hostname for runner name if empty 170 | if stage == StageInputRunnerName && value == "" { 171 | value, _ = os.Hostname() 172 | } 173 | 174 | switch stage { 175 | case StageOverwriteLocalConfig: 176 | if value == "Y" || value == "y" { 177 | return StageInputRunnerChoice 178 | } 179 | return StageExit 180 | case StageInputRunnerChoice: 181 | if value == "0" { 182 | return StageInputRunnerWorker 183 | } 184 | if value == "1" { 185 | r.RunnerType = 1 186 | return StageInputRunnerVersion 187 | } 188 | if value == "2" { 189 | r.RunnerType = 2 190 | return StageInputRunnerVersion 191 | } 192 | r.RunnerType = 0 193 | log.Infoln("Invalid choice, please input again.") 194 | return StageInputRunnerChoice 195 | case StageInputRunnerWorker: 196 | r.RunnerWorker = strings.Split(value, ",") 197 | if len(r.RunnerWorker) == 0 { 198 | log.Infoln("Invalid choice, please input again.") 199 | return StageInputRunnerChoice 200 | } 201 | return StageInputInstance 202 | case StageInputRunnerVersion: 203 | r.RunnerVersion = value 204 | return r.setupRunner() 205 | case StageInputInstance: 206 | r.InstanceAddr = value 207 | return StageInputToken 208 | case StageInputToken: 209 | r.Token = value 210 | return StageInputRunnerName 211 | case StageInputRunnerName: 212 | r.RunnerName = value 213 | return StageInputCustomLabels 214 | case StageInputCustomLabels: 215 | r.CustomLabels = defaultLabels 216 | if value != "" { 217 | r.CustomLabels = strings.Split(value, ",") 218 | } 219 | 220 | if validateLabels(r.CustomLabels) != nil { 221 | log.Infoln("Invalid labels, please input again, leave blank to use the default labels (for example, self-hosted,ubuntu-latest)") 222 | r.CustomLabels = nil 223 | return StageInputCustomLabels 224 | } 225 | return StageWaitingForRegistration 226 | } 227 | return StageUnknown 228 | } 229 | 230 | func (r *registerInputs) setupRunner() registerStage { 231 | rargs := util.SetupRunner(r.RunnerType, r.RunnerVersion) 232 | if len(rargs) == 0 { 233 | r.RunnerVersion = "" 234 | log.Infoln("Failed to setup runner, please check the input.") 235 | return StageInputRunnerChoice 236 | } 237 | r.RunnerWorker = rargs 238 | return StageInputInstance 239 | } 240 | 241 | func registerInteractive(envFile string, regArgs *registerArgs) error { 242 | var ( 243 | reader = bufio.NewReader(os.Stdin) 244 | stage = StageInputRunnerChoice 245 | inputs = initInputs(regArgs) 246 | ) 247 | 248 | // check if overwrite local config 249 | _ = godotenv.Load(envFile) 250 | cfg, _ := config.FromEnviron() 251 | if f, err := os.Stat(cfg.Runner.File); err == nil && !f.IsDir() { 252 | stage = StageOverwriteLocalConfig 253 | } 254 | 255 | for { 256 | cmdString := inputs.stageValue(stage) 257 | if cmdString == "" { 258 | printStageHelp(stage) 259 | var err error 260 | cmdString, err = reader.ReadString('\n') 261 | if err != nil { 262 | return err 263 | } 264 | } 265 | stage = inputs.assignToNext(stage, strings.TrimSpace(cmdString)) 266 | 267 | if stage == StageWaitingForRegistration { 268 | log.Infof("Registering runner, name=%s, instance=%s, labels=%v.", inputs.RunnerName, inputs.InstanceAddr, inputs.CustomLabels) 269 | if err := doRegister(&cfg, inputs); err != nil { 270 | log.Errorf("Failed to register runner: %v", err) 271 | } else { 272 | log.Infof("Runner registered successfully.") 273 | } 274 | return nil 275 | } 276 | 277 | if stage == StageExit { 278 | return nil 279 | } 280 | 281 | if stage <= StageUnknown { 282 | log.Errorf("Invalid input, please re-run act command.") 283 | return nil 284 | } 285 | } 286 | } 287 | 288 | func printStageHelp(stage registerStage) { 289 | switch stage { 290 | case StageOverwriteLocalConfig: 291 | log.Infoln("Runner is already registered, overwrite local config? [y/N]") 292 | case StageInputRunnerChoice: 293 | log.Infoln("Choose between custom worker (0) / official Github Actions Runner (1) / runner.server actions runner (windows container support) (2)? [0/1/2]") 294 | case StageInputRunnerWorker: 295 | suffix := "" 296 | if runtime.GOOS == "windows" { 297 | suffix = ".exe" 298 | } 299 | log.Infof("Enter the worker args for example pwsh,actions-runner-worker.ps1,actions-runner/bin/Runner.Worker%s:\n", suffix) 300 | case StageInputRunnerVersion: 301 | log.Infof("Specify the version of the runner? (for example %s or %s):\n", util.ActionsRunnerVersion, util.RunnerServerRunnerVersion) 302 | case StageInputInstance: 303 | log.Infoln("Enter the Gitea instance URL (for example, https://gitea.com/):") 304 | case StageInputToken: 305 | log.Infoln("Enter the runner token:") 306 | case StageInputRunnerName: 307 | hostname, _ := os.Hostname() 308 | log.Infof("Enter the runner name (if set empty, use hostname:%s ):\n", hostname) 309 | case StageInputCustomLabels: 310 | log.Infoln("Enter the runner labels, leave blank to use the default labels (comma-separated, for example, self-hosted,ubuntu-latest):") 311 | case StageWaitingForRegistration: 312 | log.Infoln("Waiting for registration...") 313 | } 314 | } 315 | 316 | func registerNoInteractive(envFile string, regArgs *registerArgs) error { 317 | _ = godotenv.Load(envFile) 318 | cfg, _ := config.FromEnviron() 319 | inputs := initInputs(regArgs) 320 | if len(inputs.CustomLabels) == 0 { 321 | inputs.CustomLabels = defaultLabels 322 | } 323 | if inputs.RunnerName == "" { 324 | inputs.RunnerName, _ = os.Hostname() 325 | log.Infof("Runner name is empty, use hostname '%s'.", inputs.RunnerName) 326 | } 327 | if err := inputs.validate(); err != nil { 328 | log.WithError(err).Errorf("Invalid input, please re-run act command.") 329 | return nil 330 | } 331 | if err := doRegister(&cfg, inputs); err != nil { 332 | log.Errorf("Failed to register runner: %v", err) 333 | return nil 334 | } 335 | log.Infof("Runner registered successfully.") 336 | return nil 337 | } 338 | 339 | func initInputs(regArgs *registerArgs) *registerInputs { 340 | inputs := ®isterInputs{ 341 | RunnerWorker: regArgs.RunnerWorker, 342 | InstanceAddr: regArgs.InstanceAddr, 343 | Token: regArgs.Token, 344 | RunnerName: regArgs.RunnerName, 345 | RunnerType: regArgs.RunnerType, 346 | RunnerVersion: regArgs.RunnerVersion, 347 | Ephemeral: regArgs.Ephemeral, 348 | } 349 | regArgs.Labels = strings.TrimSpace(regArgs.Labels) 350 | if regArgs.Labels != "" { 351 | inputs.CustomLabels = strings.Split(regArgs.Labels, ",") 352 | } 353 | return inputs 354 | } 355 | 356 | func doRegister(cfg *config.Config, inputs *registerInputs) error { 357 | ctx := context.Background() 358 | 359 | // initial http client 360 | cli := client.New( 361 | inputs.InstanceAddr, 362 | "", "", 363 | ) 364 | 365 | for { 366 | _, err := cli.Ping(ctx, connect.NewRequest(&pingv1.PingRequest{ 367 | Data: inputs.RunnerName, 368 | })) 369 | select { 370 | case <-ctx.Done(): 371 | return nil 372 | default: 373 | } 374 | if ctx.Err() != nil { 375 | break 376 | } 377 | if err != nil { 378 | log.WithError(err). 379 | Errorln("Cannot ping the Gitea instance server") 380 | // TODO: if ping failed, retry or exit 381 | time.Sleep(time.Second) 382 | } else { 383 | log.Debugln("Successfully pinged the Gitea instance server") 384 | break 385 | } 386 | } 387 | 388 | cfg.Runner.Name = inputs.RunnerName 389 | cfg.Runner.Token = inputs.Token 390 | cfg.Runner.Labels = inputs.CustomLabels 391 | cfg.Runner.RunnerWorker = inputs.RunnerWorker 392 | cfg.Runner.Ephemeral = inputs.Ephemeral 393 | _, err := register.New(cli).Register(ctx, cfg.Runner) 394 | return err 395 | } 396 | -------------------------------------------------------------------------------- /cmd/cmd.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "os" 8 | "path" 9 | "runtime" 10 | "strings" 11 | 12 | "github.com/ChristopherHX/gitea-actions-runner/config" 13 | "github.com/ChristopherHX/gitea-actions-runner/core" 14 | "github.com/ChristopherHX/gitea-actions-runner/exec" 15 | "github.com/ChristopherHX/gitea-actions-runner/util" 16 | "github.com/joho/godotenv" 17 | "github.com/kardianos/service" 18 | log "github.com/sirupsen/logrus" 19 | "github.com/spf13/cobra" 20 | ) 21 | 22 | var version = "local" 23 | 24 | type globalArgs struct { 25 | EnvFile string 26 | } 27 | 28 | type RunRunnerSvc struct { 29 | stop func() 30 | wait chan error 31 | cmd *cobra.Command 32 | } 33 | 34 | // Start implements service.Interface. 35 | func (svc *RunRunnerSvc) Start(s service.Service) error { 36 | ctx, cancel := context.WithCancel(context.Background()) 37 | svc.stop = func() { 38 | cancel() 39 | } 40 | svc.wait = make(chan error) 41 | go func() { 42 | defer cancel() 43 | defer close(svc.wait) 44 | err := runDaemon(ctx, "")(svc.cmd, nil) 45 | if err != nil { 46 | fmt.Println(err.Error()) 47 | } 48 | svc.wait <- err 49 | s.Stop() 50 | }() 51 | return nil 52 | } 53 | 54 | // Stop implements service.Interface. 55 | func (svc *RunRunnerSvc) Stop(s service.Service) error { 56 | svc.stop() 57 | if err, ok := <-svc.wait; ok && err != nil { 58 | return err 59 | } 60 | return nil 61 | } 62 | 63 | func Execute(ctx context.Context) { 64 | // task := runtime.NewTask("gitea", 0, nil, nil) 65 | 66 | var gArgs globalArgs 67 | 68 | // ./act_runner 69 | rootCmd := &cobra.Command{ 70 | Use: "actions_runner", 71 | Args: cobra.MaximumNArgs(1), 72 | Version: version, 73 | SilenceUsage: true, 74 | } 75 | rootCmd.PersistentFlags().StringVarP(&gArgs.EnvFile, "env-file", "", ".env", "Read in a file of environment variables.") 76 | 77 | // ./act_runner register 78 | var regArgs registerArgs 79 | registerCmd := &cobra.Command{ 80 | Use: "register", 81 | Short: "Register a runner to the server", 82 | Args: cobra.MaximumNArgs(0), 83 | RunE: runRegister(ctx, ®Args, gArgs.EnvFile), // must use a pointer to regArgs 84 | } 85 | registerCmd.Flags().BoolVar(®Args.NoInteractive, "no-interactive", false, "Disable interactive mode") 86 | suffix := "" 87 | if runtime.GOOS == "windows" { 88 | suffix = ".exe" 89 | } 90 | registerCmd.Flags().StringSliceVar(®Args.RunnerWorker, "worker", []string{}, fmt.Sprintf("worker args for example pwsh,actions-runner-worker.ps1,actions-runner/bin/Runner.Worker%s", suffix)) 91 | registerCmd.Flags().Int32Var(®Args.RunnerType, "type", 0, "Runner type to download, 0 for manual see --worker, 1 for official, 2 for ChristopherHX/runner.server (windows container support)") 92 | registerCmd.Flags().StringVar(®Args.RunnerVersion, "version", "", "Runner version to download without v prefix") 93 | registerCmd.Flags().StringVar(®Args.InstanceAddr, "instance", "", "Gitea instance address") 94 | registerCmd.Flags().StringVar(®Args.Token, "token", "", "Runner token") 95 | registerCmd.Flags().StringVar(®Args.RunnerName, "name", "", "Runner name") 96 | registerCmd.Flags().StringVar(®Args.Labels, "labels", "", "Runner tags, comma separated") 97 | registerCmd.Flags().BoolVar(®Args.Ephemeral, "ephemeral", false, "Configure the runner to be ephemeral and only ever be able to pick a single job (stricter than --once)") 98 | rootCmd.AddCommand(registerCmd) 99 | 100 | // ./act_runner daemon 101 | daemonCmd := &cobra.Command{ 102 | Use: "daemon", 103 | Short: "Run as a runner daemon", 104 | Args: cobra.MaximumNArgs(0), 105 | RunE: runDaemon(ctx, gArgs.EnvFile), 106 | } 107 | daemonCmd.Flags().Bool("once", false, "Run one job and exit after completion") 108 | // add all command 109 | rootCmd.AddCommand(daemonCmd) 110 | 111 | // hide completion command 112 | rootCmd.CompletionOptions.HiddenDefaultCmd = true 113 | 114 | var cmdSvc = &cobra.Command{ 115 | Use: "svc", 116 | Short: "Manage the runner as a system service", 117 | } 118 | wd, _ := os.Getwd() 119 | svcRun := &cobra.Command{ 120 | Use: "run", 121 | Short: "Used as service entrypoint", 122 | RunE: func(cmd *cobra.Command, args []string) error { 123 | err := os.Chdir(wd) 124 | if err != nil { 125 | return err 126 | } 127 | stdOut, err := os.OpenFile("gitea-actions-runner-log.txt", os.O_WRONLY|os.O_APPEND|os.O_CREATE, 0777) 128 | if err == nil { 129 | os.Stdout = stdOut 130 | log.SetOutput(os.Stdout) 131 | defer stdOut.Sync() 132 | } 133 | stdErr, err := os.OpenFile("gitea-actions-runner-log-error.txt", os.O_WRONLY|os.O_APPEND|os.O_CREATE, 0777) 134 | if err == nil { 135 | os.Stderr = stdErr 136 | defer stdErr.Sync() 137 | } 138 | 139 | err = godotenv.Overload(gArgs.EnvFile) 140 | if err != nil { 141 | fmt.Fprintf(os.Stderr, "Failed to load godotenv file '%s': %s", gArgs.EnvFile, err.Error()) 142 | } 143 | 144 | svc, err := service.New(&RunRunnerSvc{ 145 | cmd: cmd, 146 | }, getSvcConfig(wd, gArgs)) 147 | 148 | if err != nil { 149 | return err 150 | } 151 | return svc.Run() 152 | }, 153 | } 154 | svcRun.Flags().StringVar(&wd, "working-directory", wd, "path to the working directory of the runner config") 155 | svcInstall := &cobra.Command{ 156 | Use: "install", 157 | Short: "Install the service may require admin privileges", 158 | RunE: func(cmd *cobra.Command, args []string) error { 159 | svc, err := service.New(&RunRunnerSvc{ 160 | cmd: cmd, 161 | }, getSvcConfig(wd, gArgs)) 162 | 163 | if err != nil { 164 | return err 165 | } 166 | err = svc.Install() 167 | if err != nil { 168 | return err 169 | } 170 | fmt.Printf("Success\nConsider adding required env variables for your jobs like HOME or PATH to your '%s' godotenv file\nSee https://pkg.go.dev/github.com/joho/godotenv for the syntax\n", gArgs.EnvFile) 171 | return nil 172 | }, 173 | } 174 | svcUninstall := &cobra.Command{ 175 | Use: "uninstall", 176 | Short: "Uninstall the service may require admin privileges", 177 | RunE: func(cmd *cobra.Command, args []string) error { 178 | svc, err := service.New(&RunRunnerSvc{ 179 | cmd: cmd, 180 | }, getSvcConfig(wd, gArgs)) 181 | 182 | if err != nil { 183 | return err 184 | } 185 | return svc.Uninstall() 186 | }, 187 | } 188 | svcStart := &cobra.Command{ 189 | Use: "start", 190 | Short: "Start the service may require admin privileges", 191 | RunE: func(cmd *cobra.Command, args []string) error { 192 | svc, err := service.New(&RunRunnerSvc{ 193 | cmd: cmd, 194 | }, getSvcConfig(wd, gArgs)) 195 | 196 | if err != nil { 197 | return err 198 | } 199 | return svc.Start() 200 | }, 201 | } 202 | svcStop := &cobra.Command{ 203 | Use: "stop", 204 | Short: "Stop the service may require admin privileges", 205 | RunE: func(cmd *cobra.Command, args []string) error { 206 | svc, err := service.New(&RunRunnerSvc{ 207 | cmd: cmd, 208 | }, getSvcConfig(wd, gArgs)) 209 | 210 | if err != nil { 211 | return err 212 | } 213 | return svc.Stop() 214 | }, 215 | } 216 | cmdSvc.AddCommand(svcInstall, svcStart, svcStop, svcRun, svcUninstall) 217 | rootCmd.AddCommand(cmdSvc) 218 | 219 | filePath := "" 220 | workerArgs := []string{} 221 | contextPath := "" 222 | varsPath := "" 223 | secretsPath := "" 224 | cmdExec := &cobra.Command{ 225 | Use: "exec", 226 | Short: "Run a command in the runner environment", 227 | Args: cobra.MaximumNArgs(0), 228 | RunE: func(cmd *cobra.Command, args []string) error { 229 | content, _ := os.ReadFile(filePath) 230 | contentData, _ := os.ReadFile(contextPath) 231 | varsData, _ := os.ReadFile(varsPath) 232 | secretsData, _ := os.ReadFile(secretsPath) 233 | return exec.Exec(ctx, string(content), string(contentData), string(varsData), string(secretsData), workerArgs) 234 | }, 235 | } 236 | cmdExec.Flags().StringVar(&filePath, "file", "", "Read in a workflow file with a single job.") 237 | cmdExec.Flags().StringVar(&contextPath, "context", "", "Read in a context file.") 238 | cmdExec.Flags().StringVar(&varsPath, "vars-file", "", "Read in a context file.") 239 | cmdExec.Flags().StringVar(&secretsPath, "secrets-file", "", "Read in a context file.") 240 | cmdExec.Flags().StringSliceVar(&workerArgs, "worker", []string{}, "worker args for example pwsh,actions-runner-worker.ps1,actions-runner/bin/Runner.Worker") 241 | rootCmd.AddCommand(cmdExec) 242 | 243 | var capacity int 244 | var allowCloneUpgrade bool 245 | cmdUpdate := &cobra.Command{ 246 | Use: "update", 247 | Short: "Update the managed runner", 248 | Args: cobra.MaximumNArgs(0), 249 | RunE: func(cmd *cobra.Command, args []string) error { 250 | cfg, err := config.FromEnviron() 251 | if err != nil { 252 | return err 253 | } 254 | content, err := os.ReadFile(cfg.Runner.File) 255 | if err != nil { 256 | return err 257 | } 258 | 259 | var runner core.Runner 260 | if err := json.Unmarshal(content, &runner); err != nil { 261 | return err 262 | } 263 | 264 | if capacity > 0 && runner.Capacity != capacity { 265 | runner.Capacity = capacity 266 | log.Info("update: updated capacity to ", capacity) 267 | } 268 | if len(regArgs.RunnerWorker) > 0 { 269 | runner.RunnerWorker = regArgs.RunnerWorker 270 | } 271 | if regArgs.RunnerType != 0 { 272 | worker := util.SetupRunner(regArgs.RunnerType, regArgs.RunnerVersion) 273 | if worker != nil { 274 | runner.RunnerWorker = worker 275 | } else { 276 | log.Error("update: failed to setup runner of type ", regArgs.RunnerType, " and version ", regArgs.RunnerVersion) 277 | return err 278 | } 279 | } 280 | flags := []string{} 281 | if allowCloneUpgrade && len(runner.RunnerWorker) == 3 && path.IsAbs(runner.RunnerWorker[0]) && path.IsAbs(runner.RunnerWorker[1]) && path.IsAbs(runner.RunnerWorker[2]) { 282 | wd, _ := os.Getwd() 283 | bindir := path.Dir(runner.RunnerWorker[2]) 284 | rootdir := path.Dir(bindir) 285 | runnerDirBaseName := path.Base(rootdir) 286 | var version string 287 | m, err := fmt.Sscanf(runnerDirBaseName, "actions-runner-%s", &version) 288 | interpreter := strings.TrimSuffix(path.Base(runner.RunnerWorker[0]), path.Ext(runner.RunnerWorker[0])) 289 | if m == 1 && err == nil && path.Dir(rootdir) == wd && path.Base(bindir) == "bin" && (interpreter == "python" || interpreter == "python3") && path.Base(runner.RunnerWorker[1]) == "actions-runner-worker.py" || interpreter == "pwsh" && path.Base(runner.RunnerWorker[1]) == "actions-runner-worker.ps1" { 290 | flags = append(flags, "--runner-dir="+rootdir, "--allow-clone") 291 | log.Info("update: upgraded runner config to allow capacity > 1") 292 | } else { 293 | log.WithError(err).Info("update: automated upgrade not applicable") 294 | } 295 | } 296 | runner.RunnerWorker = append(flags, runner.RunnerWorker...) 297 | file, err := json.MarshalIndent(runner, "", " ") 298 | if err != nil { 299 | log.WithError(err).Error("update: cannot marshal the json input") 300 | return err 301 | } 302 | 303 | // store runner config in .runner file 304 | return os.WriteFile(cfg.Runner.File, file, 0o644) 305 | }, 306 | } 307 | cmdUpdate.Flags().IntVarP(&capacity, "capacity", "c", 0, "Runner capacity") 308 | cmdUpdate.Flags().StringSliceVar(®Args.RunnerWorker, "worker", []string{}, fmt.Sprintf("worker args for example pwsh,actions-runner-worker.ps1,actions-runner/bin/Runner.Worker%s", suffix)) 309 | cmdUpdate.Flags().Int32Var(®Args.RunnerType, "type", 0, "Runner type to download, 0 for manual see --worker, 1 for official, 2 for ChristopherHX/runner.server (windows container support)") 310 | cmdUpdate.Flags().StringVar(®Args.RunnerVersion, "version", "", "Runner version to download without v prefix") 311 | cmdUpdate.Flags().BoolVar(&allowCloneUpgrade, "allow-clone-upgrade", false, "tries to upgrade an old runner setup to allow capacity > 1") 312 | rootCmd.AddCommand(cmdUpdate) 313 | 314 | if err := rootCmd.Execute(); err != nil { 315 | os.Exit(1) 316 | } 317 | } 318 | 319 | func getSvcConfig(wd string, gArgs globalArgs) *service.Config { 320 | svcConfig := &service.Config{ 321 | Name: "gitea-actions-runner", 322 | DisplayName: "Gitea Actions Runner", 323 | Description: "Runner Proxy to use actions/runner and github-act-runner with Gitea Actions.", 324 | Arguments: []string{"svc", "run", "--working-directory", wd, "--env-file", gArgs.EnvFile}, 325 | } 326 | if runtime.GOOS == "darwin" { 327 | svcConfig.Option = service.KeyValue{ 328 | "KeepAlive": true, 329 | "RunAtLoad": true, 330 | "UserService": os.Getuid() != 0, 331 | } 332 | } 333 | return svcConfig 334 | } 335 | -------------------------------------------------------------------------------- /actions/server/server.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "io" 8 | "net/http" 9 | "net/url" 10 | "os" 11 | "path/filepath" 12 | "strings" 13 | "time" 14 | 15 | "github.com/ChristopherHX/github-act-runner/protocol" 16 | "github.com/actions-oss/act-cli/pkg/artifactcache" 17 | "github.com/sirupsen/logrus" 18 | ) 19 | 20 | type ActionsServer struct { 21 | TraceLog chan interface{} 22 | ServerURL string 23 | ActionsServerURL string 24 | AuthData map[string]*protocol.ActionDownloadAuthentication 25 | JobRequest *protocol.AgentJobRequestMessage 26 | CancelCtx context.Context 27 | CacheHandler http.Handler 28 | ExternalURL string 29 | Token string 30 | } 31 | 32 | func ToPipelineContextDataWithError(data interface{}) (protocol.PipelineContextData, error) { 33 | if b, ok := data.(bool); ok { 34 | var typ int32 = 3 35 | return protocol.PipelineContextData{ 36 | Type: &typ, 37 | BoolValue: &b, 38 | }, nil 39 | } else if n, ok := data.(float64); ok { 40 | var typ int32 = 4 41 | return protocol.PipelineContextData{ 42 | Type: &typ, 43 | NumberValue: &n, 44 | }, nil 45 | } else if s, ok := data.(string); ok { 46 | var typ int32 47 | return protocol.PipelineContextData{ 48 | Type: &typ, 49 | StringValue: &s, 50 | }, nil 51 | } else if a, ok := data.([]interface{}); ok { 52 | arr := []protocol.PipelineContextData{} 53 | for _, v := range a { 54 | e, err := ToPipelineContextDataWithError(v) 55 | if err != nil { 56 | return protocol.PipelineContextData{}, err 57 | } 58 | arr = append(arr, e) 59 | } 60 | var typ int32 = 1 61 | return protocol.PipelineContextData{ 62 | Type: &typ, 63 | ArrayValue: &arr, 64 | }, nil 65 | } else if o, ok := data.(map[string]interface{}); ok { 66 | obj := []protocol.DictionaryContextDataPair{} 67 | for k, v := range o { 68 | e, err := ToPipelineContextDataWithError(v) 69 | if err != nil { 70 | return protocol.PipelineContextData{}, err 71 | } 72 | obj = append(obj, protocol.DictionaryContextDataPair{Key: k, Value: e}) 73 | } 74 | var typ int32 = 2 75 | return protocol.PipelineContextData{ 76 | Type: &typ, 77 | DictionaryValue: &obj, 78 | }, nil 79 | } 80 | if data == nil { 81 | return protocol.PipelineContextData{}, nil 82 | } 83 | return protocol.PipelineContextData{}, fmt.Errorf("unknown type") 84 | } 85 | 86 | func ToPipelineContextData(data interface{}) protocol.PipelineContextData { 87 | ret, _ := ToPipelineContextDataWithError(data) 88 | return ret 89 | } 90 | 91 | func (server *ActionsServer) ServeHTTP(resp http.ResponseWriter, req *http.Request) { 92 | jsonRequest := func(data interface{}) { 93 | dec := json.NewDecoder(req.Body) 94 | _ = dec.Decode(data) 95 | server.TraceLog <- data 96 | } 97 | jsonResponse := func(data interface{}) { 98 | resp.Header().Add("content-type", "application/json") 99 | resp.WriteHeader(http.StatusOK) 100 | json, _ := json.Marshal(data) 101 | resp.Write(json) 102 | } 103 | if strings.HasPrefix(req.URL.Path, "/_apis/connectionData") { 104 | data := &protocol.ConnectionData{ 105 | LocationServiceData: protocol.LocationServiceData{ 106 | ServiceDefinitions: []protocol.ServiceDefinition{ 107 | {ServiceType: "ActionDownloadInfo", DisplayName: "ActionDownloadInfo", Description: "ActionDownloadInfo", Identifier: "27d7f831-88c1-4719-8ca1-6a061dad90eb", ResourceVersion: 6, RelativeToSetting: "fullyQualified", ServiceOwner: "f55bccde-c830-4f78-9a68-5c0a07deae97", RelativePath: "/_apis/v1/ActionDownloadInfo", MinVersion: "1.0", MaxVersion: "12.0"}, 108 | {ServiceType: "TimeLineWebConsoleLog", DisplayName: "TimeLineWebConsoleLog", Description: "TimeLineWebConsoleLog", Identifier: "858983e4-19bd-4c5e-864c-507b59b58b12", ResourceVersion: 6, RelativeToSetting: "fullyQualified", ServiceOwner: "f55bccde-c830-4f78-9a68-5c0a07deae97", RelativePath: "/_apis/v1/TimeLineWebConsoleLog/{timelineId}/{recordId}", MinVersion: "1.0", MaxVersion: "12.0"}, 109 | {ServiceType: "TimelineRecords", DisplayName: "TimelineRecords", Description: "TimelineRecords", Identifier: "8893bc5b-35b2-4be7-83cb-99e683551db4", ResourceVersion: 6, RelativeToSetting: "fullyQualified", ServiceOwner: "f55bccde-c830-4f78-9a68-5c0a07deae97", RelativePath: "/_apis/v1/Timeline/{timelineId}", MinVersion: "1.0", MaxVersion: "12.0"}, 110 | {ServiceType: "Logfiles", DisplayName: "Logfiles", Description: "Logfiles", Identifier: "46f5667d-263a-4684-91b1-dff7fdcf64e2", ResourceVersion: 6, RelativeToSetting: "fullyQualified", ServiceOwner: "f55bccde-c830-4f78-9a68-5c0a07deae97", RelativePath: "/_apis/v1/Logfiles/{logId}", MinVersion: "1.0", MaxVersion: "12.0"}, 111 | {ServiceType: "FinishJob", DisplayName: "FinishJob", Description: "FinishJob", Identifier: "557624af-b29e-4c20-8ab0-0399d2204f3f", ResourceVersion: 6, RelativeToSetting: "fullyQualified", ServiceOwner: "f55bccde-c830-4f78-9a68-5c0a07deae97", RelativePath: "/_apis/v1/FinishJob", MinVersion: "1.0", MaxVersion: "12.0"}, 112 | }, 113 | }, 114 | } 115 | jsonResponse(data) 116 | } else if strings.HasPrefix(req.URL.Path, "/_apis/v1/Timeline/") { 117 | recs := &protocol.TimelineRecordWrapper{} 118 | jsonRequest(recs) 119 | jsonResponse(recs) 120 | } else if strings.HasPrefix(req.URL.Path, "/_apis/v1/FinishJob") { 121 | recs := &protocol.JobEvent{} 122 | jsonRequest(recs) 123 | resp.WriteHeader(http.StatusOK) 124 | } else if strings.HasPrefix(req.URL.Path, "/_apis/v1/TimeLineWebConsoleLog/") { 125 | recs := &protocol.TimelineRecordFeedLinesWrapper{} 126 | jsonRequest(recs) 127 | resp.WriteHeader(http.StatusOK) 128 | } else if strings.HasPrefix(req.URL.Path, "/_apis/v1/Logfiles") { 129 | logPath := "/_apis/v1/Logfiles/" 130 | if strings.HasPrefix(req.URL.Path, logPath) && len(logPath) < len(req.URL.Path) { 131 | io.Copy(io.Discard, req.Body) 132 | resp.WriteHeader(http.StatusOK) 133 | } else { 134 | p := "logs\\0.log" 135 | recs := &protocol.TaskLog{ 136 | TaskLogReference: protocol.TaskLogReference{ 137 | ID: 1, 138 | }, 139 | CreatedOn: "2022-01-01T00:00:00", 140 | LastChangedOn: "2022-01-01T00:00:00", 141 | Path: &p, 142 | } 143 | jsonRequest(recs) 144 | jsonResponse(recs) 145 | } 146 | } else if strings.HasPrefix(req.URL.Path, "/_apis/v1/ActionDownloadInfo") { 147 | references := &protocol.ActionReferenceList{} 148 | jsonRequest(references) 149 | actions := map[string]protocol.ActionDownloadInfo{} 150 | for _, ref := range references.Actions { 151 | resolved := protocol.ActionDownloadInfo{ 152 | NameWithOwner: ref.NameWithOwner, 153 | ResolvedNameWithOwner: ref.NameWithOwner, 154 | Ref: ref.Ref, 155 | ResolvedSha: "N/A", 156 | } 157 | noAuth := false 158 | absolute := false 159 | for _, proto := range []string{"http~//", "https~//"} { 160 | if strings.HasPrefix(ref.NameWithOwner, proto) { 161 | absolute = true 162 | noAuth = true 163 | originalNameOwner := strings.ReplaceAll(ref.NameWithOwner, "~", ":") 164 | if authData, ok := server.AuthData[originalNameOwner]; ok { 165 | resolved.Authentication = authData 166 | noAuth = false 167 | } 168 | pURL, _ := url.Parse(originalNameOwner) 169 | p := pURL.Path 170 | pURL.Path = "" 171 | host := pURL.String() 172 | if host == "https://github.com" || noAuth { 173 | resolved.TarballUrl = fmt.Sprintf("%s/archive/%s.tar.gz", originalNameOwner, ref.Ref) 174 | resolved.ZipballUrl = fmt.Sprintf("%s/archive/%s.zip", originalNameOwner, ref.Ref) 175 | } else { 176 | // Gitea does not support auth on the web route 177 | resolved.TarballUrl = fmt.Sprintf("%s/api/v1/repos/%s/archive/%s.tar.gz", host, p, ref.Ref) 178 | resolved.ZipballUrl = fmt.Sprintf("%s/api/v1/repos/%s/archive/%s.zip", host, p, ref.Ref) 179 | } 180 | break 181 | } 182 | } 183 | if !absolute { 184 | var urls []string 185 | if server.ServerURL != server.ActionsServerURL { 186 | urls = []string{server.ServerURL, server.ActionsServerURL} 187 | } else { 188 | urls = []string{server.ActionsServerURL} 189 | } 190 | for _, url := range urls { 191 | // Gitea Actions Token currently does not work for public repositories 192 | // Try noauth first and check with token 193 | noAuth = url != server.ServerURL 194 | if checkAuth("", &resolved, url, ref) { 195 | noAuth = true 196 | break 197 | } else if !noAuth && checkAuth(server.Token, &resolved, url, ref) { 198 | break 199 | } 200 | } 201 | } 202 | logrus.Infof("Current result: %s at %s and %s", resolved.NameWithOwner, resolved.TarballUrl, resolved.ZipballUrl) 203 | if noAuth { 204 | // Using a dummy token has worked in 2022, but now it's broken 205 | // resolved.Authentication = &protocol.ActionDownloadAuthentication{ 206 | // ExpiresAt: "0001-01-01T00:00:00", 207 | // Token: "dummy-token", 208 | // } 209 | dst, _ := url.Parse(server.ExternalURL) 210 | dst.Path += "_apis/v1/ActionDownload" 211 | q := dst.Query() 212 | q.Set("url", resolved.TarballUrl) 213 | dst.RawQuery = q.Encode() 214 | resolved.TarballUrl = dst.String() 215 | q.Set("url", resolved.ZipballUrl) 216 | dst.RawQuery = q.Encode() 217 | resolved.ZipballUrl = dst.String() 218 | } 219 | actions[fmt.Sprintf("%s@%s", ref.NameWithOwner, ref.Ref)] = resolved 220 | logrus.Infof("Resolved action: %s at %s and %s", resolved.NameWithOwner, resolved.TarballUrl, resolved.ZipballUrl) 221 | } 222 | jsonResponse(&protocol.ActionDownloadInfoCollection{ 223 | Actions: actions, 224 | }) 225 | } else if strings.HasPrefix(req.URL.Path, "/_apis/v1/ActionDownload") { 226 | requestedURL := req.URL.Query().Get("url") 227 | logrus.Infof("Action download requested for URL: %s", requestedURL) 228 | req, err := http.NewRequestWithContext(req.Context(), "GET", requestedURL, nil) 229 | if err != nil { 230 | resp.WriteHeader(http.StatusNotFound) 231 | return 232 | } 233 | req.Header.Add("User-Agent", "github-act-runner/1.0.0") 234 | req.Header.Add("Accept", "*/*") 235 | rsp, err := http.DefaultClient.Do(req) 236 | if err != nil { 237 | resp.WriteHeader(http.StatusNotFound) 238 | return 239 | } 240 | defer rsp.Body.Close() 241 | logrus.Infof("Action download http code for URL: %s %d", requestedURL, rsp.StatusCode) 242 | // Forward headers 243 | for k, vs := range rsp.Header { 244 | resp.Header()[k] = vs 245 | } 246 | resp.WriteHeader(rsp.StatusCode) 247 | io.Copy(resp, rsp.Body) 248 | } else if strings.HasPrefix(req.URL.Path, "/_apis/pipelines/workflows/") { 249 | surl, _ := url.Parse(server.ServerURL) 250 | url := *req.URL 251 | url.Scheme = surl.Scheme 252 | url.Host = surl.Host 253 | url.Path = "/api/actions_pipeline" + url.Path 254 | defer req.Body.Close() 255 | myreq, err := http.NewRequestWithContext(req.Context(), req.Method, url.String(), req.Body) 256 | if err != nil { 257 | resp.WriteHeader(http.StatusNotFound) 258 | return 259 | } 260 | for k, vs := range req.Header { 261 | myreq.Header[k] = vs 262 | } 263 | rsp, err := http.DefaultClient.Do(myreq) 264 | if err != nil { 265 | resp.WriteHeader(http.StatusNotFound) 266 | return 267 | } 268 | defer rsp.Body.Close() 269 | 270 | for k, vs := range rsp.Header { 271 | resp.Header()[k] = vs 272 | } 273 | resp.WriteHeader(rsp.StatusCode) 274 | io.Copy(resp, rsp.Body) 275 | } else if strings.HasPrefix(req.URL.Path, "/_apis/v1/ActionDownload") { 276 | resp.WriteHeader(http.StatusNotFound) 277 | } else if strings.HasPrefix(req.URL.Path, "/JobRequest") { 278 | SYSTEMVSSCONNECTION := req.URL.Query().Get("SYSTEMVSSCONNECTION") 279 | // Normalize the URL to ensure it ends with a slash 280 | SYSTEMVSSCONNECTION = strings.TrimSuffix(SYSTEMVSSCONNECTION, "/") + "/" 281 | server.ExternalURL = SYSTEMVSSCONNECTION 282 | CacheServerUrl := req.URL.Query().Get("CacheServerUrl") 283 | if SYSTEMVSSCONNECTION != "" { 284 | for i, endpoint := range server.JobRequest.Resources.Endpoints { 285 | if endpoint.Name == "SYSTEMVSSCONNECTION" { 286 | server.JobRequest.Resources.Endpoints[i].URL = SYSTEMVSSCONNECTION 287 | if CacheServerUrl != "" { 288 | // Normalize the URL to ensure it ends with a slash 289 | CacheServerUrl = strings.TrimSuffix(CacheServerUrl, "/") + "/" 290 | server.JobRequest.Resources.Endpoints[i].Data["CacheServerUrl"] = CacheServerUrl 291 | } else if server.JobRequest.Resources.Endpoints[i].Data["CacheServerUrl"] == "" { 292 | server.JobRequest.Resources.Endpoints[i].Data["CacheServerUrl"] = SYSTEMVSSCONNECTION 293 | if wd, err := os.Getwd(); err == nil { 294 | _, server.CacheHandler, _ = artifactcache.CreateHandler(filepath.Join(wd, "cache"), SYSTEMVSSCONNECTION, nil) 295 | } 296 | } 297 | break 298 | } 299 | } 300 | } 301 | resp.WriteHeader(http.StatusOK) 302 | resp.Header().Add("content-type", "application/json") 303 | resp.Header().Add("accept", "application/json") 304 | src, _ := json.Marshal(server.JobRequest) 305 | resp.Write(src) 306 | } else if strings.HasPrefix(req.URL.Path, "/WaitForCancellation") { 307 | resp.Header().Add("content-type", "application/json") 308 | resp.Header().Add("accept", "application/json") 309 | resp.WriteHeader(http.StatusOK) 310 | resp.(http.Flusher).Flush() 311 | for { 312 | select { 313 | case <-server.CancelCtx.Done(): 314 | resp.Write([]byte("cancelled\n\n")) 315 | resp.(http.Flusher).Flush() 316 | return 317 | case <-req.Context().Done(): 318 | resp.Write([]byte("stopped\n\n")) 319 | resp.(http.Flusher).Flush() 320 | return 321 | case <-time.After(10 * time.Second): 322 | resp.Write([]byte("ping\n\n")) 323 | resp.(http.Flusher).Flush() 324 | } 325 | } 326 | } else if server.CacheHandler != nil { 327 | server.CacheHandler.ServeHTTP(resp, req) 328 | } else { 329 | resp.WriteHeader(http.StatusNotFound) 330 | } 331 | } 332 | 333 | func checkAuth(token string, resolved *protocol.ActionDownloadInfo, url string, ref protocol.ActionReference) bool { 334 | if token == "" { 335 | resolved.TarballUrl = fmt.Sprintf("%s/%s/archive/%s.tar.gz", strings.TrimRight(url, "/"), ref.NameWithOwner, ref.Ref) 336 | resolved.ZipballUrl = fmt.Sprintf("%s/%s/archive/%s.zip", strings.TrimRight(url, "/"), ref.NameWithOwner, ref.Ref) 337 | } else { 338 | resolved.TarballUrl = fmt.Sprintf("%s/api/v1/repos/%s/archive/%s.tar.gz", strings.TrimRight(url, "/"), ref.NameWithOwner, ref.Ref) 339 | resolved.ZipballUrl = fmt.Sprintf("%s/api/v1/repos/%s/archive/%s.zip", strings.TrimRight(url, "/"), ref.NameWithOwner, ref.Ref) 340 | } 341 | client := &http.Client{ 342 | Transport: &http.Transport{ 343 | Proxy: http.ProxyFromEnvironment, 344 | }, 345 | } 346 | req, err := http.NewRequest(http.MethodHead, resolved.TarballUrl, nil) 347 | if err != nil { 348 | return false 349 | } 350 | req.Header.Add("User-Agent", "github-act-runner/1.0.0") 351 | req.Header.Add("Accept", "*/*") 352 | if token != "" { 353 | req.SetBasicAuth("x-access-token", token) 354 | } 355 | testResp, err := client.Do(req) 356 | if err == nil { 357 | defer testResp.Body.Close() 358 | ok := testResp.StatusCode >= 200 && testResp.StatusCode < 300 359 | if !ok { 360 | logrus.Errorf("Auth check failed for %s with status %d", resolved.TarballUrl, testResp.StatusCode) 361 | resp, _ := io.ReadAll(testResp.Body) 362 | logrus.Errorf("Response: %s", string(resp)) 363 | // log headers for debugging 364 | for k, v := range testResp.Header { 365 | logrus.Errorf("Header: %s: %s", k, strings.Join(v, ", ")) 366 | } 367 | } else { 368 | logrus.Infof("Auth check succeeded for %s with status %d", resolved.TarballUrl, testResp.StatusCode) 369 | } 370 | return ok 371 | } 372 | return false 373 | } 374 | -------------------------------------------------------------------------------- /runtime/task.go: -------------------------------------------------------------------------------- 1 | package runtime 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "encoding/binary" 7 | "encoding/json" 8 | "fmt" 9 | "io" 10 | "net" 11 | "net/http" 12 | "net/url" 13 | "os" 14 | "os/exec" 15 | "path" 16 | "path/filepath" 17 | "regexp" 18 | "strconv" 19 | "strings" 20 | "sync" 21 | "time" 22 | 23 | runnerv1 "code.gitea.io/actions-proto-go/runner/v1" 24 | "github.com/ChristopherHX/gitea-actions-runner/actions/server" 25 | "github.com/ChristopherHX/gitea-actions-runner/client" 26 | "github.com/ChristopherHX/gitea-actions-runner/runners" 27 | "google.golang.org/protobuf/types/known/structpb" 28 | "google.golang.org/protobuf/types/known/timestamppb" 29 | "gopkg.in/yaml.v3" 30 | 31 | "connectrpc.com/connect" 32 | "github.com/ChristopherHX/github-act-runner/protocol" 33 | "github.com/actions-oss/act-cli/pkg/artifactcache" 34 | "github.com/google/uuid" 35 | "github.com/nektos/act/pkg/common" 36 | "github.com/nektos/act/pkg/exprparser" 37 | "github.com/nektos/act/pkg/model" 38 | "github.com/rhysd/actionlint" 39 | log "github.com/sirupsen/logrus" 40 | 41 | "github.com/avast/retry-go/v4" 42 | ) 43 | 44 | var globalTaskMap sync.Map 45 | 46 | type TaskInput struct { 47 | envs map[string]string 48 | } 49 | 50 | type Task struct { 51 | BuildID int64 52 | Input *TaskInput 53 | 54 | client client.Client 55 | platformPicker func([]string) string 56 | } 57 | 58 | // NewTask creates a new task 59 | func NewTask(forgeInstance string, buildID int64, client client.Client, runnerEnvs map[string]string, picker func([]string) string) *Task { 60 | task := &Task{ 61 | Input: &TaskInput{ 62 | envs: runnerEnvs, 63 | }, 64 | BuildID: buildID, 65 | 66 | client: client, 67 | platformPicker: picker, 68 | } 69 | return task 70 | } 71 | 72 | func ToTemplateToken(node yaml.Node) *protocol.TemplateToken { 73 | switch node.Kind { 74 | case yaml.ScalarNode: 75 | var number float64 76 | var str string 77 | var b bool 78 | var val interface{} 79 | if node.Tag == "!!null" || node.Value == "" { 80 | return nil 81 | } 82 | if err := node.Decode(&number); err == nil { 83 | if number == 0 { 84 | return nil 85 | } 86 | val = number 87 | } else if err := node.Decode(&b); err == nil { 88 | // container.reuse causes an error 89 | if !b { 90 | return nil 91 | } 92 | val = b 93 | } else if err := node.Decode(&str); err == nil { 94 | val = str 95 | } 96 | token := &protocol.TemplateToken{} 97 | token.FromRawObject(val) 98 | return token 99 | case yaml.SequenceNode: 100 | // Gitea specfic service container cmd broke services in this adapter, skip empty array to avoid error 101 | if len(node.Content) == 0 { 102 | return nil 103 | } 104 | content := make([]protocol.TemplateToken, len(node.Content)) 105 | for i := 0; i < len(content); i++ { 106 | content[i] = *ToTemplateToken(*node.Content[i]) 107 | } 108 | return &protocol.TemplateToken{ 109 | Type: 1, 110 | Seq: &content, 111 | } 112 | case yaml.MappingNode: 113 | cap := len(node.Content) / 2 114 | content := make([]protocol.MapEntry, 0, cap) 115 | for i := 0; i < cap; i++ { 116 | key := ToTemplateToken(*node.Content[i*2]) 117 | val := ToTemplateToken(*node.Content[i*2+1]) 118 | // skip null values of some yaml structures of act 119 | if key != nil && val != nil { 120 | content = append(content, protocol.MapEntry{Key: key, Value: val}) 121 | } 122 | } 123 | return &protocol.TemplateToken{ 124 | Type: 2, 125 | Map: &content, 126 | } 127 | } 128 | return nil 129 | } 130 | 131 | func escapeFormatString(in string) string { 132 | return strings.ReplaceAll(strings.ReplaceAll(in, "{", "{{"), "}", "}}") 133 | } 134 | 135 | func rewriteSubExpression(in string, forceFormat bool) (string, bool) { 136 | if !strings.Contains(in, "${{") || !strings.Contains(in, "}}") { 137 | return in, false 138 | } 139 | 140 | strPattern := regexp.MustCompile("(?:''|[^'])*'") 141 | pos := 0 142 | exprStart := -1 143 | strStart := -1 144 | var results []string 145 | formatOut := "" 146 | for pos < len(in) { 147 | if strStart > -1 { 148 | matches := strPattern.FindStringIndex(in[pos:]) 149 | if matches == nil { 150 | panic("unclosed string.") 151 | } 152 | 153 | strStart = -1 154 | pos += matches[1] 155 | } else if exprStart > -1 { 156 | exprEnd := strings.Index(in[pos:], "}}") 157 | strStart = strings.Index(in[pos:], "'") 158 | 159 | if exprEnd > -1 && strStart > -1 { 160 | if exprEnd < strStart { 161 | strStart = -1 162 | } else { 163 | exprEnd = -1 164 | } 165 | } 166 | 167 | if exprEnd > -1 { 168 | formatOut += fmt.Sprintf("{%d}", len(results)) 169 | results = append(results, strings.TrimSpace(in[exprStart:pos+exprEnd])) 170 | pos += exprEnd + 2 171 | exprStart = -1 172 | } else if strStart > -1 { 173 | pos += strStart + 1 174 | } else { 175 | panic("unclosed expression.") 176 | } 177 | } else { 178 | exprStart = strings.Index(in[pos:], "${{") 179 | if exprStart != -1 { 180 | formatOut += escapeFormatString(in[pos : pos+exprStart]) 181 | exprStart = pos + exprStart + 3 182 | pos = exprStart 183 | } else { 184 | formatOut += escapeFormatString(in[pos:]) 185 | pos = len(in) 186 | } 187 | } 188 | } 189 | 190 | if len(results) == 1 && formatOut == "{0}" && !forceFormat { 191 | return results[0], true 192 | } 193 | 194 | out := fmt.Sprintf("format('%s', %s)", strings.ReplaceAll(formatOut, "'", "''"), strings.Join(results, ", ")) 195 | return out, true 196 | } 197 | 198 | func getSubkeyMap(structVal *structpb.Struct, keys ...string) map[string]interface{} { 199 | currentVal := structVal 200 | for _, key := range keys { 201 | if value, exists := currentVal.Fields[key]; exists { 202 | if structValue := value.GetStructValue(); structValue != nil { 203 | currentVal = structValue 204 | } else { 205 | return nil 206 | } 207 | } else { 208 | return nil 209 | } 210 | } 211 | return currentVal.AsMap() 212 | } 213 | 214 | func (t *Task) Run(ctx context.Context, task *runnerv1.Task, runnerWorker []string) (errormsg error) { 215 | ctx, cancel := context.WithCancel(ctx) 216 | defer cancel() 217 | 218 | _, exist := globalTaskMap.Load(task.Id) 219 | if exist { 220 | return fmt.Errorf("task %d already exists", task.Id) 221 | } 222 | reportingCtx, reportingCancel := context.WithCancel(context.Background()) 223 | // allow up to 5 minutes to retry finishing the previous job 224 | defer func() { 225 | go func() { 226 | select { 227 | case <-reportingCtx.Done(): 228 | case <-time.After(5 * time.Minute): 229 | } 230 | reportingCancel() 231 | }() 232 | }() 233 | 234 | // set task ve to global map 235 | // when task is done or canceled, it will be removed from the map 236 | globalTaskMap.Store(task.Id, t) 237 | defer globalTaskMap.Delete(task.Id) 238 | 239 | workflow, err := model.ReadWorkflow(bytes.NewReader(task.WorkflowPayload)) 240 | if err != nil { 241 | return err 242 | } 243 | 244 | jobIDs := workflow.GetJobIDs() 245 | if len(jobIDs) != 1 { 246 | err := fmt.Errorf("multiple jobs found: %v", jobIDs) 247 | return err 248 | } 249 | jobID := jobIDs[0] 250 | job := workflow.GetJob(jobID) 251 | 252 | dataContext := task.Context.Fields 253 | 254 | log.Infof("task %v repo is %v %v %v", task.Id, dataContext["repository"].GetStringValue(), 255 | dataContext["gitea_default_actions_url"].GetStringValue(), 256 | t.client.Address()) 257 | taskContext := task.Context.Fields 258 | preset := &model.GithubContext{ 259 | Event: taskContext["event"].GetStructValue().AsMap(), 260 | RunID: taskContext["run_id"].GetStringValue(), 261 | RunNumber: taskContext["run_number"].GetStringValue(), 262 | Actor: taskContext["actor"].GetStringValue(), 263 | Repository: taskContext["repository"].GetStringValue(), 264 | EventName: taskContext["event_name"].GetStringValue(), 265 | Sha: taskContext["sha"].GetStringValue(), 266 | Ref: taskContext["ref"].GetStringValue(), 267 | RefName: taskContext["ref_name"].GetStringValue(), 268 | RefType: taskContext["ref_type"].GetStringValue(), 269 | ServerURL: taskContext["server_url"].GetStringValue(), 270 | APIURL: taskContext["api_url"].GetStringValue(), 271 | HeadRef: taskContext["head_ref"].GetStringValue(), 272 | BaseRef: taskContext["base_ref"].GetStringValue(), 273 | Token: taskContext["token"].GetStringValue(), 274 | RepositoryOwner: taskContext["repository_owner"].GetStringValue(), 275 | RetentionDays: taskContext["retention_days"].GetStringValue(), 276 | } 277 | 278 | needs := map[string]exprparser.Needs{} 279 | evalNeeds := []interface{}{} 280 | for k, v := range task.GetNeeds() { 281 | evalNeeds = append(evalNeeds, k) 282 | result := "" 283 | switch v.Result { 284 | case runnerv1.Result_RESULT_SUCCESS: 285 | result = "success" 286 | case runnerv1.Result_RESULT_FAILURE: 287 | result = "failure" 288 | case runnerv1.Result_RESULT_SKIPPED: 289 | result = "skipped" 290 | case runnerv1.Result_RESULT_CANCELLED: 291 | result = "cancelled" 292 | } 293 | workflow.Jobs[k] = &model.Job{ 294 | Name: k, 295 | Result: result, 296 | Outputs: v.GetOutputs(), 297 | } 298 | needs[k] = exprparser.Needs{ 299 | Result: result, 300 | Outputs: v.GetOutputs(), 301 | } 302 | } 303 | myenv := map[string]string{} 304 | for k, v := range workflow.Env { 305 | myenv[k] = v 306 | } 307 | for k, v := range job.Environment() { 308 | myenv[k] = v 309 | } 310 | inputs := getSubkeyMap(task.GetContext(), "event", "inputs") 311 | // Restore boolean type from string 312 | dispatchConfig := workflow.WorkflowDispatchConfig() 313 | if dispatchConfig != nil { 314 | for k, v := range dispatchConfig.Inputs { 315 | if v.Type == "boolean" { 316 | if val, ok := inputs[k]; ok { 317 | inputs[k] = val == "true" 318 | } 319 | } 320 | } 321 | } 322 | intp := exprparser.NewInterpeter(&exprparser.EvaluationEnvironment{ 323 | Github: preset, 324 | Needs: needs, 325 | Vars: task.GetVars(), 326 | Secrets: task.GetSecrets(), 327 | Inputs: inputs, 328 | Env: myenv, 329 | }, exprparser.Config{ 330 | Run: &model.Run{ 331 | Workflow: workflow, 332 | JobID: jobID, 333 | }, 334 | Context: "job", 335 | }) 336 | job.RawNeeds.Encode(evalNeeds) 337 | res, err := intp.Evaluate(fmt.Sprintf("(%v) && true || false", job.If.Value), exprparser.DefaultStatusCheckSuccess) 338 | shouldskip := false 339 | if err != nil { 340 | shouldskip = true 341 | } else if b, ok := res.(bool); ok { 342 | shouldskip = !b 343 | } else { 344 | shouldskip = true 345 | } 346 | actionsHttpServerHandler := &server.ActionsServer{ 347 | TraceLog: make(chan interface{}), 348 | ServerURL: dataContext["server_url"].GetStringValue(), 349 | ActionsServerURL: dataContext["gitea_default_actions_url"].GetStringValue(), 350 | AuthData: map[string]*protocol.ActionDownloadAuthentication{}, 351 | Token: preset.Token, 352 | } 353 | defer func() { 354 | close(actionsHttpServerHandler.TraceLog) 355 | }() 356 | steps := []protocol.ActionStep{} 357 | type StepMeta struct { 358 | LogIndex int64 359 | LogLength int64 360 | StepIndex int64 361 | Record protocol.TimelineRecord 362 | } 363 | stepMeta := make(map[string]*StepMeta) 364 | var stepIndex int64 = -1 365 | taskState := &runnerv1.TaskState{Id: task.GetId(), Steps: make([]*runnerv1.StepState, len(job.Steps)), StartedAt: timestamppb.Now()} 366 | if shouldskip { 367 | taskState.Steps = []*runnerv1.StepState{} 368 | taskState.StoppedAt = taskState.StartedAt 369 | taskState.Result = runnerv1.Result_RESULT_SKIPPED 370 | if err != nil { 371 | taskState.Result = runnerv1.Result_RESULT_FAILURE 372 | } 373 | return updateTask(reportingCtx, t, taskState, cancel, nil) 374 | } 375 | outputs := map[string]string{} 376 | for i := 0; i < len(taskState.Steps); i++ { 377 | taskState.Steps[i] = &runnerv1.StepState{ 378 | Id: int64(i), 379 | } 380 | } 381 | var logline int64 = 0 382 | var sentLogline int64 = 0 383 | rows := []*runnerv1.LogRow{} 384 | taskStateChanged := false 385 | 386 | var worker *exec.Cmd 387 | 388 | go func() { 389 | for { 390 | var obj interface{} 391 | var ok bool 392 | nextMsg := false 393 | 394 | nextLogSync := time.Hour 395 | if len(rows) > 0 && rows[0].Time != nil { 396 | nextLogSync = time.Until(rows[0].Time.AsTime().Add(time.Second)) 397 | } 398 | select { 399 | case obj, ok = <-actionsHttpServerHandler.TraceLog: 400 | case <-time.After(nextLogSync): 401 | if taskStateChanged && taskState.Result == runnerv1.Result_RESULT_UNSPECIFIED && updateTask(reportingCtx, t, taskState, cancel, nil) == nil { 402 | taskStateChanged = false 403 | } 404 | res, err := t.client.UpdateLog(reportingCtx, connect.NewRequest(&runnerv1.UpdateLogRequest{ 405 | TaskId: task.GetId(), 406 | Index: sentLogline, 407 | Rows: rows, 408 | })) 409 | if err == nil { 410 | diff := res.Msg.GetAckIndex() - sentLogline 411 | sentLogline = res.Msg.GetAckIndex() 412 | if diff >= int64(len(rows)) { 413 | rows = []*runnerv1.LogRow{} 414 | } else if diff > 0 { 415 | rows = rows[diff:] 416 | } 417 | } else if isUnauthenticatedError(err) { 418 | log.Errorf("failed to update log: %v, has been removed", err) 419 | rows = []*runnerv1.LogRow{} 420 | cancel() 421 | } else { 422 | log.Errorf("failed to update log: %v, batching later", err) 423 | } 424 | nextMsg = true 425 | case <-time.After(30 * time.Second): 426 | if taskState.Result == runnerv1.Result_RESULT_UNSPECIFIED && updateTask(reportingCtx, t, taskState, cancel, nil) == nil { 427 | taskStateChanged = false 428 | } 429 | nextMsg = true 430 | } 431 | if nextMsg { 432 | continue 433 | } 434 | if !ok { 435 | break 436 | } 437 | 438 | if v, ok := os.LookupEnv("GITEA_RUNNER_TRACE"); ok && v == "1" { 439 | j, _ := json.MarshalIndent(obj, "", " ") 440 | fmt.Printf("MESSAGE: %s\n", j) 441 | } 442 | 443 | if feed, ok := obj.(*protocol.TimelineRecordFeedLinesWrapper); ok { 444 | loglineStart := logline 445 | logline += feed.Count 446 | step, ok := stepMeta[feed.StepID] 447 | if ok { 448 | step.LogLength += feed.Count 449 | } else { 450 | step = &StepMeta{} 451 | stepMeta[feed.StepID] = step 452 | step.StepIndex = -1 453 | step.LogIndex = -1 454 | step.LogLength = feed.Count 455 | for i, s := range steps { 456 | if s.Id == feed.StepID { 457 | step.StepIndex = int64(i) 458 | break 459 | } 460 | } 461 | } 462 | if step.LogIndex == -1 { 463 | step.LogIndex = loglineStart 464 | } 465 | now := timestamppb.Now() 466 | for _, row := range feed.Value { 467 | rows = append(rows, &runnerv1.LogRow{ 468 | Time: now, 469 | Content: row, 470 | }) 471 | } 472 | 473 | if step.StepIndex != -1 { 474 | stepIndex = step.StepIndex 475 | taskState.Steps[stepIndex].LogIndex = step.LogIndex 476 | taskState.Steps[stepIndex].LogLength = step.LogLength 477 | } 478 | taskStateChanged = true 479 | } else if timeline, ok := obj.(*protocol.TimelineRecordWrapper); ok { 480 | for _, rec := range timeline.Value { 481 | step, ok := stepMeta[rec.ID] 482 | if ok { 483 | step.Record = *rec 484 | } else { 485 | step = &StepMeta{ 486 | Record: *rec, 487 | LogIndex: -1, 488 | LogLength: 0, 489 | StepIndex: -1, 490 | } 491 | stepMeta[rec.ID] = step 492 | for i, s := range steps { 493 | if s.Id == rec.ID { 494 | step.StepIndex = int64(i) 495 | break 496 | } 497 | } 498 | } 499 | if step.StepIndex >= 0 { 500 | v := rec 501 | step := taskState.Steps[step.StepIndex] 502 | if v.Result != nil && step.Result == runnerv1.Result_RESULT_UNSPECIFIED { 503 | switch strings.ToLower(*v.Result) { 504 | case "succeeded": 505 | step.Result = runnerv1.Result_RESULT_SUCCESS 506 | case "skipped": 507 | step.Result = runnerv1.Result_RESULT_SKIPPED 508 | default: 509 | step.Result = runnerv1.Result_RESULT_FAILURE 510 | } 511 | } 512 | // Updated timestamp format to allow variable amount of fraction and time offset 513 | if step.StartedAt == nil && v.StartTime != "" { 514 | if t, err := time.Parse("2006-01-02T15:04:05.9999999Z07:00", v.StartTime); err == nil && !t.IsZero() { 515 | step.StartedAt = timestamppb.New(t) 516 | } 517 | } 518 | if step.StoppedAt == nil && v.FinishTime != nil { 519 | if t, err := time.Parse("2006-01-02T15:04:05.9999999Z07:00", *v.FinishTime); err == nil && !t.IsZero() { 520 | step.StoppedAt = timestamppb.New(t) 521 | } 522 | } 523 | } 524 | } 525 | taskStateChanged = true 526 | } else if jevent, ok := obj.(*protocol.JobEvent); ok { 527 | if jevent.Result != "" { 528 | switch strings.ToLower(jevent.Result) { 529 | case "succeeded": 530 | taskState.Result = runnerv1.Result_RESULT_SUCCESS 531 | case "skipped": 532 | taskState.Result = runnerv1.Result_RESULT_SKIPPED 533 | default: 534 | taskState.Result = runnerv1.Result_RESULT_FAILURE 535 | } 536 | } else { 537 | taskState.Result = runnerv1.Result_RESULT_FAILURE 538 | } 539 | 540 | // See https://github.com/ChristopherHX/gitea-actions-runner/issues/27 541 | go func() { 542 | select { 543 | case <-reportingCtx.Done(): 544 | // process exited 545 | case <-time.After(30 * time.Second): 546 | if worker != nil && worker.Process != nil && worker.ProcessState == nil { 547 | worker.Process.Kill() 548 | } 549 | } 550 | }() 551 | if jevent.Outputs != nil { 552 | for k, v := range *jevent.Outputs { 553 | outputs[k] = v.Value 554 | } 555 | } 556 | } 557 | } 558 | }() 559 | 560 | actionsRuntimeListeningAddr := getListeningAddress("GITEA_ACTIONS_RUNNER_RUNTIME_LISTENING_ADDRESS") 561 | listener, err := net.Listen("tcp", actionsRuntimeListeningAddr) 562 | if err != nil { 563 | return err 564 | } 565 | 566 | workerOptions := map[string]string{} 567 | opts := 0 568 | for i := 0; i < len(runnerWorker); i++ { 569 | if strings.HasPrefix(runnerWorker[i], "--") { 570 | k, v, _ := strings.Cut(runnerWorker[i], "=") 571 | workerOptions[k] = v 572 | opts++ 573 | } else { 574 | break 575 | } 576 | } 577 | runnerWorker = runnerWorker[opts:] 578 | 579 | if allowClone, ok := workerOptions["--allow-clone"]; ok && allowClone == "" { 580 | if maxParallelRaw, ok := workerOptions["--max-parallel"]; ok { 581 | maxParallel, _ := strconv.Atoi(maxParallelRaw) 582 | if maxParallel > 1 { 583 | if rootDir, ok := workerOptions["--runner-dir"]; ok && len(rootDir) > 0 { 584 | _, prefix, ext, _, tmpdir, err := runners.CreateExternalRunnerDirectory(runners.Parameters{ 585 | RunnerPath: rootDir, 586 | RunnerDirectory: "runners", 587 | }) 588 | if err != nil { 589 | return err 590 | } 591 | defer os.RemoveAll(tmpdir) 592 | runnerWorker[len(runnerWorker)-1] = path.Join(tmpdir, "bin", prefix+".Worker"+ext) 593 | } 594 | } 595 | } 596 | } 597 | _, workerV2 := workerOptions["--worker-v2"] 598 | 599 | var actionsHttpServer *http.Server 600 | if !workerV2 { 601 | actionsHttpServer = &http.Server{Handler: actionsHttpServerHandler} 602 | } 603 | 604 | defer func() { 605 | if actionsHttpServer != nil { 606 | actionsHttpServer.Shutdown(context.Background()) 607 | } 608 | message := "Finished" 609 | log.Info(message) 610 | if errormsg != nil { 611 | message = fmt.Sprintf("##[Error]%s", errormsg.Error()) 612 | } 613 | rows = append(rows, &runnerv1.LogRow{ 614 | Time: timestamppb.New(time.Now()), 615 | Content: message, 616 | }) 617 | 618 | retry.Do(func() error { 619 | res, err := t.client.UpdateLog(reportingCtx, connect.NewRequest(&runnerv1.UpdateLogRequest{ 620 | TaskId: task.GetId(), 621 | Index: logline, 622 | Rows: rows, 623 | NoMore: true, 624 | })) 625 | if err == nil { 626 | diff := res.Msg.GetAckIndex() - sentLogline 627 | sentLogline = res.Msg.GetAckIndex() 628 | if diff >= int64(len(rows)) { 629 | rows = []*runnerv1.LogRow{} 630 | } else if diff > 0 { 631 | rows = rows[diff:] 632 | } 633 | if len(rows) > 0 { 634 | return fmt.Errorf("still logs missing") 635 | } 636 | } else if isUnauthenticatedError(err) { 637 | log.Errorf("final failed to update log: %v, has been removed", err) 638 | return nil 639 | } else { 640 | log.Errorf("final failed to update log: %v, batching later", err) 641 | } 642 | return err 643 | }, retry.Context(reportingCtx)) 644 | 645 | if taskState.Result == runnerv1.Result_RESULT_UNSPECIFIED { 646 | taskState.Result = runnerv1.Result_RESULT_FAILURE 647 | } 648 | taskState.StoppedAt = timestamppb.Now() 649 | updateTask(reportingCtx, t, taskState, cancel, outputs) 650 | log.Info("Reporting done") 651 | }() 652 | 653 | var hostname string 654 | if preferredIp := os.Getenv("GITEA_ACTIONS_RUNNER_RUNTIME_PREFERRED_OUTBOUND_IP"); preferredIp != "" && net.ParseIP(preferredIp) != nil { 655 | hostname = preferredIp 656 | } else { 657 | ip := common.GetOutboundIP() 658 | if ip == nil { 659 | ip = net.IPv4(127, 0, 0, 1) 660 | } 661 | hostname = ip.String() 662 | } 663 | if hn := os.Getenv("GITEA_ACTIONS_RUNNER_RUNTIME_HOSTNAME"); hn != "" { 664 | hostname = hn 665 | } else if v := os.Getenv("GITEA_ACTIONS_RUNNER_RUNTIME_USE_DNS_NAME"); v == "1" || strings.EqualFold(v, "true") || strings.EqualFold(v, "yes") || strings.EqualFold(v, "y") { 666 | names, err := net.LookupAddr(hostname) 667 | if err != nil { 668 | return err 669 | } 670 | if len(names) >= 1 { 671 | hostname = names[0] 672 | } 673 | } 674 | if v := os.Getenv("GITEA_ACTIONS_RUNNER_RUNTIME_APPEND_NO_PROXY"); v == "1" || strings.EqualFold(v, "true") || strings.EqualFold(v, "yes") || strings.EqualFold(v, "y") { 675 | no_proxy := os.Getenv("no_proxy") 676 | if no_proxy == "" { 677 | no_proxy = os.Getenv("NO_PROXY") 678 | } 679 | if no_proxy != "" { 680 | no_proxy += "," 681 | } 682 | no_proxy += hostname 683 | os.Setenv("no_proxy", no_proxy) 684 | os.Setenv("NO_PROXY", no_proxy) 685 | } 686 | 687 | externalURL := os.Getenv("GITEA_ACTIONS_RUNNER_RUNTIME_EXTERNAL_URL") 688 | if externalURL == "" { 689 | externalURL = fmt.Sprintf("http://%s:%d/", hostname, listener.Addr().(*net.TCPAddr).Port) 690 | } 691 | // Normalize externalURL to ensure it ends with a slash 692 | actionsHttpServerHandler.ExternalURL = strings.TrimSuffix(externalURL, "/") + "/" 693 | 694 | cacheServerUrl := os.Getenv("GITEA_ACTIONS_CACHE_SERVER_URL") 695 | if actionsHttpServer != nil && cacheServerUrl == "" { 696 | if wd, err := os.Getwd(); err == nil { 697 | if actionsRuntimeListeningAddr == ":0" { 698 | if cache, err := artifactcache.StartHandler(filepath.Join(wd, "cache"), hostname, 0, log.New()); err == nil { 699 | cacheServerUrl = cache.ExternalURL() + "/" 700 | defer cache.Close() 701 | } 702 | } else { 703 | _, actionsHttpServerHandler.CacheHandler, _ = artifactcache.CreateHandler(filepath.Join(wd, "cache"), externalURL, nil) 704 | cacheServerUrl = externalURL 705 | } 706 | } 707 | } 708 | // Normalize cacheServerUrl to ensure it ends with a slash 709 | if cacheServerUrl != "" { 710 | cacheServerUrl = strings.TrimSuffix(cacheServerUrl, "/") + "/" 711 | } 712 | if actionsHttpServer != nil { 713 | go func() { 714 | actionsHttpServer.Serve(listener) 715 | }() 716 | } 717 | 718 | for _, s := range job.Steps { 719 | displayName := &protocol.TemplateToken{} 720 | displayName.FromRawObject(s.Name) 721 | rawIn := map[interface{}]interface{}{} 722 | var reference protocol.ActionStepDefinitionReference 723 | 724 | if s.Run != "" { 725 | reference = protocol.ActionStepDefinitionReference{ 726 | Type: "script", 727 | } 728 | rawIn = map[interface{}]interface{}{ 729 | "script": s.Run, 730 | } 731 | if s.Shell != "" { 732 | rawIn["shell"] = s.Shell 733 | } 734 | if s.WorkingDirectory != "" { 735 | rawIn["workingDirectory"] = s.WorkingDirectory 736 | } 737 | } else { 738 | uses := s.Uses 739 | // For Gitea Actions allows expressions in uses 740 | if expression, isExpr := rewriteSubExpression(uses, true); isExpr { 741 | ruses, err := intp.Evaluate(expression, exprparser.DefaultStatusCheckNone) 742 | if err == nil { 743 | suses, ok := ruses.(string) 744 | if ok { 745 | uses = suses 746 | } 747 | } 748 | } 749 | if strings.HasPrefix(uses, "docker://") { 750 | reference = protocol.ActionStepDefinitionReference{ 751 | Type: "containerRegistry", 752 | Image: strings.TrimPrefix(uses, "docker://"), 753 | } 754 | } else { 755 | nameAndPathOrRef := strings.Split(uses, "@") 756 | nameAndPath := strings.Split(nameAndPathOrRef[0], "/") 757 | for _, proto := range []string{"http://", "https://"} { 758 | var token string 759 | if len(nameAndPathOrRef) == 3 { 760 | actionURL := nameAndPathOrRef[0] + "@" + nameAndPathOrRef[1] 761 | if pURL, err := url.Parse(actionURL); err == nil && pURL.User != nil { 762 | var ok bool 763 | if token, ok = pURL.User.Password(); ok { 764 | pURL.User = nil 765 | actionURL = pURL.String() 766 | } 767 | } 768 | nameAndPathOrRef = []string{actionURL, nameAndPathOrRef[2]} 769 | } 770 | 771 | if strings.HasPrefix(nameAndPathOrRef[0], proto) { 772 | re := strings.Split(strings.TrimPrefix(nameAndPathOrRef[0], proto), "/") 773 | nameAndPath = append([]string{strings.ReplaceAll(proto+re[0]+"/"+re[1], ":", "~")}, re[2:]...) 774 | if token != "" { 775 | actionsHttpServerHandler.AuthData[nameAndPathOrRef[0]] = &protocol.ActionDownloadAuthentication{ 776 | Token: token, 777 | } 778 | } 779 | break 780 | } 781 | } 782 | if nameAndPath[0] == "." { 783 | reference = protocol.ActionStepDefinitionReference{ 784 | Type: "repository", 785 | Path: path.Join(nameAndPath[1:]...), 786 | RepositoryType: "self", 787 | } 788 | } else { 789 | reference = protocol.ActionStepDefinitionReference{ 790 | Type: "repository", 791 | Name: nameAndPath[0] + "/" + nameAndPath[1], 792 | Path: path.Join(nameAndPath[2:]...), 793 | Ref: nameAndPathOrRef[1], 794 | RepositoryType: "GitHub", 795 | } 796 | } 797 | } 798 | for k, v := range s.With { 799 | rawIn[k] = v 800 | } 801 | } 802 | 803 | var environment *protocol.TemplateToken 804 | switch s.Env.Kind { 805 | case yaml.ScalarNode: 806 | var expr string 807 | _ = s.Env.Decode(&expr) 808 | if expr != "" { 809 | environment = &protocol.TemplateToken{} 810 | environment.FromRawObject(expr) 811 | } 812 | case yaml.MappingNode: 813 | rawEnv := map[interface{}]interface{}{} 814 | _ = s.Env.Decode(&rawEnv) 815 | environment = &protocol.TemplateToken{} 816 | environment.FromRawObject(rawEnv) 817 | } 818 | 819 | inputs := &protocol.TemplateToken{} 820 | inputs.FromRawObject(rawIn) 821 | condition := s.If.Value 822 | if condition == "" { 823 | condition = "success()" 824 | } else { 825 | // Remove surrounded expression syntax 826 | if exprcond, ok := rewriteSubExpression(condition, false); ok { 827 | condition = exprcond 828 | } 829 | // Try to parse the expression and inject success if no status check function has been applied 830 | parser := actionlint.NewExprParser() 831 | exprNode, err := parser.Parse(actionlint.NewExprLexer(condition + "}}")) 832 | if err == nil { 833 | hasStatusCheckFunction := false 834 | actionlint.VisitExprNode(exprNode, func(node, _ actionlint.ExprNode, entering bool) { 835 | if funcCallNode, ok := node.(*actionlint.FuncCallNode); entering && ok { 836 | switch strings.ToLower(funcCallNode.Callee) { 837 | case "success", "always", "cancelled", "failure": 838 | hasStatusCheckFunction = true 839 | } 840 | } 841 | }) 842 | if !hasStatusCheckFunction { 843 | condition = fmt.Sprintf("success() && (%s)", condition) 844 | } 845 | } 846 | } 847 | var timeoutInMinutes *protocol.TemplateToken 848 | if len(s.TimeoutMinutes) > 0 { 849 | timeoutInMinutes = &protocol.TemplateToken{} 850 | if timeout, err := strconv.ParseFloat(s.TimeoutMinutes, 64); err == nil { 851 | timeoutInMinutes.FromRawObject(timeout) 852 | } else { 853 | timeoutInMinutes.FromRawObject(s.TimeoutMinutes) 854 | } 855 | } 856 | var continueOnError *protocol.TemplateToken 857 | if len(s.RawContinueOnError) > 0 { 858 | continueOnError = &protocol.TemplateToken{} 859 | if continueOnErr, err := strconv.ParseBool(s.RawContinueOnError); err == nil { 860 | continueOnError.FromRawObject(continueOnErr) 861 | } else { 862 | continueOnError.FromRawObject(s.TimeoutMinutes) 863 | } 864 | } 865 | steps = append(steps, protocol.ActionStep{ 866 | Type: "action", 867 | Reference: reference, 868 | Inputs: inputs, 869 | Condition: condition, 870 | DisplayNameToken: displayName, 871 | ContextName: s.ID, 872 | Name: s.ID, 873 | Id: uuid.New().String(), 874 | Environment: environment, 875 | TimeoutInMinutes: timeoutInMinutes, 876 | ContinueOnError: continueOnError, 877 | }) 878 | } 879 | 880 | // actions/runner defect https://github.com/actions/runner/issues/2783 if and only if ACTIONS_RUNNER_CONTAINER_HOOKS is used 881 | for k := range job.Services { 882 | job.Services[k].Options += " --network-alias " + k 883 | } 884 | 885 | jobServiceContainers := yaml.Node{} 886 | jobServiceContainers.Encode(job.Services) 887 | 888 | envs := []protocol.TemplateToken{} 889 | defs := []protocol.TemplateToken{} 890 | def := yaml.Node{} 891 | 892 | def.Encode(workflow.Defaults) 893 | if d := ToTemplateToken(def); d != nil { 894 | defs = append(defs, *d) 895 | } 896 | 897 | // Only sent defaults of job if it has at least one field set, otherwise actions/runner would always ignore the globals 898 | if job.Defaults.Run.WorkingDirectory != "" || job.Defaults.Run.Shell != "" { 899 | def.Encode(job.Defaults) 900 | if d := ToTemplateToken(def); d != nil { 901 | defs = append(defs, *d) 902 | } 903 | } 904 | 905 | def.Encode(workflow.Env) 906 | if d := ToTemplateToken(def); d != nil && !def.IsZero() { 907 | envs = append(envs, *d) 908 | } 909 | 910 | if d := ToTemplateToken(job.Env); d != nil && !job.Env.IsZero() { 911 | envs = append(envs, *d) 912 | } 913 | 914 | matrix := map[string]interface{}{} 915 | matrixes, _ := job.GetMatrixes() 916 | for _, m := range matrixes { 917 | for k, v := range m { 918 | matrix[k] = v 919 | } 920 | } 921 | 922 | github := task.Context.AsMap() 923 | // Gitea Actions Bug github.server_url has a / as suffix 924 | server_url := dataContext["server_url"].GetStringValue() 925 | for server_url != "" && strings.HasSuffix(server_url, "/") { 926 | server_url = server_url[:len(server_url)-1] 927 | github["server_url"] = server_url 928 | } 929 | api_url := dataContext["api_url"].GetStringValue() 930 | if api_url == "" { 931 | github["api_url"] = fmt.Sprintf("%s/api/v1", server_url) 932 | } 933 | // Gitea Actions Bug github.job is a number 934 | // use number as id, extension 935 | github["job_id"] = github["job"] 936 | // correct the name 937 | github["job"] = jobID 938 | // Convert to raw map 939 | needsctx := map[string]interface{}{} 940 | if rawneeds := task.GetNeeds(); rawneeds != nil { 941 | for name, rawneed := range rawneeds { 942 | dep := map[string]interface{}{} 943 | switch rawneed.Result { 944 | case runnerv1.Result_RESULT_SUCCESS: 945 | dep["result"] = "success" 946 | case runnerv1.Result_RESULT_FAILURE: 947 | dep["result"] = "failure" 948 | case runnerv1.Result_RESULT_SKIPPED: 949 | dep["result"] = "skipped" 950 | case runnerv1.Result_RESULT_CANCELLED: 951 | dep["result"] = "cancelled" 952 | } 953 | dep["outputs"] = convertToRawMap(rawneed.Outputs) 954 | needsctx[name] = dep 955 | } 956 | } 957 | var jobOutputs *protocol.TemplateToken 958 | if len(job.Outputs) > 0 { 959 | jobOutputs = &protocol.TemplateToken{} 960 | jobOutputs.FromRawObject(convertToRawTemplateTokenMap(job.Outputs)) 961 | } 962 | token := taskContext["gitea_runtime_token"].GetStringValue() 963 | if token == "" { 964 | token = preset.Token 965 | } 966 | 967 | jmessage := &protocol.AgentJobRequestMessage{ 968 | MessageType: "jobRequest", 969 | Plan: &protocol.TaskOrchestrationPlanReference{ 970 | ScopeIdentifier: uuid.New().String(), 971 | PlanID: uuid.New().String(), 972 | PlanType: "free", 973 | Version: 12, 974 | }, 975 | Timeline: &protocol.TimeLineReference{ 976 | ID: uuid.New().String(), 977 | }, 978 | Resources: &protocol.JobResources{ 979 | Endpoints: []protocol.JobEndpoint{ 980 | { 981 | Name: "SYSTEMVSSCONNECTION", 982 | Data: map[string]string{ 983 | "CacheServerUrl": cacheServerUrl, 984 | "ResultsServiceUrl": server_url, 985 | }, 986 | URL: externalURL, 987 | Authorization: protocol.JobAuthorization{ 988 | Scheme: "OAuth", 989 | Parameters: map[string]string{ 990 | "AccessToken": token, 991 | }, 992 | }, 993 | }, 994 | }, 995 | }, 996 | JobID: uuid.New().String(), 997 | JobDisplayName: jobID, 998 | JobName: jobID, 999 | RequestID: 475, 1000 | LockedUntil: "0001-01-01T00:00:00", 1001 | Steps: steps, 1002 | Variables: map[string]protocol.VariableValue{}, 1003 | ContextData: map[string]protocol.PipelineContextData{ 1004 | "github": server.ToPipelineContextData(github), 1005 | "matrix": server.ToPipelineContextData(matrix), 1006 | "strategy": server.ToPipelineContextData(map[string]interface{}{}), 1007 | "inputs": server.ToPipelineContextData(inputs), 1008 | "needs": server.ToPipelineContextData(needsctx), 1009 | "vars": server.ToPipelineContextData(convertToRawMap(task.GetVars())), 1010 | }, 1011 | JobContainer: ToTemplateToken(job.RawContainer), 1012 | JobServiceContainers: ToTemplateToken(jobServiceContainers), 1013 | Defaults: defs, 1014 | EnvironmentVariables: envs, 1015 | JobOutputs: jobOutputs, 1016 | } 1017 | jmessage.Variables["DistributedTask.NewActionMetadata"] = protocol.VariableValue{Value: "true"} 1018 | jmessage.Variables["DistributedTask.EnableCompositeActions"] = protocol.VariableValue{Value: "true"} 1019 | jmessage.Variables["DistributedTask.EnhancedAnnotations"] = protocol.VariableValue{Value: "true"} 1020 | jmessage.Variables["DistributedTask.AddWarningToNode12Action"] = protocol.VariableValue{Value: "true"} 1021 | jmessage.Variables["DistributedTask.AllowRunnerContainerHooks"] = protocol.VariableValue{Value: "true"} 1022 | jmessage.Variables["DistributedTask.DeprecateStepOutputCommands"] = protocol.VariableValue{Value: "true"} 1023 | jmessage.Variables["DistributedTask.ForceGithubJavascriptActionsToNode16"] = protocol.VariableValue{Value: "true"} 1024 | jmessage.Variables["system.github.job"] = protocol.VariableValue{Value: job.Name} 1025 | // For Gitea Actions 1026 | jmessage.Variables["system.runner.server.webconsole_queue_all"] = protocol.VariableValue{Value: "true"} 1027 | jmessage.Variables["system.runner.server.github_prefixes"] = protocol.VariableValue{Value: "github,gitea"} 1028 | jmessage.Variables["system.runner.server.go_actions"] = protocol.VariableValue{Value: "true"} 1029 | jmessage.Variables["system.runner.server.absolute_actions"] = protocol.VariableValue{Value: "true"} 1030 | jmessage.Variables["system.runner.server.allow_dind"] = protocol.VariableValue{Value: "true"} 1031 | for k, v := range task.Secrets { 1032 | jmessage.Variables[k] = protocol.VariableValue{Value: v, IsSecret: true} 1033 | } 1034 | 1035 | actionsHttpServerHandler.JobRequest = jmessage 1036 | actionsHttpServerHandler.CancelCtx = ctx 1037 | src, _ := json.Marshal(jmessage) 1038 | jobExecCtx := ctx 1039 | 1040 | worker = exec.Command(runnerWorker[0], runnerWorker[1:]...) 1041 | // ignore CTRL+C 1042 | worker.SysProcAttr = getSysProcAttr() 1043 | in, err := worker.StdinPipe() 1044 | if err != nil { 1045 | return err 1046 | } 1047 | defer in.Close() 1048 | var er io.ReadCloser 1049 | if workerV2 { 1050 | worker.Stderr = os.Stderr 1051 | } else { 1052 | er, err = worker.StderrPipe() 1053 | if err != nil { 1054 | return err 1055 | } 1056 | defer er.Close() 1057 | } 1058 | out, err := worker.StdoutPipe() 1059 | if err != nil { 1060 | return err 1061 | } 1062 | defer out.Close() 1063 | err = worker.Start() 1064 | if err != nil { 1065 | return err 1066 | } 1067 | var workerLog *bytes.Buffer 1068 | if workerV2 { 1069 | go func() { 1070 | server.Server(server.CreateStdioConn(out, in), actionsHttpServerHandler) 1071 | }() 1072 | } else { 1073 | mid := make([]byte, 4) 1074 | binary.BigEndian.PutUint32(mid, 1) // NewJobRequest 1075 | in.Write(mid) 1076 | binary.BigEndian.PutUint32(mid, uint32(len(src))) 1077 | in.Write(mid) 1078 | in.Write(src) 1079 | done := make(chan struct{}) 1080 | defer close(done) 1081 | go func() { 1082 | select { 1083 | case <-jobExecCtx.Done(): 1084 | binary.BigEndian.PutUint32(mid, 2) // CancelRequest 1085 | in.Write(mid) 1086 | binary.BigEndian.PutUint32(mid, uint32(len(src))) 1087 | in.Write(mid) 1088 | in.Write(src) 1089 | case <-done: 1090 | } 1091 | }() 1092 | workerLog = &bytes.Buffer{} 1093 | workerout := io.MultiWriter(os.Stdout, workerLog) 1094 | io.Copy(workerout, out) 1095 | io.Copy(workerout, er) 1096 | } 1097 | worker.Wait() 1098 | if exitcode := worker.ProcessState.ExitCode(); exitcode != 0 { 1099 | loglines := []*runnerv1.LogRow{} 1100 | if workerLog != nil { 1101 | workerlogstr := workerLog.String() 1102 | for _, line := range strings.Split(workerlogstr, "\n") { 1103 | loglines = append(loglines, &runnerv1.LogRow{ 1104 | Time: timestamppb.New(time.Now()), 1105 | Content: line, 1106 | }) 1107 | } 1108 | } 1109 | res, err := t.client.UpdateLog(reportingCtx, connect.NewRequest(&runnerv1.UpdateLogRequest{ 1110 | TaskId: task.GetId(), 1111 | Index: logline, 1112 | Rows: loglines, 1113 | })) 1114 | if err == nil { 1115 | logline = res.Msg.GetAckIndex() 1116 | } 1117 | return fmt.Errorf("failed to execute worker exitcode: %v", exitcode) 1118 | } 1119 | 1120 | return nil 1121 | } 1122 | 1123 | func getListeningAddress(envName string) string { 1124 | addr := os.Getenv(envName) 1125 | if addr == "" { 1126 | addr = ":0" 1127 | } 1128 | return addr 1129 | } 1130 | 1131 | // Check Timeline Integrity adding fake started and stopped boundaries 1132 | func checkIntegrity(taskState *runnerv1.TaskState) { 1133 | stepsWithPrePost := []*runnerv1.StepState{ 1134 | { 1135 | StartedAt: taskState.StartedAt, 1136 | StoppedAt: taskState.StartedAt, 1137 | }, 1138 | } 1139 | stepsWithPrePost = append(stepsWithPrePost, taskState.Steps...) 1140 | stepsWithPrePost = append(stepsWithPrePost, &runnerv1.StepState{ 1141 | StartedAt: taskState.StoppedAt, 1142 | StoppedAt: taskState.StoppedAt, 1143 | Result: taskState.Result, 1144 | }) 1145 | endTime := timestamppb.Now() 1146 | for i := len(stepsWithPrePost) - 1; i > 0; i-- { 1147 | step := stepsWithPrePost[i] 1148 | pstep := stepsWithPrePost[i-1] 1149 | 1150 | if step.StoppedAt == nil && step.Result != runnerv1.Result_RESULT_UNSPECIFIED { 1151 | step.StoppedAt = endTime 1152 | } 1153 | if step.StoppedAt != nil && step.Result == runnerv1.Result_RESULT_UNSPECIFIED { 1154 | step.Result = runnerv1.Result_RESULT_SKIPPED 1155 | } 1156 | 1157 | if step.StoppedAt != nil { 1158 | if step.StartedAt == nil || !step.StartedAt.AsTime().Before(step.StoppedAt.AsTime()) { 1159 | step.StartedAt = step.StoppedAt 1160 | } 1161 | } 1162 | if step.StartedAt != nil { 1163 | if pstep.StoppedAt == nil || !pstep.StoppedAt.AsTime().Before(step.StartedAt.AsTime()) { 1164 | pstep.StoppedAt = step.StartedAt 1165 | } 1166 | } 1167 | } 1168 | // Adjust fixed times of fake pre/post steps 1169 | taskState.StartedAt = stepsWithPrePost[0].StartedAt 1170 | taskState.StoppedAt = stepsWithPrePost[len(stepsWithPrePost)-1].StoppedAt 1171 | taskState.Result = stepsWithPrePost[len(stepsWithPrePost)-1].Result 1172 | } 1173 | 1174 | func isUnauthenticatedError(err error) bool { 1175 | if err == nil { 1176 | return false 1177 | } 1178 | return strings.Contains(err.Error(), "Unauthenticated") 1179 | } 1180 | 1181 | func updateTaskNoRetry(ctx context.Context, t *Task, taskState *runnerv1.TaskState, cancel context.CancelFunc, outputs map[string]string) error { 1182 | resp, err := t.client.UpdateTask(ctx, connect.NewRequest(&runnerv1.UpdateTaskRequest{ 1183 | State: taskState, 1184 | Outputs: outputs, 1185 | })) 1186 | 1187 | if isUnauthenticatedError(err) { 1188 | log.Errorf("failed to update task: %v, has been removed", err) 1189 | cancel() 1190 | return nil 1191 | } 1192 | 1193 | if err == nil && resp.Msg.State != nil && resp.Msg.State.Result != runnerv1.Result_RESULT_UNSPECIFIED { 1194 | cancel() 1195 | } 1196 | if err != nil { 1197 | log.Errorf("failed to updateTask: %s\n", err.Error()) 1198 | } 1199 | return err 1200 | } 1201 | 1202 | func updateTask(ctx context.Context, t *Task, taskState *runnerv1.TaskState, cancel context.CancelFunc, outputs map[string]string) error { 1203 | checkIntegrity(taskState) 1204 | if taskState.Result == runnerv1.Result_RESULT_UNSPECIFIED { 1205 | return updateTaskNoRetry(ctx, t, taskState, cancel, outputs) 1206 | } 1207 | 1208 | return retry.Do(func() error { 1209 | return updateTaskNoRetry(ctx, t, taskState, cancel, outputs) 1210 | }, retry.Context(ctx)) 1211 | } 1212 | 1213 | func convertToRawMap(data map[string]string) map[string]interface{} { 1214 | outputs := map[string]interface{}{} 1215 | for k, v := range data { 1216 | outputs[k] = v 1217 | } 1218 | return outputs 1219 | } 1220 | 1221 | func convertToRawTemplateTokenMap(data map[string]string) map[interface{}]interface{} { 1222 | outputs := map[interface{}]interface{}{} 1223 | for k, v := range data { 1224 | outputs[k] = v 1225 | } 1226 | return outputs 1227 | } 1228 | --------------------------------------------------------------------------------