fix: matrix-job data races + outputs, leaner offline test suite (#994)

Running the full suite under `-race` (dropping `-short`) exposed pre-existing data races in parallel matrix-job execution, fixed by not sharing mutable state across combinations:

- `containerDaemonSocket()`/`validVolumes()` derive per-job values instead of mutating shared `Config`
- `getWorkflowSecrets` builds a fresh map, `rc.steps()` clones each step, and go-git workdir access is serialized
- every write to a shared `Job`'s result/outputs runs under a per-`Job` lock, each combo interpolating outputs from a pristine snapshot (last wins, as on GitHub)

### Test suite

- capability gates (docker / network / host-tools / Linux) replace the `-short` skips, and the suite runs offline via local fixtures (the artifact flow uses an in-process loopback server, only the docker-action force-pull needs the network)
- drops redundant tests, adds a regression test for https://gitea.com/gitea/runner/issues/981 and a docker-in-docker harness (`make test-dind`)

---
This PR was written with the help of Claude Opus 4.7

Reviewed-on: https://gitea.com/gitea/runner/pulls/994
Reviewed-by: Nicolas <bircni@icloud.com>
Co-authored-by: silverwind <me@silverwind.io>
Co-committed-by: silverwind <me@silverwind.io>
This commit is contained in:
silverwind
2026-05-29 05:23:10 +00:00
committed by silverwind
parent 0b9f251b6a
commit 270ea41232
69 changed files with 969 additions and 1176 deletions

View File

@@ -455,7 +455,7 @@ func newStepContainer(ctx context.Context, step step, image string, cmd, entrypo
Platform: rc.Config.ContainerArchitecture,
Options: rc.Config.ContainerOptions,
AutoRemove: rc.Config.AutoRemove,
ValidVolumes: rc.Config.ValidVolumes,
ValidVolumes: rc.validVolumes(),
AllocatePTY: rc.Config.AllocatePTY,
})
return stepContainer

View File

@@ -8,64 +8,139 @@ import (
"archive/tar"
"bytes"
"context"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
"gitea.com/gitea/runner/act/common"
"gitea.com/gitea/runner/act/model"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestActionCache(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test")
func runGit(t *testing.T, dir string, args ...string) {
t.Helper()
if dir != "" {
args = append([]string{"-C", dir}, args...)
}
cmd := exec.Command("git", args...)
// Fixed identity and host-config isolation so commits succeed offline regardless of the
// host's git config (mirrors gitCmd in act/common/git).
cmd.Env = append(os.Environ(),
"GIT_AUTHOR_NAME=test", "GIT_AUTHOR_EMAIL=test@example.com",
"GIT_COMMITTER_NAME=test", "GIT_COMMITTER_EMAIL=test@example.com",
"GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null",
)
out, err := cmd.CombinedOutput()
require.NoError(t, err, string(out))
}
// TestShortShaActionRejected verifies a `uses` ref that is a shortened commit SHA is rejected
// with a clear error. The action is resolved from a local repo (via DefaultActionInstance) so
// this runs offline.
func TestShortShaActionRejected(t *testing.T) {
// a local "remote" action repo at <root>/actions/hello-world-docker-action
actionRoot := t.TempDir()
repo := filepath.Join(actionRoot, "actions", "hello-world-docker-action")
require.NoError(t, os.MkdirAll(repo, 0o755))
runGit(t, "", "init", "--initial-branch=main", repo)
require.NoError(t, os.WriteFile(filepath.Join(repo, "action.yml"),
[]byte("name: hello\nruns:\n using: node24\n main: index.js\n"), 0o644))
runGit(t, repo, "add", ".")
runGit(t, repo, "commit", "-m", "initial")
out, err := exec.Command("git", "-C", repo, "rev-parse", "HEAD").Output()
require.NoError(t, err)
shortSha := strings.TrimSpace(string(out))[:7]
// a workflow that uses the action at the short SHA
wfDir := filepath.Join(t.TempDir(), "wf")
require.NoError(t, os.MkdirAll(wfDir, 0o755))
wf := fmt.Sprintf("on: push\njobs:\n test:\n runs-on: ubuntu-latest\n steps:\n - uses: actions/hello-world-docker-action@%s\n", shortSha)
require.NoError(t, os.WriteFile(filepath.Join(wfDir, "push.yml"), []byte(wf), 0o644))
runner, err := New(&Config{
Workdir: wfDir,
EventName: "push",
Platforms: map[string]string{"ubuntu-latest": baseImage},
GitHubInstance: "github.com",
DefaultActionInstance: actionRoot,
ContainerMaxLifetime: time.Hour,
})
require.NoError(t, err)
planner, err := model.NewWorkflowPlanner(wfDir, true)
require.NoError(t, err)
plan, err := planner.PlanEvent("push")
require.NoError(t, err)
err = runner.NewPlanExecutor(plan)(common.WithDryrun(context.Background(), true))
require.Error(t, err)
assert.Contains(t, err.Error(), "shortened version of a commit SHA")
}
func TestActionCache(t *testing.T) {
a := assert.New(t)
ctx := context.Background()
// Build a local bare repo with a `js` action dir so this runs offline (formerly cloned
// github.com/nektos/act-test-actions over the network). allowAnySHA1InWant lets the
// "Fetch Sha" case fetch a commit hash directly.
remoteDir := t.TempDir()
runGit(t, "", "init", "--bare", "--initial-branch=main", remoteDir)
runGit(t, remoteDir, "config", "uploadpack.allowAnySHA1InWant", "true")
workDir := t.TempDir()
runGit(t, "", "clone", remoteDir, workDir)
require.NoError(t, os.MkdirAll(filepath.Join(workDir, "js"), 0o755))
require.NoError(t, os.WriteFile(filepath.Join(workDir, "js", "action.yml"),
[]byte("name: js\nruns:\n using: node24\n main: index.js\n"), 0o644))
require.NoError(t, os.WriteFile(filepath.Join(workDir, "js", "index.js"),
[]byte("console.log('hello');\n"), 0o644))
runGit(t, workDir, "add", ".")
runGit(t, workDir, "commit", "-m", "initial")
runGit(t, workDir, "push", "-u", "origin", "main")
out, err := exec.Command("git", "-C", workDir, "rev-parse", "main").Output()
require.NoError(t, err)
fullSha := strings.TrimSpace(string(out))
cache := &GoGitActionCache{
Path: t.TempDir(),
}
ctx := context.Background()
cacheDir := "nektos/act-test-actions"
repo := "https://github.com/nektos/act-test-actions"
cacheDir := "local/act-test-actions"
refs := []struct {
Name string
CacheDir string
Repo string
Ref string
Name string
Ref string
}{
{
Name: "Fetch Branch Name",
CacheDir: cacheDir,
Repo: repo,
Ref: "main",
},
{
Name: "Fetch Branch Name Absolutely",
CacheDir: cacheDir,
Repo: repo,
Ref: "refs/heads/main",
},
{
Name: "Fetch HEAD",
CacheDir: cacheDir,
Repo: repo,
Ref: "HEAD",
},
{
Name: "Fetch Sha",
CacheDir: cacheDir,
Repo: repo,
Ref: "de984ca37e4df4cb9fd9256435a3b82c4a2662b1",
},
{Name: "Fetch Branch Name", Ref: "main"},
{Name: "Fetch Branch Name Absolutely", Ref: "refs/heads/main"},
{Name: "Fetch HEAD", Ref: "HEAD"},
{Name: "Fetch Sha", Ref: fullSha},
}
for _, c := range refs {
t.Run(c.Name, func(t *testing.T) {
sha, err := cache.Fetch(ctx, c.CacheDir, c.Repo, c.Ref, "")
sha, err := cache.Fetch(ctx, cacheDir, remoteDir, c.Ref, "")
if !a.NoError(err) || !a.NotEmpty(sha) { //nolint:testifylint // pre-existing issue from nektos/act
return
}
atar, err := cache.GetTarArchive(ctx, c.CacheDir, sha, "js")
if !a.NoError(err) || !a.NotEmpty(atar) { //nolint:testifylint // pre-existing issue from nektos/act
atar, err := cache.GetTarArchive(ctx, cacheDir, sha, "js")
// NotNil, not NotEmpty: atar is a live io.PipeReader whose producer goroutine is
// writing concurrently; NotEmpty deep-reflects over its internals and races.
if !a.NoError(err) || !a.NotNil(atar) { //nolint:testifylint // pre-existing issue from nektos/act
return
}
// GetTarArchive streams from a background goroutine walking the shared repo.
// Drain and close so it finishes before the next subtest fetches into the same
// repo; otherwise the lingering walk races with that fetch.
defer func() {
_, _ = io.Copy(io.Discard, atar)
_ = atar.Close()
}()
mytar := tar.NewReader(atar)
th, err := mytar.Next()
if !a.NoError(err) || !a.NotEqual(0, th.Size) { //nolint:testifylint // pre-existing issue from nektos/act

View File

@@ -562,15 +562,15 @@ func getWorkflowSecrets(ctx context.Context, rc *RunContext) map[string]string {
secrets = rc.caller.runContext.Config.Secrets
}
if secrets == nil {
secrets = map[string]string{}
}
// Interpolate into a new map. secrets may be the shared Config.Secrets (or the job's
// map), which other parallel jobs read concurrently (e.g. log masking), so mutating it
// in place is a data race.
interpolated := make(map[string]string, len(secrets))
for k, v := range secrets {
secrets[k] = rc.caller.runContext.ExprEval.Interpolate(ctx, v)
interpolated[k] = rc.caller.runContext.ExprEval.Interpolate(ctx, v)
}
return secrets
return interpolated
}
return rc.Config.Secrets

View File

@@ -0,0 +1,66 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package runner
import (
"context"
"net"
"os/exec"
"runtime"
"testing"
"time"
"gitea.com/gitea/runner/act/container"
mobyclient "github.com/moby/moby/client"
)
// requireLinuxDocker skips on non-Linux hosts. Some integration workflows need Docker features
// that only a Linux daemon provides (host networking, host /proc bind mounts); Docker Desktop
// on macOS/Windows does not, so those tests can only run on Linux.
func requireLinuxDocker(t *testing.T) {
t.Helper()
if runtime.GOOS != "linux" {
t.Skip("skipping: requires a Linux Docker host")
}
}
// requireDocker skips the test unless a reachable docker daemon is available.
// GetDockerClient succeeds even without a running daemon (its ping is best-effort),
// so the daemon has to be pinged explicitly here to decide whether to skip.
func requireDocker(t *testing.T) {
t.Helper()
ctx := context.Background()
cli, err := container.GetDockerClient(ctx)
if err != nil {
t.Skipf("skipping: docker client unavailable: %v", err)
}
defer cli.Close()
if _, err := cli.Ping(ctx, mobyclient.PingOptions{}); err != nil {
t.Skipf("skipping: docker daemon unreachable: %v", err)
}
}
// requireNetwork skips the test unless github.com is reachable. A few tests exercise behaviour
// that inherently needs the network (force-pulling an image, resolving a remote short-sha ref);
// gating lets the rest of the suite run offline without these failing.
func requireNetwork(t *testing.T) {
t.Helper()
conn, err := net.DialTimeout("tcp", "github.com:443", 3*time.Second)
if err != nil {
t.Skipf("skipping: network unavailable: %v", err)
}
_ = conn.Close()
}
// requireHostTools skips the test unless every named executable is on PATH. Used by the
// self-hosted (host environment) suite, which runs steps directly on the host.
func requireHostTools(t *testing.T, tools ...string) {
t.Helper()
for _, tool := range tools {
if _, err := exec.LookPath(tool); err != nil {
t.Skipf("skipping: required host tool %q not found: %v", tool, err)
}
}
}

View File

@@ -183,18 +183,25 @@ func newJobExecutor(info jobInfo, sf stepFactory, rc *RunContext) common.Executo
func setJobResult(ctx context.Context, info jobInfo, rc *RunContext, success bool) {
logger := common.Logger(ctx)
jobResult := "success"
// we have only one result for a whole matrix build, so we need
// to keep an existing result state if we run a matrix
if len(info.matrix()) > 0 && rc.Run.Job().Result != "" {
jobResult = rc.Run.Job().Result
}
// Matrix combinations share one *model.Job and run in parallel; serialize the
// read-modify-write of the job result so a failing combination is not lost-updated by a
// concurrent succeeding one.
job := rc.Run.Job()
jobResult := func() string {
defer lockJob(job)()
result := "success"
// we have only one result for a whole matrix build, so we need
// to keep an existing result state if we run a matrix
if len(info.matrix()) > 0 && job.Result != "" {
result = job.Result
}
if !success {
result = "failure"
}
info.result(result)
return result
}()
if !success {
jobResult = "failure"
}
info.result(jobResult)
if rc.caller != nil {
// set reusable workflow job result
rc.caller.setReusedWorkflowJobResult(rc.JobName, jobResult) // For Gitea
@@ -220,7 +227,11 @@ func setJobOutputs(ctx context.Context, rc *RunContext) {
callerOutputs[k] = ee.Interpolate(ctx, ee.Interpolate(ctx, v.Value))
}
rc.caller.runContext.Run.Job().Outputs = callerOutputs
// Matrix combinations of a reusable-workflow caller share the caller's *model.Job;
// serialize the write so parallel combos don't race on its Outputs field.
callerJob := rc.caller.runContext.Run.Job()
defer lockJob(callerJob)()
callerJob.Outputs = callerOutputs
}
}

View File

@@ -21,18 +21,13 @@ import (
)
func TestJobExecutor(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test")
}
// Dryrun only checks syntax/planning; all cases resolve locally, so this runs offline.
tables := []TestJobFileInfo{
{workdir, "uses-and-run-in-one-step", "push", "Invalid run/uses syntax for job:test step:Test", platforms, secrets},
{workdir, "uses-github-empty", "push", "Expected format {org}/{repo}[/path]@ref", platforms, secrets},
{workdir, "uses-github-noref", "push", "Expected format {org}/{repo}[/path]@ref", platforms, secrets},
{workdir, "uses-github-root", "push", "", platforms, secrets},
{workdir, "uses-github-path", "push", "", platforms, secrets},
{workdir, "uses-docker-url", "push", "", platforms, secrets},
{workdir, "uses-github-full-sha", "push", "", platforms, secrets},
{workdir, "uses-github-short-sha", "push", "Unable to resolve action `actions/hello-world-docker-action@b136eb8`, the provided ref `b136eb8` is the shortened version of a commit SHA, which is not supported. Please use the full commit SHA `b136eb8894c5cb1dd5807da824be97ccdf9b5423` instead", platforms, secrets},
{workdir, "job-nil-step", "push", "invalid Step 0: missing run or uses key", platforms, secrets},
}
// These tests are sufficient to only check syntax.

View File

@@ -10,6 +10,7 @@ import (
"fmt"
"net/url"
"path"
"path/filepath"
"regexp"
"strings"
@@ -27,7 +28,9 @@ func newLocalReusableWorkflowExecutor(rc *RunContext) common.Executor {
workflowDir = strings.TrimPrefix(workflowDir, "./")
return common.NewPipelineExecutor(
newReusableWorkflowExecutor(rc, workflowDir, fileName),
// resolve the local workflow against the workspace root, not the process
// working directory, so it is found regardless of where the runner is invoked
newReusableWorkflowExecutor(rc, filepath.Join(rc.Config.Workdir, workflowDir), fileName),
)
}
@@ -284,7 +287,11 @@ func setReusedWorkflowCallerResult(rc *RunContext, runner Runner) common.Executo
if rc.caller != nil {
rc.caller.setReusedWorkflowJobResult(rc.JobName, reusedWorkflowJobResult)
} else {
// Serialize this shared Job.Result write against the other matrix combos
// and setJobResult (same lockJob key).
unlock := lockJob(rc.Run.Job())
rc.result(reusedWorkflowJobResult)
unlock()
logger.WithField("jobResult", reusedWorkflowJobResult).Infof("Job %s", reusedWorkflowJobResultMessage)
}
}

View File

@@ -20,7 +20,9 @@ import (
"path/filepath"
"regexp"
"runtime"
"slices"
"strings"
"sync"
"time"
"gitea.com/gitea/runner/act/common"
@@ -55,6 +57,10 @@ type RunContext struct {
Masks []string
cleanUpJobContainer common.Executor
caller *caller // job calling this RunContext (reusable workflows)
// outputTemplate is this combination's pristine snapshot of the job's output expressions,
// captured before execution so each matrix combo interpolates from the originals rather
// than from a sibling's already-resolved values written into the shared Job.Outputs.
outputTemplate map[string]string
}
func (rc *RunContext) AddMask(mask string) {
@@ -130,17 +136,34 @@ func getDockerDaemonSocketMountPath(daemonPath string) string {
return daemonPath
}
// containerDaemonSocket returns the configured Docker daemon socket, applying the default
// without mutating the shared Config. Parallel jobs in a plan share one *Config, so a job
// must never write to it.
func (rc *RunContext) containerDaemonSocket() string {
if rc.Config.ContainerDaemonSocket == "" {
return "/var/run/docker.sock"
}
return rc.Config.ContainerDaemonSocket
}
// validVolumes returns the volumes allowed on this job's containers: the configured base
// plus the volumes the runner mounts automatically. It derives a fresh slice every call and
// never mutates the shared Config (see containerDaemonSocket).
func (rc *RunContext) validVolumes() []string {
name := rc.jobContainerName()
volumes := slices.Clone(rc.Config.ValidVolumes)
// TODO: add a new configuration to control whether the docker daemon can be mounted
return append(volumes, "act-toolcache", name, name+"-env",
getDockerDaemonSocketMountPath(rc.containerDaemonSocket()))
}
// Returns the binds and mounts for the container, resolving paths as appopriate
func (rc *RunContext) GetBindsAndMounts() ([]string, map[string]string) {
name := rc.jobContainerName()
if rc.Config.ContainerDaemonSocket == "" {
rc.Config.ContainerDaemonSocket = "/var/run/docker.sock"
}
binds := []string{}
if rc.Config.ContainerDaemonSocket != "-" {
daemonPath := getDockerDaemonSocketMountPath(rc.Config.ContainerDaemonSocket)
if daemonSocket := rc.containerDaemonSocket(); daemonSocket != "-" {
daemonPath := getDockerDaemonSocketMountPath(daemonSocket)
binds = append(binds, fmt.Sprintf("%s:%s", daemonPath, "/var/run/docker.sock"))
}
@@ -179,14 +202,6 @@ 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
}
@@ -432,7 +447,7 @@ func (rc *RunContext) startJobContainer() common.Executor {
Platform: rc.Config.ContainerArchitecture,
Options: rc.options(ctx),
AutoRemove: rc.Config.AutoRemove,
ValidVolumes: rc.Config.ValidVolumes,
ValidVolumes: rc.validVolumes(),
AllocatePTY: rc.Config.AllocatePTY,
})
if rc.JobContainer == nil {
@@ -586,14 +601,29 @@ func (rc *RunContext) ActionCacheDir() string {
}
// Interpolate outputs after a job is done
// jobMutexes serializes per-job result/output aggregation across the matrix combinations that
// share one *model.Job and run in parallel. Keyed by the shared *model.Job (mirrors the
// per-directory AcquireCloneLock pattern).
var jobMutexes sync.Map // key: *model.Job; value: *sync.Mutex
func lockJob(job *model.Job) func() {
v, _ := jobMutexes.LoadOrStore(job, &sync.Mutex{})
mu := v.(*sync.Mutex)
mu.Lock()
return mu.Unlock
}
func (rc *RunContext) interpolateOutputs() common.Executor {
return func(ctx context.Context) error {
ee := rc.NewExpressionEvaluator(ctx)
for k, v := range rc.Run.Job().Outputs {
interpolated := ee.Interpolate(ctx, v)
if v != interpolated {
rc.Run.Job().Outputs[k] = interpolated
}
job := rc.Run.Job()
// Matrix combinations share this Job and its Outputs map. Interpolate from this combo's
// pristine snapshot (outputTemplate) and write under the lock, so each combo overwrites
// with its own resolved values (last wins, as on GitHub) instead of the first combo's
// resolved values freezing the shared template against later combos.
defer lockJob(job)()
for k, v := range rc.outputTemplate {
job.Outputs[k] = ee.Interpolate(ctx, v)
}
return nil
}
@@ -660,7 +690,18 @@ func (rc *RunContext) result(result string) {
}
func (rc *RunContext) steps() []*model.Step {
return rc.Run.Job().Steps
// Return per-job copies of the steps. Matrix combinations run in parallel and share the
// workflow model, but step execution mutates per-job fields and evaluates the If/Env nodes
// in place, so the *model.Step instances must not be shared across jobs (see Step.Clone).
shared := rc.Run.Job().Steps
steps := make([]*model.Step, len(shared))
for i, step := range shared {
if step == nil {
continue
}
steps[i] = step.Clone()
}
return steps
}
// Executor returns a pipeline executor for all the steps in the job
@@ -737,12 +778,15 @@ func (rc *RunContext) runsOnPlatformNames(ctx context.Context) []string {
return []string{}
}
if err := rc.ExprEval.EvaluateYamlNode(ctx, &job.RawRunsOn); err != nil {
// Evaluate a copy: RawRunsOn is shared across parallel matrix jobs, so interpolating it in
// place would race and leak one matrix combination's runs-on into the others.
rawRunsOn := model.CloneYamlNode(job.RawRunsOn)
if err := rc.ExprEval.EvaluateYamlNode(ctx, &rawRunsOn); err != nil {
common.Logger(ctx).Errorf("Error while evaluating runs-on: %v", err)
return []string{}
}
return job.RunsOn()
return model.RunsOnFromNode(rawRunsOn)
}
func (rc *RunContext) platformImage(ctx context.Context) string {
@@ -1165,12 +1209,9 @@ func (rc *RunContext) handleServiceCredentials(ctx context.Context, creds map[st
// 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)
if daemonSocket := rc.containerDaemonSocket(); daemonSocket != "-" {
daemonPath := getDockerDaemonSocketMountPath(daemonSocket)
binds = append(binds, fmt.Sprintf("%s:%s", daemonPath, "/var/run/docker.sock"))
}

View File

@@ -281,6 +281,44 @@ func TestRunContext_GetBindsAndMounts(t *testing.T) {
})
}
func TestRunContextValidVolumes(t *testing.T) {
rc := &RunContext{
Name: "job",
Run: &model.Run{Workflow: &model.Workflow{Name: "wf"}},
Config: &Config{ValidVolumes: []string{"my-vol", "/host/path"}},
}
name := rc.jobContainerName()
got := rc.validVolumes()
// the configured volumes plus the four the runner mounts automatically
assert.Subset(t, got, []string{"my-vol", "/host/path", "act-toolcache", name, name + "-env", "/var/run/docker.sock"})
// deriving the list must never mutate or grow the shared Config slice: parallel matrix
// combinations share one *Config, and the previous in-place append was a data race.
assert.Equal(t, []string{"my-vol", "/host/path"}, rc.Config.ValidVolumes)
assert.Len(t, rc.validVolumes(), len(got), "repeated calls must be stable, not accumulate")
}
// TestInterpolateOutputsIsPerMatrixCombo guards the matrix-output fix: combinations share one
// *model.Job, so each must interpolate from its own pristine snapshot. Otherwise the first
// combo's resolved value freezes the shared template and later combos can't resolve their own.
func TestInterpolateOutputsIsPerMatrixCombo(t *testing.T) {
job := &model.Job{Outputs: map[string]string{"o": "${{ matrix.v }}"}}
run := &model.Run{JobID: "j", Workflow: &model.Workflow{Name: "w", Jobs: map[string]*model.Job{"j": job}}}
r := &runnerImpl{config: &Config{}}
ctx := context.Background()
rcA := r.newRunContext(ctx, run, map[string]any{"v": "a"})
rcB := r.newRunContext(ctx, run, map[string]any{"v": "b"})
require.NoError(t, rcA.interpolateOutputs()(ctx))
require.NoError(t, rcB.interpolateOutputs()(ctx))
// Last combo wins (matching GitHub) instead of being frozen to combo A's "a".
require.Equal(t, "b", job.Outputs["o"])
}
func TestGetGitHubContext(t *testing.T) {
log.SetLevel(log.DebugLevel)

View File

@@ -8,6 +8,7 @@ import (
"context"
"encoding/json"
"fmt"
"maps"
"os"
"runtime"
"sync"
@@ -250,7 +251,14 @@ func (runner *runnerImpl) NewPlanExecutor(plan *model.Plan) common.Executor {
return executor(common.WithJobErrorContainer(WithJobLogger(ctx, rc.Run.JobID, jobName, rc.Config, &rc.Masks, matrix)))
})
}
pipeline = append(pipeline, common.NewParallelExecutor(maxParallel, stageExecutor...))
// Run all matrix combinations of this job, then drop its aggregation mutex: the
// combos are the only users of it, so once they finish the jobMutexes entry can be
// released, keeping the map from growing unbounded over a long-lived runner.
stageParallel := common.NewParallelExecutor(maxParallel, stageExecutor...)
pipeline = append(pipeline, func(ctx context.Context) error {
defer jobMutexes.Delete(job)
return stageParallel(ctx)
})
}
// For pipeline execution:
@@ -334,6 +342,11 @@ func (runner *runnerImpl) newRunContext(ctx context.Context, run *model.Run, mat
}
rc.ExprEval = rc.NewExpressionEvaluator(ctx)
rc.Name = rc.ExprEval.Interpolate(ctx, run.String())
// Snapshot the job's pristine output expressions now, before any matrix combo runs and
// rewrites the shared Job.Outputs (see interpolateOutputs).
if job := run.Job(); job != nil {
rc.outputTemplate = maps.Clone(job.Outputs)
}
return rc
}

View File

@@ -188,14 +188,17 @@ func (j *TestJobFileInfo) runTest(ctx context.Context, t *testing.T, cfg *Config
EventPath: cfg.EventPath,
Platforms: j.platforms,
ReuseContainers: false,
ForceRebuild: true,
Env: cfg.Env,
Secrets: cfg.Secrets,
Inputs: cfg.Inputs,
GitHubInstance: "github.com",
DefaultActionInstance: cfg.DefaultActionInstance,
ContainerArchitecture: cfg.ContainerArchitecture,
ContainerMaxLifetime: time.Hour,
Matrix: cfg.Matrix,
ActionCache: cfg.ActionCache,
ValidVolumes: []string{"**"}, // allow workflow-declared volumes (e.g. container-volumes)
}
runner, err := New(runnerConfig)
@@ -223,18 +226,14 @@ type TestConfig struct {
}
func TestRunEvent(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test")
}
requireDocker(t)
ctx := context.Background()
tables := []TestJobFileInfo{
// Shells
{workdir, "shells/defaults", "push", "", platforms, secrets},
{workdir, "shells/pwsh", "push", "", map[string]string{"ubuntu-latest": "catthehacker/ubuntu:pwsh-latest"}, secrets}, // custom image with pwsh
{workdir, "shells/bash", "push", "", platforms, secrets},
{workdir, "shells/python", "push", "", map[string]string{"ubuntu-latest": "node:24-bookworm"}, secrets}, // slim doesn't have python
{workdir, "shells/sh", "push", "", platforms, secrets},
// Local action
@@ -246,11 +245,6 @@ func TestRunEvent(t *testing.T) {
// Uses
{workdir, "uses-composite", "push", "", platforms, secrets},
{workdir, "uses-composite-with-error", "push", "Job 'failing-composite-action' failed", platforms, secrets},
{workdir, "uses-nested-composite", "push", "", platforms, secrets},
{workdir, "remote-action-composite-js-pre-with-defaults", "push", "", platforms, secrets},
{workdir, "remote-action-composite-action-ref", "push", "", platforms, secrets},
{workdir, "uses-workflow", "push", "", platforms, map[string]string{"secret": "keep_it_private"}},
{workdir, "uses-workflow", "pull_request", "", platforms, map[string]string{"secret": "keep_it_private"}},
{workdir, "uses-docker-url", "push", "", platforms, secrets},
{workdir, "act-composite-env-test", "push", "", platforms, secrets},
@@ -260,21 +254,15 @@ func TestRunEvent(t *testing.T) {
{workdir, "evalmatrixneeds2", "push", "", platforms, secrets},
{workdir, "evalmatrix-merge-map", "push", "", platforms, secrets},
{workdir, "evalmatrix-merge-array", "push", "", platforms, secrets},
{workdir, "issue-1195", "push", "", platforms, secrets},
{workdir, "basic", "push", "", platforms, secrets},
{workdir, "fail", "push", "exit with `FAILURE`: 1", platforms, secrets},
{workdir, "runs-on", "push", "", platforms, secrets},
{workdir, "checkout", "push", "", platforms, secrets},
{workdir, "job-container", "push", "", platforms, secrets},
{workdir, "job-container-non-root", "push", "", platforms, secrets},
{workdir, "job-container-invalid-credentials", "push", "failed to handle credentials: failed to interpolate container.credentials.password", platforms, secrets},
{workdir, "container-hostname", "push", "", platforms, secrets},
{workdir, "remote-action-docker", "push", "", platforms, secrets},
{workdir, "remote-action-js", "push", "", platforms, secrets},
{workdir, "remote-action-js-node-user", "push", "", platforms, secrets}, // Test if this works with non root container
{workdir, "matrix", "push", "", platforms, secrets},
{workdir, "matrix-include-exclude", "push", "", platforms, secrets},
{workdir, "matrix-exitcode", "push", "Job 'test' failed", platforms, secrets},
{workdir, "commands", "push", "", platforms, secrets},
{workdir, "workdir", "push", "", platforms, secrets},
@@ -295,7 +283,6 @@ func TestRunEvent(t *testing.T) {
{workdir, "job-status-check", "push", "job 'fail' failed", platforms, secrets},
{workdir, "if-expressions", "push", "Job 'mytest' failed", platforms, secrets},
{workdir, "actions-environment-and-context-tests", "push", "", platforms, secrets},
{workdir, "uses-action-with-pre-and-post-step", "push", "", platforms, secrets},
{workdir, "evalenv", "push", "", platforms, secrets},
{workdir, "docker-action-custom-path", "push", "", platforms, secrets},
{workdir, "GITHUB_ENV-use-in-env-ctx", "push", "", platforms, secrets},
@@ -306,7 +293,6 @@ func TestRunEvent(t *testing.T) {
{workdir, "workflow_dispatch-scalar", "workflow_dispatch", "", platforms, secrets},
{workdir, "workflow_dispatch-scalar-composite-action", "workflow_dispatch", "", platforms, secrets},
{workdir, "job-needs-context-contains-result", "push", "", platforms, secrets},
{"../model/testdata", "strategy", "push", "", platforms, secrets}, // TODO: move all testdata into pkg so we can validate it with planner and runner
{"../model/testdata", "container-volumes", "push", "", platforms, secrets},
{workdir, "path-handling", "push", "", platforms, secrets},
{workdir, "do-not-leak-step-env-in-composite", "push", "", platforms, secrets},
@@ -316,7 +302,6 @@ func TestRunEvent(t *testing.T) {
// services
{workdir, "services", "push", "", platforms, secrets},
{workdir, "services-host-network", "push", "", platforms, secrets},
{workdir, "services-with-container", "push", "", platforms, secrets},
// local remote action overrides
@@ -325,6 +310,11 @@ func TestRunEvent(t *testing.T) {
for _, table := range tables {
t.Run(table.workflowPath, func(t *testing.T) {
if table.workflowPath == "container-volumes" {
// host /proc bind mounts are Linux-Docker-only
requireLinuxDocker(t)
}
config := &Config{
Secrets: table.secrets,
}
@@ -356,9 +346,12 @@ func TestRunEvent(t *testing.T) {
}
func TestRunEventHostEnvironment(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test")
}
// Runs steps directly on the host (the "-self-hosted" platform), so it needs the shells
// and tools the workflows invoke. No network gate: every action these workflows reference
// is a local `./` fixture or the skipped actions/checkout, so the suite runs offline (same
// as TestRunEvent). Only the broadly-used interpreters are required up front; the pwsh- and
// nix-specific cases gate on their own tool below so a missing pwsh/nix skips just those.
requireHostTools(t, "bash", "node")
ctx := context.Background()
@@ -374,7 +367,6 @@ func TestRunEventHostEnvironment(t *testing.T) {
{workdir, "shells/defaults", "push", "", platforms, secrets},
{workdir, "shells/pwsh", "push", "", platforms, secrets},
{workdir, "shells/bash", "push", "", platforms, secrets},
{workdir, "shells/python", "push", "", platforms, secrets},
{workdir, "shells/sh", "push", "", platforms, secrets},
// Local action
@@ -383,7 +375,6 @@ func TestRunEventHostEnvironment(t *testing.T) {
// Uses
{workdir, "uses-composite", "push", "", platforms, secrets},
{workdir, "uses-composite-with-error", "push", "Job 'failing-composite-action' failed", platforms, secrets},
{workdir, "uses-nested-composite", "push", "", platforms, secrets},
{workdir, "act-composite-env-test", "push", "", platforms, secrets},
// Eval
@@ -392,14 +383,10 @@ func TestRunEventHostEnvironment(t *testing.T) {
{workdir, "evalmatrixneeds2", "push", "", platforms, secrets},
{workdir, "evalmatrix-merge-map", "push", "", platforms, secrets},
{workdir, "evalmatrix-merge-array", "push", "", platforms, secrets},
{workdir, "issue-1195", "push", "", platforms, secrets},
{workdir, "fail", "push", "exit with `FAILURE`: 1", platforms, secrets},
{workdir, "runs-on", "push", "", platforms, secrets},
{workdir, "checkout", "push", "", platforms, secrets},
{workdir, "remote-action-js", "push", "", platforms, secrets},
{workdir, "matrix", "push", "", platforms, secrets},
{workdir, "matrix-include-exclude", "push", "", platforms, secrets},
{workdir, "commands", "push", "", platforms, secrets},
{workdir, "defaults-run", "push", "", platforms, secrets},
{workdir, "composite-fail-with-output", "push", "", platforms, secrets},
@@ -413,7 +400,6 @@ func TestRunEventHostEnvironment(t *testing.T) {
{workdir, "steps-context/outcome", "push", "", platforms, secrets},
{workdir, "job-status-check", "push", "job 'fail' failed", platforms, secrets},
{workdir, "if-expressions", "push", "Job 'mytest' failed", platforms, secrets},
{workdir, "uses-action-with-pre-and-post-step", "push", "", platforms, secrets},
{workdir, "evalenv", "push", "", platforms, secrets},
{workdir, "ensure-post-steps", "push", "Job 'second-post-step-should-fail' failed", platforms, secrets},
}...)
@@ -446,24 +432,26 @@ func TestRunEventHostEnvironment(t *testing.T) {
for _, table := range tables {
t.Run(table.workflowPath, func(t *testing.T) {
switch table.workflowPath {
case "shells/pwsh":
requireHostTools(t, "pwsh")
case "nix-prepend-path":
requireHostTools(t, "nix")
}
table.runTest(ctx, t, &Config{})
})
}
}
func TestDryrunEvent(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test")
}
// Dryrun plans without containers or network (shells and local actions only).
ctx := common.WithDryrun(context.Background(), true)
tables := []TestJobFileInfo{
// Shells
{workdir, "shells/defaults", "push", "", platforms, secrets},
{workdir, "shells/pwsh", "push", "", map[string]string{"ubuntu-latest": "catthehacker/ubuntu:pwsh-latest"}, secrets}, // custom image with pwsh
{workdir, "shells/pwsh", "push", "", platforms, secrets},
{workdir, "shells/bash", "push", "", platforms, secrets},
{workdir, "shells/python", "push", "", map[string]string{"ubuntu-latest": "node:24-bookworm"}, secrets}, // slim doesn't have python
{workdir, "shells/sh", "push", "", platforms, secrets},
// Local action
@@ -480,10 +468,18 @@ func TestDryrunEvent(t *testing.T) {
}
}
// TestReusableWorkflowCaller exercises the reusable-workflow caller path against a local
// reusable workflow (typed inputs, secrets as both a map and `inherit`, and reading the called
// workflow's outputs via `needs`).
func TestReusableWorkflowCaller(t *testing.T) {
requireDocker(t)
table := TestJobFileInfo{workdir, "uses-workflow", "push", "", platforms, map[string]string{"secret": "keep_it_private"}}
table.runTest(context.Background(), t, &Config{Secrets: table.secrets})
}
func TestDockerActionForcePullForceRebuild(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test")
}
requireDocker(t)
requireNetwork(t) // force-pulls a docker action image
ctx := context.Background()
@@ -504,22 +500,6 @@ func TestDockerActionForcePullForceRebuild(t *testing.T) {
}
}
func TestRunDifferentArchitecture(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test")
}
tjfi := TestJobFileInfo{
workdir: workdir,
workflowPath: "basic",
eventName: "push",
errorMessage: "",
platforms: platforms,
}
tjfi.runTest(context.Background(), t, &Config{ContainerArchitecture: "linux/arm64"})
}
type maskJobLoggerFactory struct {
Output bytes.Buffer
}
@@ -540,9 +520,7 @@ func TestMaskValues(t *testing.T) {
assert.False(t, strings.Contains(text, "composite secret")) //nolint:testifylint // pre-existing issue from nektos/act
}
if testing.Short() {
t.Skip("skipping integration test")
}
requireDocker(t)
log.SetLevel(log.DebugLevel)
@@ -563,9 +541,7 @@ func TestMaskValues(t *testing.T) {
}
func TestRunEventSecrets(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test")
}
requireDocker(t)
workflowPath := "secrets"
tjfi := TestJobFileInfo{
@@ -585,9 +561,7 @@ func TestRunEventSecrets(t *testing.T) {
}
func TestRunWithService(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test")
}
requireDocker(t)
log.SetLevel(log.DebugLevel)
ctx := context.Background()
@@ -603,10 +577,11 @@ func TestRunWithService(t *testing.T) {
assert.NoError(t, err, workflowPath) //nolint:testifylint // pre-existing issue from nektos/act
runnerConfig := &Config{
Workdir: workdir,
EventName: eventName,
Platforms: platforms,
ReuseContainers: false,
Workdir: workdir,
EventName: eventName,
Platforms: platforms,
ReuseContainers: false,
ContainerMaxLifetime: time.Hour, // otherwise the job container is `sleep 0` and exits at once
}
runner, err := New(runnerConfig)
assert.NoError(t, err, workflowPath) //nolint:testifylint // pre-existing issue from nektos/act
@@ -622,9 +597,7 @@ func TestRunWithService(t *testing.T) {
}
func TestRunActionInputs(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test")
}
requireDocker(t)
workflowPath := "input-from-cli"
tjfi := TestJobFileInfo{
@@ -643,9 +616,7 @@ func TestRunActionInputs(t *testing.T) {
}
func TestRunEventPullRequest(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test")
}
requireDocker(t)
workflowPath := "pull-request"
@@ -661,9 +632,7 @@ func TestRunEventPullRequest(t *testing.T) {
}
func TestRunMatrixWithUserDefinedInclusions(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test")
}
requireDocker(t)
workflowPath := "matrix-with-user-inclusions"
tjfi := TestJobFileInfo{

View File

@@ -291,7 +291,9 @@ type remoteAction struct {
func (ra *remoteAction) CloneURL(u string) string {
if ra.URL == "" {
if !strings.HasPrefix(u, "http://") && !strings.HasPrefix(u, "https://") {
// keep an absolute local path as-is (used by tests to resolve actions from a local
// repo); only bare host names get the https:// scheme prepended
if !strings.HasPrefix(u, "http://") && !strings.HasPrefix(u, "https://") && !filepath.IsAbs(u) {
u = "https://" + u
}
} else {

View File

@@ -138,7 +138,7 @@ func (sd *stepDocker) newStepContainer(ctx context.Context, image string, cmd, e
UsernsMode: rc.Config.UsernsMode,
Platform: rc.Config.ContainerArchitecture,
AutoRemove: rc.Config.AutoRemove,
ValidVolumes: rc.Config.ValidVolumes,
ValidVolumes: rc.validVolumes(),
AllocatePTY: rc.Config.AllocatePTY,
})
return stepContainer

View File

@@ -0,0 +1,34 @@
name: local-reusable-workflow
on:
workflow_call:
inputs:
string_required:
required: true
type: string
bool_required:
required: true
type: boolean
number_required:
required: true
type: number
secrets:
secret:
required: true
outputs:
output:
value: ${{ jobs.reusable.outputs.output }}
jobs:
reusable:
runs-on: ubuntu-latest
outputs:
output: ${{ steps.gen.outputs.output }}
steps:
- name: check inputs and secret arrived
run: |
[ "${{ inputs.string_required }}" = "string" ]
[ "${{ inputs.bool_required }}" = "true" ]
[ "${{ inputs.number_required }}" = "1" ]
[ "${{ secrets.secret }}" = "keep_it_private" ]
- id: gen
run: echo "output=${{ inputs.string_required }}" >> $GITHUB_OUTPUT

View File

@@ -5,10 +5,11 @@ jobs:
env:
MYGLOBALENV3: myglobalval3
steps:
- uses: actions/checkout@v4
- run: |
echo MYGLOBALENV1=myglobalval1 > $GITHUB_ENV
echo "::set-env name=MYGLOBALENV2::myglobalval2"
- uses: nektos/act-test-actions/script@main
- uses: ./actions/script
with:
main: |
env

View File

@@ -1,48 +1,31 @@
on: push
jobs:
# State saved in main (via the $GITHUB_STATE file and the ::save-state command) must surface
# as $STATE_* in the action's post step.
_:
runs-on: ubuntu-latest
steps:
- uses: nektos/act-test-actions/script@main
- uses: actions/checkout@v4
- uses: ./actions/script
with:
pre: |
env
echo mystate0=mystateval > $GITHUB_STATE
echo "::save-state name=mystate1::mystateval"
main: |
env
echo mystate2=mystateval > $GITHUB_STATE
echo "::save-state name=mystate3::mystateval"
post: |
env
[ "$STATE_mystate0" = "mystateval" ]
[ "$STATE_mystate1" = "mystateval" ]
[ "$STATE_mystate2" = "mystateval" ]
[ "$STATE_mystate3" = "mystateval" ]
# State must be isolated per action instance even when two steps use the same action.
test-id-collision-bug:
runs-on: ubuntu-latest
steps:
- uses: nektos/act-test-actions/script@main
- uses: actions/checkout@v4
- uses: ./actions/script
id: script
with:
pre: |
env
echo mystate0=mystateval > $GITHUB_STATE
echo "::save-state name=mystate1::mystateval"
main: |
env
echo mystate2=mystateval > $GITHUB_STATE
echo "::save-state name=mystate3::mystateval"
post: |
env
[ "$STATE_mystate0" = "mystateval" ]
[ "$STATE_mystate1" = "mystateval" ]
[ "$STATE_mystate2" = "mystateval" ]
[ "$STATE_mystate3" = "mystateval" ]
- uses: nektos/act-test-actions/script@main
main: echo mystate=val1 > $GITHUB_STATE
post: '[ "$STATE_mystate" = "val1" ]'
- uses: ./actions/script
id: pre-script
with:
main: |
env
echo mystate0=mystateerror > $GITHUB_STATE
echo "::save-state name=mystate1::mystateerror"
main: echo mystate=val2 > $GITHUB_STATE
post: '[ "$STATE_mystate" = "val2" ]'

View File

@@ -9,7 +9,3 @@ jobs:
- uses: actions/checkout@v3
- uses: './actions-environment-and-context-tests/js'
- uses: './actions-environment-and-context-tests/docker'
- uses: 'nektos/act-test-actions/js@main'
- uses: 'nektos/act-test-actions/docker@main'
- uses: 'nektos/act-test-actions/docker-file@main'
- uses: 'nektos/act-test-actions/docker-relative-context/action@main'

View File

@@ -0,0 +1,15 @@
name: 'script'
description: 'Run the shell scripts passed as inputs across the pre/main/post lifecycle'
inputs:
main:
description: 'shell script to run in the main step'
required: false
default: ''
post:
description: 'shell script to run in the post step'
required: false
default: ''
runs:
using: 'node24'
main: 'index.js'
post: 'post.js'

View File

@@ -0,0 +1,9 @@
import {execFileSync} from 'node:child_process';
// Run the `main` input as a bash script; its stdout (workflow commands like
// ::set-output / ::save-state) and $GITHUB_ENV / $GITHUB_STATE writes are
// processed by the runner, exactly like the remote script action this replaces.
const script = process.env.INPUT_MAIN;
if (script) {
execFileSync('bash', ['-eo', 'pipefail', '-c', script], {stdio: 'inherit'});
}

View File

@@ -0,0 +1,5 @@
{
"name": "script",
"private": true,
"type": "module"
}

View File

@@ -0,0 +1,6 @@
import {execFileSync} from 'node:child_process';
const script = process.env.INPUT_POST;
if (script) {
execFileSync('bash', ['-eo', 'pipefail', '-c', script], {stdio: 'inherit'});
}

View File

@@ -4,7 +4,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- run: |
FROM ubuntu:latest
FROM node:24-bookworm-slim
ENV PATH="/opt/texlive/texdir/bin/x86_64-linuxmusl:${PATH}"
ENV ORG_PATH="${PATH}"
ENTRYPOINT [ "bash", "-c", "echo \"PATH=$PATH\" && echo \"ORG_PATH=$ORG_PATH\" && [[ \"$PATH\" = \"$ORG_PATH\" ]]" ]

View File

@@ -1,13 +0,0 @@
on: push
env:
variable: "${{ github.repository_owner }}"
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: print env.variable
run: |
echo ${{ env.variable }}
exit ${{ (env.variable == 'nektos') && '0' || '1'}}

View File

@@ -9,24 +9,13 @@ jobs:
steps:
- name: My first false step
if: "endsWith('Should not', 'o1')"
uses: actions/checkout@v2.0.0
with:
ref: refs/pull/${{github.event.pull_request.number}}/merge
fetch-depth: 5
run: exit 1
- name: My first true step
if: ${{endsWith('Hello world', 'ld')}}
uses: actions/hello-world-javascript-action@main
with:
who-to-greet: "Renst the Octocat"
run: echo "Renst the Octocat"
- name: My second false step
if: "endsWith('Should not evaluate', 'o2')"
uses: actions/checkout@v2.0.0
with:
ref: refs/pull/${{github.event.pull_request.number}}/merge
fetch-depth: 5
run: exit 1
- name: My third false step
if: ${{endsWith('Should not evaluate', 'o3')}}
uses: actions/checkout@v2.0.0
with:
ref: refs/pull/${{github.event.pull_request.number}}/merge
fetch-depth: 5
run: exit 1

View File

@@ -1,31 +1,21 @@
name: issue-598
on: push
jobs:
my_first_job:
runs-on: ubuntu-latest
steps:
- name: My first false step
if: "endsWith('Hello world', 'o1')"
uses: actions/hello-world-javascript-action@main
with:
who-to-greet: 'Mona the Octocat'
run: exit 1
- name: My first true step
if: "!endsWith('Hello world', 'od')"
uses: actions/hello-world-javascript-action@main
with:
who-to-greet: "Renst the Octocat"
run: echo "Renst the Octocat"
- name: My second false step
if: "endsWith('Hello world', 'o2')"
uses: actions/hello-world-javascript-action@main
with:
who-to-greet: 'Act the Octocat'
run: exit 1
- name: My third false step
if: "endsWith('Hello world', 'o2')"
uses: actions/hello-world-javascript-action@main
with:
who-to-greet: 'Git the Octocat'
run: exit 1

View File

@@ -5,6 +5,7 @@ jobs:
test:
runs-on: ubuntu-latest
container:
image: catthehacker/ubuntu:runner-latest # image with user 'runner:runner' built on tag 'act-latest'
image: node:24-bookworm-slim
options: --user 1000
steps:
- run: echo PASS

View File

@@ -24,4 +24,3 @@ jobs:
args: ${{format('"{0}"', 'Mona is not the Octocat') }}
who-to-greet: 'Mona the Octocat'
- run: '[[ "${{ env.SOMEVAR }}" == "Mona is not the Octocat" ]]'
- uses: ./localdockerimagetest_

View File

@@ -30,11 +30,6 @@ runs:
who-to-greet: ${{inputs.who-to-greet}}
- run: '[[ "${{ env.SOMEVAR }}" == "Mona is not the Octocat" ]]'
shell: bash
- uses: ./localdockerimagetest_
# Also test a remote docker action here
- uses: actions/hello-world-docker-action@v2
with:
who-to-greet: 'Mona the Octocat'
# Test if GITHUB_ACTION_PATH is set correctly after all steps
- run: stat $GITHUB_ACTION_PATH/push.yml
shell: bash

View File

@@ -5,5 +5,5 @@ jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: nektos/test-override@a
- uses: https://github.com/nektos/test-override@a
- uses: nektos/test-override@b

View File

@@ -1,31 +0,0 @@
name: matrix-include-exclude
on: push
jobs:
build:
name: PHP ${{ matrix.os }} ${{ matrix.node}}
runs-on: ${{ matrix.os }}
steps:
- run: echo ${NODE_VERSION} | grep ${{ matrix.node }}
env:
NODE_VERSION: ${{ matrix.node }}
strategy:
matrix:
os: [ubuntu-18.04, macos-latest]
node: [4, 6, 8, 10]
exclude:
- os: macos-latest
node: 4
include:
- os: ubuntu-16.04
node: 10
test:
runs-on: ubuntu-latest
strategy:
matrix:
node: [8.x, 10.x, 12.x, 13.x]
steps:
- run: echo ${NODE_VERSION} | grep ${{ matrix.node }}
env:
NODE_VERSION: ${{ matrix.node }}

View File

@@ -18,12 +18,4 @@ jobs:
runs:
using: composite
shell: cp {0} action.yml
- uses: ./
remote-invalid-step:
runs-on: ubuntu-latest
steps:
- uses: nektos/act-test-actions/invalid-composite-action/invalid-step@main
remote-missing-steps:
runs-on: ubuntu-latest
steps:
- uses: nektos/act-test-actions/invalid-composite-action/missing-steps@main
- uses: ./

View File

@@ -27,7 +27,7 @@ jobs:
exit 1
fi
- uses: nektos/act-test-actions/composite@main
- uses: ./path-handling/
with:
input: some input

View File

@@ -1,8 +0,0 @@
name: remote-action-composite-action-ref
on: push
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: nektos/act-test-actions/composite-assert-action-ref-action@main

View File

@@ -1,23 +0,0 @@
name: remote-action-composite-js-pre-with-defaults
on: push
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: nektos/act-test-actions/composite-js-pre-with-defaults/js@main
with:
in: nix
- uses: nektos/act-test-actions/composite-js-pre-with-defaults@main
with:
in: secretval
- uses: nektos/act-test-actions/composite-js-pre-with-defaults@main
with:
in: secretval
- uses: nektos/act-test-actions/composite-js-pre-with-defaults/js@main
with:
pre: "true"
in: nix
- uses: nektos/act-test-actions/composite-js-pre-with-defaults/js@main
with:
in: nix

View File

@@ -1,10 +0,0 @@
name: remote-action-docker
on: push
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/hello-world-docker-action@v1
with:
who-to-greet: 'Mona the Octocat'

View File

@@ -1,30 +0,0 @@
name: remote-action-js
on: push
jobs:
test:
runs-on: ubuntu-latest
container:
image: node:24-bookworm-slim
options: --user node
steps:
- name: check permissions of env files
id: test
run: |
echo "USER: $(id -un) expected: node"
[[ "$(id -un)" = "node" ]]
echo "TEST=Value" >> $GITHUB_OUTPUT
shell: bash
- name: check if file command worked
if: steps.test.outputs.test != 'Value'
run: |
echo "steps.test.outputs.test=${{ steps.test.outputs.test || 'missing value!' }}"
exit 1
shell: bash
- uses: actions/hello-world-javascript-action@v1
with:
who-to-greet: 'Mona the Octocat'
- uses: cloudposse/actions/github/slash-command-dispatch@0.14.0

View File

@@ -1,12 +0,0 @@
name: remote-action-js
on: push
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/hello-world-javascript-action@v1
with:
who-to-greet: 'Mona the Octocat'
- uses: cloudposse/actions/github/slash-command-dispatch@0.14.0

View File

@@ -1,24 +0,0 @@
name: runs-on
on: push
jobs:
test:
runs-on: ubuntu-latest
steps:
- run: env
- run: echo ${GITHUB_ACTOR}
- run: echo ${GITHUB_ACTOR} | grep nektos/act
many:
runs-on: [ubuntu-latest]
steps:
- run: env
- run: echo ${GITHUB_ACTOR}
- run: echo ${GITHUB_ACTOR} | grep nektos/act
selfmany:
runs-on: [self-hosted, ubuntu-latest]
steps:
- run: env
- run: echo ${GITHUB_ACTOR}
- run: echo ${GITHUB_ACTOR} | grep nektos/act

View File

@@ -1,14 +0,0 @@
name: services-host-network
on: push
jobs:
services-host-network:
runs-on: ubuntu-latest
services:
nginx:
image: "nginx:latest"
ports:
- "8080:80"
steps:
- run: apt-get -qq update && apt-get -yqq install --no-install-recommends curl net-tools
- run: netstat -tlpen
- run: curl -v http://localhost:8080

View File

@@ -5,12 +5,11 @@ jobs:
runs-on: ubuntu-latest
# https://docs.github.com/en/actions/using-containerized-services/about-service-containers#running-jobs-in-a-container
container:
image: "ubuntu:latest"
image: "node:24-bookworm-slim"
services:
nginx:
image: "nginx:latest"
ports:
- "8080:80"
image: "nginx:alpine"
steps:
- run: apt-get -qq update && apt-get -yqq install --no-install-recommends curl
# reach the service over the shared job network by its alias, no host port needed
- run: curl -v http://nginx:80

View File

@@ -6,18 +6,9 @@ jobs:
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
image: nginx:alpine
ports:
- 5432:5432
- 80
steps:
- name: Echo the Postgres service ID / Network / Ports
run: |

View File

@@ -8,13 +8,6 @@ jobs:
- shell: ${{ env.MY_SHELL }}
run: |
$PSVersionTable
check-container:
runs-on: ubuntu-latest
container: catthehacker/ubuntu:pwsh-latest
steps:
- shell: ${{ env.MY_SHELL }}
run: |
$PSVersionTable
check-job-default:
runs-on: ubuntu-latest
defaults:

View File

@@ -1,28 +0,0 @@
on: push
env:
MY_SHELL: python
jobs:
check:
runs-on: ubuntu-latest
steps:
- shell: ${{ env.MY_SHELL }}
run: |
import platform
print(platform.python_version())
check-container:
runs-on: ubuntu-latest
container: node:24-bookworm
steps:
- shell: ${{ env.MY_SHELL }}
run: |
import platform
print(platform.python_version())
check-job-default:
runs-on: ubuntu-latest
defaults:
run:
shell: ${{ env.MY_SHELL }}
steps:
- run: |
import platform
print(platform.python_version())

View File

@@ -1,7 +0,0 @@
name: "last action check"
description: "last action check"
runs:
using: "node24"
main: main.js
post: post.js

View File

@@ -1,17 +0,0 @@
const pre = process.env['ACTION_OUTPUT_PRE'];
const main = process.env['ACTION_OUTPUT_MAIN'];
const post = process.env['ACTION_OUTPUT_POST'];
console.log({pre, main, post});
if (pre !== 'pre') {
throw new Error(`Expected 'pre' but got '${pre}'`);
}
if (main !== 'main') {
throw new Error(`Expected 'main' but got '${main}'`);
}
if (post !== 'post') {
throw new Error(`Expected 'post' but got '${post}'`);
}

View File

@@ -1,15 +0,0 @@
name: uses-action-with-pre-and-post-step
on: push
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: ./uses-action-with-pre-and-post-step/last-action
- uses: nektos/act-test-actions/js-with-pre-and-post-step@main
with:
pre: true
post: true
- run: |
cat $GITHUB_ENV

View File

@@ -1,7 +0,0 @@
name: uses-github-root
on: push
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/hello-world-docker-action@b136eb8894c5cb1dd5807da824be97ccdf9b5423

View File

@@ -1,7 +0,0 @@
name: uses-github-path
on: push
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: sergioramos/yarn-actions/install@v6

View File

@@ -1,7 +0,0 @@
name: uses-github-root
on: push
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/hello-world-docker-action@b136eb8

View File

@@ -1,63 +0,0 @@
---
name: "Test Composite Action"
description: "Test action uses composite"
inputs:
test_input_optional:
description: Test
runs:
using: "composite"
steps:
- uses: actions/setup-node@v6
with:
node-version: '24'
- run: |
console.log(process.version);
console.log("Hi from node");
console.log("${{ inputs.test_input_optional }}");
if("${{ inputs.test_input_optional }}" !== "Test") {
console.log("Invalid input test_input_optional expected \"Test\" as value");
process.exit(1);
}
if(!process.version.startsWith('v16')) {
console.log("Expected node v16, but got " + process.version);
process.exit(1);
}
shell: node {0}
- uses: ./uses-composite/composite_action
id: composite
with:
test_input_required: 'test_input_required_value'
test_input_optional: 'test_input_optional_value'
test_input_optional_with_default_overriden: 'test_input_optional_with_default_overriden'
test_input_required_with_default: 'test_input_optional_value'
test_input_required_with_default_overriden: 'test_input_required_with_default_overriden'
secret_input: ${{inputs.test_input_optional}}
env:
secret_input: ${{inputs.test_input_optional}}
- run: |
echo "steps.composite.outputs.test_output=${{ steps.composite.outputs.test_output }}"
[[ "${{steps.composite.outputs.test_output == 'test_output_value'}}" = "true" ]] || exit 1
shell: bash
- run: |
echo "steps.composite.outputs.secret_output=${{ steps.composite.outputs.secret_output }}"
[[ "${{steps.composite.outputs.secret_output == format('{0}/{0}', inputs.test_input_optional)}}" = "true" ]] || exit 1
shell: bash
# Now test again with default values
- name: ./uses-composite/composite_action with defaults
uses: ./uses-composite/composite_action
id: composite2
with:
test_input_required: 'test_input_required_value'
test_input_optional_with_default_overriden: 'test_input_optional_with_default_overriden'
test_input_required_with_default_overriden: 'test_input_required_with_default_overriden'
- run: |
echo "steps.composite2.outputs.test_output=${{ steps.composite2.outputs.test_output }}"
[[ "${{steps.composite2.outputs.test_output == 'test_output_value'}}" = "true" ]] || exit 1
shell: bash
- run: |
echo "steps.composite.outputs.secret_output=$COMPOSITE_ACTION_ENV_OUTPUT"
[[ "${{env.COMPOSITE_ACTION_ENV_OUTPUT == 'my test value' }}" = "true" ]] || exit 1
shell: bash

View File

@@ -1,15 +0,0 @@
name: uses-docker-url
on: push
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: ./uses-nested-composite/composite_action2
with:
test_input_optional: Test
- run: |
echo "steps.composite.outputs.secret_output=$COMPOSITE_ACTION_ENV_OUTPUT"
[[ "${{env.COMPOSITE_ACTION_ENV_OUTPUT == 'my test value' }}" = "true" ]] || exit 1
shell: bash

View File

@@ -1,42 +0,0 @@
name: local-reusable-workflows
on: pull_request
jobs:
reusable-workflow:
uses: ./.github/workflows/local-reusable-workflow.yml
with:
string_required: string
bool_required: ${{ true }}
number_required: 1
secrets:
secret: keep_it_private
reusable-workflow-with-inherited-secrets:
uses: ./.github/workflows/local-reusable-workflow.yml
with:
string_required: string
bool_required: ${{ true }}
number_required: 1
secrets: inherit
reusable-workflow-with-on-string-notation:
uses: ./.github/workflows/local-reusable-workflow-no-inputs-string.yml
reusable-workflow-with-on-array-notation:
uses: ./.github/workflows/local-reusable-workflow-no-inputs-array.yml
output-test:
runs-on: ubuntu-latest
needs:
- reusable-workflow
- reusable-workflow-with-inherited-secrets
steps:
- name: output with secrets map
run: |
echo reusable-workflow.output=${{ needs.reusable-workflow.outputs.output }}
[[ "${{ needs.reusable-workflow.outputs.output == 'string' }}" = "true" ]] || exit 1
- name: output with inherited secrets
run: |
echo reusable-workflow-with-inherited-secrets.output=${{ needs.reusable-workflow-with-inherited-secrets.outputs.output }}
[[ "${{ needs.reusable-workflow-with-inherited-secrets.outputs.output == 'string' }}" = "true" ]] || exit 1

View File

@@ -1,8 +1,11 @@
on: push
# Exercises the reusable-workflow caller path against a local reusable workflow: passing typed
# inputs and secrets (both an explicit map and `inherit`), and reading the called workflow's
# outputs back through `needs`.
jobs:
reusable-workflow:
uses: nektos/act-test-actions/.github/workflows/reusable-workflow.yml@main
uses: ./.github/workflows/local-reusable-workflow.yml
with:
string_required: string
bool_required: ${{ true }}
@@ -11,7 +14,7 @@ jobs:
secret: keep_it_private
reusable-workflow-with-inherited-secrets:
uses: nektos/act-test-actions/.github/workflows/reusable-workflow.yml@main
uses: ./.github/workflows/local-reusable-workflow.yml
with:
string_required: string
bool_required: ${{ true }}
@@ -24,12 +27,5 @@ jobs:
- reusable-workflow
- reusable-workflow-with-inherited-secrets
steps:
- name: output with secrets map
run: |
echo reusable-workflow.output=${{ needs.reusable-workflow.outputs.output }}
[[ "${{ needs.reusable-workflow.outputs.output == 'string' }}" = "true" ]] || exit 1
- name: output with inherited secrets
run: |
echo reusable-workflow-with-inherited-secrets.output=${{ needs.reusable-workflow-with-inherited-secrets.outputs.output }}
[[ "${{ needs.reusable-workflow-with-inherited-secrets.outputs.output == 'string' }}" = "true" ]] || exit 1
- run: '[[ "${{ needs.reusable-workflow.outputs.output == ''string'' }}" = "true" ]] || exit 1'
- run: '[[ "${{ needs.reusable-workflow-with-inherited-secrets.outputs.output == ''string'' }}" = "true" ]] || exit 1'