Repository: nektos/act (language: go)

Issue:
`runs-on` does not accept array expressions
### Bug report info

```plain text
act version:            0.2.53
GOOS:                   darwin
GOARCH:                 arm64
NumCPU:                 12
Docker host:            DOCKER_HOST environment variable is not set
Sockets found:
	/var/run/docker.sock
	$HOME/.docker/run/docker.sock
Config files:
	/Users/jonj/.actrc:
		-P ubuntu-latest=node:16-buster-slim
		-P ubuntu-22.04=node:16-bullseye-slim
		-P ubuntu-20.04=node:16-buster-slim
		-P ubuntu-18.04=node:16-buster-slim
Build info:
	Go version:            go1.21.3
	Module path:           command-line-arguments
	Main version:
	Main path:
	Main checksum:
	Build settings:
		-buildmode:           exe
		-compiler:            gc
		-ldflags:             -X main.version=0.2.53
		DefaultGODEBUG:       panicnil=1
		CGO_ENABLED:          1
		CGO_CFLAGS:
		CGO_CPPFLAGS:
		CGO_CXXFLAGS:
		CGO_LDFLAGS:
		GOARCH:               arm64
		GOOS:                 darwin
Docker Engine:
	Engine version:        24.0.6
	Engine runtime:        runc
	Cgroup version:        2
	Cgroup driver:         cgroupfs
	Storage driver:        overlay2
	Registry URI:          https://index.docker.io/v1/
	OS:                    Docker Desktop
	OS type:               linux
	OS version:
	OS arch:               aarch64
	OS kernel:             6.4.16-linuxkit
	OS CPU:                12
	OS memory:             7844 MB
	Security options:
		name=seccomp,profile=unconfined
		name=cgroupns
```


### Command used with act

```sh
act -vW .github/workflows/sample.yml
```


### Describe issue

If `runs-on` receives an array expression, act incorrectly evaluates it to `%!t(string=Array)`. act correctly handles string expressions, as well as static string and array yaml values.

I.e. these work:
* `runs-on: ubuntu-latest`
* `runs-on: ${{ fromJSON('"ubuntu-latest"') }}`
* `runs-on: [ubuntu-latest]`

But this does not:
* `runs-on: ${{ fromJSON('["ubuntu-latest"]') }}`

The bug isn't specific to `fromJSON`, as the same issue occurs if the expression is an array value from a matrix, e.g.

```yaml
runs-on: ${{ matrix.runner }}
strategy:
  matrix:
    runner: [[ubuntu-latest], [self-hosted, my-cool-label]]
```

These scenarios all work as expected in actual GitHub Actions workflow runs.

### Link to GitHub repository

https://github.com/jenseng/dynamic-uses/actions/runs/6801153354/job/18491354468

### Workflow content

```yml
name: test
on: push
jobs:
  test:
    # runs-on: ubuntu-latest
    # runs-on: ${{ fromJSON('"ubuntu-latest"') }}
    # runs-on: [ubuntu-latest]
    runs-on: ${{ fromJSON('["ubuntu-latest"]') }}
    steps:
      - run: echo "Hello, world!"
```


### Relevant log output

```sh
WARN  ⚠ You are using Apple M-series chip and you have not specified container architecture, you might encounter issues while running act. If so, try running it with '--container-architecture linux/amd64'. ⚠
DEBU[0000] Loading environment from /Users/jonj/projects/dynamic-uses/.env
DEBU[0000] Loading action inputs from /Users/jonj/projects/dynamic-uses/.input
DEBU[0000] Loading secrets from /Users/jonj/projects/dynamic-uses/.secrets
DEBU[0000] Loading vars from /Users/jonj/projects/dynamic-uses/.vars
DEBU[0000] Evaluated matrix inclusions: map[]
DEBU[0000] Loading workflow '/Users/jonj/projects/dynamic-uses/.github/workflows/sample.yml'
DEBU[0000] Reading workflow '/Users/jonj/projects/dynamic-uses/.github/workflows/sample.yml'
DEBU[0000] Preparing plan with all jobs
DEBU[0000] Using the only detected workflow event: push
DEBU[0000] Planning jobs for event: push
DEBU[0000] Conditional GET for notices etag=c694094f-9a86-4866-bf11-876c821d9fcb
DEBU[0000] gc: 2023-11-08 09:31:06.262245 -0700 MST m=+0.030477292  module=artifactcache
DEBU[0000] Plan Stages: [0x1400011ee10]
DEBU[0000] Stages Runs: [test]
DEBU[0000] Job.Name: test
DEBU[0000] Job.RawNeeds: {0 0    <nil> []    0 0}
DEBU[0000] Job.RawRunsOn: {8 0 !!str ${{ fromJSON('["ubuntu-latest"]') }}  <nil> []    8 14}
DEBU[0000] Job.Env: {0 0    <nil> []    0 0}
DEBU[0000] Job.If: {0 0  success()  <nil> []    0 0}
DEBU[0000] Job.Steps: echo "Hello, world!"
DEBU[0000] Job.TimeoutMinutes:
DEBU[0000] Job.Services: map[]
DEBU[0000] Job.Strategy: <nil>
DEBU[0000] Job.RawContainer: {0 0    <nil> []    0 0}
DEBU[0000] Job.Defaults.Run.Shell:
DEBU[0000] Job.Defaults.Run.WorkingDirectory:
DEBU[0000] Job.Outputs: map[]
DEBU[0000] Job.Uses:
DEBU[0000] Job.With: map[]
DEBU[0000] Job.Result:
DEBU[0000] Empty Strategy, matrixes=[map[]]
DEBU[0000] Job Matrices: [map[]]
DEBU[0000] Runner Matrices: map[]
DEBU[0000] Final matrix after applying user inclusions '[map[]]'
DEBU[0000] Loading revision from git directory
DEBU[0000] Found revision: 4af4b6a3356b02639095850fe2dce350906070ba
DEBU[0000] HEAD points to '4af4b6a3356b02639095850fe2dce350906070ba'
DEBU[0000] using github ref: refs/heads/debug-act-bug3
DEBU[0000] Found revision: 4af4b6a3356b02639095850fe2dce350906070ba
DEBU[0000] Detected CPUs: 12
[test/test] [DEBUG] evaluating expression 'success()'
[test/test] [DEBUG] expression 'success()' evaluated to 'true'
[test/test] [DEBUG] expression '${{ fromJSON('["ubuntu-latest"]') }}' rewritten to 'format('{0}', fromJSON('["ubuntu-latest"]'))'
[test/test] [DEBUG] evaluating expression 'format('{0}', fromJSON('["ubuntu-latest"]'))'
[test/test] [DEBUG] expression 'format('{0}', fromJSON('["ubuntu-latest"]'))' evaluated to '%!t(string=Array)'
[test/test] [DEBUG] expression '${{ fromJSON('["ubuntu-latest"]') }}' rewritten to 'format('{0}', fromJSON('["ubuntu-latest"]'))'
[test/test] [DEBUG] evaluating expression 'format('{0}', fromJSON('["ubuntu-latest"]'))'
[test/test] [DEBUG] expression 'format('{0}', fromJSON('["ubuntu-latest"]'))' evaluated to '%!t(string=Array)'
[test/test] 🚧  Skipping unsupported platform -- Try running with `-P Array=...`
DEBU[0000] Saving notices etag=c694094f-9a86-4866-bf11-876c821d9fcb
DEBU[0000] No new notices
```


### Additional information

_No response_


Discussion / hints:
As a potential workaround, you can set `-P array=someimage...`, though that image would then be used for any runs-on array expression without considering its actual values.

Fix patch:
diff --git a/cmd/platforms.go b/cmd/platforms.go
index 9d7e97a8697..45724d752dc 100644
--- a/cmd/platforms.go
+++ b/cmd/platforms.go
@@ -15,7 +15,7 @@ func (i *Input) newPlatforms() map[string]string {
 	for _, p := range i.platforms {
 		pParts := strings.Split(p, "=")
 		if len(pParts) == 2 {
-			platforms[pParts[0]] = pParts[1]
+			platforms[strings.ToLower(pParts[0])] = pParts[1]
 		}
 	}
 	return platforms
diff --git a/pkg/runner/run_context.go b/pkg/runner/run_context.go
index a69abc6e224..faf64e11f19 100644
--- a/pkg/runner/run_context.go
+++ b/pkg/runner/run_context.go
@@ -626,21 +626,30 @@ func (rc *RunContext) containerImage(ctx context.Context) string {
 }
 
 func (rc *RunContext) runsOnImage(ctx context.Context) string {
+	for _, platformName := range rc.runsOnPlatformNames(ctx) {
+		image := rc.Config.Platforms[strings.ToLower(platformName)]
+		if image != "" {
+			return image
+		}
+	}
+
+	return ""
+}
+
+func (rc *RunContext) runsOnPlatformNames(ctx context.Context) []string {
 	job := rc.Run.Job()
 
 	if job.RunsOn() == nil {
 		common.Logger(ctx).Errorf("'runs-on' key not defined in %s", rc.String())
+		return []string{}
 	}
 
-	for _, runnerLabel := range job.RunsOn() {
-		platformName := rc.ExprEval.Interpolate(ctx, runnerLabel)
-		image := rc.Config.Platforms[strings.ToLower(platformName)]
-		if image != "" {
-			return image
-		}
+	if err := rc.ExprEval.EvaluateYamlNode(ctx, &job.RawRunsOn); err != nil {
+		common.Logger(ctx).Errorf("Error while evaluating runs-on: %v", err)
+		return []string{}
 	}
 
-	return ""
+	return job.RunsOn()
 }
 
 func (rc *RunContext) platformImage(ctx context.Context) string {
@@ -684,12 +693,7 @@ func (rc *RunContext) isEnabled(ctx context.Context) (bool, error) {
 
 	img := rc.platformImage(ctx)
 	if img == "" {
-		if job.RunsOn() == nil {
-			l.Errorf("'runs-on' key not defined in %s", rc.String())
-		}
-
-		for _, runnerLabel := range job.RunsOn() {
-			platformName := rc.ExprEval.Interpolate(ctx, runnerLabel)
+		for _, platformName := range rc.runsOnPlatformNames(ctx) {
 			l.Infof("\U0001F6A7  Skipping unsupported platform -- Try running with `-P %+v=...`", platformName)
 		}
 		return false, nil
@@ -923,9 +927,7 @@ func (rc *RunContext) withGithubEnv(ctx context.Context, github *model.GithubCon
 		setActionRuntimeVars(rc, env)
 	}
 
-	job := rc.Run.Job()
-	for _, runnerLabel := range job.RunsOn() {
-		platformName := rc.ExprEval.Interpolate(ctx, runnerLabel)
+	for _, platformName := range rc.runsOnPlatformNames(ctx) {
 		if platformName != "" {
 			if platformName == "ubuntu-latest" {
 				// hardcode current ubuntu-latest since we have no way to check that 'on the fly'


Test patch:
diff --git a/pkg/runner/run_context_test.go b/pkg/runner/run_context_test.go
index 3e26a02245c..8ab643ec077 100644
--- a/pkg/runner/run_context_test.go
+++ b/pkg/runner/run_context_test.go
@@ -470,6 +470,53 @@ func createJob(t *testing.T, input string, result string) *model.Job {
 	return job
 }
 
+func TestRunContextRunsOnPlatformNames(t *testing.T) {
+	log.SetLevel(log.DebugLevel)
+	assertObject := assert.New(t)
+
+	rc := createIfTestRunContext(map[string]*model.Job{
+		"job1": createJob(t, `runs-on: ubuntu-latest`, ""),
+	})
+	assertObject.Equal([]string{"ubuntu-latest"}, rc.runsOnPlatformNames(context.Background()))
+
+	rc = createIfTestRunContext(map[string]*model.Job{
+		"job1": createJob(t, `runs-on: ${{ 'ubuntu-latest' }}`, ""),
+	})
+	assertObject.Equal([]string{"ubuntu-latest"}, rc.runsOnPlatformNames(context.Background()))
+
+	rc = createIfTestRunContext(map[string]*model.Job{
+		"job1": createJob(t, `runs-on: [self-hosted, my-runner]`, ""),
+	})
+	assertObject.Equal([]string{"self-hosted", "my-runner"}, rc.runsOnPlatformNames(context.Background()))
+
+	rc = createIfTestRunContext(map[string]*model.Job{
+		"job1": createJob(t, `runs-on: [self-hosted, "${{ 'my-runner' }}"]`, ""),
+	})
+	assertObject.Equal([]string{"self-hosted", "my-runner"}, rc.runsOnPlatformNames(context.Background()))
+
+	rc = createIfTestRunContext(map[string]*model.Job{
+		"job1": createJob(t, `runs-on: ${{ fromJSON('["ubuntu-latest"]') }}`, ""),
+	})
+	assertObject.Equal([]string{"ubuntu-latest"}, rc.runsOnPlatformNames(context.Background()))
+
+	// test missing / invalid runs-on
+	rc = createIfTestRunContext(map[string]*model.Job{
+		"job1": createJob(t, `name: something`, ""),
+	})
+	assertObject.Equal([]string{}, rc.runsOnPlatformNames(context.Background()))
+
+	rc = createIfTestRunContext(map[string]*model.Job{
+		"job1": createJob(t, `runs-on:
+  mapping: value`, ""),
+	})
+	assertObject.Equal([]string{}, rc.runsOnPlatformNames(context.Background()))
+
+	rc = createIfTestRunContext(map[string]*model.Job{
+		"job1": createJob(t, `runs-on: ${{ invalid expression }}`, ""),
+	})
+	assertObject.Equal([]string{}, rc.runsOnPlatformNames(context.Background()))
+}
+
 func TestRunContextIsEnabled(t *testing.T) {
 	log.SetLevel(log.DebugLevel)
 	assertObject := assert.New(t)