summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--.gitea/workflows/test.yml44
-rw-r--r--.gitignore1
-rw-r--r--LICENSE1
-rw-r--r--README.md25
-rw-r--r--go.mod1
-rw-r--r--go.sum2
-rw-r--r--pkg/common/logger.go21
-rw-r--r--pkg/container/container_types.go7
-rw-r--r--pkg/container/docker_network.go40
-rw-r--r--pkg/container/docker_run.go129
-rw-r--r--pkg/container/docker_run_test.go77
-rw-r--r--pkg/container/docker_stub.go12
-rw-r--r--pkg/container/host_environment.go6
-rw-r--r--pkg/exprparser/interpreter.go2
-rw-r--r--pkg/jobparser/evaluator.go185
-rw-r--r--pkg/jobparser/interpeter.go81
-rw-r--r--pkg/jobparser/jobparser.go150
-rw-r--r--pkg/jobparser/jobparser_test.go76
-rw-r--r--pkg/jobparser/model.go333
-rw-r--r--pkg/jobparser/model_test.go306
-rw-r--r--pkg/jobparser/testdata/empty_step.in.yaml8
-rw-r--r--pkg/jobparser/testdata/empty_step.out.yaml7
-rw-r--r--pkg/jobparser/testdata/erase_needs.in.yaml16
-rw-r--r--pkg/jobparser/testdata/erase_needs.out.yaml23
-rw-r--r--pkg/jobparser/testdata/has_needs.in.yaml16
-rw-r--r--pkg/jobparser/testdata/has_needs.out.yaml25
-rw-r--r--pkg/jobparser/testdata/has_secrets.in.yaml14
-rw-r--r--pkg/jobparser/testdata/has_secrets.out.yaml16
-rw-r--r--pkg/jobparser/testdata/has_with.in.yaml15
-rw-r--r--pkg/jobparser/testdata/has_with.out.yaml17
-rw-r--r--pkg/jobparser/testdata/multiple_jobs.in.yaml22
-rw-r--r--pkg/jobparser/testdata/multiple_jobs.out.yaml39
-rw-r--r--pkg/jobparser/testdata/multiple_matrix.in.yaml13
-rw-r--r--pkg/jobparser/testdata/multiple_matrix.out.yaml101
-rw-r--r--pkg/jobparser/testdata_test.go18
-rw-r--r--pkg/model/action.go5
-rw-r--r--pkg/model/planner.go7
-rw-r--r--pkg/model/workflow.go30
-rw-r--r--pkg/model/workflow_test.go82
-rw-r--r--pkg/runner/action.go108
-rw-r--r--pkg/runner/action_composite.go1
-rw-r--r--pkg/runner/command.go3
-rw-r--r--pkg/runner/job_executor.go40
-rw-r--r--pkg/runner/logger.go20
-rw-r--r--pkg/runner/reusable_workflow.go87
-rw-r--r--pkg/runner/run_context.go291
-rw-r--r--pkg/runner/run_context_test.go21
-rw-r--r--pkg/runner/runner.go25
-rw-r--r--pkg/runner/runner_test.go37
-rw-r--r--pkg/runner/step_action_remote.go56
-rw-r--r--pkg/runner/step_action_remote_test.go94
-rw-r--r--pkg/runner/step_docker.go34
-rw-r--r--pkg/runner/testdata/services/push.yaml26
53 files changed, 2723 insertions, 93 deletions
diff --git a/.gitea/workflows/test.yml b/.gitea/workflows/test.yml
new file mode 100644
index 0000000..6973ddc
--- /dev/null
+++ b/.gitea/workflows/test.yml
@@ -0,0 +1,44 @@
+name: checks
+on:
+ - push
+ - pull_request
+
+env:
+ GOPROXY: https://goproxy.io,direct
+ GOPATH: /go_path
+ GOCACHE: /go_cache
+
+jobs:
+ lint:
+ name: check and test
+ runs-on: ubuntu-latest
+ steps:
+ - name: cache go path
+ id: cache-go-path
+ uses: https://github.com/actions/cache@v3
+ with:
+ path: /go_path
+ key: go_path-${{ github.repository }}-${{ github.ref_name }}
+ restore-keys: |
+ go_path-${{ github.repository }}-
+ go_path-
+ - name: cache go cache
+ id: cache-go-cache
+ uses: https://github.com/actions/cache@v3
+ with:
+ path: /go_cache
+ key: go_cache-${{ github.repository }}-${{ github.ref_name }}
+ restore-keys: |
+ go_cache-${{ github.repository }}-
+ go_cache-
+ - uses: actions/setup-go@v3
+ with:
+ go-version: 1.20
+ - uses: actions/checkout@v3
+ - name: vet checks
+ run: go vet -v ./...
+ - name: build
+ run: go build -v ./...
+ - name: test
+ run: go test -v ./pkg/jobparser
+ # TODO test more packages
diff --git a/.gitignore b/.gitignore
index 2415210..c5ac0d9 100644
--- a/.gitignore
+++ b/.gitignore
@@ -31,3 +31,4 @@ coverage.txt
# megalinter
report/
+act
diff --git a/LICENSE b/LICENSE
index 15bc72f..fb4080f 100644
--- a/LICENSE
+++ b/LICENSE
@@ -1,5 +1,6 @@
MIT License
+Copyright (c) 2022 The Gitea Authors
Copyright (c) 2019
Permission is hereby granted, free of charge, to any person obtaining a copy
diff --git a/README.md b/README.md
index 2902a8c..63ee16d 100644
--- a/README.md
+++ b/README.md
@@ -1,3 +1,28 @@
+## Forking rules
+
+This is a custom fork of [nektos/act](https://github.com/nektos/act/), for the purpose of serving [act_runner](https://gitea.com/gitea/act_runner).
+
+It cannot be used as command line tool anymore, but only as a library.
+
+It's a soft fork, which means that it will tracking the latest release of nektos/act.
+
+Branches:
+
+- `main`: default branch, contains custom changes, based on the latest release(not the latest of the master branch) of nektos/act.
+- `nektos/master`: mirror for the master branch of nektos/act.
+
+Tags:
+
+- `nektos/vX.Y.Z`: mirror for `vX.Y.Z` of [nektos/act](https://github.com/nektos/act/).
+- `vX.YZ.*`: based on `nektos/vX.Y.Z`, contains custom changes.
+ - Examples:
+ - `nektos/v0.2.23` -> `v0.223.*`
+ - `nektos/v0.3.1` -> `v0.301.*`, not ~~`v0.31.*`~~
+ - `nektos/v0.10.1` -> `v0.1001.*`, not ~~`v0.101.*`~~
+ - `nektos/v0.3.100` -> not ~~`v0.3100.*`~~, I don't think it's really going to happen, if it does, we can find a way to handle it.
+
+---
+
![act-logo](https://github.com/nektos/act/wiki/img/logo-150.png)
# Overview [![push](https://github.com/nektos/act/workflows/push/badge.svg?branch=master&event=push)](https://github.com/nektos/act/actions) [![Join the chat at https://gitter.im/nektos/act](https://badges.gitter.im/nektos/act.svg)](https://gitter.im/nektos/act?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge) [![Go Report Card](https://goreportcard.com/badge/github.com/nektos/act)](https://goreportcard.com/report/github.com/nektos/act) [![awesome-runners](https://img.shields.io/badge/listed%20on-awesome--runners-blue.svg)](https://github.com/jonico/awesome-runners)
diff --git a/go.mod b/go.mod
index 7322090..90dbbe8 100644
--- a/go.mod
+++ b/go.mod
@@ -14,6 +14,7 @@ require (
github.com/docker/go-connections v0.4.0
github.com/go-git/go-billy/v5 v5.5.0
github.com/go-git/go-git/v5 v5.9.0
+ github.com/gobwas/glob v0.2.3
github.com/imdario/mergo v0.3.16
github.com/joho/godotenv v1.5.1
github.com/julienschmidt/httprouter v1.3.0
diff --git a/go.sum b/go.sum
index 122a6b7..0f204ed 100644
--- a/go.sum
+++ b/go.sum
@@ -62,6 +62,8 @@ github.com/go-git/go-billy/v5 v5.5.0/go.mod h1:hmexnoNsr2SJU1Ju67OaNz5ASJY3+sHgF
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20230305113008-0c11038e723f h1:Pz0DHeFij3XFhoBRGUDPzSJ+w2UcK5/0JvF8DRI58r8=
github.com/go-git/go-git/v5 v5.9.0 h1:cD9SFA7sHVRdJ7AYck1ZaAa/yeuBvGPxwXDL8cxrObY=
github.com/go-git/go-git/v5 v5.9.0/go.mod h1:RKIqga24sWdMGZF+1Ekv9kylsDz6LzdTSI2s/OsZWE0=
+github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y=
+github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
diff --git a/pkg/common/logger.go b/pkg/common/logger.go
index a9501ce..74fc96d 100644
--- a/pkg/common/logger.go
+++ b/pkg/common/logger.go
@@ -25,3 +25,24 @@ func Logger(ctx context.Context) logrus.FieldLogger {
func WithLogger(ctx context.Context, logger logrus.FieldLogger) context.Context {
return context.WithValue(ctx, loggerContextKeyVal, logger)
}
+
+type loggerHookKey string
+
+const loggerHookKeyVal = loggerHookKey("logrus.Hook")
+
+// LoggerHook returns the appropriate logger hook for current context
+// the hook affects job logger, not global logger
+func LoggerHook(ctx context.Context) logrus.Hook {
+ val := ctx.Value(loggerHookKeyVal)
+ if val != nil {
+ if hook, ok := val.(logrus.Hook); ok {
+ return hook
+ }
+ }
+ return nil
+}
+
+// WithLoggerHook adds a value to the context for the logger hook
+func WithLoggerHook(ctx context.Context, hook logrus.Hook) context.Context {
+ return context.WithValue(ctx, loggerHookKeyVal, hook)
+}
diff --git a/pkg/container/container_types.go b/pkg/container/container_types.go
index 767beb5..ab70f98 100644
--- a/pkg/container/container_types.go
+++ b/pkg/container/container_types.go
@@ -26,6 +26,12 @@ type NewContainerInput struct {
UsernsMode string
Platform string
Options string
+
+ // Gitea specific
+ AutoRemove bool
+
+ NetworkAliases []string
+ ValidVolumes []string
}
// FileEntry is a file to copy to a container
@@ -38,6 +44,7 @@ type FileEntry struct {
// Container for managing docker run containers
type Container interface {
Create(capAdd []string, capDrop []string) common.Executor
+ ConnectToNetwork(name string) common.Executor
Copy(destPath string, files ...*FileEntry) common.Executor
CopyTarStream(ctx context.Context, destPath string, tarStream io.Reader) error
CopyDir(destPath string, srcPath string, useGitIgnore bool) common.Executor
diff --git a/pkg/container/docker_network.go b/pkg/container/docker_network.go
new file mode 100644
index 0000000..d3a0046
--- /dev/null
+++ b/pkg/container/docker_network.go
@@ -0,0 +1,40 @@
+//go:build !(WITHOUT_DOCKER || !(linux || darwin || windows))
+
+package container
+
+import (
+ "context"
+
+ "github.com/docker/docker/api/types"
+ "github.com/nektos/act/pkg/common"
+)
+
+func NewDockerNetworkCreateExecutor(name string) common.Executor {
+ return func(ctx context.Context) error {
+ cli, err := GetDockerClient(ctx)
+ if err != nil {
+ return err
+ }
+
+ _, err = cli.NetworkCreate(ctx, name, types.NetworkCreate{
+ Driver: "bridge",
+ Scope: "local",
+ })
+ if err != nil {
+ return err
+ }
+
+ return nil
+ }
+}
+
+func NewDockerNetworkRemoveExecutor(name string) common.Executor {
+ return func(ctx context.Context) error {
+ cli, err := GetDockerClient(ctx)
+ if err != nil {
+ return err
+ }
+
+ return cli.NetworkRemove(ctx, name)
+ }
+}
diff --git a/pkg/container/docker_run.go b/pkg/container/docker_run.go
index cf58aee..5304707 100644
--- a/pkg/container/docker_run.go
+++ b/pkg/container/docker_run.go
@@ -16,6 +16,10 @@ import (
"strconv"
"strings"
+ "github.com/docker/docker/api/types/network"
+ networktypes "github.com/docker/docker/api/types/network"
+ "github.com/gobwas/glob"
+
"github.com/go-git/go-billy/v5/helper/polyfill"
"github.com/go-git/go-billy/v5/osfs"
"github.com/go-git/go-git/v5/plumbing/format/gitignore"
@@ -25,6 +29,7 @@ import (
"github.com/kballard/go-shellquote"
"github.com/spf13/pflag"
+ "github.com/docker/cli/cli/compose/loader"
"github.com/docker/cli/cli/connhelper"
"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/container"
@@ -46,6 +51,25 @@ func NewContainer(input *NewContainerInput) ExecutionsEnvironment {
return cr
}
+func (cr *containerReference) ConnectToNetwork(name string) common.Executor {
+ return common.
+ NewDebugExecutor("%sdocker network connect %s %s", logPrefix, name, cr.input.Name).
+ Then(
+ common.NewPipelineExecutor(
+ cr.connect(),
+ cr.connectToNetwork(name, cr.input.NetworkAliases),
+ ).IfNot(common.Dryrun),
+ )
+}
+
+func (cr *containerReference) connectToNetwork(name string, aliases []string) common.Executor {
+ return func(ctx context.Context) error {
+ return cr.cli.NetworkConnect(ctx, name, cr.input.Name, &networktypes.EndpointSettings{
+ Aliases: aliases,
+ })
+ }
+}
+
// supportsContainerImagePlatform returns true if the underlying Docker server
// API version is 1.41 and beyond
func supportsContainerImagePlatform(ctx context.Context, cli client.APIClient) bool {
@@ -345,10 +369,20 @@ func (cr *containerReference) mergeContainerConfigs(ctx context.Context, config
return nil, nil, fmt.Errorf("Cannot parse container options: '%s': '%w'", input.Options, err)
}
- if len(copts.netMode.Value()) == 0 {
- if err = copts.netMode.Set("host"); err != nil {
- return nil, nil, fmt.Errorf("Cannot parse networkmode=host. This is an internal error and should not happen: '%w'", err)
- }
+ // If a service container's network is set to `host`, the container will not be able to
+ // connect to the specified network created for the job container and the service containers.
+ // So comment out the following code.
+
+ // if len(copts.netMode.Value()) == 0 {
+ // if err = copts.netMode.Set("host"); err != nil {
+ // return nil, nil, fmt.Errorf("Cannot parse networkmode=host. This is an internal error and should not happen: '%w'", err)
+ // }
+ // }
+
+ // If the `privileged` config has been disabled, `copts.privileged` need to be forced to false,
+ // even if the user specifies `--privileged` in the options string.
+ if !hostConfig.Privileged {
+ copts.privileged = false
}
containerConfig, err := parse(flags, copts, runtime.GOOS)
@@ -358,7 +392,7 @@ func (cr *containerReference) mergeContainerConfigs(ctx context.Context, config
logger.Debugf("Custom container.Config from options ==> %+v", containerConfig.Config)
- err = mergo.Merge(config, containerConfig.Config, mergo.WithOverride)
+ err = mergo.Merge(config, containerConfig.Config, mergo.WithOverride, mergo.WithAppendSlice)
if err != nil {
return nil, nil, fmt.Errorf("Cannot merge container.Config options: '%s': '%w'", input.Options, err)
}
@@ -370,12 +404,17 @@ func (cr *containerReference) mergeContainerConfigs(ctx context.Context, config
hostConfig.Mounts = append(hostConfig.Mounts, containerConfig.HostConfig.Mounts...)
binds := hostConfig.Binds
mounts := hostConfig.Mounts
+ networkMode := hostConfig.NetworkMode
err = mergo.Merge(hostConfig, containerConfig.HostConfig, mergo.WithOverride)
if err != nil {
return nil, nil, fmt.Errorf("Cannot merge container.HostConfig options: '%s': '%w'", input.Options, err)
}
hostConfig.Binds = binds
hostConfig.Mounts = mounts
+ if len(copts.netMode.Value()) > 0 {
+ logger.Warn("--network and --net in the options will be ignored.")
+ }
+ hostConfig.NetworkMode = networkMode
logger.Debugf("Merged container.HostConfig ==> %+v", hostConfig)
return config, hostConfig, nil
@@ -437,6 +476,7 @@ func (cr *containerReference) create(capAdd []string, capDrop []string) common.E
NetworkMode: container.NetworkMode(input.NetworkMode),
Privileged: input.Privileged,
UsernsMode: container.UsernsMode(input.UsernsMode),
+ AutoRemove: input.AutoRemove,
}
logger.Debugf("Common container.HostConfig ==> %+v", hostConfig)
@@ -445,7 +485,24 @@ func (cr *containerReference) create(capAdd []string, capDrop []string) common.E
return err
}
- resp, err := cr.cli.ContainerCreate(ctx, config, hostConfig, nil, platSpecs, input.Name)
+ // For Gitea
+ config, hostConfig = cr.sanitizeConfig(ctx, config, hostConfig)
+
+ // For Gitea
+ // network-scoped alias is supported only for containers in user defined networks
+ var networkingConfig *network.NetworkingConfig
+ if hostConfig.NetworkMode.IsUserDefined() && len(input.NetworkAliases) > 0 {
+ endpointConfig := &network.EndpointSettings{
+ Aliases: input.NetworkAliases,
+ }
+ networkingConfig = &network.NetworkingConfig{
+ EndpointsConfig: map[string]*network.EndpointSettings{
+ input.NetworkMode: endpointConfig,
+ },
+ }
+ }
+
+ resp, err := cr.cli.ContainerCreate(ctx, config, hostConfig, networkingConfig, platSpecs, input.Name)
if err != nil {
return fmt.Errorf("failed to create container: '%w'", err)
}
@@ -834,3 +891,63 @@ func (cr *containerReference) wait() common.Executor {
return fmt.Errorf("exit with `FAILURE`: %v", statusCode)
}
}
+
+// For Gitea
+// sanitizeConfig remove the invalid configurations from `config` and `hostConfig`
+func (cr *containerReference) sanitizeConfig(ctx context.Context, config *container.Config, hostConfig *container.HostConfig) (*container.Config, *container.HostConfig) {
+ logger := common.Logger(ctx)
+
+ if len(cr.input.ValidVolumes) > 0 {
+ globs := make([]glob.Glob, 0, len(cr.input.ValidVolumes))
+ for _, v := range cr.input.ValidVolumes {
+ if g, err := glob.Compile(v); err != nil {
+ logger.Errorf("create glob from %s error: %v", v, err)
+ } else {
+ globs = append(globs, g)
+ }
+ }
+ isValid := func(v string) bool {
+ for _, g := range globs {
+ if g.Match(v) {
+ return true
+ }
+ }
+ return false
+ }
+ // sanitize binds
+ sanitizedBinds := make([]string, 0, len(hostConfig.Binds))
+ for _, bind := range hostConfig.Binds {
+ parsed, err := loader.ParseVolume(bind)
+ if err != nil {
+ logger.Warnf("parse volume [%s] error: %v", bind, err)
+ continue
+ }
+ if parsed.Source == "" {
+ // anonymous volume
+ sanitizedBinds = append(sanitizedBinds, bind)
+ continue
+ }
+ if isValid(parsed.Source) {
+ sanitizedBinds = append(sanitizedBinds, bind)
+ } else {
+ logger.Warnf("[%s] is not a valid volume, will be ignored", parsed.Source)
+ }
+ }
+ hostConfig.Binds = sanitizedBinds
+ // sanitize mounts
+ sanitizedMounts := make([]mount.Mount, 0, len(hostConfig.Mounts))
+ for _, mt := range hostConfig.Mounts {
+ if isValid(mt.Source) {
+ sanitizedMounts = append(sanitizedMounts, mt)
+ } else {
+ logger.Warnf("[%s] is not a valid volume, will be ignored", mt.Source)
+ }
+ }
+ hostConfig.Mounts = sanitizedMounts
+ } else {
+ hostConfig.Binds = []string{}
+ hostConfig.Mounts = []mount.Mount{}
+ }
+
+ return config, hostConfig
+}
diff --git a/pkg/container/docker_run_test.go b/pkg/container/docker_run_test.go
index 8309df6..2a2007a 100644
--- a/pkg/container/docker_run_test.go
+++ b/pkg/container/docker_run_test.go
@@ -9,8 +9,12 @@ import (
"testing"
"time"
+ "github.com/nektos/act/pkg/common"
+
"github.com/docker/docker/api/types"
+ "github.com/docker/docker/api/types/container"
"github.com/docker/docker/client"
+ "github.com/sirupsen/logrus/hooks/test"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
)
@@ -166,3 +170,76 @@ func TestDockerExecFailure(t *testing.T) {
// Type assert containerReference implements ExecutionsEnvironment
var _ ExecutionsEnvironment = &containerReference{}
+
+func TestCheckVolumes(t *testing.T) {
+ testCases := []struct {
+ desc string
+ validVolumes []string
+ binds []string
+ expectedBinds []string
+ }{
+ {
+ desc: "match all volumes",
+ validVolumes: []string{"**"},
+ binds: []string{
+ "shared_volume:/shared_volume",
+ "/home/test/data:/test_data",
+ "/etc/conf.d/base.json:/config/base.json",
+ "sql_data:/sql_data",
+ "/secrets/keys:/keys",
+ },
+ expectedBinds: []string{
+ "shared_volume:/shared_volume",
+ "/home/test/data:/test_data",
+ "/etc/conf.d/base.json:/config/base.json",
+ "sql_data:/sql_data",
+ "/secrets/keys:/keys",
+ },
+ },
+ {
+ desc: "no volumes can be matched",
+ validVolumes: []string{},
+ binds: []string{
+ "shared_volume:/shared_volume",
+ "/home/test/data:/test_data",
+ "/etc/conf.d/base.json:/config/base.json",
+ "sql_data:/sql_data",
+ "/secrets/keys:/keys",
+ },
+ expectedBinds: []string{},
+ },
+ {
+ desc: "only allowed volumes can be matched",
+ validVolumes: []string{
+ "shared_volume",
+ "/home/test/data",
+ "/etc/conf.d/*.json",
+ },
+ binds: []string{
+ "shared_volume:/shared_volume",
+ "/home/test/data:/test_data",
+ "/etc/conf.d/base.json:/config/base.json",
+ "sql_data:/sql_data",
+ "/secrets/keys:/keys",
+ },
+ expectedBinds: []string{
+ "shared_volume:/shared_volume",
+ "/home/test/data:/test_data",
+ "/etc/conf.d/base.json:/config/base.json",
+ },
+ },
+ }
+ for _, tc := range testCases {
+ t.Run(tc.desc, func(t *testing.T) {
+ logger, _ := test.NewNullLogger()
+ ctx := common.WithLogger(context.Background(), logger)
+ cr := &containerReference{
+ input: &NewContainerInput{
+ ValidVolumes: tc.validVolumes,
+ },
+ }
+ _, hostConf := cr.sanitizeConfig(ctx, &container.Config{}, &container.HostConfig{Binds: tc.binds})
+ assert.Equal(t, tc.expectedBinds, hostConf.Binds)
+ })
+ }
+}
diff --git a/pkg/container/docker_stub.go b/pkg/container/docker_stub.go
index b28c90d..36f530e 100644
--- a/pkg/container/docker_stub.go
+++ b/pkg/container/docker_stub.go
@@ -55,3 +55,15 @@ func NewDockerVolumeRemoveExecutor(volume string, force bool) common.Executor {
return nil
}
}
+
+func NewDockerNetworkCreateExecutor(name string) common.Executor {
+ return func(ctx context.Context) error {
+ return nil
+ }
+}
+
+func NewDockerNetworkRemoveExecutor(name string) common.Executor {
+ return func(ctx context.Context) error {
+ return nil
+ }
+}
diff --git a/pkg/container/host_environment.go b/pkg/container/host_environment.go
index 91dae4c..a131f81 100644
--- a/pkg/container/host_environment.go
+++ b/pkg/container/host_environment.go
@@ -40,6 +40,12 @@ func (e *HostEnvironment) Create(_ []string, _ []string) common.Executor {
}
}
+func (e *HostEnvironment) ConnectToNetwork(name string) common.Executor {
+ return func(ctx context.Context) error {
+ return nil
+ }
+}
+
func (e *HostEnvironment) Close() common.Executor {
return func(ctx context.Context) error {
return nil
diff --git a/pkg/exprparser/interpreter.go b/pkg/exprparser/interpreter.go
index feff80f..4a1cdb1 100644
--- a/pkg/exprparser/interpreter.go
+++ b/pkg/exprparser/interpreter.go
@@ -154,6 +154,8 @@ func (impl *interperterImpl) evaluateVariable(variableNode *actionlint.VariableN
switch strings.ToLower(variableNode.Name) {
case "github":
return impl.env.Github, nil
+ case "gitea": // compatible with Gitea
+ return impl.env.Github, nil
case "env":
return impl.env.Env, nil
case "job":
diff --git a/pkg/jobparser/evaluator.go b/pkg/jobparser/evaluator.go
new file mode 100644
index 0000000..80a1397
--- /dev/null
+++ b/pkg/jobparser/evaluator.go
@@ -0,0 +1,185 @@
+package jobparser
+
+import (
+ "fmt"
+ "regexp"
+ "strings"
+
+ "github.com/nektos/act/pkg/exprparser"
+ "gopkg.in/yaml.v3"
+)
+
+// ExpressionEvaluator is copied from runner.expressionEvaluator,
+// to avoid unnecessary dependencies
+type ExpressionEvaluator struct {
+ interpreter exprparser.Interpreter
+}
+
+func NewExpressionEvaluator(interpreter exprparser.Interpreter) *ExpressionEvaluator {
+ return &ExpressionEvaluator{interpreter: interpreter}
+}
+
+func (ee ExpressionEvaluator) evaluate(in string, defaultStatusCheck exprparser.DefaultStatusCheck) (interface{}, error) {
+ evaluated, err := ee.interpreter.Evaluate(in, defaultStatusCheck)
+
+ return evaluated, err
+}
+
+func (ee ExpressionEvaluator) evaluateScalarYamlNode(node *yaml.Node) error {
+ var in string
+ if err := node.Decode(&in); err != nil {
+ return err
+ }
+ if !strings.Contains(in, "${{") || !strings.Contains(in, "}}") {
+ return nil
+ }
+ expr, _ := rewriteSubExpression(in, false)
+ res, err := ee.evaluate(expr, exprparser.DefaultStatusCheckNone)
+ if err != nil {
+ return err
+ }
+ return node.Encode(res)
+}
+
+func (ee ExpressionEvaluator) evaluateMappingYamlNode(node *yaml.Node) error {
+ // GitHub has this undocumented feature to merge maps, called insert directive
+ insertDirective := regexp.MustCompile(`\${{\s*insert\s*}}`)
+ for i := 0; i < len(node.Content)/2; {
+ k := node.Content[i*2]
+ v := node.Content[i*2+1]
+ if err := ee.EvaluateYamlNode(v); err != nil {
+ return err
+ }
+ var sk string
+ // Merge the nested map of the insert directive
+ if k.Decode(&sk) == nil && insertDirective.MatchString(sk) {
+ node.Content = append(append(node.Content[:i*2], v.Content...), node.Content[(i+1)*2:]...)
+ i += len(v.Content) / 2
+ } else {
+ if err := ee.EvaluateYamlNode(k); err != nil {
+ return err
+ }
+ i++
+ }
+ }
+ return nil
+}
+
+func (ee ExpressionEvaluator) evaluateSequenceYamlNode(node *yaml.Node) error {
+ for i := 0; i < len(node.Content); {
+ v := node.Content[i]
+ // Preserve nested sequences
+ wasseq := v.Kind == yaml.SequenceNode
+ if err := ee.EvaluateYamlNode(v); err != nil {
+ return err
+ }
+ // GitHub has this undocumented feature to merge sequences / arrays
+ // We have a nested sequence via evaluation, merge the arrays
+ if v.Kind == yaml.SequenceNode && !wasseq {
+ node.Content = append(append(node.Content[:i], v.Content...), node.Content[i+1:]...)
+ i += len(v.Content)
+ } else {
+ i++
+ }
+ }
+ return nil
+}
+
+func (ee ExpressionEvaluator) EvaluateYamlNode(node *yaml.Node) error {
+ switch node.Kind {
+ case yaml.ScalarNode:
+ return ee.evaluateScalarYamlNode(node)
+ case yaml.MappingNode:
+ return ee.evaluateMappingYamlNode(node)
+ case yaml.SequenceNode:
+ return ee.evaluateSequenceYamlNode(node)
+ default:
+ return nil
+ }
+}
+
+func (ee ExpressionEvaluator) Interpolate(in string) string {
+ if !strings.Contains(in, "${{") || !strings.Contains(in, "}}") {
+ return in
+ }
+
+ expr, _ := rewriteSubExpression(in, true)
+ evaluated, err := ee.evaluate(expr, exprparser.DefaultStatusCheckNone)
+ if err != nil {
+ return ""
+ }
+
+ value, ok := evaluated.(string)
+ if !ok {
+ panic(fmt.Sprintf("Expression %s did not evaluate to a string", expr))
+ }
+
+ return value
+}
+
+func escapeFormatString(in string) string {
+ return strings.ReplaceAll(strings.ReplaceAll(in, "{", "{{"), "}", "}}")
+}
+
+func rewriteSubExpression(in string, forceFormat bool) (string, error) {
+ if !strings.Contains(in, "${{") || !strings.Contains(in, "}}") {
+ return in, nil
+ }
+
+ strPattern := regexp.MustCompile("(?:''|[^'])*'")
+ pos := 0
+ exprStart := -1
+ strStart := -1
+ var results []string
+ formatOut := ""
+ for pos < len(in) {
+ if strStart > -1 {
+ matches := strPattern.FindStringIndex(in[pos:])
+ if matches == nil {
+ panic("unclosed string.")
+ }
+
+ strStart = -1
+ pos += matches[1]
+ } else if exprStart > -1 {
+ exprEnd := strings.Index(in[pos:], "}}")
+ strStart = strings.Index(in[pos:], "'")
+
+ if exprEnd > -1 && strStart > -1 {
+ if exprEnd < strStart {
+ strStart = -1
+ } else {
+ exprEnd = -1
+ }
+ }
+
+ if exprEnd > -1 {
+ formatOut += fmt.Sprintf("{%d}", len(results))
+ results = append(results, strings.TrimSpace(in[exprStart:pos+exprEnd]))
+ pos += exprEnd + 2
+ exprStart = -1
+ } else if strStart > -1 {
+ pos += strStart + 1
+ } else {
+ panic("unclosed expression.")
+ }
+ } else {
+ exprStart = strings.Index(in[pos:], "${{")
+ if exprStart != -1 {
+ formatOut += escapeFormatString(in[pos : pos+exprStart])
+ exprStart = pos + exprStart + 3
+ pos = exprStart
+ } else {
+ formatOut += escapeFormatString(in[pos:])
+ pos = len(in)
+ }
+ }
+ }
+
+ if len(results) == 1 && formatOut == "{0}" && !forceFormat {
+ return in, nil
+ }
+
+ out := fmt.Sprintf("format('%s', %s)", strings.ReplaceAll(formatOut, "'", "''"), strings.Join(results, ", "))
+ return out, nil
+}
diff --git a/pkg/jobparser/interpeter.go b/pkg/jobparser/interpeter.go
new file mode 100644
index 0000000..750f964
--- /dev/null
+++ b/pkg/jobparser/interpeter.go
@@ -0,0 +1,81 @@
+package jobparser
+
+import (
+ "github.com/nektos/act/pkg/exprparser"
+ "github.com/nektos/act/pkg/model"
+ "gopkg.in/yaml.v3"
+)
+
+// NewInterpeter returns an interpeter used in the server,
+// need github, needs, strategy, matrix, inputs context only,
+// see https://docs.github.com/en/actions/learn-github-actions/contexts#context-availability
+func NewInterpeter(
+ jobID string,
+ job *model.Job,
+ matrix map[string]interface{},
+ gitCtx *model.GithubContext,
+ results map[string]*JobResult,
+) exprparser.Interpreter {
+ strategy := make(map[string]interface{})
+ if job.Strategy != nil {
+ strategy["fail-fast"] = job.Strategy.FailFast
+ strategy["max-parallel"] = job.Strategy.MaxParallel
+ }
+
+ run := &model.Run{
+ Workflow: &model.Workflow{
+ Jobs: map[string]*model.Job{},
+ },
+ JobID: jobID,
+ }
+ for id, result := range results {
+ need := yaml.Node{}
+ _ = need.Encode(result.Needs)
+ run.Workflow.Jobs[id] = &model.Job{
+ RawNeeds: need,
+ Result: result.Result,
+ Outputs: result.Outputs,
+ }
+ }
+
+ jobs := run.Workflow.Jobs
+ jobNeeds := run.Job().Needs()
+
+ using := map[string]exprparser.Needs{}
+ for _, need := range jobNeeds {
+ if v, ok := jobs[need]; ok {
+ using[need] = exprparser.Needs{
+ Outputs: v.Outputs,
+ Result: v.Result,
+ }
+ }
+ }
+
+ ee := &exprparser.EvaluationEnvironment{
+ Github: gitCtx,
+ Env: nil, // no need
+ Job: nil, // no need
+ Steps: nil, // no need
+ Runner: nil, // no need
+ Secrets: nil, // no need
+ Strategy: strategy,
+ Matrix: matrix,
+ Needs: using,
+ Inputs: nil, // not supported yet
+ }
+
+ config := exprparser.Config{
+ Run: run,
+ WorkingDir: "", // WorkingDir is used for the function hashFiles, but it's not needed in the server
+ Context: "job",
+ }
+
+ return exprparser.NewInterpeter(ee, config)
+}
+
+// JobResult is the minimum requirement of job results for Interpeter
+type JobResult struct {
+ Needs []string
+ Result string
+ Outputs map[string]string
+}
diff --git a/pkg/jobparser/jobparser.go b/pkg/jobparser/jobparser.go
new file mode 100644
index 0000000..cd84651
--- /dev/null
+++ b/pkg/jobparser/jobparser.go
@@ -0,0 +1,150 @@
+package jobparser
+
+import (
+ "bytes"
+ "fmt"
+ "sort"
+ "strings"
+
+ "gopkg.in/yaml.v3"
+
+ "github.com/nektos/act/pkg/model"
+)
+
+func Parse(content []byte, options ...ParseOption) ([]*SingleWorkflow, error) {
+ origin, err := model.ReadWorkflow(bytes.NewReader(content))
+ if err != nil {
+ return nil, fmt.Errorf("model.ReadWorkflow: %w", err)
+ }
+
+ workflow := &SingleWorkflow{}
+ if err := yaml.Unmarshal(content, workflow); err != nil {
+ return nil, fmt.Errorf("yaml.Unmarshal: %w", err)
+ }
+
+ pc := &parseContext{}
+ for _, o := range options {
+ o(pc)
+ }
+ results := map[string]*JobResult{}
+ for id, job := range origin.Jobs {
+ results[id] = &JobResult{
+ Needs: job.Needs(),
+ Result: pc.jobResults[id],
+ Outputs: nil, // not supported yet
+ }
+ }
+
+ var ret []*SingleWorkflow
+ ids, jobs, err := workflow.jobs()
+ if err != nil {
+ return nil, fmt.Errorf("invalid jobs: %w", err)
+ }
+ for i, id := range ids {
+ job := jobs[i]
+ matricxes, err := getMatrixes(origin.GetJob(id))
+ if err != nil {
+ return nil, fmt.Errorf("getMatrixes: %w", err)
+ }
+ for _, matrix := range matricxes {
+ job := job.Clone()
+ if job.Name == "" {
+ job.Name = id
+ }
+ job.Name = nameWithMatrix(job.Name, matrix)
+ job.Strategy.RawMatrix = encodeMatrix(matrix)
+ evaluator := NewExpressionEvaluator(NewInterpeter(id, origin.GetJob(id), matrix, pc.gitContext, results))
+ runsOn := origin.GetJob(id).RunsOn()
+ for i, v := range runsOn {
+ runsOn[i] = evaluator.Interpolate(v)
+ }
+ job.RawRunsOn = encodeRunsOn(runsOn)
+ swf := &SingleWorkflow{
+ Name: workflow.Name,
+ RawOn: workflow.RawOn,
+ Env: workflow.Env,
+ Defaults: workflow.Defaults,
+ }
+ if err := swf.SetJob(id, job); err != nil {
+ return nil, fmt.Errorf("SetJob: %w", err)
+ }
+ ret = append(ret, swf)
+ }
+ }
+ return ret, nil
+}
+
+func WithJobResults(results map[string]string) ParseOption {
+ return func(c *parseContext) {
+ c.jobResults = results
+ }
+}
+
+func WithGitContext(context *model.GithubContext) ParseOption {
+ return func(c *parseContext) {
+ c.gitContext = context
+ }
+}
+
+type parseContext struct {
+ jobResults map[string]string
+ gitContext *model.GithubContext
+}
+
+type ParseOption func(c *parseContext)
+
+func getMatrixes(job *model.Job) ([]map[string]interface{}, error) {
+ ret, err := job.GetMatrixes()
+ if err != nil {
+ return nil, fmt.Errorf("GetMatrixes: %w", err)
+ }
+ sort.Slice(ret, func(i, j int) bool {
+ return matrixName(ret[i]) < matrixName(ret[j])
+ })
+ return ret, nil
+}
+
+func encodeMatrix(matrix map[string]interface{}) yaml.Node {
+ if len(matrix) == 0 {
+ return yaml.Node{}
+ }
+ value := map[string][]interface{}{}
+ for k, v := range matrix {
+ value[k] = []interface{}{v}
+ }
+ node := yaml.Node{}
+ _ = node.Encode(value)
+ return node
+}
+
+func encodeRunsOn(runsOn []string) yaml.Node {
+ node := yaml.Node{}
+ if len(runsOn) == 1 {
+ _ = node.Encode(runsOn[0])
+ } else {
+ _ = node.Encode(runsOn)
+ }
+ return node
+}
+
+func nameWithMatrix(name string, m map[string]interface{}) string {
+ if len(m) == 0 {
+ return name
+ }
+
+ return name + " " + matrixName(m)
+}
+
+func matrixName(m map[string]interface{}) string {
+ ks := make([]string, 0, len(m))
+ for k := range m {
+ ks = append(ks, k)
+ }
+ sort.Strings(ks)
+ vs := make([]string, 0, len(m))
+ for _, v := range ks {
+ vs = append(vs, fmt.Sprint(m[v]))
+ }
+
+ return fmt.Sprintf("(%s)", strings.Join(vs, ", "))
+}
diff --git a/pkg/jobparser/jobparser_test.go b/pkg/jobparser/jobparser_test.go
new file mode 100644
index 0000000..454d9e4
--- /dev/null
+++ b/pkg/jobparser/jobparser_test.go
@@ -0,0 +1,76 @@
+package jobparser
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+
+ "github.com/stretchr/testify/require"
+
+ "gopkg.in/yaml.v3"
+)
+
+func TestParse(t *testing.T) {
+ tests := []struct {
+ name string
+ options []ParseOption
+ wantErr bool
+ }{
+ {
+ name: "multiple_jobs",
+ options: nil,
+ wantErr: false,
+ },
+ {
+ name: "multiple_matrix",
+ options: nil,
+ wantErr: false,
+ },
+ {
+ name: "has_needs",
+ options: nil,
+ wantErr: false,
+ },
+ {
+ name: "has_with",
+ options: nil,
+ wantErr: false,
+ },
+ {
+ name: "has_secrets",
+ options: nil,
+ wantErr: false,
+ },
+ {
+ name: "empty_step",
+ options: nil,
+ wantErr: false,
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ content := ReadTestdata(t, tt.name+".in.yaml")
+ want := ReadTestdata(t, tt.name+".out.yaml")
+ got, err := Parse(content, tt.options...)
+ if tt.wantErr {
+ require.Error(t, err)
+ }
+ require.NoError(t, err)
+
+ builder := &strings.Builder{}
+ for _, v := range got {
+ if builder.Len() > 0 {
+ builder.WriteString("---\n")
+ }
+ encoder := yaml.NewEncoder(builder)
+ encoder.SetIndent(2)
+ require.NoError(t, encoder.Encode(v))
+ id, job := v.Job()
+ assert.NotEmpty(t, id)
+ assert.NotNil(t, job)
+ }
+ assert.Equal(t, string(want), builder.String())
+ })
+ }
+}
diff --git a/pkg/jobparser/model.go b/pkg/jobparser/model.go
new file mode 100644
index 0000000..2ad615d
--- /dev/null
+++ b/pkg/jobparser/model.go
@@ -0,0 +1,333 @@
+package jobparser
+
+import (
+ "fmt"
+
+ "github.com/nektos/act/pkg/model"
+ "gopkg.in/yaml.v3"
+)
+
+// SingleWorkflow is a workflow with single job and single matrix
+type SingleWorkflow struct {
+ Name string `yaml:"name,omitempty"`
+ RawOn yaml.Node `yaml:"on,omitempty"`
+ Env map[string]string `yaml:"env,omitempty"`
+ RawJobs yaml.Node `yaml:"jobs,omitempty"`
+ Defaults Defaults `yaml:"defaults,omitempty"`
+}
+
+func (w *SingleWorkflow) Job() (string, *Job) {
+ ids, jobs, _ := w.jobs()
+ if len(ids) >= 1 {
+ return ids[0], jobs[0]
+ }
+ return "", nil
+}
+
+func (w *SingleWorkflow) jobs() ([]string, []*Job, error) {
+ ids, jobs, err := parseMappingNode[*Job](&w.RawJobs)
+ if err != nil {
+ return nil, nil, err
+ }
+
+ for _, job := range jobs {
+ steps := make([]*Step, 0, len(job.Steps))
+ for _, s := range job.Steps {
+ if s != nil {
+ steps = append(steps, s)
+ }
+ }
+ job.Steps = steps
+ }
+
+ return ids, jobs, nil
+}
+
+func (w *SingleWorkflow) SetJob(id string, job *Job) error {
+ m := map[string]*Job{
+ id: job,
+ }
+ out, err := yaml.Marshal(m)
+ if err != nil {
+ return err
+ }
+ node := yaml.Node{}
+ if err := yaml.Unmarshal(out, &node); err != nil {
+ return err
+ }
+ if len(node.Content) != 1 || node.Content[0].Kind != yaml.MappingNode {
+ return fmt.Errorf("can not set job: %q", out)
+ }
+ w.RawJobs = *node.Content[0]
+ return nil
+}
+
+func (w *SingleWorkflow) Marshal() ([]byte, error) {
+ return yaml.Marshal(w)
+}
+
+type Job struct {
+ Name string `yaml:"name,omitempty"`
+ RawNeeds yaml.Node `yaml:"needs,omitempty"`
+ RawRunsOn yaml.Node `yaml:"runs-on,omitempty"`
+ Env yaml.Node `yaml:"env,omitempty"`
+ If yaml.Node `yaml:"if,omitempty"`
+ Steps []*Step `yaml:"steps,omitempty"`
+ TimeoutMinutes string `yaml:"timeout-minutes,omitempty"`
+ Services map[string]*ContainerSpec `yaml:"services,omitempty"`
+ Strategy Strategy `yaml:"strategy,omitempty"`
+ RawContainer yaml.Node `yaml:"container,omitempty"`
+ Defaults Defaults `yaml:"defaults,omitempty"`
+ Outputs map[string]string `yaml:"outputs,omitempty"`
+ Uses string `yaml:"uses,omitempty"`
+ With map[string]interface{} `yaml:"with,omitempty"`
+ RawSecrets yaml.Node `yaml:"secrets,omitempty"`
+}
+
+func (j *Job) Clone() *Job {
+ if j == nil {
+ return nil
+ }
+ return &Job{
+ Name: j.Name,
+ RawNeeds: j.RawNeeds,
+ RawRunsOn: j.RawRunsOn,
+ Env: j.Env,
+ If: j.If,
+ Steps: j.Steps,
+ TimeoutMinutes: j.TimeoutMinutes,
+ Services: j.Services,
+ Strategy: j.Strategy,
+ RawContainer: j.RawContainer,
+ Defaults: j.Defaults,
+ Outputs: j.Outputs,
+ Uses: j.Uses,
+ With: j.With,
+ RawSecrets: j.RawSecrets,
+ }
+}
+
+func (j *Job) Needs() []string {
+ return (&model.Job{RawNeeds: j.RawNeeds}).Needs()
+}
+
+func (j *Job) EraseNeeds() *Job {
+ j.RawNeeds = yaml.Node{}
+ return j
+}
+
+func (j *Job) RunsOn() []string {
+ return (&model.Job{RawRunsOn: j.RawRunsOn}).RunsOn()
+}
+
+type Step struct {
+ ID string `yaml:"id,omitempty"`
+ If yaml.Node `yaml:"if,omitempty"`
+ Name string `yaml:"name,omitempty"`
+ Uses string `yaml:"uses,omitempty"`
+ Run string `yaml:"run,omitempty"`
+ WorkingDirectory string `yaml:"working-directory,omitempty"`
+ Shell string `yaml:"shell,omitempty"`
+ Env yaml.Node `yaml:"env,omitempty"`
+ With map[string]string `yaml:"with,omitempty"`
+ ContinueOnError bool `yaml:"continue-on-error,omitempty"`
+ TimeoutMinutes string `yaml:"timeout-minutes,omitempty"`
+}
+
+// String gets the name of step
+func (s *Step) String() string {
+ if s == nil {
+ return ""
+ }
+ return (&model.Step{
+ ID: s.ID,
+ Name: s.Name,
+ Uses: s.Uses,
+ Run: s.Run,
+ }).String()
+}
+
+type ContainerSpec struct {
+ Image string `yaml:"image,omitempty"`
+ Env map[string]string `yaml:"env,omitempty"`
+ Ports []string `yaml:"ports,omitempty"`
+ Volumes []string `yaml:"volumes,omitempty"`
+ Options string `yaml:"options,omitempty"`
+ Credentials map[string]string `yaml:"credentials,omitempty"`
+ Cmd []string `yaml:"cmd,omitempty"`
+}
+
+type Strategy struct {
+ FailFastString string `yaml:"fail-fast,omitempty"`
+ MaxParallelString string `yaml:"max-parallel,omitempty"`
+ RawMatrix yaml.Node `yaml:"matrix,omitempty"`
+}
+
+type Defaults struct {
+ Run RunDefaults `yaml:"run,omitempty"`
+}
+
+type RunDefaults struct {
+ Shell string `yaml:"shell,omitempty"`
+ WorkingDirectory string `yaml:"working-directory,omitempty"`
+}
+
+type Event struct {
+ Name string
+ acts map[string][]string
+ schedules []map[string]string
+}
+
+func (evt *Event) IsSchedule() bool {
+ return evt.schedules != nil
+}
+
+func (evt *Event) Acts() map[string][]string {
+ return evt.acts
+}
+
+func (evt *Event) Schedules() []map[string]string {
+ return evt.schedules
+}
+
+func ParseRawOn(rawOn *yaml.Node) ([]*Event, error) {
+ switch rawOn.Kind {
+ case yaml.ScalarNode:
+ var val string
+ err := rawOn.Decode(&val)
+ if err != nil {
+ return nil, err
+ }
+ return []*Event{
+ {Name: val},
+ }, nil
+ case yaml.SequenceNode:
+ var val []interface{}
+ err := rawOn.Decode(&val)
+ if err != nil {
+ return nil, err
+ }
+ res := make([]*Event, 0, len(val))
+ for _, v := range val {
+ switch t := v.(type) {
+ case string:
+ res = append(res, &Event{Name: t})
+ default:
+ return nil, fmt.Errorf("invalid type %T", t)
+ }
+ }
+ return res, nil
+ case yaml.MappingNode:
+ events, triggers, err := parseMappingNode[interface{}](rawOn)
+ if err != nil {
+ return nil, err
+ }
+ res := make([]*Event, 0, len(events))
+ for i, k := range events {
+ v := triggers[i]
+ if v == nil {
+ res = append(res, &Event{
+ Name: k,
+ acts: map[string][]string{},
+ })
+ continue
+ }
+ switch t := v.(type) {
+ case string:
+ res = append(res, &Event{
+ Name: k,
+ acts: map[string][]string{},
+ })
+ case []string:
+ res = append(res, &Event{
+ Name: k,
+ acts: map[string][]string{},
+ })
+ case map[string]interface{}:
+ acts := make(map[string][]string, len(t))
+ for act, branches := range t {
+ switch b := branches.(type) {
+ case string:
+ acts[act] = []string{b}
+ case []string:
+ acts[act] = b
+ case []interface{}:
+ acts[act] = make([]string, len(b))
+ for i, v := range b {
+ var ok bool
+ if acts[act][i], ok = v.(string); !ok {
+ return nil, fmt.Errorf("unknown on type: %#v", branches)
+ }
+ }
+ default:
+ return nil, fmt.Errorf("unknown on type: %#v", branches)
+ }
+ }
+ res = append(res, &Event{
+ Name: k,
+ acts: acts,
+ })
+ case []interface{}:
+ if k != "schedule" {
+ return nil, fmt.Errorf("unknown on type: %#v", v)
+ }
+ schedules := make([]map[string]string, len(t))
+ for i, tt := range t {
+ vv, ok := tt.(map[string]interface{})
+ if !ok {
+ return nil, fmt.Errorf("unknown on type: %#v", v)
+ }
+ schedules[i] = make(map[string]string, len(vv))
+ for k, vvv := range vv {
+ var ok bool
+ if schedules[i][k], ok = vvv.(string); !ok {
+ return nil, fmt.Errorf("unknown on type: %#v", v)
+ }
+ }
+ }
+ res = append(res, &Event{
+ Name: k,
+ schedules: schedules,
+ })
+ default:
+ return nil, fmt.Errorf("unknown on type: %#v", v)
+ }
+ }
+ return res, nil
+ default:
+ return nil, fmt.Errorf("unknown on type: %v", rawOn.Kind)
+ }
+}
+
+// parseMappingNode parse a mapping node and preserve order.
+func parseMappingNode[T any](node *yaml.Node) ([]string, []T, error) {
+ if node.Kind != yaml.MappingNode {
+ return nil, nil, fmt.Errorf("input node is not a mapping node")
+ }
+
+ var scalars []string
+ var datas []T
+ expectKey := true
+ for _, item := range node.Content {
+ if expectKey {
+ if item.Kind != yaml.ScalarNode {
+ return nil, nil, fmt.Errorf("not a valid scalar node: %v", item.Value)
+ }
+ scalars = append(scalars, item.Value)
+ expectKey = false
+ } else {
+ var val T
+ if err := item.Decode(&val); err != nil {
+ return nil, nil, err
+ }
+ datas = append(datas, val)
+ expectKey = true
+ }
+ }
+
+ if len(scalars) != len(datas) {
+ return nil, nil, fmt.Errorf("invalid definition of on: %v", node.Value)
+ }
+
+ return scalars, datas, nil
+}
diff --git a/pkg/jobparser/model_test.go b/pkg/jobparser/model_test.go
new file mode 100644
index 0000000..859ee92
--- /dev/null
+++ b/pkg/jobparser/model_test.go
@@ -0,0 +1,306 @@
+package jobparser
+
+import (
+ "fmt"
+ "strings"
+ "testing"
+
+ "github.com/nektos/act/pkg/model"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "gopkg.in/yaml.v3"
+)
+
+func TestParseRawOn(t *testing.T) {
+ kases := []struct {
+ input string
+ result []*Event
+ }{
+ {
+ input: "on: issue_comment",
+ result: []*Event{
+ {
+ Name: "issue_comment",
+ },
+ },
+ },
+ {
+ input: "on:\n push",
+ result: []*Event{
+ {
+ Name: "push",
+ },
+ },
+ },
+
+ {
+ input: "on:\n - push\n - pull_request",
+ result: []*Event{
+ {
+ Name: "push",
+ },
+ {
+ Name: "pull_request",
+ },
+ },
+ },
+ {
+ input: "on:\n push:\n branches:\n - master",
+ result: []*Event{
+ {
+ Name: "push",
+ acts: map[string][]string{
+ "branches": {
+ "master",
+ },
+ },
+ },
+ },
+ },
+ {
+ input: "on:\n branch_protection_rule:\n types: [created, deleted]",
+ result: []*Event{
+ {
+ Name: "branch_protection_rule",
+ acts: map[string][]string{
+ "types": {
+ "created",
+ "deleted",
+ },
+ },
+ },
+ },
+ },
+ {
+ input: "on:\n project:\n types: [created, deleted]\n milestone:\n types: [opened, deleted]",
+ result: []*Event{
+ {
+ Name: "project",
+ acts: map[string][]string{
+ "types": {
+ "created",
+ "deleted",
+ },
+ },
+ },
+ {
+ Name: "milestone",
+ acts: map[string][]string{
+ "types": {
+ "opened",
+ "deleted",
+ },
+ },
+ },
+ },
+ },
+ {
+ input: "on:\n pull_request:\n types:\n - opened\n branches:\n - 'releases/**'",
+ result: []*Event{
+ {
+ Name: "pull_request",
+ acts: map[string][]string{
+ "types": {
+ "opened",
+ },
+ "branches": {
+ "releases/**",
+ },
+ },
+ },
+ },
+ },
+ {
+ input: "on:\n push:\n branches:\n - main\n pull_request:\n types:\n - opened\n branches:\n - '**'",
+ result: []*Event{
+ {
+ Name: "push",
+ acts: map[string][]string{
+ "branches": {
+ "main",
+ },
+ },
+ },
+ {
+ Name: "pull_request",
+ acts: map[string][]string{
+ "types": {
+ "opened",
+ },
+ "branches": {
+ "**",
+ },
+ },
+ },
+ },
+ },
+ {
+ input: "on:\n push:\n branches:\n - 'main'\n - 'releases/**'",
+ result: []*Event{
+ {
+ Name: "push",
+ acts: map[string][]string{
+ "branches": {
+ "main",
+ "releases/**",
+ },
+ },
+ },
+ },
+ },
+ {
+ input: "on:\n push:\n tags:\n - v1.**",
+ result: []*Event{
+ {
+ Name: "push",
+ acts: map[string][]string{
+ "tags": {
+ "v1.**",
+ },
+ },
+ },
+ },
+ },
+ {
+ input: "on: [pull_request, workflow_dispatch]",
+ result: []*Event{
+ {
+ Name: "pull_request",
+ },
+ {
+ Name: "workflow_dispatch",
+ },
+ },
+ },
+ {
+ input: "on:\n schedule:\n - cron: '20 6 * * *'",
+ result: []*Event{
+ {
+ Name: "schedule",
+ schedules: []map[string]string{
+ {
+ "cron": "20 6 * * *",
+ },
+ },
+ },
+ },
+ },
+ }
+ for _, kase := range kases {
+ t.Run(kase.input, func(t *testing.T) {
+ origin, err := model.ReadWorkflow(strings.NewReader(kase.input))
+ assert.NoError(t, err)
+
+ events, err := ParseRawOn(&origin.RawOn)
+ assert.NoError(t, err)
+ assert.EqualValues(t, kase.result, events, fmt.Sprintf("%#v", events))
+ })
+ }
+}
+
+func TestSingleWorkflow_SetJob(t *testing.T) {
+ t.Run("erase needs", func(t *testing.T) {
+ content := ReadTestdata(t, "erase_needs.in.yaml")
+ want := ReadTestdata(t, "erase_needs.out.yaml")
+ swf, err := Parse(content)
+ require.NoError(t, err)
+ builder := &strings.Builder{}
+ for _, v := range swf {
+ id, job := v.Job()
+ require.NoError(t, v.SetJob(id, job.EraseNeeds()))
+
+ if builder.Len() > 0 {
+ builder.WriteString("---\n")
+ }
+ encoder := yaml.NewEncoder(builder)
+ encoder.SetIndent(2)
+ require.NoError(t, encoder.Encode(v))
+ }
+ assert.Equal(t, string(want), builder.String())
+ })
+}
+
+func TestParseMappingNode(t *testing.T) {
+ tests := []struct {
+ input string
+ scalars []string
+ datas []interface{}
+ }{
+ {
+ input: "on:\n push:\n branches:\n - master",
+ scalars: []string{"push"},
+ datas: []interface {
+ }{
+ map[string]interface{}{
+ "branches": []interface{}{"master"},
+ },
+ },
+ },
+ {
+ input: "on:\n branch_protection_rule:\n types: [created, deleted]",
+ scalars: []string{"branch_protection_rule"},
+ datas: []interface{}{
+ map[string]interface{}{
+ "types": []interface{}{"created", "deleted"},
+ },
+ },
+ },
+ {
+ input: "on:\n project:\n types: [created, deleted]\n milestone:\n types: [opened, deleted]",
+ scalars: []string{"project", "milestone"},
+ datas: []interface{}{
+ map[string]interface{}{
+ "types": []interface{}{"created", "deleted"},
+ },
+ map[string]interface{}{
+ "types": []interface{}{"opened", "deleted"},
+ },
+ },
+ },
+ {
+ input: "on:\n pull_request:\n types:\n - opened\n branches:\n - 'releases/**'",
+ scalars: []string{"pull_request"},
+ datas: []interface{}{
+ map[string]interface{}{
+ "types": []interface{}{"opened"},
+ "branches": []interface{}{"releases/**"},
+ },
+ },
+ },
+ {
+ input: "on:\n push:\n branches:\n - main\n pull_request:\n types:\n - opened\n branches:\n - '**'",
+ scalars: []string{"push", "pull_request"},
+ datas: []interface{}{
+ map[string]interface{}{
+ "branches": []interface{}{"main"},
+ },
+ map[string]interface{}{
+ "types": []interface{}{"opened"},
+ "branches": []interface{}{"**"},
+ },
+ },
+ },
+ {
+ input: "on:\n schedule:\n - cron: '20 6 * * *'",
+ scalars: []string{"schedule"},
+ datas: []interface{}{
+ []interface{}{map[string]interface{}{
+ "cron": "20 6 * * *",
+ }},
+ },
+ },
+ }
+
+ for _, test := range tests {
+ t.Run(test.input, func(t *testing.T) {
+ workflow, err := model.ReadWorkflow(strings.NewReader(test.input))
+ assert.NoError(t, err)
+
+ scalars, datas, err := parseMappingNode[interface{}](&workflow.RawOn)
+ assert.NoError(t, err)
+ assert.EqualValues(t, test.scalars, scalars, fmt.Sprintf("%#v", scalars))
+ assert.EqualValues(t, test.datas, datas, fmt.Sprintf("%#v", datas))
+ })
+ }
+}
diff --git a/pkg/jobparser/testdata/empty_step.in.yaml b/pkg/jobparser/testdata/empty_step.in.yaml
new file mode 100644
index 0000000..737ac0b
--- /dev/null
+++ b/pkg/jobparser/testdata/empty_step.in.yaml
@@ -0,0 +1,8 @@
+name: test
+jobs:
+ job1:
+ name: job1
+ runs-on: linux
+ steps:
+ - run: echo job-1
+ -
diff --git a/pkg/jobparser/testdata/empty_step.out.yaml b/pkg/jobparser/testdata/empty_step.out.yaml
new file mode 100644
index 0000000..06828e0
--- /dev/null
+++ b/pkg/jobparser/testdata/empty_step.out.yaml
@@ -0,0 +1,7 @@
+name: test
+jobs:
+ job1:
+ name: job1
+ runs-on: linux
+ steps:
+ - run: echo job-1
diff --git a/pkg/jobparser/testdata/erase_needs.in.yaml b/pkg/jobparser/testdata/erase_needs.in.yaml
new file mode 100644
index 0000000..a7d1f9b
--- /dev/null
+++ b/pkg/jobparser/testdata/erase_needs.in.yaml
@@ -0,0 +1,16 @@
+name: test
+jobs:
+ job1:
+ runs-on: linux
+ steps:
+ - run: uname -a
+ job2:
+ runs-on: linux
+ steps:
+ - run: uname -a
+ needs: job1
+ job3:
+ runs-on: linux
+ steps:
+ - run: uname -a
+ needs: [job1, job2]
diff --git a/pkg/jobparser/testdata/erase_needs.out.yaml b/pkg/jobparser/testdata/erase_needs.out.yaml
new file mode 100644
index 0000000..959960d
--- /dev/null
+++ b/pkg/jobparser/testdata/erase_needs.out.yaml
@@ -0,0 +1,23 @@
+name: test
+jobs:
+ job1:
+ name: job1
+ runs-on: linux
+ steps:
+ - run: uname -a
+---
+name: test
+jobs:
+ job2:
+ name: job2
+ runs-on: linux
+ steps:
+ - run: uname -a
+---
+name: test
+jobs:
+ job3:
+ name: job3
+ runs-on: linux
+ steps:
+ - run: uname -a
diff --git a/pkg/jobparser/testdata/has_needs.in.yaml b/pkg/jobparser/testdata/has_needs.in.yaml
new file mode 100644
index 0000000..a7d1f9b
--- /dev/null
+++ b/pkg/jobparser/testdata/has_needs.in.yaml
@@ -0,0 +1,16 @@
+name: test
+jobs:
+ job1:
+ runs-on: linux
+ steps:
+ - run: uname -a
+ job2:
+ runs-on: linux
+ steps:
+ - run: uname -a
+ needs: job1
+ job3:
+ runs-on: linux
+ steps:
+ - run: uname -a
+ needs: [job1, job2]
diff --git a/pkg/jobparser/testdata/has_needs.out.yaml b/pkg/jobparser/testdata/has_needs.out.yaml
new file mode 100644
index 0000000..a544aa2
--- /dev/null
+++ b/pkg/jobparser/testdata/has_needs.out.yaml
@@ -0,0 +1,25 @@
+name: test
+jobs:
+ job1:
+ name: job1
+ runs-on: linux
+ steps:
+ - run: uname -a
+---
+name: test
+jobs:
+ job2:
+ name: job2
+ needs: job1
+ runs-on: linux
+ steps:
+ - run: uname -a
+---
+name: test
+jobs:
+ job3:
+ name: job3
+ needs: [job1, job2]
+ runs-on: linux
+ steps:
+ - run: uname -a
diff --git a/pkg/jobparser/testdata/has_secrets.in.yaml b/pkg/jobparser/testdata/has_secrets.in.yaml
new file mode 100644
index 0000000..64b9f69
--- /dev/null
+++ b/pkg/jobparser/testdata/has_secrets.in.yaml
@@ -0,0 +1,14 @@
+name: test
+jobs:
+ job1:
+ name: job1
+ runs-on: linux
+ uses: .gitea/workflows/build.yml
+ secrets:
+ secret: hideme
+
+ job2:
+ name: job2
+ runs-on: linux
+ uses: .gitea/workflows/build.yml
+ secrets: inherit
diff --git a/pkg/jobparser/testdata/has_secrets.out.yaml b/pkg/jobparser/testdata/has_secrets.out.yaml
new file mode 100644
index 0000000..23dfb80
--- /dev/null
+++ b/pkg/jobparser/testdata/has_secrets.out.yaml
@@ -0,0 +1,16 @@
+name: test
+jobs:
+ job1:
+ name: job1
+ runs-on: linux
+ uses: .gitea/workflows/build.yml
+ secrets:
+ secret: hideme
+---
+name: test
+jobs:
+ job2:
+ name: job2
+ runs-on: linux
+ uses: .gitea/workflows/build.yml
+ secrets: inherit
diff --git a/pkg/jobparser/testdata/has_with.in.yaml b/pkg/jobparser/testdata/has_with.in.yaml
new file mode 100644
index 0000000..4e3dc74
--- /dev/null
+++ b/pkg/jobparser/testdata/has_with.in.yaml
@@ -0,0 +1,15 @@
+name: test
+jobs:
+ job1:
+ name: job1
+ runs-on: linux
+ uses: .gitea/workflows/build.yml
+ with:
+ package: service
+
+ job2:
+ name: job2
+ runs-on: linux
+ uses: .gitea/workflows/build.yml
+ with:
+ package: module
diff --git a/pkg/jobparser/testdata/has_with.out.yaml b/pkg/jobparser/testdata/has_with.out.yaml
new file mode 100644
index 0000000..de79b80
--- /dev/null
+++ b/pkg/jobparser/testdata/has_with.out.yaml
@@ -0,0 +1,17 @@
+name: test
+jobs:
+ job1:
+ name: job1
+ runs-on: linux
+ uses: .gitea/workflows/build.yml
+ with:
+ package: service
+---
+name: test
+jobs:
+ job2:
+ name: job2
+ runs-on: linux
+ uses: .gitea/workflows/build.yml
+ with:
+ package: module
diff --git a/pkg/jobparser/testdata/multiple_jobs.in.yaml b/pkg/jobparser/testdata/multiple_jobs.in.yaml
new file mode 100644
index 0000000..266ede8
--- /dev/null
+++ b/pkg/jobparser/testdata/multiple_jobs.in.yaml
@@ -0,0 +1,22 @@
+name: test
+jobs:
+ zzz:
+ runs-on: linux
+ steps:
+ - run: echo zzz
+ job1:
+ runs-on: linux
+ steps:
+ - run: uname -a && go version
+ job2:
+ runs-on: linux
+ steps:
+ - run: uname -a && go version
+ job3:
+ runs-on: linux
+ steps:
+ - run: uname -a && go version
+ aaa:
+ runs-on: linux
+ steps:
+ - run: uname -a && go version
diff --git a/pkg/jobparser/testdata/multiple_jobs.out.yaml b/pkg/jobparser/testdata/multiple_jobs.out.yaml
new file mode 100644
index 0000000..ea22350
--- /dev/null
+++ b/pkg/jobparser/testdata/multiple_jobs.out.yaml
@@ -0,0 +1,39 @@
+name: test
+jobs:
+ zzz:
+ name: zzz
+ runs-on: linux
+ steps:
+ - run: echo zzz
+---
+name: test
+jobs:
+ job1:
+ name: job1
+ runs-on: linux
+ steps:
+ - run: uname -a && go version
+---
+name: test
+jobs:
+ job2:
+ name: job2
+ runs-on: linux
+ steps:
+ - run: uname -a && go version
+---
+name: test
+jobs:
+ job3:
+ name: job3
+ runs-on: linux
+ steps:
+ - run: uname -a && go version
+---
+name: test
+jobs:
+ aaa:
+ name: aaa
+ runs-on: linux
+ steps:
+ - run: uname -a && go version
diff --git a/pkg/jobparser/testdata/multiple_matrix.in.yaml b/pkg/jobparser/testdata/multiple_matrix.in.yaml
new file mode 100644
index 0000000..99985f3
--- /dev/null
+++ b/pkg/jobparser/testdata/multiple_matrix.in.yaml
@@ -0,0 +1,13 @@
+name: test
+jobs:
+ job1:
+ strategy:
+ matrix:
+ os: [ubuntu-22.04, ubuntu-20.04]
+ version: [1.17, 1.18, 1.19]
+ runs-on: ${{ matrix.os }}
+ steps:
+ - uses: actions/setup-go@v3
+ with:
+ go-version: ${{ matrix.version }}
+ - run: uname -a && go version \ No newline at end of file
diff --git a/pkg/jobparser/testdata/multiple_matrix.out.yaml b/pkg/jobparser/testdata/multiple_matrix.out.yaml
new file mode 100644
index 0000000..e277cdd
--- /dev/null
+++ b/pkg/jobparser/testdata/multiple_matrix.out.yaml
@@ -0,0 +1,101 @@
+name: test
+jobs:
+ job1:
+ name: job1 (ubuntu-20.04, 1.17)
+ runs-on: ubuntu-20.04
+ steps:
+ - uses: actions/setup-go@v3
+ with:
+ go-version: ${{ matrix.version }}
+ - run: uname -a && go version
+ strategy:
+ matrix:
+ os:
+ - ubuntu-20.04
+ version:
+ - 1.17
+---
+name: test
+jobs:
+ job1:
+ name: job1 (ubuntu-20.04, 1.18)
+ runs-on: ubuntu-20.04
+ steps:
+ - uses: actions/setup-go@v3
+ with:
+ go-version: ${{ matrix.version }}
+ - run: uname -a && go version
+ strategy:
+ matrix:
+ os:
+ - ubuntu-20.04
+ version:
+ - 1.18
+---
+name: test
+jobs:
+ job1:
+ name: job1 (ubuntu-20.04, 1.19)
+ runs-on: ubuntu-20.04
+ steps:
+ - uses: actions/setup-go@v3
+ with:
+ go-version: ${{ matrix.version }}
+ - run: uname -a && go version
+ strategy:
+ matrix:
+ os:
+ - ubuntu-20.04
+ version:
+ - 1.19
+---
+name: test
+jobs:
+ job1:
+ name: job1 (ubuntu-22.04, 1.17)
+ runs-on: ubuntu-22.04
+ steps:
+ - uses: actions/setup-go@v3
+ with:
+ go-version: ${{ matrix.version }}
+ - run: uname -a && go version
+ strategy:
+ matrix:
+ os:
+ - ubuntu-22.04
+ version:
+ - 1.17
+---
+name: test
+jobs:
+ job1:
+ name: job1 (ubuntu-22.04, 1.18)
+ runs-on: ubuntu-22.04
+ steps:
+ - uses: actions/setup-go@v3
+ with:
+ go-version: ${{ matrix.version }}
+ - run: uname -a && go version
+ strategy:
+ matrix:
+ os:
+ - ubuntu-22.04
+ version:
+ - 1.18
+---
+name: test
+jobs:
+ job1:
+ name: job1 (ubuntu-22.04, 1.19)
+ runs-on: ubuntu-22.04
+ steps:
+ - uses: actions/setup-go@v3
+ with:
+ go-version: ${{ matrix.version }}
+ - run: uname -a && go version
+ strategy:
+ matrix:
+ os:
+ - ubuntu-22.04
+ version:
+ - 1.19
diff --git a/pkg/jobparser/testdata_test.go b/pkg/jobparser/testdata_test.go
new file mode 100644
index 0000000..fb75a50
--- /dev/null
+++ b/pkg/jobparser/testdata_test.go
@@ -0,0 +1,18 @@
+package jobparser
+
+import (
+ "embed"
+ "path/filepath"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+//go:embed testdata
+var testdata embed.FS
+
+func ReadTestdata(t *testing.T, name string) []byte {
+ content, err := testdata.ReadFile(filepath.Join("testdata", name))
+ require.NoError(t, err)
+ return content
+}
diff --git a/pkg/model/action.go b/pkg/model/action.go
index 6da142e..2fc39db 100644
--- a/pkg/model/action.go
+++ b/pkg/model/action.go
@@ -20,7 +20,7 @@ func (a *ActionRunsUsing) UnmarshalYAML(unmarshal func(interface{}) error) error
// Force input to lowercase for case insensitive comparison
format := ActionRunsUsing(strings.ToLower(using))
switch format {
- case ActionRunsUsingNode20, ActionRunsUsingNode16, ActionRunsUsingNode12, ActionRunsUsingDocker, ActionRunsUsingComposite:
+ case ActionRunsUsingNode20, ActionRunsUsingNode16, ActionRunsUsingNode12, ActionRunsUsingDocker, ActionRunsUsingComposite, ActionRunsUsingGo:
*a = format
default:
return fmt.Errorf(fmt.Sprintf("The runs.using key in action.yml must be one of: %v, got %s", []string{
@@ -29,6 +29,7 @@ func (a *ActionRunsUsing) UnmarshalYAML(unmarshal func(interface{}) error) error
ActionRunsUsingNode12,
ActionRunsUsingNode16,
ActionRunsUsingNode20,
+ ActionRunsUsingGo,
}, format))
}
return nil
@@ -45,6 +46,8 @@ const (
ActionRunsUsingDocker = "docker"
// ActionRunsUsingComposite for running composite
ActionRunsUsingComposite = "composite"
+ // ActionRunsUsingGo for running with go
+ ActionRunsUsingGo = "go"
)
// ActionRuns are a field in Action
diff --git a/pkg/model/planner.go b/pkg/model/planner.go
index 089d67d..1b34d15 100644
--- a/pkg/model/planner.go
+++ b/pkg/model/planner.go
@@ -164,6 +164,13 @@ func NewWorkflowPlanner(path string, noWorkflowRecurse bool) (WorkflowPlanner, e
return wp, nil
}
+// CombineWorkflowPlanner combines workflows to a WorkflowPlanner
+func CombineWorkflowPlanner(workflows ...*Workflow) WorkflowPlanner {
+ return &workflowPlanner{
+ workflows: workflows,
+ }
+}
+
type workflowPlanner struct {
workflows []*Workflow
}
diff --git a/pkg/model/workflow.go b/pkg/model/workflow.go
index 1573c76..a54004b 100644
--- a/pkg/model/workflow.go
+++ b/pkg/model/workflow.go
@@ -66,6 +66,30 @@ func (w *Workflow) OnEvent(event string) interface{} {
return nil
}
+func (w *Workflow) OnSchedule() []string {
+ schedules := w.OnEvent("schedule")
+ if schedules == nil {
+ return []string{}
+ }
+
+ switch val := schedules.(type) {
+ case []interface{}:
+ allSchedules := []string{}
+ for _, v := range val {
+ for k, cron := range v.(map[string]interface{}) {
+ if k != "cron" {
+ continue
+ }
+ allSchedules = append(allSchedules, cron.(string))
+ }
+ }
+ return allSchedules
+ default:
+ }
+
+ return []string{}
+}
+
type WorkflowDispatchInput struct {
Description string `yaml:"description"`
Required bool `yaml:"required"`
@@ -507,10 +531,14 @@ type ContainerSpec struct {
Args string
Name string
Reuse bool
+
+ // Gitea specific
+ Cmd []string `yaml:"cmd"`
}
// Step is the structure of one step in a job
type Step struct {
+ Number int `yaml:"-"`
ID string `yaml:"id"`
If yaml.Node `yaml:"if"`
Name string `yaml:"name"`
@@ -557,7 +585,7 @@ func (s *Step) GetEnv() map[string]string {
func (s *Step) ShellCommand() string {
shellCommand := ""
- //Reference: https://github.com/actions/runner/blob/8109c962f09d9acc473d92c595ff43afceddb347/src/Runner.Worker/Handlers/ScriptHandlerHelpers.cs#L9-L17
+ // Reference: https://github.com/actions/runner/blob/8109c962f09d9acc473d92c595ff43afceddb347/src/Runner.Worker/Handlers/ScriptHandlerHelpers.cs#L9-L17
switch s.Shell {
case "", "bash":
shellCommand = "bash --noprofile --norc -e -o pipefail {0}"
diff --git a/pkg/model/workflow_test.go b/pkg/model/workflow_test.go
index 292c0bf..8b33662 100644
--- a/pkg/model/workflow_test.go
+++ b/pkg/model/workflow_test.go
@@ -7,6 +7,88 @@ import (
"github.com/stretchr/testify/assert"
)
+func TestReadWorkflow_ScheduleEvent(t *testing.T) {
+ yaml := `
+name: local-action-docker-url
+on:
+ schedule:
+ - cron: '30 5 * * 1,3'
+ - cron: '30 5 * * 2,4'
+
+jobs:
+ test:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: ./actions/docker-url
+`
+
+ workflow, err := ReadWorkflow(strings.NewReader(yaml))
+ assert.NoError(t, err, "read workflow should succeed")
+
+ schedules := workflow.OnEvent("schedule")
+ assert.Len(t, schedules, 2)
+
+ newSchedules := workflow.OnSchedule()
+ assert.Len(t, newSchedules, 2)
+
+ assert.Equal(t, "30 5 * * 1,3", newSchedules[0])
+ assert.Equal(t, "30 5 * * 2,4", newSchedules[1])
+
+ yaml = `
+name: local-action-docker-url
+on:
+ schedule:
+ test: '30 5 * * 1,3'
+
+jobs:
+ test:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: ./actions/docker-url
+`
+
+ workflow, err = ReadWorkflow(strings.NewReader(yaml))
+ assert.NoError(t, err, "read workflow should succeed")
+
+ newSchedules = workflow.OnSchedule()
+ assert.Len(t, newSchedules, 0)
+
+ yaml = `
+name: local-action-docker-url
+on:
+ schedule:
+
+jobs:
+ test:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: ./actions/docker-url
+`
+
+ workflow, err = ReadWorkflow(strings.NewReader(yaml))
+ assert.NoError(t, err, "read workflow should succeed")
+
+ newSchedules = workflow.OnSchedule()
+ assert.Len(t, newSchedules, 0)
+
+ yaml = `
+name: local-action-docker-url
+on: [push, tag]
+
+jobs:
+ test:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: ./actions/docker-url
+`
+
+ workflow, err = ReadWorkflow(strings.NewReader(yaml))
+ assert.NoError(t, err, "read workflow should succeed")
+
+ newSchedules = workflow.OnSchedule()
+ assert.Len(t, newSchedules, 0)
+}
+
func TestReadWorkflow_StringEvent(t *testing.T) {
yaml := `
name: local-action-docker-url
diff --git a/pkg/runner/action.go b/pkg/runner/action.go
index a8b8912..f769eee 100644
--- a/pkg/runner/action.go
+++ b/pkg/runner/action.go
@@ -171,6 +171,21 @@ func runActionImpl(step actionStep, actionDir string, remoteAction *remoteAction
}
return execAsComposite(step)(ctx)
+ case model.ActionRunsUsingGo:
+ if err := maybeCopyToActionDir(ctx, step, actionDir, actionPath, containerActionDir); err != nil {
+ return err
+ }
+
+ rc.ApplyExtraPath(ctx, step.getEnv())
+
+ execFileName := fmt.Sprintf("%s.out", action.Runs.Main)
+ buildArgs := []string{"go", "build", "-o", execFileName, action.Runs.Main}
+ execArgs := []string{filepath.Join(containerActionDir, execFileName)}
+
+ return common.NewPipelineExecutor(
+ rc.execJobContainer(buildArgs, *step.getEnv(), "", containerActionDir),
+ rc.execJobContainer(execArgs, *step.getEnv(), "", ""),
+ )(ctx)
default:
return fmt.Errorf(fmt.Sprintf("The runs.using key must be one of: %v, got %s", []string{
model.ActionRunsUsingDocker,
@@ -178,6 +193,7 @@ func runActionImpl(step actionStep, actionDir string, remoteAction *remoteAction
model.ActionRunsUsingNode16,
model.ActionRunsUsingNode20,
model.ActionRunsUsingComposite,
+ model.ActionRunsUsingGo,
}, action.Runs.Using))
}
}
@@ -366,23 +382,25 @@ func newStepContainer(ctx context.Context, step step, image string, cmd []string
networkMode = "default"
}
stepContainer := container.NewContainer(&container.NewContainerInput{
- Cmd: cmd,
- Entrypoint: entrypoint,
- WorkingDir: rc.JobContainer.ToContainerPath(rc.Config.Workdir),
- Image: image,
- Username: rc.Config.Secrets["DOCKER_USERNAME"],
- Password: rc.Config.Secrets["DOCKER_PASSWORD"],
- Name: createContainerName(rc.jobContainerName(), stepModel.ID),
- Env: envList,
- Mounts: mounts,
- NetworkMode: networkMode,
- Binds: binds,
- Stdout: logWriter,
- Stderr: logWriter,
- Privileged: rc.Config.Privileged,
- UsernsMode: rc.Config.UsernsMode,
- Platform: rc.Config.ContainerArchitecture,
- Options: rc.Config.ContainerOptions,
+ Cmd: cmd,
+ Entrypoint: entrypoint,
+ WorkingDir: rc.JobContainer.ToContainerPath(rc.Config.Workdir),
+ Image: image,
+ Username: rc.Config.Secrets["DOCKER_USERNAME"],
+ Password: rc.Config.Secrets["DOCKER_PASSWORD"],
+ Name: createSimpleContainerName(rc.jobContainerName(), "STEP-"+stepModel.ID),
+ Env: envList,
+ Mounts: mounts,
+ NetworkMode: networkMode,
+ Binds: binds,
+ Stdout: logWriter,
+ Stderr: logWriter,
+ Privileged: rc.Config.Privileged,
+ UsernsMode: rc.Config.UsernsMode,
+ Platform: rc.Config.ContainerArchitecture,
+ Options: rc.Config.ContainerOptions,
+ AutoRemove: rc.Config.AutoRemove,
+ ValidVolumes: rc.Config.ValidVolumes,
})
return stepContainer
}
@@ -458,7 +476,8 @@ func hasPreStep(step actionStep) common.Conditional {
return action.Runs.Using == model.ActionRunsUsingComposite ||
((action.Runs.Using == model.ActionRunsUsingNode12 ||
action.Runs.Using == model.ActionRunsUsingNode16 ||
- action.Runs.Using == model.ActionRunsUsingNode20) &&
+ action.Runs.Using == model.ActionRunsUsingNode20 ||
+ action.Runs.Using == model.ActionRunsUsingGo) &&
action.Runs.Pre != "")
}
}
@@ -517,6 +536,43 @@ func runPreStep(step actionStep) common.Executor {
}
return fmt.Errorf("missing steps in composite action")
+ case model.ActionRunsUsingGo:
+ // defaults in pre steps were missing, however provided inputs are available
+ populateEnvsFromInput(ctx, step.getEnv(), action, rc)
+ // todo: refactor into step
+ var actionDir string
+ var actionPath string
+ if _, ok := step.(*stepActionRemote); ok {
+ actionPath = newRemoteAction(stepModel.Uses).Path
+ actionDir = fmt.Sprintf("%s/%s", rc.ActionCacheDir(), safeFilename(stepModel.Uses))
+ } else {
+ actionDir = filepath.Join(rc.Config.Workdir, stepModel.Uses)
+ actionPath = ""
+ }
+
+ actionLocation := ""
+ if actionPath != "" {
+ actionLocation = path.Join(actionDir, actionPath)
+ } else {
+ actionLocation = actionDir
+ }
+
+ _, containerActionDir := getContainerActionPaths(stepModel, actionLocation, rc)
+
+ if err := maybeCopyToActionDir(ctx, step, actionDir, actionPath, containerActionDir); err != nil {
+ return err
+ }
+
+ rc.ApplyExtraPath(ctx, step.getEnv())
+
+ execFileName := fmt.Sprintf("%s.out", action.Runs.Pre)
+ buildArgs := []string{"go", "build", "-o", execFileName, action.Runs.Pre}
+ execArgs := []string{filepath.Join(containerActionDir, execFileName)}
+
+ return common.NewPipelineExecutor(
+ rc.execJobContainer(buildArgs, *step.getEnv(), "", containerActionDir),
+ rc.execJobContainer(execArgs, *step.getEnv(), "", ""),
+ )(ctx)
default:
return nil
}
@@ -554,7 +610,8 @@ func hasPostStep(step actionStep) common.Conditional {
return action.Runs.Using == model.ActionRunsUsingComposite ||
((action.Runs.Using == model.ActionRunsUsingNode12 ||
action.Runs.Using == model.ActionRunsUsingNode16 ||
- action.Runs.Using == model.ActionRunsUsingNode20) &&
+ action.Runs.Using == model.ActionRunsUsingNode20 ||
+ action.Runs.Using == model.ActionRunsUsingGo) &&
action.Runs.Post != "")
}
}
@@ -610,6 +667,19 @@ func runPostStep(step actionStep) common.Executor {
}
return fmt.Errorf("missing steps in composite action")
+ case model.ActionRunsUsingGo:
+ populateEnvsFromSavedState(step.getEnv(), step, rc)
+ rc.ApplyExtraPath(ctx, step.getEnv())
+
+ execFileName := fmt.Sprintf("%s.out", action.Runs.Post)
+ buildArgs := []string{"go", "build", "-o", execFileName, action.Runs.Post}
+ execArgs := []string{filepath.Join(containerActionDir, execFileName)}
+
+ return common.NewPipelineExecutor(
+ rc.execJobContainer(buildArgs, *step.getEnv(), "", containerActionDir),
+ rc.execJobContainer(execArgs, *step.getEnv(), "", ""),
+ )(ctx)
+
default:
return nil
}
diff --git a/pkg/runner/action_composite.go b/pkg/runner/action_composite.go
index 0fc1fd8..ee9a6cc 100644
--- a/pkg/runner/action_composite.go
+++ b/pkg/runner/action_composite.go
@@ -137,6 +137,7 @@ func (rc *RunContext) compositeExecutor(action *model.Action) *compositeSteps {
if step.ID == "" {
step.ID = fmt.Sprintf("%d", i)
}
+ step.Number = i
// create a copy of the step, since this composite action could
// run multiple times and we might modify the instance
diff --git a/pkg/runner/command.go b/pkg/runner/command.go
index d79ac03..9b59a97 100644
--- a/pkg/runner/command.go
+++ b/pkg/runner/command.go
@@ -77,7 +77,8 @@ func (rc *RunContext) commandHandler(ctx context.Context) common.LineHandler {
logger.Infof(" \U00002753 %s", line)
}
- return false
+ // return true to let gitea's logger handle these special outputs also
+ return true
}
}
diff --git a/pkg/runner/job_executor.go b/pkg/runner/job_executor.go
index 3f2e41e..67708c0 100644
--- a/pkg/runner/job_executor.go
+++ b/pkg/runner/job_executor.go
@@ -6,6 +6,7 @@ import (
"time"
"github.com/nektos/act/pkg/common"
+ "github.com/nektos/act/pkg/container"
"github.com/nektos/act/pkg/model"
)
@@ -62,6 +63,7 @@ func newJobExecutor(info jobInfo, sf stepFactory, rc *RunContext) common.Executo
if stepModel.ID == "" {
stepModel.ID = fmt.Sprintf("%d", i)
}
+ stepModel.Number = i
step, err := sf.newStep(stepModel, rc)
@@ -69,7 +71,19 @@ func newJobExecutor(info jobInfo, sf stepFactory, rc *RunContext) common.Executo
return common.NewErrorExecutor(err)
}
- preSteps = append(preSteps, useStepLogger(rc, stepModel, stepStagePre, step.pre()))
+ preExec := step.pre()
+ preSteps = append(preSteps, useStepLogger(rc, stepModel, stepStagePre, func(ctx context.Context) error {
+ logger := common.Logger(ctx)
+ preErr := preExec(ctx)
+ if preErr != nil {
+ logger.Errorf("%v", preErr)
+ common.SetJobError(ctx, preErr)
+ } else if ctx.Err() != nil {
+ logger.Errorf("%v", ctx.Err())
+ common.SetJobError(ctx, ctx.Err())
+ }
+ return preErr
+ }))
stepExec := step.main()
steps = append(steps, useStepLogger(rc, stepModel, stepStageMain, func(ctx context.Context) error {
@@ -101,7 +115,27 @@ func newJobExecutor(info jobInfo, sf stepFactory, rc *RunContext) common.Executo
// always allow 1 min for stopping and removing the runner, even if we were cancelled
ctx, cancel := context.WithTimeout(common.WithLogger(context.Background(), common.Logger(ctx)), time.Minute)
defer cancel()
- err = info.stopContainer()(ctx) //nolint:contextcheck
+
+ logger := common.Logger(ctx)
+ logger.Infof("Cleaning up services for job %s", rc.JobName)
+ if err := rc.stopServiceContainers()(ctx); err != nil {
+ logger.Errorf("Error while cleaning services: %v", err)
+ }
+
+ logger.Infof("Cleaning up container for job %s", rc.JobName)
+ if err = info.stopContainer()(ctx); err != nil {
+ logger.Errorf("Error while stop job container: %v", err)
+ }
+ if !rc.IsHostEnv(ctx) && rc.Config.ContainerNetworkMode == "" {
+ // clean network in docker mode only
+ // if the value of `ContainerNetworkMode` is empty string,
+ // it means that the network to which containers are connecting is created by `act_runner`,
+ // so, we should remove the network at last.
+ logger.Infof("Cleaning up network for job %s, and network name is: %s", rc.JobName, rc.networkName())
+ if err := container.NewDockerNetworkRemoveExecutor(rc.networkName())(ctx); err != nil {
+ logger.Errorf("Error while cleaning network: %v", err)
+ }
+ }
}
setJobResult(ctx, info, rc, jobError == nil)
setJobOutputs(ctx, rc)
@@ -173,7 +207,7 @@ func setJobOutputs(ctx context.Context, rc *RunContext) {
func useStepLogger(rc *RunContext, stepModel *model.Step, stage stepStage, executor common.Executor) common.Executor {
return func(ctx context.Context) error {
- ctx = withStepLogger(ctx, stepModel.ID, rc.ExprEval.Interpolate(ctx, stepModel.String()), stage.String())
+ ctx = withStepLogger(ctx, stepModel.Number, stepModel.ID, rc.ExprEval.Interpolate(ctx, stepModel.String()), stage.String())
rawLogger := common.Logger(ctx).WithField("raw_output", true)
logWriter := common.NewLineWriter(rc.commandHandler(ctx), func(s string) bool {
diff --git a/pkg/runner/logger.go b/pkg/runner/logger.go
index 5a98210..6890293 100644
--- a/pkg/runner/logger.go
+++ b/pkg/runner/logger.go
@@ -96,6 +96,17 @@ func WithJobLogger(ctx context.Context, jobID string, jobName string, config *Co
logger.SetFormatter(formatter)
}
+ { // Adapt to Gitea
+ if hook := common.LoggerHook(ctx); hook != nil {
+ logger.AddHook(hook)
+ }
+ if config.JobLoggerLevel != nil {
+ logger.SetLevel(*config.JobLoggerLevel)
+ } else {
+ logger.SetLevel(logrus.TraceLevel)
+ }
+ }
+
logger.SetFormatter(&maskedFormatter{
Formatter: logger.Formatter,
masker: valueMasker(config.InsecureSecrets, config.Secrets),
@@ -132,11 +143,12 @@ func WithCompositeStepLogger(ctx context.Context, stepID string) context.Context
}).WithContext(ctx))
}
-func withStepLogger(ctx context.Context, stepID string, stepName string, stageName string) context.Context {
+func withStepLogger(ctx context.Context, stepNumber int, stepID, stepName, stageName string) context.Context {
rtn := common.Logger(ctx).WithFields(logrus.Fields{
- "step": stepName,
- "stepID": []string{stepID},
- "stage": stageName,
+ "stepNumber": stepNumber,
+ "step": stepName,
+ "stepID": []string{stepID},
+ "stage": stageName,
})
return common.WithLogger(ctx, rtn)
}
diff --git a/pkg/runner/reusable_workflow.go b/pkg/runner/reusable_workflow.go
index 67e0403..721c2a2 100644
--- a/pkg/runner/reusable_workflow.go
+++ b/pkg/runner/reusable_workflow.go
@@ -8,6 +8,7 @@ import (
"os"
"path"
"regexp"
+ "strings"
"sync"
"github.com/nektos/act/pkg/common"
@@ -16,15 +17,45 @@ import (
)
func newLocalReusableWorkflowExecutor(rc *RunContext) common.Executor {
- return newReusableWorkflowExecutor(rc, rc.Config.Workdir, rc.Run.Job().Uses)
+ if !rc.Config.NoSkipCheckout {
+ fullPath := rc.Run.Job().Uses
+
+ fileName := path.Base(fullPath)
+ workflowDir := strings.TrimSuffix(fullPath, path.Join("/", fileName))
+ workflowDir = strings.TrimPrefix(workflowDir, "./")
+
+ return common.NewPipelineExecutor(
+ newReusableWorkflowExecutor(rc, workflowDir, fileName),
+ )
+ }
+
+ // ./.gitea/workflows/wf.yml -> .gitea/workflows/wf.yml
+ trimmedUses := strings.TrimPrefix(rc.Run.Job().Uses, "./")
+ // uses string format is {owner}/{repo}/.{git_platform}/workflows/{filename}@{ref}
+ uses := fmt.Sprintf("%s/%s@%s", rc.Config.PresetGitHubContext.Repository, trimmedUses, rc.Config.PresetGitHubContext.Sha)
+
+ remoteReusableWorkflow := newRemoteReusableWorkflowWithPlat(rc.Config.GitHubInstance, uses)
+ if remoteReusableWorkflow == nil {
+ return common.NewErrorExecutor(fmt.Errorf("expected format {owner}/{repo}/.{git_platform}/workflows/{filename}@{ref}. Actual '%s' Input string was not in a correct format", uses))
+ }
+
+ workflowDir := fmt.Sprintf("%s/%s", rc.ActionCacheDir(), safeFilename(uses))
+
+ // If the repository is private, we need a token to clone it
+ token := rc.Config.GetToken()
+
+ return common.NewPipelineExecutor(
+ newMutexExecutor(cloneIfRequired(rc, *remoteReusableWorkflow, workflowDir, token)),
+ newReusableWorkflowExecutor(rc, workflowDir, remoteReusableWorkflow.FilePath()),
+ )
}
func newRemoteReusableWorkflowExecutor(rc *RunContext) common.Executor {
uses := rc.Run.Job().Uses
- remoteReusableWorkflow := newRemoteReusableWorkflow(uses)
+ remoteReusableWorkflow := newRemoteReusableWorkflowWithPlat(rc.Config.GitHubInstance, uses)
if remoteReusableWorkflow == nil {
- return common.NewErrorExecutor(fmt.Errorf("expected format {owner}/{repo}/.github/workflows/{filename}@{ref}. Actual '%s' Input string was not in a correct format", uses))
+ return common.NewErrorExecutor(fmt.Errorf("expected format {owner}/{repo}/.{git_platform}/workflows/{filename}@{ref}. Actual '%s' Input string was not in a correct format", uses))
}
// uses with safe filename makes the target directory look something like this {owner}-{repo}-.github-workflows-{filename}@{ref}
@@ -33,9 +64,12 @@ func newRemoteReusableWorkflowExecutor(rc *RunContext) common.Executor {
filename := fmt.Sprintf("%s/%s@%s", remoteReusableWorkflow.Org, remoteReusableWorkflow.Repo, remoteReusableWorkflow.Ref)
workflowDir := fmt.Sprintf("%s/%s", rc.ActionCacheDir(), safeFilename(filename))
+ // FIXME: if the reusable workflow is from a private repository, we need to provide a token to access the repository.
+ token := ""
+
return common.NewPipelineExecutor(
- newMutexExecutor(cloneIfRequired(rc, *remoteReusableWorkflow, workflowDir)),
- newReusableWorkflowExecutor(rc, workflowDir, fmt.Sprintf("./.github/workflows/%s", remoteReusableWorkflow.Filename)),
+ newMutexExecutor(cloneIfRequired(rc, *remoteReusableWorkflow, workflowDir, token)),
+ newReusableWorkflowExecutor(rc, workflowDir, remoteReusableWorkflow.FilePath()),
)
}
@@ -52,7 +86,7 @@ func newMutexExecutor(executor common.Executor) common.Executor {
}
}
-func cloneIfRequired(rc *RunContext, remoteReusableWorkflow remoteReusableWorkflow, targetDirectory string) common.Executor {
+func cloneIfRequired(rc *RunContext, remoteReusableWorkflow remoteReusableWorkflow, targetDirectory, token string) common.Executor {
return common.NewConditionalExecutor(
func(ctx context.Context) bool {
_, err := os.Stat(targetDirectory)
@@ -60,12 +94,15 @@ func cloneIfRequired(rc *RunContext, remoteReusableWorkflow remoteReusableWorkfl
return notExists
},
func(ctx context.Context) error {
- remoteReusableWorkflow.URL = rc.getGithubContext(ctx).ServerURL
+ // Do not change the remoteReusableWorkflow.URL, because:
+ // 1. Gitea doesn't support specifying GithubContext.ServerURL by the GITHUB_SERVER_URL env
+ // 2. Gitea has already full URL with rc.Config.GitHubInstance when calling newRemoteReusableWorkflowWithPlat
+ // remoteReusableWorkflow.URL = rc.getGithubContext(ctx).ServerURL
return git.NewGitCloneExecutor(git.NewGitCloneExecutorInput{
URL: remoteReusableWorkflow.CloneURL(),
Ref: remoteReusableWorkflow.Ref,
Dir: targetDirectory,
- Token: rc.Config.Token,
+ Token: token,
})(ctx)
},
nil,
@@ -111,12 +148,44 @@ type remoteReusableWorkflow struct {
Repo string
Filename string
Ref string
+
+ GitPlatform string
}
func (r *remoteReusableWorkflow) CloneURL() string {
- return fmt.Sprintf("%s/%s/%s", r.URL, r.Org, r.Repo)
+ // In Gitea, r.URL always has the protocol prefix, we don't need to add extra prefix in this case.
+ if strings.HasPrefix(r.URL, "http://") || strings.HasPrefix(r.URL, "https://") {
+ return fmt.Sprintf("%s/%s/%s", r.URL, r.Org, r.Repo)
+ }
+ return fmt.Sprintf("https://%s/%s/%s", r.URL, r.Org, r.Repo)
+}
+
+func (r *remoteReusableWorkflow) FilePath() string {
+ return fmt.Sprintf("./.%s/workflows/%s", r.GitPlatform, r.Filename)
+}
+
+// For Gitea
+// newRemoteReusableWorkflowWithPlat create a `remoteReusableWorkflow`
+// workflows from `.gitea/workflows` and `.github/workflows` are supported
+func newRemoteReusableWorkflowWithPlat(url, uses string) *remoteReusableWorkflow {
+ // GitHub docs:
+ // https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_iduses
+ r := regexp.MustCompile(`^([^/]+)/([^/]+)/\.([^/]+)/workflows/([^@]+)@(.*)$`)
+ matches := r.FindStringSubmatch(uses)
+ if len(matches) != 6 {
+ return nil
+ }
+ return &remoteReusableWorkflow{
+ Org: matches[1],
+ Repo: matches[2],
+ GitPlatform: matches[3],
+ Filename: matches[4],
+ Ref: matches[5],
+ URL: url,
+ }
}
+// deprecated: use newRemoteReusableWorkflowWithPlat
func newRemoteReusableWorkflow(uses string) *remoteReusableWorkflow {
// GitHub docs:
// https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_iduses
diff --git a/pkg/runner/run_context.go b/pkg/runner/run_context.go
index 51a7c9f..9706529 100644
--- a/pkg/runner/run_context.go
+++ b/pkg/runner/run_context.go
@@ -16,6 +16,7 @@ import (
"regexp"
"runtime"
"strings"
+ "time"
"github.com/opencontainers/selinux/go-selinux"
@@ -40,6 +41,7 @@ type RunContext struct {
IntraActionState map[string]map[string]string
ExprEval ExpressionEvaluator
JobContainer container.ExecutionsEnvironment
+ ServiceContainers []container.ExecutionsEnvironment
OutputMappings map[MappableOutput]MappableOutput
JobName string
ActionPath string
@@ -80,11 +82,22 @@ func (rc *RunContext) GetEnv() map[string]string {
}
}
rc.Env["ACT"] = "true"
+
+ if !rc.Config.NoSkipCheckout {
+ rc.Env["ACT_SKIP_CHECKOUT"] = "true"
+ }
+
return rc.Env
}
func (rc *RunContext) jobContainerName() string {
- return createContainerName("act", rc.String())
+ return createSimpleContainerName(rc.Config.ContainerNamePrefix, "WORKFLOW-"+rc.Run.Workflow.Name, "JOB-"+rc.Name)
+}
+
+// networkName return the name of the network which will be created by `act` automatically for job,
+// only create network if `rc.Config.ContainerNetworkMode` is empty string.
+func (rc *RunContext) networkName() string {
+ return fmt.Sprintf("%s-network", rc.jobContainerName())
}
func getDockerDaemonSocketMountPath(daemonPath string) string {
@@ -154,6 +167,14 @@ func (rc *RunContext) GetBindsAndMounts() ([]string, map[string]string) {
mounts[name] = ext.ToContainerPath(rc.Config.Workdir)
}
+ // For Gitea
+ // add some default binds and mounts to ValidVolumes
+ rc.Config.ValidVolumes = append(rc.Config.ValidVolumes, "act-toolcache")
+ rc.Config.ValidVolumes = append(rc.Config.ValidVolumes, name)
+ rc.Config.ValidVolumes = append(rc.Config.ValidVolumes, name+"-env")
+ // TODO: add a new configuration to control whether the docker daemon can be mounted
+ rc.Config.ValidVolumes = append(rc.Config.ValidVolumes, getDockerDaemonSocketMountPath(rc.Config.ContainerDaemonSocket))
+
return binds, mounts
}
@@ -247,6 +268,9 @@ func (rc *RunContext) startJobContainer() common.Executor {
logger.Infof("\U0001f680 Start image=%s", image)
name := rc.jobContainerName()
+ // For gitea, to support --volumes-from <container_name_or_id> in options.
+ // We need to set the container name to the environment variable.
+ rc.Env["JOB_CONTAINER_NAME"] = name
envList := make([]string, 0)
@@ -259,6 +283,60 @@ func (rc *RunContext) startJobContainer() common.Executor {
ext := container.LinuxContainerEnvironmentExtensions{}
binds, mounts := rc.GetBindsAndMounts()
+ // specify the network to which the container will connect when `docker create` stage. (like execute command line: docker create --network <networkName> <image>)
+ networkName := string(rc.Config.ContainerNetworkMode)
+ if networkName == "" {
+ // if networkName is empty string, will create a new network for the containers.
+ // and it will be removed after at last.
+ networkName = rc.networkName()
+ }
+
+ // add service containers
+ for serviceId, spec := range rc.Run.Job().Services {
+ // interpolate env
+ interpolatedEnvs := make(map[string]string, len(spec.Env))
+ for k, v := range spec.Env {
+ interpolatedEnvs[k] = rc.ExprEval.Interpolate(ctx, v)
+ }
+ envs := make([]string, 0, len(interpolatedEnvs))
+ for k, v := range interpolatedEnvs {
+ envs = append(envs, fmt.Sprintf("%s=%s", k, v))
+ }
+ // interpolate cmd
+ interpolatedCmd := make([]string, 0, len(spec.Cmd))
+ for _, v := range spec.Cmd {
+ interpolatedCmd = append(interpolatedCmd, rc.ExprEval.Interpolate(ctx, v))
+ }
+ username, password, err := rc.handleServiceCredentials(ctx, spec.Credentials)
+ if err != nil {
+ return fmt.Errorf("failed to handle service %s credentials: %w", serviceId, err)
+ }
+ serviceBinds, serviceMounts := rc.GetServiceBindsAndMounts(spec.Volumes)
+ serviceContainerName := createSimpleContainerName(rc.jobContainerName(), serviceId)
+ c := container.NewContainer(&container.NewContainerInput{
+ Name: serviceContainerName,
+ WorkingDir: ext.ToContainerPath(rc.Config.Workdir),
+ Image: spec.Image,
+ Username: username,
+ Password: password,
+ Cmd: interpolatedCmd,
+ Env: envs,
+ Mounts: serviceMounts,
+ Binds: serviceBinds,
+ Stdout: logWriter,
+ Stderr: logWriter,
+ Privileged: rc.Config.Privileged,
+ UsernsMode: rc.Config.UsernsMode,
+ Platform: rc.Config.ContainerArchitecture,
+ AutoRemove: rc.Config.AutoRemove,
+ Options: spec.Options,
+ NetworkMode: networkName,
+ NetworkAliases: []string{serviceId},
+ ValidVolumes: rc.Config.ValidVolumes,
+ })
+ rc.ServiceContainers = append(rc.ServiceContainers, c)
+ }
+
rc.cleanUpJobContainer = func(ctx context.Context) error {
if rc.JobContainer != nil && !rc.Config.ReuseContainers {
return rc.JobContainer.Remove().
@@ -269,31 +347,36 @@ func (rc *RunContext) startJobContainer() common.Executor {
}
rc.JobContainer = container.NewContainer(&container.NewContainerInput{
- Cmd: nil,
- Entrypoint: []string{"tail", "-f", "/dev/null"},
- WorkingDir: ext.ToContainerPath(rc.Config.Workdir),
- Image: image,
- Username: username,
- Password: password,
- Name: name,
- Env: envList,
- Mounts: mounts,
- NetworkMode: "host",
- Binds: binds,
- Stdout: logWriter,
- Stderr: logWriter,
- Privileged: rc.Config.Privileged,
- UsernsMode: rc.Config.UsernsMode,
- Platform: rc.Config.ContainerArchitecture,
- Options: rc.options(ctx),
+ Cmd: nil,
+ Entrypoint: []string{"/bin/sleep", fmt.Sprint(rc.Config.ContainerMaxLifetime.Round(time.Second).Seconds())},
+ WorkingDir: ext.ToContainerPath(rc.Config.Workdir),
+ Image: image,
+ Username: username,
+ Password: password,
+ Name: name,
+ Env: envList,
+ Mounts: mounts,
+ NetworkMode: networkName,
+ NetworkAliases: []string{rc.Name},
+ Binds: binds,
+ Stdout: logWriter,
+ Stderr: logWriter,
+ Privileged: rc.Config.Privileged,
+ UsernsMode: rc.Config.UsernsMode,
+ Platform: rc.Config.ContainerArchitecture,
+ Options: rc.options(ctx),
+ AutoRemove: rc.Config.AutoRemove,
+ ValidVolumes: rc.Config.ValidVolumes,
})
if rc.JobContainer == nil {
return errors.New("Failed to create job container")
}
return common.NewPipelineExecutor(
+ rc.pullServicesImages(rc.Config.ForcePull),
rc.JobContainer.Pull(rc.Config.ForcePull),
- rc.stopJobContainer(),
+ container.NewDockerNetworkCreateExecutor(networkName).IfBool(!rc.IsHostEnv(ctx) && rc.Config.ContainerNetworkMode == ""), // if the value of `ContainerNetworkMode` is empty string, then will create a new network for containers.
+ rc.startServiceContainers(networkName),
rc.JobContainer.Create(rc.Config.ContainerCapAdd, rc.Config.ContainerCapDrop),
rc.JobContainer.Start(false),
rc.JobContainer.Copy(rc.JobContainer.GetActPath()+"/", &container.FileEntry{
@@ -379,6 +462,40 @@ func (rc *RunContext) stopJobContainer() common.Executor {
}
}
+func (rc *RunContext) pullServicesImages(forcePull bool) common.Executor {
+ return func(ctx context.Context) error {
+ execs := []common.Executor{}
+ for _, c := range rc.ServiceContainers {
+ execs = append(execs, c.Pull(forcePull))
+ }
+ return common.NewParallelExecutor(len(execs), execs...)(ctx)
+ }
+}
+
+func (rc *RunContext) startServiceContainers(networkName string) common.Executor {
+ return func(ctx context.Context) error {
+ execs := []common.Executor{}
+ for _, c := range rc.ServiceContainers {
+ execs = append(execs, common.NewPipelineExecutor(
+ c.Pull(false),
+ c.Create(rc.Config.ContainerCapAdd, rc.Config.ContainerCapDrop),
+ c.Start(false),
+ ))
+ }
+ return common.NewParallelExecutor(len(execs), execs...)(ctx)
+ }
+}
+
+func (rc *RunContext) stopServiceContainers() common.Executor {
+ return func(ctx context.Context) error {
+ execs := []common.Executor{}
+ for _, c := range rc.ServiceContainers {
+ execs = append(execs, c.Remove())
+ }
+ return common.NewParallelExecutor(len(execs), execs...)(ctx)
+ }
+}
+
// Prepare the mounts and binds for the worker
// ActionCacheDir is for rc
@@ -499,9 +616,19 @@ func (rc *RunContext) runsOnImage(ctx context.Context) string {
common.Logger(ctx).Errorf("'runs-on' key not defined in %s", rc.String())
}
- for _, runnerLabel := range job.RunsOn() {
- platformName := rc.ExprEval.Interpolate(ctx, runnerLabel)
- image := rc.Config.Platforms[strings.ToLower(platformName)]
+ runsOn := job.RunsOn()
+ for i, v := range runsOn {
+ runsOn[i] = rc.ExprEval.Interpolate(ctx, v)
+ }
+
+ if pick := rc.Config.PlatformPicker; pick != nil {
+ if image := pick(runsOn); image != "" {
+ return image
+ }
+ }
+
+ for _, runnerLabel := range runsOn {
+ image := rc.Config.Platforms[strings.ToLower(runnerLabel)]
if image != "" {
return image
}
@@ -574,6 +701,7 @@ func mergeMaps(maps ...map[string]string) map[string]string {
return rtnMap
}
+// deprecated: use createSimpleContainerName
func createContainerName(parts ...string) string {
name := strings.Join(parts, "-")
pattern := regexp.MustCompile("[^a-zA-Z0-9]")
@@ -587,6 +715,22 @@ func createContainerName(parts ...string) string {
return fmt.Sprintf("%s-%x", trimmedName, hash)
}
+func createSimpleContainerName(parts ...string) string {
+ pattern := regexp.MustCompile("[^a-zA-Z0-9-]")
+ name := make([]string, 0, len(parts))
+ for _, v := range parts {
+ v = pattern.ReplaceAllString(v, "-")
+ v = strings.Trim(v, "-")
+ for strings.Contains(v, "--") {
+ v = strings.ReplaceAll(v, "--", "-")
+ }
+ if v != "" {
+ name = append(name, v)
+ }
+ }
+ return strings.Join(name, "_")
+}
+
func trimToLen(s string, l int) string {
if l < 0 {
l = 0
@@ -667,6 +811,36 @@ func (rc *RunContext) getGithubContext(ctx context.Context) *model.GithubContext
ghc.Actor = "nektos/act"
}
+ { // Adapt to Gitea
+ if preset := rc.Config.PresetGitHubContext; preset != nil {
+ ghc.Event = preset.Event
+ ghc.RunID = preset.RunID
+ ghc.RunNumber = preset.RunNumber
+ ghc.Actor = preset.Actor
+ ghc.Repository = preset.Repository
+ ghc.EventName = preset.EventName
+ ghc.Sha = preset.Sha
+ ghc.Ref = preset.Ref
+ ghc.RefName = preset.RefName
+ ghc.RefType = preset.RefType
+ ghc.HeadRef = preset.HeadRef
+ ghc.BaseRef = preset.BaseRef
+ ghc.Token = preset.Token
+ ghc.RepositoryOwner = preset.RepositoryOwner
+ ghc.RetentionDays = preset.RetentionDays
+
+ instance := rc.Config.GitHubInstance
+ if !strings.HasPrefix(instance, "http://") &&
+ !strings.HasPrefix(instance, "https://") {
+ instance = "https://" + instance
+ }
+ ghc.ServerURL = instance
+ ghc.APIURL = instance + "/api/v1" // the version of Gitea is v1
+ ghc.GraphQLURL = "" // Gitea doesn't support graphql
+ return ghc
+ }
+ }
+
if rc.EventJSON != "" {
err := json.Unmarshal([]byte(rc.EventJSON), &ghc.Event)
if err != nil {
@@ -696,6 +870,18 @@ func (rc *RunContext) getGithubContext(ctx context.Context) *model.GithubContext
ghc.APIURL = fmt.Sprintf("https://%s/api/v3", rc.Config.GitHubInstance)
ghc.GraphQLURL = fmt.Sprintf("https://%s/api/graphql", rc.Config.GitHubInstance)
}
+
+ { // Adapt to Gitea
+ instance := rc.Config.GitHubInstance
+ if !strings.HasPrefix(instance, "http://") &&
+ !strings.HasPrefix(instance, "https://") {
+ instance = "https://" + instance
+ }
+ ghc.ServerURL = instance
+ ghc.APIURL = instance + "/api/v1" // the version of Gitea is v1
+ ghc.GraphQLURL = "" // Gitea doesn't support graphql
+ }
+
// allow to be overridden by user
if rc.Config.Env["GITHUB_SERVER_URL"] != "" {
ghc.ServerURL = rc.Config.Env["GITHUB_SERVER_URL"]
@@ -784,6 +970,17 @@ func (rc *RunContext) withGithubEnv(ctx context.Context, github *model.GithubCon
env["GITHUB_API_URL"] = github.APIURL
env["GITHUB_GRAPHQL_URL"] = github.GraphQLURL
+ { // Adapt to Gitea
+ instance := rc.Config.GitHubInstance
+ if !strings.HasPrefix(instance, "http://") &&
+ !strings.HasPrefix(instance, "https://") {
+ instance = "https://" + instance
+ }
+ env["GITHUB_SERVER_URL"] = instance
+ env["GITHUB_API_URL"] = instance + "/api/v1" // the version of Gitea is v1
+ env["GITHUB_GRAPHQL_URL"] = "" // Gitea doesn't support graphql
+ }
+
if rc.Config.ArtifactServerPath != "" {
setActionRuntimeVars(rc, env)
}
@@ -851,3 +1048,53 @@ func (rc *RunContext) handleCredentials(ctx context.Context) (string, string, er
return username, password, nil
}
+
+func (rc *RunContext) handleServiceCredentials(ctx context.Context, creds map[string]string) (username, password string, err error) {
+ if creds == nil {
+ return
+ }
+ if len(creds) != 2 {
+ err = fmt.Errorf("invalid property count for key 'credentials:'")
+ return
+ }
+
+ ee := rc.NewExpressionEvaluator(ctx)
+ if username = ee.Interpolate(ctx, creds["username"]); username == "" {
+ err = fmt.Errorf("failed to interpolate credentials.username")
+ return
+ }
+
+ if password = ee.Interpolate(ctx, creds["password"]); password == "" {
+ err = fmt.Errorf("failed to interpolate credentials.password")
+ return
+ }
+
+ return
+}
+
+// GetServiceBindsAndMounts returns the binds and mounts for the service container, resolving paths as appopriate
+func (rc *RunContext) GetServiceBindsAndMounts(svcVolumes []string) ([]string, map[string]string) {
+ if rc.Config.ContainerDaemonSocket == "" {
+ rc.Config.ContainerDaemonSocket = "/var/run/docker.sock"
+ }
+ binds := []string{}
+ if rc.Config.ContainerDaemonSocket != "-" {
+ daemonPath := getDockerDaemonSocketMountPath(rc.Config.ContainerDaemonSocket)
+ binds = append(binds, fmt.Sprintf("%s:%s", daemonPath, "/var/run/docker.sock"))
+ }
+
+ mounts := map[string]string{}
+
+ for _, v := range svcVolumes {
+ if !strings.Contains(v, ":") || filepath.IsAbs(v) {
+ // Bind anonymous volume or host file.
+ binds = append(binds, v)
+ } else {
+ // Mount existing volume.
+ paths := strings.SplitN(v, ":", 2)
+ mounts[paths[0]] = paths[1]
+ }
+ }
+
+ return binds, mounts
+}
diff --git a/pkg/runner/run_context_test.go b/pkg/runner/run_context_test.go
index 3e26a02..86ad44e 100644
--- a/pkg/runner/run_context_test.go
+++ b/pkg/runner/run_context_test.go
@@ -624,3 +624,24 @@ func TestRunContextGetEnv(t *testing.T) {
})
}
}
+
+func Test_createSimpleContainerName(t *testing.T) {
+ tests := []struct {
+ parts []string
+ want string
+ }{
+ {
+ parts: []string{"a--a", "BBæ­£", "c-C"},
+ want: "a-a_BB_c-C",
+ },
+ {
+ parts: []string{"a-a", "", "-"},
+ want: "a-a",
+ },
+ }
+ for _, tt := range tests {
+ t.Run(strings.Join(tt.parts, " "), func(t *testing.T) {
+ assert.Equalf(t, tt.want, createSimpleContainerName(tt.parts...), "createSimpleContainerName(%v)", tt.parts)
+ })
+ }
+}
diff --git a/pkg/runner/runner.go b/pkg/runner/runner.go
index 09c1731..e9f00e2 100644
--- a/pkg/runner/runner.go
+++ b/pkg/runner/runner.go
@@ -6,7 +6,9 @@ import (
"fmt"
"os"
"runtime"
+ "time"
+ docker_container "github.com/docker/docker/api/types/container"
log "github.com/sirupsen/logrus"
"github.com/nektos/act/pkg/common"
@@ -58,6 +60,25 @@ type Config struct {
ReplaceGheActionWithGithubCom []string // Use actions from GitHub Enterprise instance to GitHub
ReplaceGheActionTokenWithGithubCom string // Token of private action repo on GitHub.
Matrix map[string]map[string]bool // Matrix config to run
+
+ PresetGitHubContext *model.GithubContext // the preset github context, overrides some fields like DefaultBranch, Env, Secrets etc.
+ EventJSON string // the content of JSON file to use for event.json in containers, overrides EventPath
+ ContainerNamePrefix string // the prefix of container name
+ ContainerMaxLifetime time.Duration // the max lifetime of job containers
+ ContainerNetworkMode docker_container.NetworkMode // the network mode of job containers (the value of --network)
+ DefaultActionInstance string // the default actions web site
+ PlatformPicker func(labels []string) string // platform picker, it will take precedence over Platforms if isn't nil
+ JobLoggerLevel *log.Level // the level of job logger
+ ValidVolumes []string // only volumes (and bind mounts) in this slice can be mounted on the job container or service containers
+}
+
+// GetToken: Adapt to Gitea
+func (c Config) GetToken() string {
+ token := c.Secrets["GITHUB_TOKEN"]
+ if c.Secrets["GITEA_TOKEN"] != "" {
+ token = c.Secrets["GITEA_TOKEN"]
+ }
+ return token
}
type caller struct {
@@ -81,7 +102,9 @@ func New(runnerConfig *Config) (Runner, error) {
func (runner *runnerImpl) configure() (Runner, error) {
runner.eventJSON = "{}"
- if runner.config.EventPath != "" {
+ if runner.config.EventJSON != "" {
+ runner.eventJSON = runner.config.EventJSON
+ } else if runner.config.EventPath != "" {
log.Debugf("Reading event.json from %s", runner.config.EventPath)
eventJSONBytes, err := os.ReadFile(runner.config.EventPath)
if err != nil {
diff --git a/pkg/runner/runner_test.go b/pkg/runner/runner_test.go
index 4034e96..b1de915 100644
--- a/pkg/runner/runner_test.go
+++ b/pkg/runner/runner_test.go
@@ -548,6 +548,43 @@ func TestRunEventSecrets(t *testing.T) {
tjfi.runTest(context.Background(), t, &Config{Secrets: secrets, Env: env})
}
+func TestRunWithService(t *testing.T) {
+ if testing.Short() {
+ t.Skip("skipping integration test")
+ }
+
+ log.SetLevel(log.DebugLevel)
+ ctx := context.Background()
+
+ platforms := map[string]string{
+ "ubuntu-latest": "node:12.20.1-buster-slim",
+ }
+
+ workflowPath := "services"
+ eventName := "push"
+
+ workdir, err := filepath.Abs("testdata")
+ assert.NoError(t, err, workflowPath)
+
+ runnerConfig := &Config{
+ Workdir: workdir,
+ EventName: eventName,
+ Platforms: platforms,
+ ReuseContainers: false,
+ }
+ runner, err := New(runnerConfig)
+ assert.NoError(t, err, workflowPath)
+
+ planner, err := model.NewWorkflowPlanner(fmt.Sprintf("testdata/%s", workflowPath), true)
+ assert.NoError(t, err, workflowPath)
+
+ plan, err := planner.PlanEvent(eventName)
+ assert.NoError(t, err, workflowPath)
+
+ err = runner.NewPlanExecutor(plan)(ctx)
+ assert.NoError(t, err, workflowPath)
+}
+
func TestRunActionInputs(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test")
diff --git a/pkg/runner/step_action_remote.go b/pkg/runner/step_action_remote.go
index e23dcf9..4aca8f5 100644
--- a/pkg/runner/step_action_remote.go
+++ b/pkg/runner/step_action_remote.go
@@ -30,9 +30,7 @@ type stepActionRemote struct {
remoteAction *remoteAction
}
-var (
- stepActionRemoteNewCloneExecutor = git.NewGitCloneExecutor
-)
+var stepActionRemoteNewCloneExecutor = git.NewGitCloneExecutor
func (sar *stepActionRemote) prepareActionExecutor() common.Executor {
return func(ctx context.Context) error {
@@ -41,14 +39,18 @@ func (sar *stepActionRemote) prepareActionExecutor() common.Executor {
return nil
}
+ // For gitea:
+ // Since actions can specify the download source via a url prefix.
+ // The prefix may contain some sensitive information that needs to be stored in secrets,
+ // so we need to interpolate the expression value for uses first.
+ sar.Step.Uses = sar.RunContext.NewExpressionEvaluator(ctx).Interpolate(ctx, sar.Step.Uses)
+
sar.remoteAction = newRemoteAction(sar.Step.Uses)
if sar.remoteAction == nil {
return fmt.Errorf("Expected format {org}/{repo}[/path]@ref. Actual '%s' Input string was not in a correct format", sar.Step.Uses)
}
github := sar.getGithubContext(ctx)
- sar.remoteAction.URL = github.ServerURL
-
if sar.remoteAction.IsCheckout() && isLocalCheckout(github, sar.Step) && !sar.RunContext.Config.NoSkipCheckout {
common.Logger(ctx).Debugf("Skipping local actions/checkout because workdir was already copied")
return nil
@@ -63,10 +65,16 @@ func (sar *stepActionRemote) prepareActionExecutor() common.Executor {
actionDir := fmt.Sprintf("%s/%s", sar.RunContext.ActionCacheDir(), safeFilename(sar.Step.Uses))
gitClone := stepActionRemoteNewCloneExecutor(git.NewGitCloneExecutorInput{
- URL: sar.remoteAction.CloneURL(),
+ URL: sar.remoteAction.CloneURL(sar.RunContext.Config.DefaultActionInstance),
Ref: sar.remoteAction.Ref,
Dir: actionDir,
- Token: github.Token,
+ Token: "", /*
+ Shouldn't provide token when cloning actions,
+ the token comes from the instance which triggered the task,
+ however, it might be not the same instance which provides actions.
+ For GitHub, they are the same, always github.com.
+ But for Gitea, tasks triggered by a.com can clone actions from b.com.
+ */
})
var ntErr common.Executor
if err := gitClone(ctx); err != nil {
@@ -212,8 +220,16 @@ type remoteAction struct {
Ref string
}
-func (ra *remoteAction) CloneURL() string {
- return fmt.Sprintf("%s/%s/%s", ra.URL, ra.Org, ra.Repo)
+func (ra *remoteAction) CloneURL(u string) string {
+ if ra.URL == "" {
+ if !strings.HasPrefix(u, "http://") && !strings.HasPrefix(u, "https://") {
+ u = "https://" + u
+ }
+ } else {
+ u = ra.URL
+ }
+
+ return fmt.Sprintf("%s/%s/%s", u, ra.Org, ra.Repo)
}
func (ra *remoteAction) IsCheckout() bool {
@@ -224,6 +240,26 @@ func (ra *remoteAction) IsCheckout() bool {
}
func newRemoteAction(action string) *remoteAction {
+ // support http(s)://host/owner/repo@v3
+ for _, schema := range []string{"https://", "http://"} {
+ if strings.HasPrefix(action, schema) {
+ splits := strings.SplitN(strings.TrimPrefix(action, schema), "/", 2)
+ if len(splits) != 2 {
+ return nil
+ }
+ ret := parseAction(splits[1])
+ if ret == nil {
+ return nil
+ }
+ ret.URL = schema + splits[0]
+ return ret
+ }
+ }
+
+ return parseAction(action)
+}
+
+func parseAction(action string) *remoteAction {
// GitHub's document[^] describes:
// > We strongly recommend that you include the version of
// > the action you are using by specifying a Git ref, SHA, or Docker tag number.
@@ -239,7 +275,7 @@ func newRemoteAction(action string) *remoteAction {
Repo: matches[2],
Path: matches[4],
Ref: matches[6],
- URL: "https://github.com",
+ URL: "",
}
}
diff --git a/pkg/runner/step_action_remote_test.go b/pkg/runner/step_action_remote_test.go
index ec1028a..9dcc338 100644
--- a/pkg/runner/step_action_remote_test.go
+++ b/pkg/runner/step_action_remote_test.go
@@ -616,6 +616,100 @@ func TestStepActionRemotePost(t *testing.T) {
}
}
+func Test_newRemoteAction(t *testing.T) {
+ tests := []struct {
+ action string
+ want *remoteAction
+ wantCloneURL string
+ }{
+ {
+ action: "actions/heroku@main",
+ want: &remoteAction{
+ URL: "",
+ Org: "actions",
+ Repo: "heroku",
+ Path: "",
+ Ref: "main",
+ },
+ wantCloneURL: "https://github.com/actions/heroku",
+ },
+ {
+ action: "actions/aws/ec2@main",
+ want: &remoteAction{
+ URL: "",
+ Org: "actions",
+ Repo: "aws",
+ Path: "ec2",
+ Ref: "main",
+ },
+ wantCloneURL: "https://github.com/actions/aws",
+ },
+ {
+ action: "./.github/actions/my-action", // it's valid for GitHub, but act don't support it
+ want: nil,
+ },
+ {
+ action: "docker://alpine:3.8", // it's valid for GitHub, but act don't support it
+ want: nil,
+ },
+ {
+ action: "https://gitea.com/actions/heroku@main", // it's invalid for GitHub, but gitea supports it
+ want: &remoteAction{
+ URL: "https://gitea.com",
+ Org: "actions",
+ Repo: "heroku",
+ Path: "",
+ Ref: "main",
+ },
+ wantCloneURL: "https://gitea.com/actions/heroku",
+ },
+ {
+ action: "https://gitea.com/actions/aws/ec2@main", // it's invalid for GitHub, but gitea supports it
+ want: &remoteAction{
+ URL: "https://gitea.com",
+ Org: "actions",
+ Repo: "aws",
+ Path: "ec2",
+ Ref: "main",
+ },
+ wantCloneURL: "https://gitea.com/actions/aws",
+ },
+ {
+ action: "http://gitea.com/actions/heroku@main", // it's invalid for GitHub, but gitea supports it
+ want: &remoteAction{
+ URL: "http://gitea.com",
+ Org: "actions",
+ Repo: "heroku",
+ Path: "",
+ Ref: "main",
+ },
+ wantCloneURL: "http://gitea.com/actions/heroku",
+ },
+ {
+ action: "http://gitea.com/actions/aws/ec2@main", // it's invalid for GitHub, but gitea supports it
+ want: &remoteAction{
+ URL: "http://gitea.com",
+ Org: "actions",
+ Repo: "aws",
+ Path: "ec2",
+ Ref: "main",
+ },
+ wantCloneURL: "http://gitea.com/actions/aws",
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.action, func(t *testing.T) {
+ got := newRemoteAction(tt.action)
+ assert.Equalf(t, tt.want, got, "newRemoteAction(%v)", tt.action)
+ cloneURL := ""
+ if got != nil {
+ cloneURL = got.CloneURL("github.com")
+ }
+ assert.Equalf(t, tt.wantCloneURL, cloneURL, "newRemoteAction(%v).CloneURL()", tt.action)
+ })
+ }
+}
+
func Test_safeFilename(t *testing.T) {
tests := []struct {
s string
diff --git a/pkg/runner/step_docker.go b/pkg/runner/step_docker.go
index 1c7e39e..2f23986 100644
--- a/pkg/runner/step_docker.go
+++ b/pkg/runner/step_docker.go
@@ -114,22 +114,24 @@ func (sd *stepDocker) newStepContainer(ctx context.Context, image string, cmd []
binds, mounts := rc.GetBindsAndMounts()
stepContainer := ContainerNewContainer(&container.NewContainerInput{
- Cmd: cmd,
- Entrypoint: entrypoint,
- WorkingDir: rc.JobContainer.ToContainerPath(rc.Config.Workdir),
- Image: image,
- Username: rc.Config.Secrets["DOCKER_USERNAME"],
- Password: rc.Config.Secrets["DOCKER_PASSWORD"],
- Name: createContainerName(rc.jobContainerName(), step.ID),
- Env: envList,
- Mounts: mounts,
- NetworkMode: fmt.Sprintf("container:%s", rc.jobContainerName()),
- Binds: binds,
- Stdout: logWriter,
- Stderr: logWriter,
- Privileged: rc.Config.Privileged,
- UsernsMode: rc.Config.UsernsMode,
- Platform: rc.Config.ContainerArchitecture,
+ Cmd: cmd,
+ Entrypoint: entrypoint,
+ WorkingDir: rc.JobContainer.ToContainerPath(rc.Config.Workdir),
+ Image: image,
+ Username: rc.Config.Secrets["DOCKER_USERNAME"],
+ Password: rc.Config.Secrets["DOCKER_PASSWORD"],
+ Name: createSimpleContainerName(rc.jobContainerName(), "STEP-"+step.ID),
+ Env: envList,
+ Mounts: mounts,
+ NetworkMode: fmt.Sprintf("container:%s", rc.jobContainerName()),
+ Binds: binds,
+ Stdout: logWriter,
+ Stderr: logWriter,
+ Privileged: rc.Config.Privileged,
+ UsernsMode: rc.Config.UsernsMode,
+ Platform: rc.Config.ContainerArchitecture,
+ AutoRemove: rc.Config.AutoRemove,
+ ValidVolumes: rc.Config.ValidVolumes,
})
return stepContainer
}
diff --git a/pkg/runner/testdata/services/push.yaml b/pkg/runner/testdata/services/push.yaml
new file mode 100644
index 0000000..f6ca7bc
--- /dev/null
+++ b/pkg/runner/testdata/services/push.yaml
@@ -0,0 +1,26 @@
+name: services
+on: push
+jobs:
+ services:
+ name: Reproduction of failing Services interpolation
+ runs-on: ubuntu-latest
+ services:
+ postgres:
+ image: postgres:12
+ env:
+ POSTGRES_USER: runner
+ POSTGRES_PASSWORD: mysecretdbpass
+ POSTGRES_DB: mydb
+ options: >-
+ --health-cmd pg_isready
+ --health-interval 10s
+ --health-timeout 5s
+ --health-retries 5
+ ports:
+ - 5432:5432
+ steps:
+ - name: Echo the Postgres service ID / Network / Ports
+ run: |
+ echo "id: ${{ job.services.postgres.id }}"
+ echo "network: ${{ job.services.postgres.network }}"
+ echo "ports: ${{ job.services.postgres.ports }}"