fix: job container, daemon startup, action cache and exec fixes (#1294)

- Keep `GITHUB_ENV`, `GITHUB_OUTPUT`, `GITHUB_STATE` and `GITHUB_PATH` when a job times out or is cancelled, so post steps still get their state
- Retry the startup Declare while Gitea is unreachable instead of exiting, still failing fast on rejected registrations, auth and misconfiguration
- Reuse a local image when a platform variant such as `linux/arm/v7` is requested instead of pulling again
- Read `PATH` from the created job container so cross-architecture jobs get tool paths for the right architecture
- Extract action copies relative to the act volume so they never cross an image's `/var/run` symlink, which rootless Podman rejects
- Keep the job container's entrypoint when container options set `--entrypoint`, warning instead of appending it to the keep-alive command
- Lock action cache entries across runner processes that share a cache directory
- `exec`: omit `GITHUB_SERVER_URL` and `GITHUB_API_URL` without `--gitea-instance` instead of exporting a bare `https://`, and read owner and repository from any origin remote

Reviewed-on: https://gitea.com/gitea/runner/pulls/1294
Reviewed-by: bircni <bircni@icloud.com>
Co-authored-by: silverwind <me@silverwind.io>
This commit is contained in:
silverwind
2026-10-10 18:11:03 +00:00
committed by bircni
parent b741335222
commit ad9eeb668a
23 changed files with 537 additions and 308 deletions

View File

@@ -145,7 +145,11 @@ func maybeCopyToActionDir(ctx context.Context, step actionStep, actionDir, actio
containerActionDirCopy += `/`
}
defer git.AcquireCloneLock(actionDir)()
unlock, err := git.AcquireCloneLock(ctx, actionDir)
if err != nil {
return err
}
defer unlock()
if !rc.Config.NoActionPatch {
// A concurrent job's prepare resets this directory, so patch under the copy's lock.
@@ -366,7 +370,11 @@ func execAsDocker(ctx context.Context, step actionStep, actionName, actionDir, b
// Held across the whole build: the daemon drains contextDir lazily.
inner := prepImage
prepImage = func(ctx context.Context) error {
defer git.AcquireCloneLock(actionDir)()
unlock, err := git.AcquireCloneLock(ctx, actionDir)
if err != nil {
return err
}
defer unlock()
return inner(ctx)
}
}

View File

@@ -143,6 +143,7 @@ runs:
}
func TestActionRunner(t *testing.T) {
t.Chdir(t.TempDir())
table := []struct {
name string
step actionStep
@@ -366,7 +367,10 @@ func TestMaybeCopyToActionDirHoldsCloneLock(t *testing.T) {
peerAcquired := make(chan struct{})
go func() {
unlock := git.AcquireCloneLock(actionDir)
unlock, err := git.AcquireCloneLock(t.Context(), actionDir)
if !assert.NoError(t, err) {
return
}
close(peerAcquired)
unlock()
}()
@@ -400,7 +404,9 @@ func TestMaybeCopyToActionDirHoldsCloneLock(t *testing.T) {
func TestExecAsDockerHoldsCloneLockForRemoteUncached(t *testing.T) {
actionDir := t.TempDir()
unlockOnce := sync.OnceFunc(git.AcquireCloneLock(actionDir))
unlock, err := git.AcquireCloneLock(t.Context(), actionDir)
require.NoError(t, err)
unlockOnce := sync.OnceFunc(unlock)
defer unlockOnce()
innerEntered := make(chan struct{})

View File

@@ -29,7 +29,10 @@ import (
"gitea.dev/actionslib/pkg/model"
)
const maxJobSummaryBytes = 1024 * 1024
const (
jobCleanupTimeout = time.Minute
maxJobSummaryBytes = 1024 * 1024
)
// jobSummaryTruncationMarker is appended to a summary that exceeded the size limit
// so the rendered output makes the truncation visible instead of silently cutting off.
@@ -249,7 +252,7 @@ func newJobExecutor(info jobInfo, sf stepFactory, rc *RunContext) common.Executo
jobError := common.JobError(ctx)
var err error
// always allow 1 min for stopping and removing the runner, even if we were cancelled
ctx, cancel := context.WithTimeout(common.WithLogger(context.Background(), common.Logger(ctx)), time.Minute)
ctx, cancel := context.WithTimeout(common.WithLogger(context.Background(), common.Logger(ctx)), jobCleanupTimeout)
defer cancel()
logger := common.Logger(ctx)

View File

@@ -10,7 +10,6 @@ import (
"net/url"
"path"
"regexp"
"strings"
"gitea.com/gitea/runner/act/common"
"gitea.com/gitea/runner/act/common/git"
@@ -27,7 +26,7 @@ func newLocalReusableWorkflowExecutor(rc *RunContext) common.Executor {
if !rc.Config.NoSkipCheckout {
// resolve the local workflow against the workspace root, not the process
// working directory, so it is found regardless of where the runner is invoked
return newReusableWorkflowExecutor(rc, rc.Config.Workdir, localWorkflow.Path)
return newReusableWorkflowExecutor(rc, rc.Config.Workdir, localWorkflow.Path, false)
}
uses := fmt.Sprintf("%s/%s@%s", rc.Config.PresetGitHubContext.Repository, localWorkflow.Path, rc.Config.PresetGitHubContext.Sha)
@@ -41,7 +40,7 @@ func newLocalReusableWorkflowExecutor(rc *RunContext) common.Executor {
return common.NewPipelineExecutor(
cloneRemoteReusableWorkflow(rc, remoteReusableWorkflow.CloneURL(), remoteReusableWorkflow.Ref, workflowDir),
newReusableWorkflowExecutor(rc, workflowDir, remoteReusableWorkflow.Path),
newReusableWorkflowExecutor(rc, workflowDir, remoteReusableWorkflow.Path, true),
)
}
@@ -59,7 +58,7 @@ func newRemoteReusableWorkflowExecutor(rc *RunContext) common.Executor {
return common.NewPipelineExecutor(
cloneRemoteReusableWorkflow(rc, remoteReusableWorkflow.CloneURL(), remoteReusableWorkflow.Ref, workflowDir),
newReusableWorkflowExecutor(rc, workflowDir, remoteReusableWorkflow.Path),
newReusableWorkflowExecutor(rc, workflowDir, remoteReusableWorkflow.Path, true),
)
}
@@ -87,11 +86,16 @@ func cloneRemoteReusableWorkflow(rc *RunContext, cloneURL, ref, targetDirectory
}
}
func newReusableWorkflowExecutor(rc *RunContext, directory, workflow string) common.Executor {
func newReusableWorkflowExecutor(rc *RunContext, directory, workflow string, cached bool) common.Executor {
return func(ctx context.Context) error {
// Serialize workflow reads with cache updates.
planner, err := func() (model.WorkflowPlanner, error) {
defer git.AcquireCloneLock(directory)()
if cached {
unlock, err := git.AcquireCloneLock(ctx, directory)
if err != nil {
return nil, err
}
defer unlock()
}
return model.NewWorkflowPlanner(path.Join(directory, workflow), true)
}()
if err != nil {
@@ -135,11 +139,7 @@ type remoteReusableWorkflow struct {
}
func (r *remoteReusableWorkflow) CloneURL() string {
// In Gitea, r.URL always has the protocol prefix, we don't need to add extra prefix in this case.
if strings.HasPrefix(r.URL, "http://") || strings.HasPrefix(r.URL, "https://") {
return fmt.Sprintf("%s/%s/%s", r.URL, r.Owner, r.Repo)
}
return fmt.Sprintf("https://%s/%s/%s", r.URL, r.Owner, r.Repo)
return fmt.Sprintf("%s/%s/%s", withHTTPS(r.URL), r.Owner, r.Repo)
}
var absoluteReusableWorkflowURLRegex = regexp.MustCompile(`^(https?://.*)/([^/]+/[^/]+/\.[^/]+/workflows/[^@]+@.*)$`)
@@ -233,10 +233,7 @@ func onInstanceHost(host string, instanceURLs ...string) bool {
if candidate == "" {
continue
}
if !strings.HasPrefix(candidate, "http://") && !strings.HasPrefix(candidate, "https://") {
candidate = "https://" + candidate
}
if u, err := url.Parse(candidate); err == nil && u.Host == host {
if u, err := url.Parse(withHTTPS(candidate)); err == nil && u.Host == host {
return true
}
}

View File

@@ -8,7 +8,6 @@ import (
"crypto/tls"
"crypto/x509"
"encoding/pem"
"io/fs"
"net/http"
"net/http/httptest"
"os"
@@ -84,14 +83,16 @@ func TestNewReusableWorkflowExecutorHoldsCloneLock(t *testing.T) {
workflowDir := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(workflowDir, "reusable.yml"), []byte(":"), 0o644))
unlockOnce := sync.OnceFunc(git.AcquireCloneLock(workflowDir))
unlock, err := git.AcquireCloneLock(t.Context(), workflowDir)
require.NoError(t, err)
unlockOnce := sync.OnceFunc(unlock)
defer unlockOnce()
rc := &RunContext{
Config: &Config{},
Run: &model.Run{Workflow: &model.Workflow{Jobs: map[string]*model.Job{}}},
}
exec := newReusableWorkflowExecutor(rc, workflowDir, "reusable.yml")
exec := newReusableWorkflowExecutor(rc, workflowDir, "reusable.yml", true)
done := make(chan error, 1)
go func() { done <- exec(context.Background()) }()
@@ -113,8 +114,9 @@ func TestNewReusableWorkflowExecutorHoldsCloneLock(t *testing.T) {
}
func TestNewLocalReusableWorkflowExecutorFindsSameRepositoryPaths(t *testing.T) {
workdir := t.TempDir()
workdir := filepath.Join(t.TempDir(), "workspace")
require.NoError(t, os.MkdirAll(filepath.Join(workdir, ".gitea", "workflows"), 0o755))
require.NoError(t, os.Mkdir(workdir+".lock", 0o755))
require.NoError(t, os.WriteFile(filepath.Join(workdir, ".gitea", "workflows", "reusable.yml"), []byte(":"), 0o644))
for _, uses := range []string{"./.gitea/workflows/reusable.yml", "$/.gitea/workflows/reusable.yml"} {
@@ -123,8 +125,7 @@ func TestNewLocalReusableWorkflowExecutorFindsSameRepositoryPaths(t *testing.T)
Run: &model.Run{JobID: "job", Workflow: &model.Workflow{Jobs: map[string]*model.Job{"job": {Uses: uses}}}},
}
err := newLocalReusableWorkflowExecutor(rc)(t.Context())
require.Error(t, err)
require.NotErrorIs(t, err, fs.ErrNotExist)
require.ErrorContains(t, err, "workflow is not valid")
}
}

View File

@@ -1541,14 +1541,8 @@ func (rc *RunContext) getGithubContext(ctx context.Context) *model.GithubContext
merged.RunnerPerflog, merged.RunnerTrackingID = ghc.RunnerPerflog, ghc.RunnerTrackingID
*ghc = merged
instance := rc.Config.GitHubInstance
if !strings.HasPrefix(instance, "http://") &&
!strings.HasPrefix(instance, "https://") {
instance = "https://" + instance
}
ghc.ServerURL = instance
ghc.APIURL = instance + "/api/v1" // the version of Gitea is v1
ghc.GraphQLURL = "" // Gitea doesn't support graphql
rc.setInstanceURLs(ghc)
ghc.GraphQLURL = "" // Gitea doesn't support graphql
return ghc
}
}
@@ -1562,7 +1556,7 @@ func (rc *RunContext) getGithubContext(ctx context.Context) *model.GithubContext
ghc.SetBaseAndHeadRef()
repoPath := rc.Config.Workdir
ghcontext.SetRepositoryAndOwner(ctx, ghc, rc.Config.GitHubInstance, repoPath)
ghcontext.SetRepositoryAndOwner(ctx, ghc, repoPath)
if ghc.Ref == "" {
ghcontext.SetRef(ctx, ghc, repoPath)
}
@@ -1572,27 +1566,7 @@ func (rc *RunContext) getGithubContext(ctx context.Context) *model.GithubContext
ghc.SetRefTypeAndName()
// defaults
ghc.ServerURL = "https://github.com"
ghc.APIURL = "https://api.github.com"
ghc.GraphQLURL = "https://api.github.com/graphql"
// per GHES
if rc.Config.GitHubInstance != "github.com" {
ghc.ServerURL = "https://" + rc.Config.GitHubInstance
ghc.APIURL = fmt.Sprintf("https://%s/api/v3", rc.Config.GitHubInstance)
ghc.GraphQLURL = fmt.Sprintf("https://%s/api/graphql", rc.Config.GitHubInstance)
}
{ // Adapt to Gitea
instance := rc.Config.GitHubInstance
if !strings.HasPrefix(instance, "http://") &&
!strings.HasPrefix(instance, "https://") {
instance = "https://" + instance
}
ghc.ServerURL = instance
ghc.APIURL = instance + "/api/v1" // the version of Gitea is v1
ghc.GraphQLURL = "" // Gitea doesn't support graphql
}
rc.setInstanceURLs(ghc)
// allow to be overridden by user
if rc.Config.Env["GITHUB_SERVER_URL"] != "" {
@@ -1608,6 +1582,20 @@ func (rc *RunContext) getGithubContext(ctx context.Context) *model.GithubContext
return ghc
}
func (rc *RunContext) setInstanceURLs(ghc *model.GithubContext) {
if instance := strings.TrimRight(rc.Config.GitHubInstance, "/"); instance != "" {
ghc.ServerURL = withHTTPS(instance)
ghc.APIURL = ghc.ServerURL + "/api/v1"
}
}
func withHTTPS(u string) string {
if strings.HasPrefix(u, "http://") || strings.HasPrefix(u, "https://") {
return u
}
return "https://" + u
}
func isLocalCheckout(ghc *model.GithubContext, step *model.Step) bool {
if step.Type() == model.StepTypeInvalid {
// This will be errored out by the executor later, we need this here to avoid a null panic though
@@ -1657,20 +1645,16 @@ func (rc *RunContext) withGithubEnv(ctx context.Context, github *model.GithubCon
env["GITHUB_HEAD_REF"] = github.HeadRef
env["GITHUB_SERVER_URL"] = github.ServerURL
env["GITHUB_API_URL"] = github.APIURL
env["GITHUB_GRAPHQL_URL"] = github.GraphQLURL
{ // Adapt to Gitea
instance := rc.Config.GitHubInstance
if !strings.HasPrefix(instance, "http://") &&
!strings.HasPrefix(instance, "https://") {
instance = "https://" + instance
for _, name := range []string{"GITHUB_SERVER_URL", "GITHUB_API_URL"} {
if env[name] == "" {
delete(env, name)
}
env["GITHUB_SERVER_URL"] = instance
env["GITHUB_API_URL"] = instance + "/api/v1" // the version of Gitea is v1
env["GITHUB_GRAPHQL_URL"] = "" // Gitea doesn't support graphql
env["GITHUB_RUN_ATTEMPT"] = github.RunAttempt
}
env["GITHUB_GRAPHQL_URL"] = ""
env["GITHUB_RUN_ATTEMPT"] = github.RunAttempt
env["RUNNER_NAME"] = rc.Config.RunnerName
env["RUNNER_ENVIRONMENT"] = "self-hosted"
if workspace := parentDir(github.Workspace); workspace != "" {

View File

@@ -915,6 +915,7 @@ func TestGetGitHubContext(t *testing.T) {
Run: &model.Run{
Workflow: &model.Workflow{
Name: "GitHubContextTest",
Jobs: map[string]*model.Job{"job1": {}},
},
},
Name: "GitHubContextTest",
@@ -960,6 +961,36 @@ func TestGetGitHubContext(t *testing.T) {
assert.Equal(t, "preset-actor", ghc.Actor)
assert.Equal(t, "triggerer", ghc.TriggeringActor)
assert.Equal(t, "job1", ghc.Job)
preset := &model.GithubContext{ServerURL: "https://preset.test", APIURL: "https://preset.test/api/v1"}
for _, testcase := range []struct {
name, instance, serverURL, apiURL string
preset *model.GithubContext
env map[string]string
}{
{name: "unknown instance"},
{name: "bare host with trailing slash", instance: "gitea.test/", serverURL: "https://gitea.test", apiURL: "https://gitea.test/api/v1"},
{name: "scheme port and base path", instance: "http://gitea.test:3000/gitea/", serverURL: "http://gitea.test:3000/gitea", apiURL: "http://gitea.test:3000/gitea/api/v1"},
{name: "daemon instance replaces preset endpoints", instance: "gitea.test", preset: preset, serverURL: "https://gitea.test", apiURL: "https://gitea.test/api/v1"},
{name: "daemon keeps preset endpoints without instance", preset: preset, serverURL: "https://preset.test", apiURL: "https://preset.test/api/v1"},
{name: "environment overrides", env: map[string]string{"GITHUB_SERVER_URL": "https://override.test", "GITHUB_API_URL": "https://override.test/api"}, serverURL: "https://override.test", apiURL: "https://override.test/api"},
} {
t.Run(testcase.name, func(t *testing.T) {
rc.Config.GitHubInstance = testcase.instance
rc.Config.PresetGitHubContext = testcase.preset
rc.Config.Env = map[string]string{"GITHUB_REPOSITORY": "owner/repo", "GITHUB_REF": "refs/heads/main", "SHA_REF": "1234567890"}
maps.Copy(rc.Config.Env, testcase.env)
github := rc.getGithubContext(t.Context())
assert.Equal(t, testcase.serverURL, github.ServerURL)
assert.Equal(t, testcase.apiURL, github.APIURL)
env := map[string]string{}
rc.withGithubEnv(t.Context(), github, env)
_, exported := env["GITHUB_SERVER_URL"]
assert.Equal(t, testcase.serverURL != "", exported)
assert.Equal(t, testcase.serverURL, env["GITHUB_SERVER_URL"])
assert.Equal(t, testcase.apiURL, env["GITHUB_API_URL"])
})
}
}
func TestGetGithubContextRef(t *testing.T) {

View File

@@ -5,6 +5,7 @@
package runner
import (
"cmp"
"context"
"fmt"
"net/http"
@@ -49,7 +50,7 @@ type Config struct {
ContainerDaemonSocket string // Path to Docker daemon socket
ContainerOptions string // Options for the job container
UseGitIgnore bool // controls if paths in .gitignore should not be copied into container, default true
GitHubInstance string // GitHub instance to use, default "github.com"
GitHubInstance string // instance hostname or URL, empty when unknown
ContainerCapAdd []string // list of kernel capabilities to add to the containers
ContainerCapDrop []string // list of kernel capabilities to remove from the containers
ArtifactServerPath string // the path where the artifact server stores uploads
@@ -111,13 +112,7 @@ func (c Config) GetToken() string {
// DefaultActionURL returns the host used for implicit remote actions.
func (c Config) DefaultActionURL() string {
if c.DefaultActionInstance != "" {
return c.DefaultActionInstance
}
if c.GitHubInstance != "" {
return c.GitHubInstance
}
return "github.com"
return cmp.Or(c.DefaultActionInstance, c.GitHubInstance, "github.com")
}
type caller struct {

View File

@@ -186,19 +186,25 @@ func runStepExecutor(step step, stage stepStage, executor common.Executor) commo
if err == nil {
err = insecureErr
}
if fileErr := processRunnerEnvFileCommand(ctx, envFileCommand, rc, rc.setEnvFile); fileErr != nil && err == nil {
fileCommandCtx := ctx
if ctx.Err() != nil { // an interrupted step still records its file commands, a live deadline stays in force
var cancelFileCommands context.CancelFunc
fileCommandCtx, cancelFileCommands = context.WithTimeout(context.WithoutCancel(ctx), jobCleanupTimeout)
defer cancelFileCommands()
}
if fileErr := processRunnerEnvFileCommand(fileCommandCtx, envFileCommand, rc, rc.setEnvFile); fileErr != nil && err == nil {
err = fileErr
}
if fileErr := processRunnerEnvFileCommand(ctx, stateFileCommand, rc, rc.saveState); fileErr != nil && err == nil {
if fileErr := processRunnerEnvFileCommand(fileCommandCtx, stateFileCommand, rc, rc.saveState); fileErr != nil && err == nil {
err = fileErr
}
if fileErr := processRunnerEnvFileCommand(ctx, outputFileCommand, rc, rc.setOutput); fileErr != nil && err == nil {
if fileErr := processRunnerEnvFileCommand(fileCommandCtx, outputFileCommand, rc, rc.setOutput); fileErr != nil && err == nil {
err = fileErr
}
if fileErr := rc.UpdateExtraPath(ctx, path.Join(actPath, pathFileCommand)); fileErr != nil && err == nil {
if fileErr := rc.UpdateExtraPath(fileCommandCtx, path.Join(actPath, pathFileCommand)); fileErr != nil && err == nil {
err = fileErr
}
_ = rc.JobContainer.Copy(actPath, files...)(ctx)
_ = rc.JobContainer.Copy(actPath, files...)(fileCommandCtx)
if err == nil {
logger.WithField("stepResult", stepResult.Conclusion).Infof("Success - %s %s", stage, stepString)

View File

@@ -88,6 +88,12 @@ func (sar *stepActionRemote) prepareActionExecutor() common.Executor {
}
}
unlock, err := git.AcquireCloneLock(ctx, actionDir)
if err != nil {
return err
}
defer unlock()
// Best effort: the download report falls back to the ref alone when the commit is unknown.
if _, sha, err := git.FindGitRevision(ctx, actionDir); err != nil {
common.Logger(ctx).Debugf("unable to resolve the commit of %s: %v", sar.remoteAction.Reference(), err)
@@ -100,7 +106,6 @@ func (sar *stepActionRemote) prepareActionExecutor() common.Executor {
return f, f, err
}
defer git.AcquireCloneLock(actionDir)()
actionModel, err := sar.readAction(ctx, sar.Step, actionDir, sar.remoteAction.Path, remoteReader, os.WriteFile)
sar.action = actionModel
return err
@@ -251,8 +256,8 @@ func (ra *remoteAction) CloneURL(u string) string {
if ra.URL == "" {
// 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
if !filepath.IsAbs(u) {
u = withHTTPS(u)
}
} else {
u = ra.URL
@@ -301,6 +306,9 @@ func newRemoteAction(action string, github *model.GithubContext, instanceURL str
}
ra.Org, ra.Repo, _ = strings.Cut(repo, "/")
ra.URL, ra.Ref = github.ServerURL, ref
if ra.URL == "" {
return nil, fmt.Errorf("unable to resolve %q without a Gitea instance", action)
}
if ra.Org == "" || ra.Repo == "" || ra.Ref == "" {
return nil, fmt.Errorf("unable to resolve %q without a repository and commit", action)
}

View File

@@ -193,7 +193,7 @@ func TestStepActionRemote(t *testing.T) {
RunContext: &RunContext{
Config: &Config{
GitHubInstance: "github.com",
ActionCacheDir: "/tmp/test-cache",
ActionCacheDir: t.TempDir(),
},
Run: &model.Run{
JobID: "1",
@@ -447,7 +447,7 @@ func TestStepActionRemotePost(t *testing.T) {
RunContext: &RunContext{
Config: &Config{
GitHubInstance: "https://github.com",
ActionCacheDir: "/tmp/test-cache",
ActionCacheDir: t.TempDir(),
},
JobContainer: cm,
Run: &model.Run{
@@ -688,6 +688,7 @@ func Test_newRemoteActionSelfRepo(t *testing.T) {
Ref: "v1",
},
},
{name: "unknown instance", action: "$/.gitea/actions/build", github: &model.GithubContext{Repository: "owner/workflow-repo", Sha: "abc123"}},
{name: "empty path", action: "$/", github: workflow},
{name: "ref suffix is not allowed", action: "$/.gitea/actions/build@v1", github: workflow},
{name: "path traversal", action: "$/../escape", github: workflow},
@@ -867,7 +868,7 @@ func TestStepActionRemoteCloneTokenSurvivesNilSecrets(t *testing.T) {
Config: &Config{
GitHubInstance: tt.gitHubInstance,
DefaultActionInstance: tt.defaultActionInstance,
ActionCacheDir: "/tmp/test-cache",
ActionCacheDir: t.TempDir(),
// Mirrors the state of a composite RunContext: job secrets are
// stripped, but the job token is still reachable via Config.Token.
Secrets: nil,

View File

@@ -7,9 +7,11 @@ package runner
import (
"context"
"errors"
"io"
"os"
"strings"
"testing"
"testing/synctest"
"gitea.com/gitea/runner/act/common"
"gitea.com/gitea/runner/act/container"
@@ -189,7 +191,6 @@ func TestSetupEnv(t *testing.T) {
"GITHUB_ACTION_PATH": "",
"GITHUB_ACTION_REF": "",
"GITHUB_ACTION_REPOSITORY": "",
"GITHUB_API_URL": "https:///api/v1", // Gitea uses api/v1 (upstream GitHub: api/v3)
"GITHUB_BASE_REF": "",
"GITHUB_EVENT_NAME": "",
"GITHUB_EVENT_PATH": "/var/run/act/workflow/event.json",
@@ -200,7 +201,6 @@ func TestSetupEnv(t *testing.T) {
"GITHUB_RUN_ATTEMPT": "",
"GITHUB_RUN_ID": "runId",
"GITHUB_RUN_NUMBER": "1",
"GITHUB_SERVER_URL": "https://",
"GITHUB_WORKFLOW": "",
"INPUT_ID": "1234567",
"INPUT_STEP_WITH": "with-value",
@@ -475,6 +475,26 @@ func TestRunStepExecutorDoesNotLeakRefusalToNextStep(t *testing.T) {
require.NoError(t, errB)
}
type fileCommandTestContainer struct {
*container.HostEnvironment
}
func (c *fileCommandTestContainer) UpdateFromEnv(srcPath string, env *map[string]string) common.Executor {
return func(ctx context.Context) error {
if err := ctx.Err(); err != nil {
return err
}
return c.HostEnvironment.UpdateFromEnv(srcPath, env)(ctx)
}
}
func (c *fileCommandTestContainer) GetContainerArchive(ctx context.Context, srcPath string) (io.ReadCloser, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
return c.HostEnvironment.GetContainerArchive(ctx, srcPath)
}
func TestRunStepExecutorParity(t *testing.T) {
newStep := func(t *testing.T, stepModel *model.Step) *stepRun {
rc := createRunContext(t)
@@ -527,6 +547,30 @@ func TestRunStepExecutorParity(t *testing.T) {
}
})
t.Run("file commands survive a job timeout", func(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
step := newStep(t, &model.Step{ID: "interrupted"})
ctx, cancel := applyJobTimeout(t.Context(), step.RunContext, &model.Job{TimeoutMinutes: "1"})
defer cancel()
step.RunContext.JobContainer = &fileCommandTestContainer{step.RunContext.JobContainer.(*container.HostEnvironment)}
err := runStepExecutor(step, stepStageMain, func(execCtx context.Context) error {
for name, content := range map[string]string{
"GITHUB_ENV": "KEPT_ENV=value\n", "GITHUB_STATE": "saved=value\n",
"GITHUB_OUTPUT": "kept=value\n", "GITHUB_PATH": "/kept/bin\n",
} {
require.NoError(t, os.WriteFile(step.env[name], []byte(content), 0o600))
}
<-execCtx.Done()
return execCtx.Err()
})(ctx)
require.ErrorIs(t, err, context.DeadlineExceeded)
assert.Equal(t, "value", step.RunContext.Env["KEPT_ENV"])
assert.Equal(t, "value", step.RunContext.IntraActionState[step.Step.ID]["saved"])
assert.Equal(t, "value", step.RunContext.StepResults[step.Step.ID].Outputs["kept"])
assert.Contains(t, step.RunContext.ExtraPath, "/kept/bin")
})
})
t.Run("composite child files do not leak", func(t *testing.T) {
outer := newStep(t, &model.Step{ID: "outer"})
childRC := &RunContext{