Merge pull request #98 from nektos/ISS-86

Iss 86
This commit is contained in:
Casey Lee 2020-02-24 17:51:40 -08:00 committed by GitHub
commit 0f796ff9f6
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
33 changed files with 1089 additions and 710 deletions

View file

@ -1,9 +0,0 @@
FROM golangci/golangci-lint:v1.23.6
RUN apt-get install git
COPY "entrypoint.sh" "/entrypoint.sh"
RUN chmod +x /entrypoint.sh
ENV GOFLAGS -mod=vendor
ENTRYPOINT ["/entrypoint.sh"]

View file

@ -1,8 +0,0 @@
name: Check
description: Run static analysis and unit tests
branding:
icon: check-circle
color: green
runs:
using: 'docker'
image: 'Dockerfile'

View file

@ -1,4 +0,0 @@
#!/bin/sh
set -e
golangci-lint run
go test -cover -short ./...

View file

@ -1,7 +0,0 @@
FROM golangci/golangci-lint:v1.23.6
COPY "entrypoint.sh" "/entrypoint.sh"
RUN chmod +x /entrypoint.sh
ENV GOFLAGS -mod=vendor
ENTRYPOINT ["/entrypoint.sh"]

View file

@ -1,8 +0,0 @@
name: Check
description: Run integration tests
branding:
icon: check-circle
color: green
runs:
using: 'docker'
image: 'Dockerfile'

View file

@ -1,3 +0,0 @@
#!/bin/sh
set -e
go test -cover ./pkg/runner

View file

@ -2,9 +2,40 @@ name: push
on: push
jobs:
ci:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: ./.github/workflows/check
#- uses: ./.github/workflows/integration
- uses: docker://golangci/golangci-lint:v1.23.6
with:
args: golangci-lint run
env:
CGO_ENABLED: 0
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/setup-go@v1
with:
go-version: 1.13
- run: go test -cover ./...
env:
CGO_ENABLED: 0
GOFLAGS: -mod=vendor
release:
if: startsWith(github.ref, 'refs/tags/v')
needs:
- lint
- test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: GoReleaser
uses: goreleaser/goreleaser-action@v1
with:
version: latest
args: release --rm-dist
env:
GITHUB_TOKEN: ${{ secrets.GORELEASER_GITHUB_TOKEN }}

View file

@ -1,20 +0,0 @@
name: tag
on:
push:
tags:
- 'v*'
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: ./.github/workflows/check
#- uses: ./.github/workflows/integration
- name: Run GoReleaser
uses: goreleaser/goreleaser-action@v1
with:
version: latest
args: release --rm-dist
env:
GITHUB_TOKEN: ${{ secrets.GORELEASER_GITHUB_TOKEN }}

View file

@ -1,30 +1,21 @@
LATEST_VERSION := $(shell git tag -l --sort=creatordate | grep "^v[0-9]*.[0-9]*.[0-9]*$$" | tail -1 | cut -c 2-)
ifeq "$(shell git tag -l v$(LATEST_VERSION) --points-at HEAD)" "v$(LATEST_VERSION)"
### latest tag points to current commit, this is a release build
VERSION ?= $(LATEST_VERSION)
else
### latest tag points to prior commit, this is a snapshot build
MAJOR_VERSION := $(word 1, $(subst ., ,$(LATEST_VERSION)))
MINOR_VERSION := $(word 2, $(subst ., ,$(LATEST_VERSION)))
PATCH_VERSION := $(word 3, $(subst ., ,$(LATEST_VERSION)))
VERSION ?= $(MAJOR_VERSION).$(MINOR_VERSION).$(shell echo $$(( $(PATCH_VERSION) + 1)) )-develop
endif
VERSION?=$(shell git describe --tags --dirty | cut -c 2-)
IS_SNAPSHOT = $(if $(findstring -, $(VERSION)),true,false)
TAG_VERSION = v$(VERSION)
MAJOR_VERSION = $(word 1, $(subst ., ,$(VERSION)))
MINOR_VERSION = $(word 2, $(subst ., ,$(VERSION)))
PATCH_VERSION = $(word 3, $(subst ., ,$(word 1,$(subst -, , $(VERSION)))))
NEW_VERSION ?= $(MAJOR_VERSION).$(MINOR_VERSION).$(shell echo $$(( $(PATCH_VERSION) + 1)) )
ACT ?= go run -mod=vendor main.go
ACT ?= go run main.go
export GITHUB_TOKEN = $(shell cat ~/.config/github/token)
check:
@golangci-lint run
@go test -cover ./...
build:
go build -ldflags "-X main.version=$(VERSION)" -o dist/local/act main.go
build: check
$(eval export SNAPSHOT_VERSION=$(VERSION))
$(ACT) -ra build
test:
$(ACT) -P ubuntu-latest=nektos/act-environments-ubuntu:18.04
install: build
@cp dist/$(shell go env GOOS)_$(shell go env GOARCH)/act /usr/local/bin/act
@cp dist/local/act /usr/local/bin/act
@chmod 755 /usr/local/bin/act
@act --version
@ -33,7 +24,8 @@ installer:
godownloader -r nektos/act -o install.sh
promote: vendor
@echo "VERSION:$(VERSION) IS_SNAPSHOT:$(IS_SNAPSHOT) LATEST_VERSION:$(LATEST_VERSION)"
@git fetch --tags
@echo "VERSION:$(VERSION) IS_SNAPSHOT:$(IS_SNAPSHOT) NEW_VERSION:$(NEW_VERSION)"
ifeq (false,$(IS_SNAPSHOT))
@echo "Unable to promote a non-snapshot"
@exit 1
@ -42,9 +34,8 @@ ifneq ($(shell git status -s),)
@echo "Unable to promote a dirty workspace"
@exit 1
endif
$(eval NEW_VERSION := $(word 1,$(subst -, , $(TAG_VERSION))))
git tag -a -m "releasing $(NEW_VERSION)" $(NEW_VERSION)
git push origin $(NEW_VERSION)
git tag -a -m "releasing v$(NEW_VERSION)" v$(NEW_VERSION)
git push origin v$(NEW_VERSION)
vendor:
go mod vendor

View file

@ -56,9 +56,9 @@ GitHub Actions offers managed [virtual environments](https://help.github.com/en/
| GitHub Runner | Docker Image |
| --------------- | ------------ |
| ubuntu-latest | [ubuntu:18.04](https://hub.docker.com/_/ubuntu) |
| ubuntu-18.04 | [ubuntu:18.04](https://hub.docker.com/_/ubuntu) |
| ubuntu-16.04 | [ubuntu:16.04](https://hub.docker.com/_/ubuntu) |
| ubuntu-latest | [node:12.6-buster-slim](https://hub.docker.com/_/buildpack-deps) |
| ubuntu-18.04 | [node:12.6-buster-slim](https://hub.docker.com/_/buildpack-deps) |
| ubuntu-16.04 | [node:12.6-stretch-slim](https://hub.docker.com/_/buildpack-deps) |
| windows-latest | `unsupported` |
| windows-2019 | `unsupported` |
| macos-latest | `unsupported` |

View file

@ -11,6 +11,7 @@ type Input struct {
workflowsPath string
eventPath string
reuseContainers bool
bindWorkdir bool
secrets []string
platforms []string
dryrun bool

View file

@ -6,9 +6,9 @@ import (
func (i *Input) newPlatforms() map[string]string {
platforms := map[string]string{
"ubuntu-latest": "ubuntu:18.04",
"ubuntu-18.04": "ubuntu:18.04",
"ubuntu-16.04": "ubuntu:16.04",
"ubuntu-latest": "node:12.6-buster-slim",
"ubuntu-18.04": "node:12.6-buster-slim",
"ubuntu-16.04": "node:12.6-stretch-slim",
"windows-latest": "",
"windows-2019": "",
"macos-latest": "",

View file

@ -33,6 +33,7 @@ func Execute(ctx context.Context, version string) {
rootCmd.Flags().StringArrayVarP(&input.secrets, "secret", "s", []string{}, "secret to make available to actions with optional value (e.g. -s mysecret=foo or -s mysecret)")
rootCmd.Flags().StringArrayVarP(&input.platforms, "platform", "P", []string{}, "custom image to use per platform (e.g. -P ubuntu-18.04=nektos/act-environments-ubuntu:18.04)")
rootCmd.Flags().BoolVarP(&input.reuseContainers, "reuse", "r", false, "reuse action containers to maintain state")
rootCmd.Flags().BoolVarP(&input.bindWorkdir, "bind", "b", false, "bind working directory to container, rather than copy")
rootCmd.Flags().BoolVarP(&input.forcePull, "pull", "p", false, "pull docker image(s) if already present")
rootCmd.Flags().StringVarP(&input.eventPath, "eventpath", "e", "", "path to event JSON file")
rootCmd.PersistentFlags().StringVarP(&input.workflowsPath, "workflows", "W", "./.github/workflows/", "path to workflow files")
@ -97,6 +98,7 @@ func newRunCommand(ctx context.Context, input *Input) func(*cobra.Command, []str
ForcePull: input.forcePull,
ReuseContainers: input.reuseContainers,
Workdir: input.Workdir(),
BindWorkdir: input.bindWorkdir,
LogOutput: !input.noOutput,
Secrets: newSecrets(input.secrets),
Platforms: input.newPlatforms(),

1
go.sum
View file

@ -23,6 +23,7 @@ github.com/docker/distribution v2.7.1+incompatible h1:a5mlkVzth6W5A4fOsS3D2EO5BU
github.com/docker/distribution v2.7.1+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w=
github.com/docker/engine v0.0.0-20181106193140-f5749085e9cb h1:PyjxRdW1mqCmSoxy/6uP01P7CGbsD+woX+oOWbaUPwQ=
github.com/docker/engine v0.0.0-20181106193140-f5749085e9cb/go.mod h1:3CPr2caMgTHxxIAZgEMd3uLYPDlRvPqCpyeRf6ncPcY=
github.com/docker/engine v1.13.1 h1:Cks33UT9YBW5Xyc3MtGDq2IPgqfJtJ+qkFaxc2b0Euc=
github.com/docker/go-connections v0.4.0 h1:El9xVISelRB7BuFusrZozjnkIM5YnzCViNKohAFqRJQ=
github.com/docker/go-connections v0.4.0/go.mod h1:Gbd7IOopHjR8Iph03tsViu4nIes5XhDvyHbTtUxmeec=
github.com/docker/go-units v0.3.3 h1:Xk8S3Xj5sLGlG5g67hJmYMmUgXv5N4PhkjJHHqrwnTk=

View file

@ -40,6 +40,15 @@ func NewInfoExecutor(format string, args ...interface{}) Executor {
}
}
// NewDebugExecutor is an executor that logs messages
func NewDebugExecutor(format string, args ...interface{}) Executor {
return func(ctx context.Context) error {
logger := Logger(ctx)
logger.Debugf(format, args...)
return nil
}
}
// NewPipelineExecutor creates a new executor from a series of other executors
func NewPipelineExecutor(executors ...Executor) Executor {
if len(executors) == 0 {

View file

@ -254,7 +254,7 @@ func NewGitCloneExecutor(input NewGitCloneExecutorInput) Executor {
Force: true,
})
if err != nil {
logger.Errorf("Unable to checkout %s: %v", refName, err)
logger.Errorf("Unable to checkout %s: %v", *hash, err)
return err
}

View file

@ -6,7 +6,7 @@ import (
)
// LineHandler is a callback function for handling a line
type LineHandler func(line string)
type LineHandler func(line string) bool
type lineWriter struct {
buffer bytes.Buffer
@ -42,6 +42,9 @@ func (lw *lineWriter) Write(p []byte) (n int, err error) {
func (lw *lineWriter) handleLine(line string) {
for _, h := range lw.handlers {
h(line)
ok := h(line)
if !ok {
break
}
}
}

View file

@ -8,8 +8,9 @@ import (
func TestLineWriter(t *testing.T) {
lines := make([]string, 0)
lineHandler := func(s string) {
lineHandler := func(s string) bool {
lines = append(lines, s)
return true
}
lineWriter := NewLineWriter(lineHandler)

View file

@ -21,7 +21,7 @@ type NewDockerPullExecutorInput struct {
func NewDockerPullExecutor(input NewDockerPullExecutorInput) common.Executor {
return func(ctx context.Context) error {
logger := common.Logger(ctx)
logger.Infof("%sdocker pull %v", logPrefix, input.Image)
logger.Debugf("%sdocker pull %v", logPrefix, input.Image)
if common.Dryrun(ctx) {
return nil

View file

@ -1,67 +1,141 @@
package container
import (
"archive/tar"
"bytes"
"context"
"fmt"
"io"
"io/ioutil"
"os"
"path/filepath"
"strings"
"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/mount"
"github.com/docker/docker/client"
"github.com/docker/docker/pkg/stdcopy"
"github.com/nektos/act/pkg/common"
"github.com/pkg/errors"
log "github.com/sirupsen/logrus"
"golang.org/x/crypto/ssh/terminal"
)
// NewDockerRunExecutorInput the input for the NewDockerRunExecutor function
type NewDockerRunExecutorInput struct {
Image string
Entrypoint []string
Cmd []string
WorkingDir string
Env []string
Binds []string
Content map[string]io.Reader
Volumes []string
Name string
ReuseContainers bool
Stdout io.Writer
Stderr io.Writer
// NewContainerInput the input for the New function
type NewContainerInput struct {
Image string
Entrypoint []string
Cmd []string
WorkingDir string
Env []string
Binds []string
Mounts map[string]string
Name string
Stdout io.Writer
Stderr io.Writer
}
// NewDockerRunExecutor function to create a run executor for the container
func NewDockerRunExecutor(input NewDockerRunExecutorInput) common.Executor {
// FileEntry is a file to copy to a container
type FileEntry struct {
Name string
Mode int64
Body string
}
// Container for managing docker run containers
type Container interface {
Create() common.Executor
Copy(destPath string, files ...*FileEntry) common.Executor
CopyDir(destPath string, srcPath string) common.Executor
Pull(forcePull bool) common.Executor
Start(attach bool) common.Executor
Exec(command []string, env map[string]string) common.Executor
Remove() common.Executor
}
// NewContainer creates a reference to a container
func NewContainer(input *NewContainerInput) Container {
cr := new(containerReference)
cr.input = input
return cr
}
func (cr *containerReference) Create() common.Executor {
return common.
NewInfoExecutor("%sdocker run image=%s entrypoint=%+q cmd=%+q", logPrefix, input.Image, input.Entrypoint, input.Cmd).
NewDebugExecutor("%sdocker create image=%s entrypoint=%+q cmd=%+q", logPrefix, cr.input.Image, cr.input.Entrypoint, cr.input.Cmd).
Then(
common.NewPipelineExecutor(
cr.connect(),
cr.find(),
cr.remove().IfBool(!input.ReuseContainers),
cr.create(),
cr.copyContent(),
cr.attach(),
cr.start(),
cr.wait(),
).Finally(
cr.remove().IfBool(!input.ReuseContainers),
).IfNot(common.Dryrun),
)
}
func (cr *containerReference) Start(attach bool) common.Executor {
return common.
NewInfoExecutor("%sdocker run image=%s entrypoint=%+q cmd=%+q", logPrefix, cr.input.Image, cr.input.Entrypoint, cr.input.Cmd).
Then(
common.NewPipelineExecutor(
cr.connect(),
cr.find(),
cr.attach().IfBool(attach),
cr.start(),
cr.wait().IfBool(attach),
).IfNot(common.Dryrun),
)
}
func (cr *containerReference) Pull(forcePull bool) common.Executor {
return NewDockerPullExecutor(NewDockerPullExecutorInput{
Image: cr.input.Image,
ForcePull: forcePull,
})
}
func (cr *containerReference) Copy(destPath string, files ...*FileEntry) common.Executor {
return common.NewPipelineExecutor(
cr.connect(),
cr.find(),
cr.copyContent(destPath, files...),
).IfNot(common.Dryrun)
}
func (cr *containerReference) CopyDir(destPath string, srcPath string) common.Executor {
return common.NewPipelineExecutor(
common.NewInfoExecutor("%sdocker cp src=%s dst=%s", logPrefix, srcPath, destPath),
cr.connect(),
cr.find(),
cr.copyDir(destPath, srcPath),
).IfNot(common.Dryrun)
}
func (cr *containerReference) Exec(command []string, env map[string]string) common.Executor {
return common.NewPipelineExecutor(
cr.connect(),
cr.find(),
cr.exec(command, env),
).IfNot(common.Dryrun)
}
func (cr *containerReference) Remove() common.Executor {
return common.NewPipelineExecutor(
cr.connect(),
cr.find(),
).Finally(
cr.remove(),
).IfNot(common.Dryrun)
}
type containerReference struct {
input NewDockerRunExecutorInput
cli *client.Client
id string
input *NewContainerInput
}
func (cr *containerReference) connect() common.Executor {
return func(ctx context.Context) error {
if cr.cli != nil {
return nil
}
cli, err := client.NewClientWithOpts(client.FromEnv)
if err != nil {
return errors.WithStack(err)
@ -74,6 +148,9 @@ func (cr *containerReference) connect() common.Executor {
func (cr *containerReference) find() common.Executor {
return func(ctx context.Context) error {
if cr.id != "" {
return nil
}
containers, err := cr.cli.ContainerList(ctx, types.ContainerListOptions{
All: true,
})
@ -107,11 +184,11 @@ func (cr *containerReference) remove() common.Executor {
Force: true,
})
if err != nil {
return errors.WithStack(err)
logger.Error(errors.WithStack(err))
}
cr.id = ""
logger.Debugf("Removed container: %v", cr.id)
cr.id = ""
return nil
}
}
@ -134,15 +211,18 @@ func (cr *containerReference) create() common.Executor {
Tty: isTerminal,
}
if len(input.Volumes) > 0 {
config.Volumes = make(map[string]struct{})
for _, vol := range input.Volumes {
config.Volumes[vol] = struct{}{}
}
mounts := make([]mount.Mount, 0)
for mountSource, mountTarget := range input.Mounts {
mounts = append(mounts, mount.Mount{
Type: mount.TypeVolume,
Source: mountSource,
Target: mountTarget,
})
}
resp, err := cr.cli.ContainerCreate(ctx, config, &container.HostConfig{
Binds: input.Binds,
Binds: input.Binds,
Mounts: mounts,
}, nil, input.Name)
if err != nil {
return errors.WithStack(err)
@ -155,15 +235,180 @@ func (cr *containerReference) create() common.Executor {
}
}
func (cr *containerReference) copyContent() common.Executor {
func (cr *containerReference) exec(cmd []string, env map[string]string) common.Executor {
return func(ctx context.Context) error {
logger := common.Logger(ctx)
for dstPath, srcReader := range cr.input.Content {
logger.Debugf("Extracting content to '%s'", dstPath)
err := cr.cli.CopyToContainer(ctx, cr.id, dstPath, srcReader, types.CopyToContainerOptions{})
logger.Debugf("Exec command '%s'", cmd)
isTerminal := terminal.IsTerminal(int(os.Stdout.Fd()))
envList := make([]string, 0)
for k, v := range env {
envList = append(envList, fmt.Sprintf("%s=%s", k, v))
}
idResp, err := cr.cli.ContainerExecCreate(ctx, cr.id, types.ExecConfig{
Cmd: cmd,
WorkingDir: cr.input.WorkingDir,
Env: envList,
Tty: isTerminal,
AttachStderr: true,
AttachStdout: true,
})
if err != nil {
return errors.WithStack(err)
}
resp, err := cr.cli.ContainerExecAttach(ctx, idResp.ID, types.ExecStartCheck{
Tty: isTerminal,
})
if err != nil {
return errors.WithStack(err)
}
var outWriter io.Writer
outWriter = cr.input.Stdout
if outWriter == nil {
outWriter = os.Stdout
}
errWriter := cr.input.Stderr
if errWriter == nil {
errWriter = os.Stderr
}
err = cr.cli.ContainerExecStart(ctx, idResp.ID, types.ExecStartCheck{
Tty: isTerminal,
})
if err != nil {
return errors.WithStack(err)
}
if !isTerminal || os.Getenv("NORAW") != "" {
_, err = stdcopy.StdCopy(outWriter, errWriter, resp.Reader)
} else {
_, err = io.Copy(outWriter, resp.Reader)
}
if err != nil {
logger.Error(err)
}
inspectResp, err := cr.cli.ContainerExecInspect(ctx, idResp.ID)
if err != nil {
return errors.WithStack(err)
}
if inspectResp.ExitCode == 0 {
return nil
}
return fmt.Errorf("exit with `FAILURE`: %v", inspectResp.ExitCode)
}
}
func (cr *containerReference) copyDir(dstPath string, srcPath string) common.Executor {
return func(ctx context.Context) error {
logger := common.Logger(ctx)
tarFile, err := ioutil.TempFile("", "act")
if err != nil {
return err
}
log.Debugf("Writing tarball %s from %s", tarFile.Name(), srcPath)
defer tarFile.Close()
defer os.Remove(tarFile.Name())
tw := tar.NewWriter(tarFile)
srcPrefix := filepath.Dir(srcPath)
if !strings.HasSuffix(srcPrefix, string(filepath.Separator)) {
srcPrefix += string(filepath.Separator)
}
log.Debugf("Stripping prefix:%s src:%s", srcPrefix, srcPath)
err = filepath.Walk(srcPath, func(file string, fi os.FileInfo, err error) error {
if err != nil {
return errors.WithStack(err)
return err
}
// return on non-regular files (thanks to [kumo](https://medium.com/@komuw/just-like-you-did-fbdd7df829d3) for this suggested update)
if !fi.Mode().IsRegular() {
return nil
}
// create a new dir/file header
header, err := tar.FileInfoHeader(fi, fi.Name())
if err != nil {
return err
}
// update the name to correctly reflect the desired destination when untaring
header.Name = strings.TrimPrefix(file, srcPrefix)
log.Debugf("%s -> %s", file, header.Name)
// write the header
if err := tw.WriteHeader(header); err != nil {
return err
}
// open files for taring
f, err := os.Open(file)
if err != nil {
return err
}
// copy file data into tar writer
if _, err := io.Copy(tw, f); err != nil {
return err
}
// manually close here after each file operation; defering would cause each file close
// to wait until all operations have completed.
f.Close()
return nil
})
if err != nil {
return err
}
if err := tw.Close(); err != nil {
return err
}
logger.Debugf("Extracting content from '%s' to '%s'", tarFile.Name(), dstPath)
_, err = tarFile.Seek(0, 0)
if err != nil {
return errors.WithStack(err)
}
err = cr.cli.CopyToContainer(ctx, cr.id, dstPath, tarFile, types.CopyToContainerOptions{})
if err != nil {
return errors.WithStack(err)
}
return nil
}
}
func (cr *containerReference) copyContent(dstPath string, files ...*FileEntry) common.Executor {
return func(ctx context.Context) error {
logger := common.Logger(ctx)
var buf bytes.Buffer
tw := tar.NewWriter(&buf)
for _, file := range files {
log.Debugf("Writing entry to tarball %s len:%d", file.Name, len(file.Body))
hdr := &tar.Header{
Name: file.Name,
Mode: file.Mode,
Size: int64(len(file.Body)),
}
if err := tw.WriteHeader(hdr); err != nil {
return err
}
if _, err := tw.Write([]byte(file.Body)); err != nil {
return err
}
}
if err := tw.Close(); err != nil {
return err
}
logger.Debugf("Extracting content to '%s'", dstPath)
err := cr.cli.CopyToContainer(ctx, cr.id, dstPath, &buf, types.CopyToContainerOptions{})
if err != nil {
return errors.WithStack(err)
}
return nil
}
@ -207,7 +452,7 @@ func (cr *containerReference) attach() common.Executor {
func (cr *containerReference) start() common.Executor {
return func(ctx context.Context) error {
logger := common.Logger(ctx)
logger.Debugf("STARTING image=%s entrypoint=%s cmd=%v", cr.input.Image, cr.input.Entrypoint, cr.input.Cmd)
logger.Debugf("Starting container: %v", cr.id)
if err := cr.cli.ContainerStart(ctx, cr.id, types.ContainerStartOptions{}); err != nil {
return errors.WithStack(err)

View file

@ -1,52 +0,0 @@
package container
import (
"bytes"
"context"
"io/ioutil"
"testing"
"github.com/nektos/act/pkg/common"
"github.com/sirupsen/logrus"
"github.com/stretchr/testify/assert"
)
type rawFormatter struct{}
func (f *rawFormatter) Format(entry *logrus.Entry) ([]byte, error) {
return []byte(entry.Message), nil
}
func TestNewDockerRunExecutor(t *testing.T) {
if testing.Short() {
t.Skip("skipping slower test")
}
noopLogger := logrus.New()
noopLogger.SetOutput(ioutil.Discard)
buf := &bytes.Buffer{}
logger := logrus.New()
logger.SetOutput(buf)
logger.SetFormatter(&rawFormatter{})
ctx := common.WithLogger(context.Background(), logger)
runner := NewDockerRunExecutor(NewDockerRunExecutorInput{
Image: "hello-world",
Stdout: buf,
})
puller := NewDockerPullExecutor(NewDockerPullExecutorInput{
Image: "hello-world",
})
pipeline := common.NewPipelineExecutor(puller, runner)
err := pipeline(ctx)
assert.NoError(t, err)
actual := buf.String()
assert.Contains(t, actual, `docker pull hello-world`)
assert.Contains(t, actual, `docker run image=hello-world entrypoint=[] cmd=[]`)
assert.Contains(t, actual, `Hello from Docker!`)
}

View file

@ -0,0 +1,29 @@
package container
import (
"context"
"github.com/docker/docker/client"
"github.com/nektos/act/pkg/common"
)
// NewDockerVolumeRemoveExecutor function
func NewDockerVolumeRemoveExecutor(volume string, force bool) common.Executor {
return func(ctx context.Context) error {
logger := common.Logger(ctx)
logger.Debugf("%sdocker volume rm %s", logPrefix, volume)
if common.Dryrun(ctx) {
return nil
}
cli, err := client.NewClientWithOpts(client.FromEnv)
if err != nil {
return err
}
cli.NegotiateAPIVersion(ctx)
return cli.VolumeRemove(ctx, volume, force)
}
}

View file

@ -3,10 +3,11 @@ package model
import (
"fmt"
"io"
"log"
"regexp"
"strings"
"github.com/nektos/act/pkg/common"
log "github.com/sirupsen/logrus"
"gopkg.in/yaml.v3"
)
@ -94,6 +95,58 @@ func (j *Job) Needs() []string {
return nil
}
// GetMatrixes returns the matrix cross product
func (j *Job) GetMatrixes() []map[string]interface{} {
matrixes := make([]map[string]interface{}, 0)
if j.Strategy != nil {
includes := make([]map[string]interface{}, 0)
for _, v := range j.Strategy.Matrix["include"] {
includes = append(includes, v.(map[string]interface{}))
}
delete(j.Strategy.Matrix, "include")
excludes := make([]map[string]interface{}, 0)
for _, v := range j.Strategy.Matrix["exclude"] {
excludes = append(excludes, v.(map[string]interface{}))
}
delete(j.Strategy.Matrix, "exclude")
matrixProduct := common.CartesianProduct(j.Strategy.Matrix)
MATRIX:
for _, matrix := range matrixProduct {
for _, exclude := range excludes {
if commonKeysMatch(matrix, exclude) {
log.Debugf("Skipping matrix '%v' due to exclude '%v'", matrix, exclude)
continue MATRIX
}
}
for _, include := range includes {
if commonKeysMatch(matrix, include) {
log.Debugf("Setting add'l values on matrix '%v' due to include '%v'", matrix, include)
for k, v := range include {
matrix[k] = v
}
}
}
matrixes = append(matrixes, matrix)
}
} else {
matrixes = append(matrixes, make(map[string]interface{}))
}
return matrixes
}
func commonKeysMatch(a map[string]interface{}, b map[string]interface{}) bool {
for aKey, aVal := range a {
if bVal, ok := b[aKey]; ok && aVal != bVal {
return false
}
}
return true
}
// ContainerSpec is the specification of the container to use for the job
type ContainerSpec struct {
Image string `yaml:"image"`
@ -104,6 +157,7 @@ type ContainerSpec struct {
Entrypoint string
Args string
Name string
Reuse bool
}
// Step is the structure of one step in a job
@ -147,6 +201,29 @@ func (s *Step) GetEnv() map[string]string {
return rtnEnv
}
// ShellCommand returns the command for the shell
func (s *Step) ShellCommand() string {
shellCommand := ""
switch s.Shell {
case "", "bash":
shellCommand = "bash --noprofile --norc -eo pipefail {0}"
case "pwsh":
shellCommand = "pwsh -command \"& '{0}'\""
case "python":
shellCommand = "python {0}"
case "sh":
shellCommand = "sh -e -c {0}"
case "cmd":
shellCommand = "%ComSpec% /D /E:ON /V:OFF /S /C \"CALL \"{0}\"\""
case "powershell":
shellCommand = "powershell -command \"& '{0}'\""
default:
shellCommand = s.Shell
}
return shellCommand
}
// StepType describes what type of step we are about to run
type StepType int

View file

@ -8,50 +8,63 @@ import (
"github.com/nektos/act/pkg/common"
)
var commandPattern *regexp.Regexp
var commandPatternGA *regexp.Regexp
var commandPatternADO *regexp.Regexp
func init() {
commandPattern = regexp.MustCompile("^::([^ ]+)( (.+))?::([^\r\n]*)[\r\n]+$")
commandPatternGA = regexp.MustCompile("^::([^ ]+)( (.+))?::([^\r\n]*)[\r\n]+$")
commandPatternADO = regexp.MustCompile("^##\\[([^ ]+)( (.+))?\\]([^\r\n]*)[\r\n]+$")
}
func (rc *RunContext) commandHandler(ctx context.Context) common.LineHandler {
logger := common.Logger(ctx)
resumeCommand := ""
return func(line string) {
if m := commandPattern.FindStringSubmatch(line); m != nil {
command := m[1]
kvPairs := parseKeyValuePairs(m[3])
arg := m[4]
if resumeCommand != "" && command != resumeCommand {
return
}
switch command {
case "set-env":
rc.setEnv(ctx, kvPairs, arg)
case "set-output":
rc.setOutput(ctx, kvPairs, arg)
case "add-path":
rc.addPath(ctx, arg)
case "debug":
logger.Infof(" \U0001F4AC %s", line)
case "warning":
logger.Infof(" \U0001F6A7 %s", line)
case "error":
logger.Infof(" \U00002757 %s", line)
case "add-mask":
logger.Infof(" \U00002699 %s", line)
case "stop-commands":
resumeCommand = arg
logger.Infof(" \U00002699 %s", line)
case resumeCommand:
resumeCommand = ""
logger.Infof(" \U00002699 %s", line)
default:
logger.Infof(" \U00002753 %s", line)
}
return func(line string) bool {
var command string
var kvPairs map[string]string
var arg string
if m := commandPatternGA.FindStringSubmatch(line); m != nil {
command = m[1]
kvPairs = parseKeyValuePairs(m[3], ",")
arg = m[4]
} else if m := commandPatternADO.FindStringSubmatch(line); m != nil {
command = m[1]
kvPairs = parseKeyValuePairs(m[3], ";")
arg = m[4]
} else {
return true
}
if resumeCommand != "" && command != resumeCommand {
return false
}
switch command {
case "set-env":
rc.setEnv(ctx, kvPairs, arg)
case "set-output":
rc.setOutput(ctx, kvPairs, arg)
case "add-path":
rc.addPath(ctx, arg)
case "debug":
logger.Infof(" \U0001F4AC %s", line)
case "warning":
logger.Infof(" \U0001F6A7 %s", line)
case "error":
logger.Infof(" \U00002757 %s", line)
case "add-mask":
logger.Infof(" \U00002699 %s", line)
case "stop-commands":
resumeCommand = arg
logger.Infof(" \U00002699 %s", line)
case resumeCommand:
resumeCommand = ""
logger.Infof(" \U00002699 %s", line)
default:
logger.Infof(" \U00002753 %s", line)
}
return false
}
}
@ -71,9 +84,9 @@ func (rc *RunContext) addPath(ctx context.Context, arg string) {
rc.ExtraPath = append(rc.ExtraPath, arg)
}
func parseKeyValuePairs(kvPairs string) map[string]string {
func parseKeyValuePairs(kvPairs string, separator string) map[string]string {
rtn := make(map[string]string)
kvPairList := strings.Split(kvPairs, ",")
kvPairList := strings.Split(kvPairs, separator)
for _, kvPair := range kvPairList {
kv := strings.Split(kvPair, "=")
if len(kv) == 2 {

View file

@ -60,3 +60,16 @@ func TestStopCommands(t *testing.T) {
handler("::set-env name=x::abcd\n")
assert.Equal("abcd", rc.Env["x"])
}
func TestAddpathADO(t *testing.T) {
assert := assert.New(t)
ctx := context.Background()
rc := new(RunContext)
handler := rc.commandHandler(ctx)
handler("##[add-path]/zoo\n")
assert.Equal("/zoo", rc.ExtraPath[0])
handler("##[add-path]/boo\n")
assert.Equal("/boo", rc.ExtraPath[1])
}

View file

@ -11,7 +11,6 @@ import (
"regexp"
"strings"
"github.com/nektos/act/pkg/model"
"github.com/robertkrimen/otto"
"github.com/sirupsen/logrus"
"gopkg.in/godo.v2/glob"
@ -34,11 +33,12 @@ func (rc *RunContext) NewExpressionEvaluator() ExpressionEvaluator {
}
}
// NewStepExpressionEvaluator creates a new evaluator
func (rc *RunContext) NewStepExpressionEvaluator(step *model.Step) ExpressionEvaluator {
vm := rc.newVM()
// NewExpressionEvaluator creates a new evaluator
func (sc *StepContext) NewExpressionEvaluator() ExpressionEvaluator {
vm := sc.RunContext.newVM()
configers := []func(*otto.Otto){
rc.vmEnv(step),
sc.vmEnv(),
sc.vmInputs(),
}
for _, configer := range configers {
configer(vm)
@ -236,10 +236,19 @@ func (rc *RunContext) vmGithub() func(*otto.Otto) {
}
}
func (rc *RunContext) vmEnv(step *model.Step) func(*otto.Otto) {
func (sc *StepContext) vmEnv() func(*otto.Otto) {
return func(vm *otto.Otto) {
env := rc.StepEnv(step)
_ = vm.Set("env", env)
_ = vm.Set("env", sc.Env)
}
}
func (sc *StepContext) vmInputs() func(*otto.Otto) {
inputs := make(map[string]string)
for k, v := range sc.Step.With {
inputs[k] = v
}
return func(vm *otto.Otto) {
_ = vm.Set("inputs", inputs)
}
}

View file

@ -1,13 +1,9 @@
package runner
import (
"archive/tar"
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"io/ioutil"
"os"
"path/filepath"
"regexp"
@ -24,16 +20,16 @@ import (
// RunContext contains info about current job
type RunContext struct {
Config *Config
Matrix map[string]interface{}
Run *model.Run
EventJSON string
Env map[string]string
Tempdir string
ExtraPath []string
CurrentStep string
StepResults map[string]*stepResult
ExprEval ExpressionEvaluator
Config *Config
Matrix map[string]interface{}
Run *model.Run
EventJSON string
Env map[string]string
ExtraPath []string
CurrentStep string
StepResults map[string]*stepResult
ExprEval ExpressionEvaluator
JobContainer container.Container
}
type stepResult struct {
@ -49,68 +45,175 @@ func (rc *RunContext) GetEnv() map[string]string {
return rc.Env
}
// Close cleans up temp dir
func (rc *RunContext) Close(ctx context.Context) error {
return os.RemoveAll(rc.Tempdir)
func (rc *RunContext) jobContainerName() string {
return createContainerName("act", rc.Run.String())
}
func (rc *RunContext) startJobContainer() common.Executor {
job := rc.Run.Job()
var image string
if job.Container != nil {
image = job.Container.Image
} else {
platformName := rc.ExprEval.Interpolate(job.RunsOn)
image = rc.Config.Platforms[strings.ToLower(platformName)]
}
return func(ctx context.Context) error {
rawLogger := common.Logger(ctx).WithField("raw_output", true)
logWriter := common.NewLineWriter(rc.commandHandler(ctx), func(s string) bool {
if rc.Config.LogOutput {
rawLogger.Infof(s)
} else {
rawLogger.Debugf(s)
}
return true
})
common.Logger(ctx).Infof("\U0001f680 Start image=%s", image)
name := rc.jobContainerName()
envList := make([]string, 0)
bindModifiers := ""
if runtime.GOOS == "darwin" {
bindModifiers = ":delegated"
}
envList = append(envList, fmt.Sprintf("%s=%s", "RUNNER_TOOL_CACHE", "/toolcache"))
binds := []string{
fmt.Sprintf("%s:%s", "/var/run/docker.sock", "/var/run/docker.sock"),
}
if rc.Config.BindWorkdir {
binds = append(binds, fmt.Sprintf("%s:%s%s", rc.Config.Workdir, "/github/workspace", bindModifiers))
}
rc.JobContainer = container.NewContainer(&container.NewContainerInput{
Cmd: nil,
Entrypoint: []string{"/usr/bin/tail", "-f", "/dev/null"},
WorkingDir: "/github/workspace",
Image: image,
Name: name,
Env: envList,
Mounts: map[string]string{
name: "/github",
"act-toolcache": "/toolcache",
"act-actions": "/actions",
},
Binds: binds,
Stdout: logWriter,
Stderr: logWriter,
})
return common.NewPipelineExecutor(
rc.JobContainer.Pull(rc.Config.ForcePull),
rc.JobContainer.Remove().IfBool(!rc.Config.ReuseContainers),
rc.JobContainer.Create(),
rc.JobContainer.Start(false),
rc.JobContainer.CopyDir("/github/workspace", rc.Config.Workdir+"/.").IfBool(!rc.Config.BindWorkdir),
rc.JobContainer.Copy("/github/", &container.FileEntry{
Name: "workflow/event.json",
Mode: 644,
Body: rc.EventJSON,
}, &container.FileEntry{
Name: "home/.act",
Mode: 644,
Body: "",
}),
)(ctx)
}
}
func (rc *RunContext) execJobContainer(cmd []string, env map[string]string) common.Executor {
return func(ctx context.Context) error {
return rc.JobContainer.Exec(cmd, env)(ctx)
}
}
func (rc *RunContext) stopJobContainer() common.Executor {
return func(ctx context.Context) error {
if rc.JobContainer != nil && !rc.Config.ReuseContainers {
return rc.JobContainer.Remove().
Then(container.NewDockerVolumeRemoveExecutor(rc.jobContainerName(), false))(ctx)
}
return nil
}
}
// ActionCacheDir is for rc
func (rc *RunContext) ActionCacheDir() string {
var xdgCache string
var ok bool
if xdgCache, ok = os.LookupEnv("XDG_CACHE_HOME"); !ok {
if home, ok := os.LookupEnv("HOME"); ok {
xdgCache = fmt.Sprintf("%s/.cache", home)
}
}
return filepath.Join(xdgCache, "act")
}
// Executor returns a pipeline executor for all the steps in the job
func (rc *RunContext) Executor() common.Executor {
err := rc.setupTempDir()
if err != nil {
return common.NewErrorExecutor(err)
}
steps := make([]common.Executor, 0)
steps = append(steps, rc.startJobContainer())
for i, step := range rc.Run.Job().Steps {
if step.ID == "" {
step.ID = fmt.Sprintf("%d", i)
}
s := step
steps = append(steps, func(ctx context.Context) error {
rc.CurrentStep = s.ID
rc.StepResults[rc.CurrentStep] = &stepResult{
Success: true,
Outputs: make(map[string]string),
}
rc.ExprEval = rc.NewStepExpressionEvaluator(s)
steps = append(steps, rc.newStepExecutor(step))
}
steps = append(steps, rc.stopJobContainer())
if !rc.EvalBool(s.If) {
log.Debugf("Skipping step '%s' due to '%s'", s.String(), s.If)
return nil
}
return common.NewPipelineExecutor(steps...).If(rc.isEnabled)
}
common.Logger(ctx).Infof("\u2B50 Run %s", s)
err := rc.newStepExecutor(s)(ctx)
if err == nil {
common.Logger(ctx).Infof(" \u2705 Success - %s", s)
} else {
common.Logger(ctx).Errorf(" \u274C Failure - %s", s)
rc.StepResults[rc.CurrentStep].Success = false
}
return err
})
func (rc *RunContext) newStepExecutor(step *model.Step) common.Executor {
sc := &StepContext{
RunContext: rc,
Step: step,
}
return func(ctx context.Context) error {
defer rc.Close(ctx)
job := rc.Run.Job()
log := common.Logger(ctx)
if !rc.EvalBool(job.If) {
log.Debugf("Skipping job '%s' due to '%s'", job.Name, job.If)
rc.CurrentStep = sc.Step.ID
rc.StepResults[rc.CurrentStep] = &stepResult{
Success: true,
Outputs: make(map[string]string),
}
rc.ExprEval = sc.NewExpressionEvaluator()
if !rc.EvalBool(sc.Step.If) {
log.Debugf("Skipping step '%s' due to '%s'", sc.Step.String(), sc.Step.If)
return nil
}
platformName := rc.ExprEval.Interpolate(rc.Run.Job().RunsOn)
if img, ok := rc.Config.Platforms[strings.ToLower(platformName)]; !ok || img == "" {
log.Infof(" \U0001F6A7 Skipping unsupported platform '%s'", platformName)
return nil
common.Logger(ctx).Infof("\u2B50 Run %s", sc.Step)
err := sc.Executor()(ctx)
if err == nil {
common.Logger(ctx).Infof(" \u2705 Success - %s", sc.Step)
} else {
common.Logger(ctx).Errorf(" \u274C Failure - %s", sc.Step)
rc.StepResults[rc.CurrentStep].Success = false
}
return common.NewPipelineExecutor(steps...)(ctx)
return err
}
}
func (rc *RunContext) isEnabled(ctx context.Context) bool {
job := rc.Run.Job()
log := common.Logger(ctx)
if !rc.EvalBool(job.If) {
log.Debugf("Skipping job '%s' due to '%s'", job.Name, job.If)
return false
}
platformName := rc.ExprEval.Interpolate(rc.Run.Job().RunsOn)
if img, ok := rc.Config.Platforms[strings.ToLower(platformName)]; !ok || img == "" {
log.Infof(" \U0001F6A7 Skipping unsupported platform '%s'", platformName)
return false
}
return true
}
// EvalBool evaluates an expression against current run context
func (rc *RunContext) EvalBool(expr string) bool {
if expr != "" {
@ -134,124 +237,24 @@ func mergeMaps(maps ...map[string]string) map[string]string {
return rtnMap
}
func (rc *RunContext) setupTempDir() error {
var err error
tempBase := ""
if runtime.GOOS == "darwin" {
tempBase = "/tmp"
}
rc.Tempdir, err = ioutil.TempDir(tempBase, "act-")
if err != nil {
return err
}
err = os.Chmod(rc.Tempdir, 0755)
if err != nil {
return err
}
log.Debugf("Setup tempdir %s", rc.Tempdir)
return err
}
func (rc *RunContext) pullImage(containerSpec *model.ContainerSpec) common.Executor {
return func(ctx context.Context) error {
return container.NewDockerPullExecutor(container.NewDockerPullExecutorInput{
Image: containerSpec.Image,
ForcePull: rc.Config.ForcePull,
})(ctx)
}
}
func (rc *RunContext) runContainer(containerSpec *model.ContainerSpec) common.Executor {
return func(ctx context.Context) error {
ghReader, err := rc.createGithubTarball()
if err != nil {
return err
}
envList := make([]string, 0)
for k, v := range containerSpec.Env {
envList = append(envList, fmt.Sprintf("%s=%s", k, v))
}
var cmd, entrypoint []string
if containerSpec.Args != "" {
cmd = strings.Fields(rc.ExprEval.Interpolate(containerSpec.Args))
}
if containerSpec.Entrypoint != "" {
entrypoint = strings.Fields(rc.ExprEval.Interpolate(containerSpec.Entrypoint))
}
rawLogger := common.Logger(ctx).WithField("raw_output", true)
logWriter := common.NewLineWriter(rc.commandHandler(ctx), func(s string) {
if rc.Config.LogOutput {
rawLogger.Infof(s)
} else {
rawLogger.Debugf(s)
}
})
return container.NewDockerRunExecutor(container.NewDockerRunExecutorInput{
Cmd: cmd,
Entrypoint: entrypoint,
Image: containerSpec.Image,
WorkingDir: "/github/workspace",
Env: envList,
Name: containerSpec.Name,
Binds: []string{
fmt.Sprintf("%s:%s", rc.Config.Workdir, "/github/workspace"),
fmt.Sprintf("%s:%s", rc.Tempdir, "/github/home"),
fmt.Sprintf("%s:%s", "/var/run/docker.sock", "/var/run/docker.sock"),
},
Content: map[string]io.Reader{"/github": ghReader},
ReuseContainers: rc.Config.ReuseContainers,
Stdout: logWriter,
Stderr: logWriter,
})(ctx)
}
}
func (rc *RunContext) createGithubTarball() (io.Reader, error) {
var buf bytes.Buffer
tw := tar.NewWriter(&buf)
var files = []struct {
Name string
Mode int64
Body string
}{
{"workflow/event.json", 0644, rc.EventJSON},
}
for _, file := range files {
log.Debugf("Writing entry to tarball %s len:%d", file.Name, len(rc.EventJSON))
hdr := &tar.Header{
Name: file.Name,
Mode: file.Mode,
Size: int64(len(rc.EventJSON)),
}
if err := tw.WriteHeader(hdr); err != nil {
return nil, err
}
if _, err := tw.Write([]byte(rc.EventJSON)); err != nil {
return nil, err
func createContainerName(parts ...string) string {
name := make([]string, 0)
pattern := regexp.MustCompile("[^a-zA-Z0-9]")
partLen := (30 / len(parts)) - 1
for i, part := range parts {
if i == len(parts)-1 {
name = append(name, pattern.ReplaceAllString(part, "-"))
} else {
name = append(name, trimToLen(pattern.ReplaceAllString(part, "-"), partLen))
}
}
if err := tw.Close(); err != nil {
return nil, err
}
return &buf, nil
}
func (rc *RunContext) createContainerName(stepID string) string {
containerName := fmt.Sprintf("%s-%s", stepID, rc.Tempdir)
containerName = regexp.MustCompile("[^a-zA-Z0-9]").ReplaceAllString(containerName, "-")
prefix := fmt.Sprintf("%s-", trimToLen(filepath.Base(rc.Config.Workdir), 10))
suffix := ""
containerName = trimToLen(containerName, 30-(len(prefix)+len(suffix)))
return fmt.Sprintf("%s%s%s", prefix, containerName, suffix)
return trimToLen(strings.Trim(strings.Join(name, "-"), "-"), 30)
}
func trimToLen(s string, l int) string {
if l < 0 {
l = 0
}
if len(s) > l {
return s[:l]
}
@ -355,6 +358,7 @@ func (rc *RunContext) withGithubEnv(env map[string]string) map[string]string {
env["GITHUB_RUN_ID"] = github.RunID
env["GITHUB_RUN_NUMBER"] = github.RunNumber
env["GITHUB_ACTION"] = github.Action
env["GITHUB_ACTIONS"] = "true"
env["GITHUB_ACTOR"] = github.Actor
env["GITHUB_REPOSITORY"] = github.Repository
env["GITHUB_EVENT_NAME"] = github.EventName

View file

@ -13,12 +13,12 @@ import (
// Runner provides capabilities to run GitHub actions
type Runner interface {
NewPlanExecutor(plan *model.Plan) common.Executor
NewRunExecutor(run *model.Run, matrix map[string]interface{}) common.Executor
}
// Config contains the config for a new runner
type Config struct {
Workdir string // path to working directory
BindWorkdir bool // bind the workdir to the job container
EventName string // name of event to run
EventPath string // path to JSON file to use for event.json in containers
ReuseContainers bool // reuse containers to maintain state
@ -59,49 +59,12 @@ func (runner *runnerImpl) NewPlanExecutor(plan *model.Plan) common.Executor {
stageExecutor := make([]common.Executor, 0)
for _, run := range stage.Runs {
job := run.Job()
matrixes := make([]map[string]interface{}, 0)
if job.Strategy != nil {
includes := make([]map[string]interface{}, 0)
for _, v := range job.Strategy.Matrix["include"] {
includes = append(includes, v.(map[string]interface{}))
}
delete(job.Strategy.Matrix, "include")
excludes := make([]map[string]interface{}, 0)
for _, v := range job.Strategy.Matrix["exclude"] {
excludes = append(excludes, v.(map[string]interface{}))
}
delete(job.Strategy.Matrix, "exclude")
matrixProduct := common.CartesianProduct(job.Strategy.Matrix)
MATRIX:
for _, matrix := range matrixProduct {
for _, exclude := range excludes {
if commonKeysMatch(matrix, exclude) {
log.Debugf("Skipping matrix '%v' due to exclude '%v'", matrix, exclude)
continue MATRIX
}
}
for _, include := range includes {
if commonKeysMatch(matrix, include) {
log.Debugf("Setting add'l values on matrix '%v' due to include '%v'", matrix, include)
for k, v := range include {
matrix[k] = v
}
}
}
matrixes = append(matrixes, matrix)
}
} else {
matrixes = append(matrixes, make(map[string]interface{}))
}
matrixes := job.GetMatrixes()
jobName := fmt.Sprintf("%-*s", maxJobNameLen, run.String())
for _, matrix := range matrixes {
m := matrix
runExecutor := runner.NewRunExecutor(run, matrix)
runExecutor := runner.newRunExecutor(run, matrix)
stageExecutor = append(stageExecutor, func(ctx context.Context) error {
ctx = WithJobLogger(ctx, jobName)
if len(m) > 0 {
@ -117,22 +80,14 @@ func (runner *runnerImpl) NewPlanExecutor(plan *model.Plan) common.Executor {
return common.NewPipelineExecutor(pipeline...)
}
func commonKeysMatch(a map[string]interface{}, b map[string]interface{}) bool {
for aKey, aVal := range a {
if bVal, ok := b[aKey]; ok && aVal != bVal {
return false
}
func (runner *runnerImpl) newRunExecutor(run *model.Run, matrix map[string]interface{}) common.Executor {
rc := &RunContext{
Config: runner.config,
Run: run,
EventJSON: runner.eventJSON,
StepResults: make(map[string]*stepResult),
Matrix: matrix,
}
return true
}
func (runner *runnerImpl) NewRunExecutor(run *model.Run, matrix map[string]interface{}) common.Executor {
rc := new(RunContext)
rc.Config = runner.config
rc.Run = run
rc.EventJSON = runner.eventJSON
rc.StepResults = make(map[string]*stepResult)
rc.Matrix = matrix
rc.ExprEval = rc.NewExpressionEvaluator()
return rc.Executor()
}

View file

@ -3,6 +3,7 @@ package runner
import (
"context"
"fmt"
"path/filepath"
"testing"
"github.com/nektos/act/pkg/model"
@ -57,13 +58,16 @@ func TestRunEvent(t *testing.T) {
table := table
t.Run(table.workflowPath, func(t *testing.T) {
platforms := map[string]string{
"ubuntu-latest": "ubuntu:18.04",
"ubuntu-latest": "node:12.6-buster-slim",
}
workdir, err := filepath.Abs("testdata")
assert.NilError(t, err, table.workflowPath)
runnerConfig := &Config{
Workdir: "testdata",
Workdir: workdir,
EventName: table.eventName,
Platforms: platforms,
ReuseContainers: true,
ReuseContainers: false,
}
runner, err := New(runnerConfig)
assert.NilError(t, err, table.workflowPath)

View file

@ -1,247 +0,0 @@
package runner
import (
"context"
"fmt"
"io/ioutil"
"os"
"path/filepath"
"regexp"
"strings"
"github.com/nektos/act/pkg/common"
"github.com/nektos/act/pkg/container"
"github.com/nektos/act/pkg/model"
log "github.com/sirupsen/logrus"
)
func (rc *RunContext) StepEnv(step *model.Step) map[string]string {
var env map[string]string
job := rc.Run.Job()
if job.Container != nil {
env = mergeMaps(rc.GetEnv(), job.Container.Env, step.GetEnv())
} else {
env = mergeMaps(rc.GetEnv(), step.GetEnv())
}
for k, v := range env {
env[k] = rc.ExprEval.Interpolate(v)
}
return env
}
func (rc *RunContext) setupEnv(containerSpec *model.ContainerSpec, step *model.Step) common.Executor {
return func(ctx context.Context) error {
containerSpec.Env = rc.withGithubEnv(rc.StepEnv(step))
return nil
}
}
func (rc *RunContext) newStepExecutor(step *model.Step) common.Executor {
job := rc.Run.Job()
containerSpec := new(model.ContainerSpec)
containerSpec.Name = rc.createContainerName(step.ID)
switch step.Type() {
case model.StepTypeRun:
if job.Container != nil {
containerSpec.Image = job.Container.Image
containerSpec.Ports = job.Container.Ports
containerSpec.Volumes = job.Container.Volumes
containerSpec.Options = job.Container.Options
} else {
platformName := rc.ExprEval.Interpolate(rc.Run.Job().RunsOn)
containerSpec.Image = rc.Config.Platforms[strings.ToLower(platformName)]
}
return common.NewPipelineExecutor(
rc.setupEnv(containerSpec, step),
rc.setupShellCommand(containerSpec, step.Shell, step.Run),
rc.pullImage(containerSpec),
rc.runContainer(containerSpec),
)
case model.StepTypeUsesDockerURL:
containerSpec.Image = strings.TrimPrefix(step.Uses, "docker://")
containerSpec.Entrypoint = step.With["entrypoint"]
containerSpec.Args = step.With["args"]
return common.NewPipelineExecutor(
rc.setupEnv(containerSpec, step),
rc.pullImage(containerSpec),
rc.runContainer(containerSpec),
)
case model.StepTypeUsesActionLocal:
containerSpec.Image = fmt.Sprintf("%s:%s", containerSpec.Name, "latest")
return common.NewPipelineExecutor(
rc.setupEnv(containerSpec, step),
rc.setupAction(containerSpec, filepath.Join(rc.Config.Workdir, step.Uses)),
applyWith(containerSpec, step),
rc.pullImage(containerSpec),
rc.runContainer(containerSpec),
)
case model.StepTypeUsesActionRemote:
remoteAction := newRemoteAction(step.Uses)
if remoteAction.Org == "actions" && remoteAction.Repo == "checkout" {
return func(ctx context.Context) error {
common.Logger(ctx).Debugf("Skipping actions/checkout")
return nil
}
}
cloneDir, err := ioutil.TempDir(rc.Tempdir, remoteAction.Repo)
if err != nil {
return common.NewErrorExecutor(err)
}
containerSpec.Image = fmt.Sprintf("%s:%s", remoteAction.Repo, remoteAction.Ref)
return common.NewPipelineExecutor(
common.NewGitCloneExecutor(common.NewGitCloneExecutorInput{
URL: remoteAction.CloneURL(),
Ref: remoteAction.Ref,
Dir: cloneDir,
}),
rc.setupEnv(containerSpec, step),
rc.setupAction(containerSpec, filepath.Join(cloneDir, remoteAction.Path)),
applyWith(containerSpec, step),
rc.pullImage(containerSpec),
rc.runContainer(containerSpec),
)
}
return common.NewErrorExecutor(fmt.Errorf("Unable to determine how to run job:%s step:%+v", rc.Run, step))
}
func applyWith(containerSpec *model.ContainerSpec, step *model.Step) common.Executor {
return func(ctx context.Context) error {
if entrypoint, ok := step.With["entrypoint"]; ok {
containerSpec.Entrypoint = entrypoint
}
if args, ok := step.With["args"]; ok {
containerSpec.Args = args
}
return nil
}
}
func (rc *RunContext) setupShellCommand(containerSpec *model.ContainerSpec, shell string, run string) common.Executor {
return func(ctx context.Context) error {
shellCommand := ""
switch shell {
case "", "bash":
shellCommand = "bash --noprofile --norc -eo pipefail {0}"
case "pwsh":
shellCommand = "pwsh -command \"& '{0}'\""
case "python":
shellCommand = "python {0}"
case "sh":
shellCommand = "sh -e -c {0}"
case "cmd":
shellCommand = "%ComSpec% /D /E:ON /V:OFF /S /C \"CALL \"{0}\"\""
case "powershell":
shellCommand = "powershell -command \"& '{0}'\""
default:
shellCommand = shell
}
tempScript, err := ioutil.TempFile(rc.Tempdir, ".temp-script-")
if err != nil {
return err
}
_, err = tempScript.WriteString(fmt.Sprintf("PATH=\"%s:${PATH}\"\n", strings.Join(rc.ExtraPath, ":")))
if err != nil {
return err
}
run = rc.ExprEval.Interpolate(run)
if _, err := tempScript.WriteString(run); err != nil {
return err
}
log.Debugf("Wrote command '%s' to '%s'", run, tempScript.Name())
if err := tempScript.Close(); err != nil {
return err
}
containerPath := fmt.Sprintf("/github/home/%s", filepath.Base(tempScript.Name()))
containerSpec.Entrypoint = strings.Replace(shellCommand, "{0}", containerPath, 1)
return nil
}
}
func (rc *RunContext) setupAction(containerSpec *model.ContainerSpec, actionDir string) common.Executor {
return func(ctx context.Context) error {
f, err := os.Open(filepath.Join(actionDir, "action.yml"))
if os.IsNotExist(err) {
f, err = os.Open(filepath.Join(actionDir, "action.yaml"))
if err != nil {
return err
}
} else if err != nil {
return err
}
action, err := model.ReadAction(f)
if err != nil {
return err
}
for inputID, input := range action.Inputs {
envKey := regexp.MustCompile("[^A-Z0-9-]").ReplaceAllString(strings.ToUpper(inputID), "_")
envKey = fmt.Sprintf("INPUT_%s", envKey)
if _, ok := containerSpec.Env[envKey]; !ok {
containerSpec.Env[envKey] = input.Default
}
}
switch action.Runs.Using {
case model.ActionRunsUsingNode12:
containerSpec.Image = "node:12-alpine"
if strings.HasPrefix(actionDir, rc.Config.Workdir) {
containerSpec.Args = fmt.Sprintf("node /github/workspace/%s/%s", strings.TrimPrefix(actionDir, rc.Config.Workdir), action.Runs.Main)
} else if strings.HasPrefix(actionDir, rc.Tempdir) {
containerSpec.Args = fmt.Sprintf("node /github/home/%s/%s", strings.TrimPrefix(actionDir, rc.Tempdir), action.Runs.Main)
}
case model.ActionRunsUsingDocker:
if strings.HasPrefix(action.Runs.Image, "docker://") {
containerSpec.Image = strings.TrimPrefix(action.Runs.Image, "docker://")
containerSpec.Entrypoint = strings.Join(action.Runs.Entrypoint, " ")
containerSpec.Args = strings.Join(action.Runs.Args, " ")
} else {
contextDir := filepath.Join(actionDir, action.Runs.Main)
return container.NewDockerBuildExecutor(container.NewDockerBuildExecutorInput{
ContextDir: contextDir,
ImageTag: containerSpec.Image,
})(ctx)
}
}
return nil
}
}
type remoteAction struct {
Org string
Repo string
Path string
Ref string
}
func (ra *remoteAction) CloneURL() string {
return fmt.Sprintf("https://github.com/%s/%s", ra.Org, ra.Repo)
}
func newRemoteAction(action string) *remoteAction {
r := regexp.MustCompile(`^([^/@]+)/([^/@]+)(/([^@]*))?(@(.*))?$`)
matches := r.FindStringSubmatch(action)
ra := new(remoteAction)
ra.Org = matches[1]
ra.Repo = matches[2]
ra.Path = ""
ra.Ref = "master"
if len(matches) >= 5 {
ra.Path = matches[4]
}
if len(matches) >= 7 {
ra.Ref = matches[6]
}
return ra
}

326
pkg/runner/step_context.go Normal file
View file

@ -0,0 +1,326 @@
package runner
import (
"context"
"fmt"
"os"
"path/filepath"
"regexp"
"runtime"
"strings"
"github.com/nektos/act/pkg/common"
"github.com/nektos/act/pkg/container"
"github.com/nektos/act/pkg/model"
log "github.com/sirupsen/logrus"
)
// StepContext contains info about current job
type StepContext struct {
RunContext *RunContext
Step *model.Step
Env map[string]string
Cmd []string
Action *model.Action
}
func (sc *StepContext) execJobContainer() common.Executor {
return func(ctx context.Context) error {
return sc.RunContext.execJobContainer(sc.Cmd, sc.Env)(ctx)
}
}
// Executor for a step context
func (sc *StepContext) Executor() common.Executor {
rc := sc.RunContext
step := sc.Step
switch step.Type() {
case model.StepTypeRun:
return common.NewPipelineExecutor(
sc.setupEnv(),
sc.setupShellCommand(),
sc.execJobContainer(),
)
case model.StepTypeUsesDockerURL:
return common.NewPipelineExecutor(
sc.setupEnv(),
sc.runUsesContainer(),
)
case model.StepTypeUsesActionLocal:
actionDir := filepath.Join(rc.Config.Workdir, step.Uses)
return common.NewPipelineExecutor(
sc.setupEnv(),
sc.setupAction(actionDir),
sc.runAction(actionDir),
)
case model.StepTypeUsesActionRemote:
remoteAction := newRemoteAction(step.Uses)
if remoteAction.Org == "actions" && remoteAction.Repo == "checkout" {
return func(ctx context.Context) error {
common.Logger(ctx).Debugf("Skipping actions/checkout")
return nil
}
}
actionDir := fmt.Sprintf("%s/%s", rc.ActionCacheDir(), strings.ReplaceAll(step.Uses, "/", "-"))
return common.NewPipelineExecutor(
common.NewGitCloneExecutor(common.NewGitCloneExecutorInput{
URL: remoteAction.CloneURL(),
Ref: remoteAction.Ref,
Dir: actionDir,
}),
sc.setupEnv(),
sc.setupAction(actionDir),
sc.runAction(actionDir),
)
}
return common.NewErrorExecutor(fmt.Errorf("Unable to determine how to run job:%s step:%+v", rc.Run, step))
}
func (sc *StepContext) setupEnv() common.Executor {
rc := sc.RunContext
job := rc.Run.Job()
step := sc.Step
return func(ctx context.Context) error {
var env map[string]string
if job.Container != nil {
env = mergeMaps(rc.GetEnv(), job.Container.Env, step.GetEnv())
} else {
env = mergeMaps(rc.GetEnv(), step.GetEnv())
}
for k, v := range env {
env[k] = rc.ExprEval.Interpolate(v)
}
sc.Env = rc.withGithubEnv(env)
return nil
}
}
func (sc *StepContext) setupShellCommand() common.Executor {
rc := sc.RunContext
step := sc.Step
return func(ctx context.Context) error {
var script strings.Builder
_, err := script.WriteString(fmt.Sprintf("PATH=\"%s:${PATH}\"\n", strings.Join(rc.ExtraPath, ":")))
if err != nil {
return err
}
run := rc.ExprEval.Interpolate(step.Run)
if _, err = script.WriteString(run); err != nil {
return err
}
scriptName := fmt.Sprintf("workflow/%s", step.ID)
log.Debugf("Wrote command '%s' to '%s'", run, scriptName)
containerPath := fmt.Sprintf("/github/%s", scriptName)
sc.Cmd = strings.Fields(strings.Replace(step.ShellCommand(), "{0}", containerPath, 1))
return rc.JobContainer.Copy("/github/", &container.FileEntry{
Name: scriptName,
Mode: 755,
Body: script.String(),
})(ctx)
}
}
func (sc *StepContext) newStepContainer(ctx context.Context, image string, cmd []string, entrypoint []string) container.Container {
rc := sc.RunContext
step := sc.Step
rawLogger := common.Logger(ctx).WithField("raw_output", true)
logWriter := common.NewLineWriter(rc.commandHandler(ctx), func(s string) bool {
if rc.Config.LogOutput {
rawLogger.Infof(s)
} else {
rawLogger.Debugf(s)
}
return true
})
envList := make([]string, 0)
for k, v := range sc.Env {
envList = append(envList, fmt.Sprintf("%s=%s", k, v))
}
stepEE := sc.NewExpressionEvaluator()
for i, v := range cmd {
cmd[i] = stepEE.Interpolate(v)
}
for i, v := range entrypoint {
entrypoint[i] = stepEE.Interpolate(v)
}
bindModifiers := ""
if runtime.GOOS == "darwin" {
bindModifiers = ":delegated"
}
envList = append(envList, fmt.Sprintf("%s=%s", "RUNNER_TOOL_CACHE", "/toolcache"))
binds := []string{
fmt.Sprintf("%s:%s", "/var/run/docker.sock", "/var/run/docker.sock"),
}
if rc.Config.BindWorkdir {
binds = append(binds, fmt.Sprintf("%s:%s%s", rc.Config.Workdir, "/github/workspace", bindModifiers))
}
stepContainer := container.NewContainer(&container.NewContainerInput{
Cmd: cmd,
Entrypoint: entrypoint,
WorkingDir: "/github/workspace",
Image: image,
Name: createContainerName(rc.jobContainerName(), step.ID),
Env: envList,
Mounts: map[string]string{
rc.jobContainerName(): "/github",
"act-toolcache": "/toolcache",
"act-actions": "/actions",
},
Binds: binds,
Stdout: logWriter,
Stderr: logWriter,
})
return stepContainer
}
func (sc *StepContext) runUsesContainer() common.Executor {
rc := sc.RunContext
step := sc.Step
return func(ctx context.Context) error {
image := strings.TrimPrefix(step.Uses, "docker://")
cmd := strings.Fields(step.With["args"])
entrypoint := strings.Fields(step.With["entrypoint"])
stepContainer := sc.newStepContainer(ctx, image, cmd, entrypoint)
return common.NewPipelineExecutor(
stepContainer.Pull(rc.Config.ForcePull),
stepContainer.Remove().IfBool(!rc.Config.ReuseContainers),
stepContainer.Create(),
stepContainer.Start(true),
).Finally(
stepContainer.Remove().IfBool(!rc.Config.ReuseContainers),
)(ctx)
}
}
func (sc *StepContext) setupAction(actionDir string) common.Executor {
return func(ctx context.Context) error {
f, err := os.Open(filepath.Join(actionDir, "action.yml"))
if os.IsNotExist(err) {
f, err = os.Open(filepath.Join(actionDir, "action.yaml"))
if err != nil {
return err
}
} else if err != nil {
return err
}
sc.Action, err = model.ReadAction(f)
log.Debugf("Read action %v from '%s'", sc.Action, f.Name())
return err
}
}
func (sc *StepContext) runAction(actionDir string) common.Executor {
rc := sc.RunContext
step := sc.Step
return func(ctx context.Context) error {
action := sc.Action
log.Debugf("About to run action %v", action)
for inputID, input := range action.Inputs {
envKey := regexp.MustCompile("[^A-Z0-9-]").ReplaceAllString(strings.ToUpper(inputID), "_")
envKey = fmt.Sprintf("INPUT_%s", envKey)
if _, ok := sc.Env[envKey]; !ok {
sc.Env[envKey] = input.Default
}
}
actionName := ""
containerActionDir := "."
if step.Type() == model.StepTypeUsesActionLocal {
actionName = strings.TrimPrefix(strings.TrimPrefix(actionDir, rc.Config.Workdir), string(filepath.Separator))
containerActionDir = "/github/workspace"
} else if step.Type() == model.StepTypeUsesActionRemote {
actionName = strings.TrimPrefix(strings.TrimPrefix(actionDir, rc.ActionCacheDir()), string(filepath.Separator))
containerActionDir = "/actions"
}
log.Debugf("actionDir=%s Workdir=%s ActionCacheDir=%s actionName=%s containerActionDir=%s", actionDir, rc.Config.Workdir, rc.ActionCacheDir(), actionName, containerActionDir)
switch action.Runs.Using {
case model.ActionRunsUsingNode12:
if step.Type() == model.StepTypeUsesActionRemote {
err := rc.JobContainer.CopyDir(containerActionDir+string(filepath.Separator), actionDir)(ctx)
if err != nil {
return err
}
}
return rc.execJobContainer([]string{"node", fmt.Sprintf("%s/%s/%s", containerActionDir, actionName, action.Runs.Main)}, sc.Env)(ctx)
case model.ActionRunsUsingDocker:
var prepImage common.Executor
var image string
if strings.HasPrefix(action.Runs.Image, "docker://") {
image = strings.TrimPrefix(action.Runs.Image, "docker://")
} else {
image = fmt.Sprintf("%s:%s", regexp.MustCompile("[^a-zA-Z0-9]").ReplaceAllString(actionName, "-"), "latest")
image = fmt.Sprintf("act-%s", strings.TrimLeft(image, "-"))
contextDir := filepath.Join(actionDir, action.Runs.Main)
prepImage = container.NewDockerBuildExecutor(container.NewDockerBuildExecutorInput{
ContextDir: contextDir,
ImageTag: image,
})
}
cmd := strings.Fields(step.With["args"])
if len(cmd) == 0 {
cmd = action.Runs.Args
}
entrypoint := strings.Fields(step.With["entrypoint"])
if len(entrypoint) == 0 {
entrypoint = action.Runs.Entrypoint
}
stepContainer := sc.newStepContainer(ctx, image, cmd, entrypoint)
return common.NewPipelineExecutor(
prepImage,
stepContainer.Pull(rc.Config.ForcePull),
stepContainer.Remove().IfBool(!rc.Config.ReuseContainers),
stepContainer.Create(),
stepContainer.Start(true),
).Finally(
stepContainer.Remove().IfBool(!rc.Config.ReuseContainers),
)(ctx)
}
return nil
}
}
type remoteAction struct {
Org string
Repo string
Path string
Ref string
}
func (ra *remoteAction) CloneURL() string {
return fmt.Sprintf("https://github.com/%s/%s", ra.Org, ra.Repo)
}
func newRemoteAction(action string) *remoteAction {
r := regexp.MustCompile(`^([^/@]+)/([^/@]+)(/([^@]*))?(@(.*))?$`)
matches := r.FindStringSubmatch(action)
ra := new(remoteAction)
ra.Org = matches[1]
ra.Repo = matches[2]
ra.Path = ""
ra.Ref = "master"
if len(matches) >= 5 {
ra.Path = matches[4]
}
if len(matches) >= 7 {
ra.Ref = matches[6]
}
return ra
}

View file

@ -5,7 +5,10 @@ jobs:
check:
runs-on: ubuntu-latest
steps:
- run: echo 'hello world'
- run: ls
- run: echo 'hello world'
- run: echo ${GITHUB_SHA} >> /github/sha.txt
- run: cat /github/sha.txt | grep ${GITHUB_SHA}
build:
runs-on: ubuntu-latest
@ -20,4 +23,8 @@ jobs:
steps:
- uses: docker://ubuntu:18.04
with:
args: echo ${GITHUB_REF} | grep nektos/act
args: env
- uses: docker://ubuntu:18.04
with:
entrypoint: /bin/echo
args: ${{github.event_name}}

16
pkg/runner/testdata/node/push.yml vendored Normal file
View file

@ -0,0 +1,16 @@
name: NodeJS Test
on: push
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v1
with:
node-version: 12
registry-url: https://registry.npmjs.org/
- run: which node
- name: Install Dependencies
run: npm install