b3299ecd30
* Test more if env variants * The correct negation syntax is != * Make the Interpolate function support negated booleans from envs * Move assert := a.New(t) into t.Run This uncovered that some of the test premisses was wrong and the Eval Bool function also had flaws * Remove a stray logrus import * Add an ACT env set to true This can be used to skip certain steps that you don't want to run locally when testing. E.g. steps that sends messages to Slack channels on successful builds etc. * Add a description about env.ACT to the readme * A new attempt at Interpolation and EvalBool * One small merge fix * Remove some fmt.Printfs * Fix some merge conflicts
574 lines
15 KiB
Go
574 lines
15 KiB
Go
package runner
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"runtime"
|
|
"strings"
|
|
|
|
"github.com/nektos/act/pkg/container"
|
|
|
|
"github.com/nektos/act/pkg/common"
|
|
"github.com/nektos/act/pkg/model"
|
|
log "github.com/sirupsen/logrus"
|
|
)
|
|
|
|
// RunContext contains info about current job
|
|
type RunContext struct {
|
|
Name string
|
|
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
|
|
}
|
|
|
|
func (rc *RunContext) String() string {
|
|
return fmt.Sprintf("%s/%s", rc.Run.Workflow.Name, rc.Name)
|
|
}
|
|
|
|
type stepResult struct {
|
|
Success bool `json:"success"`
|
|
Outputs map[string]string `json:"outputs"`
|
|
}
|
|
|
|
// GetEnv returns the env for the context
|
|
func (rc *RunContext) GetEnv() map[string]string {
|
|
if rc.Env == nil {
|
|
rc.Env = mergeMaps(rc.Config.Env, rc.Run.Workflow.Env, rc.Run.Job().Env)
|
|
}
|
|
rc.Env["ACT"] = "true"
|
|
return rc.Env
|
|
}
|
|
|
|
func (rc *RunContext) jobContainerName() string {
|
|
return createContainerName("act", rc.String())
|
|
}
|
|
|
|
func (rc *RunContext) startJobContainer() common.Executor {
|
|
image := rc.platformImage()
|
|
|
|
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", s)
|
|
} else {
|
|
rawLogger.Debugf("%s", 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", "/opt/hostedtoolcache"))
|
|
envList = append(envList, fmt.Sprintf("%s=%s", "RUNNER_OS", "Linux"))
|
|
envList = append(envList, fmt.Sprintf("%s=%s", "RUNNER_TEMP", "/tmp"))
|
|
|
|
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",
|
|
},
|
|
NetworkMode: "host",
|
|
Binds: binds,
|
|
Stdout: logWriter,
|
|
Stderr: logWriter,
|
|
Privileged: rc.Config.Privileged,
|
|
})
|
|
|
|
var copyWorkspace bool
|
|
var copyToPath string
|
|
if !rc.Config.BindWorkdir {
|
|
copyToPath, copyWorkspace = rc.localCheckoutPath()
|
|
copyToPath = filepath.Join("/github/workspace", copyToPath)
|
|
}
|
|
|
|
return common.NewPipelineExecutor(
|
|
rc.JobContainer.Pull(rc.Config.ForcePull),
|
|
rc.stopJobContainer(),
|
|
rc.JobContainer.Create(),
|
|
rc.JobContainer.Start(false),
|
|
rc.JobContainer.CopyDir(copyToPath, rc.Config.Workdir+string(filepath.Separator)+".").IfBool(copyWorkspace),
|
|
rc.JobContainer.Copy("/github/", &container.FileEntry{
|
|
Name: "workflow/event.json",
|
|
Mode: 0644,
|
|
Body: rc.EventJSON,
|
|
}, &container.FileEntry{
|
|
Name: "home/.act",
|
|
Mode: 0644,
|
|
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)
|
|
}
|
|
}
|
|
|
|
// stopJobContainer removes the job container (if it exists) and its volume (if it exists) if !rc.Config.ReuseContainers
|
|
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 {
|
|
steps := make([]common.Executor, 0)
|
|
|
|
steps = append(steps, func(ctx context.Context) error {
|
|
if len(rc.Matrix) > 0 {
|
|
common.Logger(ctx).Infof("\U0001F9EA Matrix: %v", rc.Matrix)
|
|
}
|
|
return nil
|
|
})
|
|
|
|
steps = append(steps, rc.startJobContainer())
|
|
|
|
for i, step := range rc.Run.Job().Steps {
|
|
if step.ID == "" {
|
|
step.ID = fmt.Sprintf("%d", i)
|
|
}
|
|
steps = append(steps, rc.newStepExecutor(step))
|
|
}
|
|
steps = append(steps, rc.stopJobContainer())
|
|
|
|
return common.NewPipelineExecutor(steps...).If(rc.isEnabled)
|
|
}
|
|
|
|
func (rc *RunContext) newStepExecutor(step *model.Step) common.Executor {
|
|
sc := &StepContext{
|
|
RunContext: rc,
|
|
Step: step,
|
|
}
|
|
return func(ctx context.Context) error {
|
|
rc.CurrentStep = sc.Step.ID
|
|
rc.StepResults[rc.CurrentStep] = &stepResult{
|
|
Success: true,
|
|
Outputs: make(map[string]string),
|
|
}
|
|
|
|
_ = sc.setupEnv()(ctx)
|
|
rc.ExprEval = sc.NewExpressionEvaluator()
|
|
|
|
runStep, err := rc.EvalBool(sc.Step.If)
|
|
if err != nil {
|
|
common.Logger(ctx).Errorf(" \u274C Error in if: expression - %s", sc.Step)
|
|
rc.StepResults[rc.CurrentStep].Success = false
|
|
return err
|
|
}
|
|
if !runStep {
|
|
log.Debugf("Skipping step '%s' due to '%s'", sc.Step.String(), sc.Step.If)
|
|
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 err
|
|
}
|
|
}
|
|
|
|
func (rc *RunContext) platformImage() string {
|
|
job := rc.Run.Job()
|
|
|
|
c := job.Container()
|
|
if c != nil {
|
|
return rc.ExprEval.Interpolate(c.Image)
|
|
}
|
|
|
|
for _, runnerLabel := range job.RunsOn() {
|
|
platformName := rc.ExprEval.Interpolate(runnerLabel)
|
|
image := rc.Config.Platforms[strings.ToLower(platformName)]
|
|
if image != "" {
|
|
return image
|
|
}
|
|
}
|
|
|
|
return ""
|
|
}
|
|
|
|
func (rc *RunContext) isEnabled(ctx context.Context) bool {
|
|
job := rc.Run.Job()
|
|
l := common.Logger(ctx)
|
|
runJob, err := rc.EvalBool(job.If)
|
|
if err != nil {
|
|
common.Logger(ctx).Errorf(" \u274C Error in if: expression - %s", job.Name)
|
|
return false
|
|
}
|
|
if !runJob {
|
|
l.Debugf("Skipping job '%s' due to '%s'", job.Name, job.If)
|
|
return false
|
|
}
|
|
|
|
img := rc.platformImage()
|
|
if img == "" {
|
|
l.Infof("\U0001F6A7 Skipping unsupported platform '%+v'", job.RunsOn())
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
// EvalBool evaluates an expression against current run context
|
|
func (rc *RunContext) EvalBool(expr string) (bool, error) {
|
|
if strings.HasPrefix(strings.TrimSpace(expr), "!") {
|
|
return false, errors.New("expressions starting with ! must be wrapped in ${{ }}")
|
|
}
|
|
if expr != "" {
|
|
splitPattern := regexp.MustCompile(fmt.Sprintf(`%s|%s|\S+`, expressionPattern.String(), operatorPattern.String()))
|
|
|
|
parts := splitPattern.FindAllString(expr, -1)
|
|
var evaluatedParts []string
|
|
for i, part := range parts {
|
|
if operatorPattern.MatchString(part) {
|
|
evaluatedParts = append(evaluatedParts, part)
|
|
continue
|
|
}
|
|
|
|
interpolatedPart, isString := rc.ExprEval.InterpolateWithStringCheck(part)
|
|
|
|
// This peculiar transformation has to be done because the Github parser
|
|
// treats false retured from contexts as a string, not a boolean.
|
|
// Hence env.SOMETHING will be evaluated to true in an if: expression
|
|
// regardless if SOMETHING is set to false, true or any other string.
|
|
// It also handles some other weirdness that I found by trial and error.
|
|
if (expressionPattern.MatchString(part) && // it is an expression
|
|
!strings.Contains(part, "!")) && // but it's not negated
|
|
interpolatedPart == "false" && // and the interpolated string is false
|
|
(isString || previousOrNextPartIsAnOperator(i, parts)) { // and it's of type string or has an logical operator before or after
|
|
|
|
interpolatedPart = fmt.Sprintf("'%s'", interpolatedPart) // then we have to quote the false expression
|
|
}
|
|
|
|
evaluatedParts = append(evaluatedParts, interpolatedPart)
|
|
}
|
|
|
|
joined := strings.Join(evaluatedParts, " ")
|
|
v, _, err := rc.ExprEval.Evaluate(fmt.Sprintf("Boolean(%s)", joined))
|
|
if err != nil {
|
|
return false, nil
|
|
}
|
|
log.Debugf("expression '%s' evaluated to '%s'", expr, v)
|
|
return v == "true", nil
|
|
}
|
|
return true, nil
|
|
}
|
|
|
|
func previousOrNextPartIsAnOperator(i int, parts []string) bool {
|
|
operator := false
|
|
if i > 0 {
|
|
operator = operatorPattern.MatchString(parts[i-1])
|
|
}
|
|
if i+1 < len(parts) {
|
|
operator = operator || operatorPattern.MatchString(parts[i+1])
|
|
}
|
|
return operator
|
|
}
|
|
|
|
func mergeMaps(maps ...map[string]string) map[string]string {
|
|
rtnMap := make(map[string]string)
|
|
for _, m := range maps {
|
|
for k, v := range m {
|
|
rtnMap[k] = v
|
|
}
|
|
}
|
|
return rtnMap
|
|
}
|
|
|
|
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))
|
|
}
|
|
}
|
|
return strings.Trim(strings.Join(name, "-"), "-")
|
|
}
|
|
|
|
func trimToLen(s string, l int) string {
|
|
if l < 0 {
|
|
l = 0
|
|
}
|
|
if len(s) > l {
|
|
return s[:l]
|
|
}
|
|
return s
|
|
}
|
|
|
|
type jobContext struct {
|
|
Status string `json:"status"`
|
|
Container struct {
|
|
ID string `json:"id"`
|
|
Network string `json:"network"`
|
|
} `json:"container"`
|
|
Services map[string]struct {
|
|
ID string `json:"id"`
|
|
} `json:"services"`
|
|
}
|
|
|
|
func (rc *RunContext) getJobContext() *jobContext {
|
|
jobStatus := "success"
|
|
for _, stepStatus := range rc.StepResults {
|
|
if !stepStatus.Success {
|
|
jobStatus = "failure"
|
|
break
|
|
}
|
|
}
|
|
return &jobContext{
|
|
Status: jobStatus,
|
|
}
|
|
}
|
|
|
|
func (rc *RunContext) getStepsContext() map[string]*stepResult {
|
|
return rc.StepResults
|
|
}
|
|
|
|
type githubContext struct {
|
|
Event map[string]interface{} `json:"event"`
|
|
EventPath string `json:"event_path"`
|
|
Workflow string `json:"workflow"`
|
|
RunID string `json:"run_id"`
|
|
RunNumber string `json:"run_number"`
|
|
Actor string `json:"actor"`
|
|
Repository string `json:"repository"`
|
|
EventName string `json:"event_name"`
|
|
Sha string `json:"sha"`
|
|
Ref string `json:"ref"`
|
|
HeadRef string `json:"head_ref"`
|
|
BaseRef string `json:"base_ref"`
|
|
Token string `json:"token"`
|
|
Workspace string `json:"workspace"`
|
|
Action string `json:"action"`
|
|
}
|
|
|
|
func (rc *RunContext) getGithubContext() *githubContext {
|
|
token, ok := rc.Config.Secrets["GITHUB_TOKEN"]
|
|
if !ok {
|
|
token = os.Getenv("GITHUB_TOKEN")
|
|
}
|
|
runID := rc.Config.Env["GITHUB_RUN_ID"]
|
|
if runID == "" {
|
|
runID = "1"
|
|
}
|
|
runNumber := rc.Config.Env["GITHUB_RUN_NUMBER"]
|
|
if runNumber == "" {
|
|
runNumber = "1"
|
|
}
|
|
ghc := &githubContext{
|
|
Event: make(map[string]interface{}),
|
|
EventPath: "/github/workflow/event.json",
|
|
Workflow: rc.Run.Workflow.Name,
|
|
RunID: runID,
|
|
RunNumber: runNumber,
|
|
Actor: rc.Config.Actor,
|
|
EventName: rc.Config.EventName,
|
|
Token: token,
|
|
Workspace: "/github/workspace",
|
|
Action: rc.CurrentStep,
|
|
}
|
|
|
|
// Backwards compatibility for configs that require
|
|
// a default rather than being run as a cmd
|
|
if ghc.Actor == "" {
|
|
ghc.Actor = "nektos/act"
|
|
}
|
|
|
|
repoPath := rc.Config.Workdir
|
|
repo, err := common.FindGithubRepo(repoPath)
|
|
if err != nil {
|
|
log.Warningf("unable to get git repo: %v", err)
|
|
} else {
|
|
ghc.Repository = repo
|
|
}
|
|
|
|
_, sha, err := common.FindGitRevision(repoPath)
|
|
if err != nil {
|
|
log.Warningf("unable to get git revision: %v", err)
|
|
} else {
|
|
ghc.Sha = sha
|
|
}
|
|
|
|
ref, err := common.FindGitRef(repoPath)
|
|
if err != nil {
|
|
log.Warningf("unable to get git ref: %v", err)
|
|
} else {
|
|
log.Debugf("using github ref: %s", ref)
|
|
ghc.Ref = ref
|
|
}
|
|
if rc.EventJSON != "" {
|
|
err = json.Unmarshal([]byte(rc.EventJSON), &ghc.Event)
|
|
if err != nil {
|
|
log.Errorf("Unable to Unmarshal event '%s': %v", rc.EventJSON, err)
|
|
}
|
|
}
|
|
|
|
// set the branch in the event data
|
|
if rc.Config.DefaultBranch != "" {
|
|
ghc.Event = withDefaultBranch(rc.Config.DefaultBranch, ghc.Event)
|
|
} else {
|
|
ghc.Event = withDefaultBranch("master", ghc.Event)
|
|
}
|
|
|
|
if ghc.EventName == "pull_request" {
|
|
ghc.BaseRef = asString(nestedMapLookup(ghc.Event, "pull_request", "base", "ref"))
|
|
ghc.HeadRef = asString(nestedMapLookup(ghc.Event, "pull_request", "head", "ref"))
|
|
}
|
|
|
|
return ghc
|
|
}
|
|
|
|
func (ghc *githubContext) isLocalCheckout(step *model.Step) bool {
|
|
if step.Type() != model.StepTypeUsesActionRemote {
|
|
return false
|
|
}
|
|
remoteAction := newRemoteAction(step.Uses)
|
|
if !remoteAction.IsCheckout() {
|
|
return false
|
|
}
|
|
|
|
if repository, ok := step.With["repository"]; ok && repository != ghc.Repository {
|
|
return false
|
|
}
|
|
if repository, ok := step.With["ref"]; ok && repository != ghc.Ref {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
func asString(v interface{}) string {
|
|
if v == nil {
|
|
return ""
|
|
} else if s, ok := v.(string); ok {
|
|
return s
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func nestedMapLookup(m map[string]interface{}, ks ...string) (rval interface{}) {
|
|
var ok bool
|
|
|
|
if len(ks) == 0 { // degenerate input
|
|
return nil
|
|
}
|
|
if rval, ok = m[ks[0]]; !ok {
|
|
return nil
|
|
} else if len(ks) == 1 { // we've reached the final key
|
|
return rval
|
|
} else if m, ok = rval.(map[string]interface{}); !ok {
|
|
return nil
|
|
} else { // 1+ more keys
|
|
return nestedMapLookup(m, ks[1:]...)
|
|
}
|
|
}
|
|
|
|
func withDefaultBranch(b string, event map[string]interface{}) map[string]interface{} {
|
|
repoI, ok := event["repository"]
|
|
if !ok {
|
|
repoI = make(map[string]interface{})
|
|
}
|
|
|
|
repo, ok := repoI.(map[string]interface{})
|
|
if !ok {
|
|
log.Warnf("unable to set default branch to %v", b)
|
|
return event
|
|
}
|
|
|
|
// if the branch is already there return with no changes
|
|
if _, ok = repo["default_branch"]; ok {
|
|
return event
|
|
}
|
|
|
|
repo["default_branch"] = b
|
|
event["repository"] = repo
|
|
|
|
return event
|
|
}
|
|
|
|
func (rc *RunContext) withGithubEnv(env map[string]string) map[string]string {
|
|
github := rc.getGithubContext()
|
|
env["CI"] = "true"
|
|
env["HOME"] = "/github/home"
|
|
env["GITHUB_WORKFLOW"] = github.Workflow
|
|
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
|
|
env["GITHUB_EVENT_PATH"] = github.EventPath
|
|
env["GITHUB_WORKSPACE"] = github.Workspace
|
|
env["GITHUB_SHA"] = github.Sha
|
|
env["GITHUB_REF"] = github.Ref
|
|
env["GITHUB_TOKEN"] = github.Token
|
|
return env
|
|
}
|
|
|
|
func (rc *RunContext) localCheckoutPath() (string, bool) {
|
|
ghContext := rc.getGithubContext()
|
|
for _, step := range rc.Run.Job().Steps {
|
|
if ghContext.isLocalCheckout(step) {
|
|
return step.With["path"], true
|
|
}
|
|
}
|
|
return "", false
|
|
}
|