Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .golangci.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
version: "2"
run:
go: "1.21"
go: "1.24"
linters:
enable:
- asciicheck
Expand Down
8 changes: 6 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,10 @@ go run . run
- removed openbsd/mips binaries, because this prevents updates to go and dependencies
- go 1.21 now required

# Breaking changes in 0.11.0
- based on [actions-oss/act-cli@v0.3.4](https://github.com/actions-oss/act-cli/tree/v0.3.4)
- go 1.24 now required

# Known Limitations
- ~~This runner ignores pre and post steps of javascript actions~~ Is now working in 0.6.0
- ~~[actions/cache](https://github.com/actions/cache) is incompatible and won't be able to **save your cache**~~
Expand All @@ -150,10 +154,10 @@ go run . run
- Job Outputs are sent regardless if they would leak secret data to non secret storage
- You need to provide the `node` program yourself in all containers / host configurations
- You need to manually update the runner
- Most issues of https://github.com/nektos/act/issues applies to this runner as well
- Most issues of https://github.com/nektos/act/issues and https://github.com/actions-oss/act-cli/issues applies to this runner as well

# How does it work?
This runner implements the same protocol as the [actions/runner](https://github.com/actions/runner) in a different way, as such it can be used as a self-hosted runner exactly like the official one. To get this working, I initially built an actions service replacement [ChristopherHX/runner.server](https://github.com/ChristopherHX/runner.server) for the official [actions/runner](https://github.com/actions/runner). My own actions service allowed me to implement the base protocol for this runner and debug how the protocol is serializeing and parsing json messages, while still being incompatible with github. After testing against github, the first thing happend was loosing the ability to run any github action workflows on my test repository. My invalid attempts to register a custom runner caused unrecoverable Internal Server Errors on githubs side, I decided to delete this test repository. After some work everything worked and finally it is safe to register this runner against github. To execute steps this runner translates the github actions job request to be compatible with a modified version of [nektos/act](https://github.com/nektos/act) ( [ChristopherHX/act](https://github.com/ChristopherHX/act) ), which adds a local task runner without the need for docker and increased platform support, also the log output of act gets redirected to github for live logs and storing log files.
This runner implements the same protocol as the [actions/runner](https://github.com/actions/runner) in a different way, as such it can be used as a self-hosted runner exactly like the official one. To get this working, I initially built an actions service replacement [ChristopherHX/runner.server](https://github.com/ChristopherHX/runner.server) for the official [actions/runner](https://github.com/actions/runner). My own actions service allowed me to implement the base protocol for this runner and debug how the protocol is serializeing and parsing json messages, while still being incompatible with github. After testing against github, the first thing happend was loosing the ability to run any github action workflows on my test repository. My invalid attempts to register a custom runner caused unrecoverable Internal Server Errors on githubs side, I decided to delete this test repository. After some work everything worked and finally it is safe to register this runner against github. To execute steps this runner translates the github actions job request to be compatible with a modified version of [nektos/act](https://github.com/nektos/act) ( [actions-oss/act-cli](https://github.com/actions-oss/act-cli) ), which adds a local task runner without the need for docker and increased platform support, also the log output of act gets redirected to github for live logs and storing log files.

# Does this runner work without github?
Yes, you can use this runner together with [ChristopherHX/runner.server](https://github.com/ChristopherHX/runner.server) locally on your PC without depending on compatibility with github. Also CI tests for this runner are using [ChristopherHX/runner.server](https://github.com/ChristopherHX/runner.server), this avoids requiring a PAT for github to run tests and enshures that you are always able to run it locally without github.
242 changes: 70 additions & 172 deletions actionsdotnetactcompat/act_worker.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,34 +2,28 @@ package actionsdotnetactcompat

import (
"bytes"
"compress/gzip"
"context"
"encoding/json"
"fmt"
"io"
"math"
"net/http"
"os"
"path/filepath"
"regexp"
"runtime"
"strings"
"time"

"github.com/actions-oss/act-cli/pkg/common"
"github.com/actions-oss/act-cli/pkg/model"
"github.com/actions-oss/act-cli/pkg/runner"
"github.com/google/uuid"
"github.com/nektos/act/pkg/common"
"github.com/nektos/act/pkg/common/git"
"github.com/nektos/act/pkg/filecollector"
"github.com/nektos/act/pkg/model"
"github.com/nektos/act/pkg/runner"
"github.com/rhysd/actionlint"
"github.com/sirupsen/logrus"
"gopkg.in/yaml.v3"

"github.com/ChristopherHX/github-act-runner/actionsrunner"
rcommon "github.com/ChristopherHX/github-act-runner/common"
"github.com/ChristopherHX/github-act-runner/protocol"
"github.com/ChristopherHX/github-act-runner/protocol/launch"
"github.com/ChristopherHX/github-act-runner/protocol/logger"
)

Expand Down Expand Up @@ -163,19 +157,20 @@ func (f *ghaFormatter) Format(entry *logrus.Entry) ([]byte, error) {
_ = f.logger.Update() // Ignore logger update errors
}
}
msg := entry.Message
if f.rqt.MaskHints != nil {
for _, v := range f.rqt.MaskHints {
if strings.EqualFold(v.Type, "regex") {
r, _ := regexp.Compile(v.Value)
entry.Message = r.ReplaceAllString(entry.Message, "***")
msg = r.ReplaceAllString(msg, "***")
}
}
}
if f.rqt.Variables != nil {
for _, v := range f.rqt.Variables {
if v.IsSecret && v.Value != "" && !strings.EqualFold(v.Value, "true") &&
!strings.EqualFold(v.Value, "false") && !strings.EqualFold(v.Value, "0") && !strings.EqualFold(v.Value, "1") {
entry.Message = strings.ReplaceAll(entry.Message, v.Value, "***")
msg = strings.ReplaceAll(msg, v.Value, "***")
}
}
}
Expand All @@ -201,9 +196,26 @@ func (f *ghaFormatter) Format(entry *logrus.Entry) ([]byte, error) {
case logrus.TraceLevel:
prefix += debugPrefix
}
entry.Message = f.linefeedregex.ReplaceAllString(prefix+strings.Trim(entry.Message, "\r\n"), "\n"+prefix)

b.WriteString(entry.Message)
command, _ := entry.Data["command"].(string)
arg, _ := entry.Data["arg"].(string)
raw, _ := entry.Data["raw"].(string)
switch strings.ToLower(command) {
case "group":
msg = "##[group]" + arg
case "endgroup":
msg = "##[endgroup]" + arg
case "debug":
msg = arg
case "warning":
msg = arg
case "error":
msg = arg
case "ignored":
msg = raw
}
msg = f.linefeedregex.ReplaceAllString(prefix+strings.Trim(msg, "\r\n"), "\n"+prefix)

b.WriteString(msg)
b.WriteByte('\n')
return b.Bytes(), nil
}
Expand Down Expand Up @@ -231,7 +243,7 @@ func ExecWorker(rqt *protocol.AgentJobRequestMessage, wc actionsrunner.WorkerCon
ctx: jobExecCtx,
}
actLogger.SetFormatter(formatter)
actLogger.Println("Initialize translating the job request to nektos/act")
actLogger.Println("Initialize translating the job request to actions-oss/act-cli (nektos/act)")
vssConnection, vssConnectionData, _ := rqt.GetConnection("SystemVssConnection")
if jlogger.Connection != nil {
vssConnection.Client = jlogger.Connection.Client
Expand Down Expand Up @@ -345,9 +357,9 @@ func ExecWorker(rqt *protocol.AgentJobRequestMessage, wc actionsrunner.WorkerCon
runnerConfig.LogOutput = true
runnerConfig.EventName = githubCtxMap["event_name"].(string)
runnerConfig.GitHubInstance = "github.com"
runnerConfig.GitHubServerUrl = githubCtxMap["server_url"].(string)
runnerConfig.GitHubApiServerUrl = githubCtxMap["api_url"].(string)
runnerConfig.GitHubGraphQlApiServerUrl = githubCtxMap["graphql_url"].(string)
runnerConfig.GitHubServerURL = githubCtxMap["server_url"].(string)
runnerConfig.GitHubAPIServerURL = githubCtxMap["api_url"].(string)
runnerConfig.GitHubGraphQlAPIServerURL = githubCtxMap["graphql_url"].(string)
runnerConfig.NoSkipCheckout = true // Needed to avoid copy the non exiting working dir
runnerConfig.AutoRemove = true // Needed to cleanup always cleanup container
runnerConfig.ForcePull = true
Expand All @@ -363,80 +375,6 @@ func ExecWorker(rqt *protocol.AgentJobRequestMessage, wc actionsrunner.WorkerCon
}
return nil
}
runnerConfig.DownloadAction = func(ngcei git.NewGitCloneExecutorInput) common.Executor {
return func(ctx context.Context) error {
actionList := &protocol.ActionReferenceList{}
actionurl := strings.Split(ngcei.URL, "/")
actionurl = actionurl[len(actionurl)-2:]
actionList.Actions = []protocol.ActionReference{
{NameWithOwner: strings.Join(actionurl, "/"), Ref: ngcei.Ref},
}
actionDownloadInfo := &protocol.ActionDownloadInfoCollection{}
if requestErr := vssConnection.RequestWithContext(ctx, "27d7f831-88c1-4719-8ca1-6a061dad90eb", "6.0-preview", "POST", map[string]string{
"scopeIdentifier": rqt.Plan.ScopeIdentifier,
"hubName": rqt.Plan.PlanType,
"planId": rqt.Plan.PlanID,
}, nil, actionList, actionDownloadInfo); requestErr != nil {
return requestErr
}
for _, v := range actionDownloadInfo.Actions {
token := runnerConfig.Token
if v.Authentication != nil && v.Authentication.Token != "" {
token = v.Authentication.Token
}
downloadErr := downloadAndExtractAction(ctx, ngcei.Dir, actionurl[0], actionurl[1],
v.ResolvedSha, v.TarballURL, token, &downloadActionHTTPClient)
if downloadErr != nil {
return downloadErr
}
}
return nil
}
}
if strings.EqualFold(rqt.MessageType, "RunnerJobRequest") {
runnerConfig.DownloadAction = nil
launchEndpoint, hasLaunchEndpoint := rqt.Variables["system.github.launch_endpoint"]
if hasLaunchEndpoint && launchEndpoint.Value != "" {
runnerConfig.DownloadAction = func(ngcei git.NewGitCloneExecutorInput) common.Executor {
return func(ctx context.Context) error {
actionList := &launch.ActionReferenceRequestList{}
actionurl := strings.Split(ngcei.URL, "/")
actionurl = actionurl[len(actionurl)-2:]
actionList.Actions = []launch.ActionReferenceRequest{
{Action: strings.Join(actionurl, "/"), Version: ngcei.Ref},
}
actionDownloadInfo := &launch.ActionDownloadInfoResponseCollection{}
urlBuilder := protocol.VssConnection{TenantURL: launchEndpoint.Value}
url, urlErr := urlBuilder.BuildURL("actions/build/{planId}/jobs/{jobId}/runnerresolve/actions", map[string]string{
"jobId": rqt.JobID,
"planId": rqt.Plan.PlanID,
}, nil)
if urlErr != nil {
return urlErr
}
if requestErr := vssConnection.RequestWithContext2(ctx, "POST", url, "", actionList, actionDownloadInfo); requestErr != nil {
return requestErr
}
for _, v := range actionDownloadInfo.Actions {
token := runnerConfig.Token
if v.Authentication != nil && v.Authentication.Token != "" {
token = v.Authentication.Token
}
downloadErr := downloadAndExtractAction(
ctx, ngcei.Dir, actionurl[0], actionurl[1], v.ResolvedSha, v.TarURL, token, &downloadActionHTTPClient,
)
if downloadErr != nil {
return downloadErr
}
}
return nil
}
}
}
}
if viaGit, hasViaGit := rcommon.LookupEnvBool("GITHUB_ACT_RUNNER_DOWNLOAD_ACTIONS_VIA_GIT"); hasViaGit && viaGit {
runnerConfig.DownloadAction = nil
}
rc := &runner.RunContext{
Name: uuid.New().String(),
Config: runnerConfig,
Expand All @@ -463,6 +401,45 @@ func ExecWorker(rqt *protocol.AgentJobRequestMessage, wc actionsrunner.WorkerCon
EventJSON: payload,
ContextData: map[string]interface{}{},
}
cacheBase := ActionCacheBase{
VssConnection: vssConnection,
Plan: rqt.Plan,
GHToken: runnerConfig.Token,
HttpClient: &downloadActionHTTPClient,
CacheDir: rc.ActionCacheDir(),
}
if viaGit, hasViaGit := rcommon.LookupEnvBool("GITHUB_ACT_RUNNER_DOWNLOAD_ACTIONS_VIA_GIT"); hasViaGit && viaGit {
runnerConfig.ActionCache = nil
} else if strings.EqualFold(rqt.MessageType, "RunnerJobRequest") {
launchEndpoint, hasLaunchEndpoint := rqt.Variables["system.github.launch_endpoint"]
if hasLaunchEndpoint && launchEndpoint.Value != "" {
launchCache := &LaunchActionCache{
ActionCacheBase: cacheBase,
LaunchEndpoint: launchEndpoint.Value,
JobID: rqt.JobID,
}
runnerConfig.ActionCache = launchCache
defer func() {
for _, v := range launchCache.delete {
if removeErr := os.Remove(v); removeErr != nil {
actLogger.Warnf("Unable to remove %v: %v", v, removeErr)
}
}
}()
}
} else {
vssCache := &VssActionCache{
ActionCacheBase: cacheBase,
}
runnerConfig.ActionCache = vssCache
defer func() {
for _, v := range vssCache.delete {
if removeErr := os.Remove(v); removeErr != nil {
actLogger.Warnf("Unable to remove %v: %v", v, removeErr)
}
}
}()
}
for k, v := range rqt.ContextData {
rc.ContextData[k] = v.ToRawObject()
}
Expand Down Expand Up @@ -527,9 +504,6 @@ func ExecWorker(rqt *protocol.AgentJobRequestMessage, wc actionsrunner.WorkerCon
}
}
rc.ContextData["github"] = githubCtxMap
val, _ := json.Marshal(githubCtx)
sv := string(val)
rc.GHContextData = &sv

ee := rc.NewExpressionEvaluator(jobExecCtx)
rc.ExprEval = ee
Expand Down Expand Up @@ -578,7 +552,7 @@ func ExecWorker(rqt *protocol.AgentJobRequestMessage, wc actionsrunner.WorkerCon
actLogger.Warn("github-act-runner is be unable to access \"" + cacheDir + "\". You might want set one of the " +
"following environment variables XDG_CACHE_HOME, HOME to a user read and writeable location. Details: " + mkdirErr.Error())
}
actLogger.Println("Starting nektos/act")
actLogger.Println("Starting actions-oss/act-cli (nektos/act)")
select {
case <-jobExecCtx.Done():
default:
Expand Down Expand Up @@ -634,79 +608,3 @@ func ExecWorker(rqt *protocol.AgentJobRequestMessage, wc actionsrunner.WorkerCon
}
finishJob2(jobStatus, outputMap)
}

func downloadAndExtractAction(
ctx context.Context, target, owner, name, resolvedSha, tarURL, token string, httpClient *http.Client,
) (reterr error) {
contextLogger := common.Logger(ctx)
cachedTar := filepath.Join(target, "..", owner+"."+name+"."+resolvedSha+".tar")
defer func() {
if reterr != nil {
_ = os.Remove(cachedTar) // Ignore cleanup errors
}
}()
var tarstream io.Reader
//nolint:gosec // cachedTar is constructed from controlled inputs (owner, name, resolvedSha)
if fr, err := os.Open(cachedTar); err == nil {
tarstream = fr
defer func() { _ = fr.Close() }() // Ignore file close errors
if contextLogger != nil {
contextLogger.Infof("Found cache for action %v/%v (sha:%v) from %v", owner, name, resolvedSha, cachedTar)
}
} else {
if contextLogger != nil {
contextLogger.Infof("Downloading action %v/%v (sha:%v) from %v", owner, name, resolvedSha, tarURL)
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, tarURL, http.NoBody)
if err != nil {
return err
}
if token != "" {
req.Header.Add("Authorization", "token "+token)
}
req.Header.Add("User-Agent", "github-act-runner/1.0.0")
req.Header.Add("Accept", "*/*")
rsp, err := httpClient.Do(req)
if err != nil {
return err
}
defer func() { _ = rsp.Body.Close() }() // Ignore response body close errors
if rsp.StatusCode != http.StatusOK {
buf := &bytes.Buffer{}
_, _ = io.Copy(buf, rsp.Body) // Ignore error copy for error message
return fmt.Errorf("failed to download action from %v response %v", tarURL, buf.String())
}
if len(resolvedSha) == len("0000000000000000000000000000000000000000") {
//nolint:gosec // cachedTar is constructed from controlled inputs (owner, name, resolvedSha)
fo, err := os.Create(cachedTar)
if err != nil {
return err
}
defer func() { _ = fo.Close() }() // Ignore file close errors
bytesWritten, err := io.Copy(fo, rsp.Body)
if err != nil {
return err
}
if rsp.ContentLength >= 0 && bytesWritten != rsp.ContentLength {
return fmt.Errorf("failed to download tar expected %v, but copied %v", rsp.ContentLength, bytesWritten)
}
tarstream = fo
_, _ = fo.Seek(0, 0) // Ignore seek errors
} else {
tarstream = rsp.Body
}
}
if err := extractTarGz(tarstream, target); err != nil {
return err
}
return nil
}

func extractTarGz(reader io.Reader, dir string) error {
gzr, err := gzip.NewReader(reader)
if err != nil {
return err
}
defer func() { _ = gzr.Close() }() // Ignore gzip reader close errors
return filecollector.ExtractTar(gzr, dir)
}
Loading
Loading