Files
act_runner/act/runner/action_composite.go
Nicolas eeb479ea89 fix: prevent exponential growth of RunContext masks in composite actions (#1059)
A composite action's `RunContext` is **seeded with its parent's `Masks` slice** in `newCompositeRunContext`. After the composite finished running, the *entire* `compositeRC.Masks` (the seeded parent masks plus any added during the run) was appended straight back into the parent:

```go
rc.Masks = append(rc.Masks, compositeRC.Masks...)
```

Because the composite already contained the parent's masks, every composite action roughly **doubled** the parent's `Masks` length. With many nested/repeated composite actions this grows exponentially:

```
before=1152 after=2304 compositeLen=1152
before=2304 after=4608 compositeLen=2304
before=4608 after=9216 compositeLen=4608
...
```

### Impact

The bloated `Masks` slice made per-log-line secret redaction (`strings.makeGenericReplacer`, rebuilt per line since #1001) extremely slow. Log writes fell behind enough to keep subprocess pipes open after the process had already exited, surfacing as seemingly random CI failures — most often in later steps of mask-heavy workflows:

```
Error: exec: WaitDelay expired before I/O complete
```

The split of `cmd.Run()` into `cmd.Start()` + `cmd.Wait()` (#883) is what turned this long-standing latency into a hard failure.

### Fix

Only append masks that aren't already present in the parent, via a small `appendUniqueMasks` helper. This keeps the mask count bounded by the number of *distinct* secrets instead of growing exponentially.

I kept `Masks` as a `[]string` rather than switching to a set type, since it's passed by pointer to loggers across `logger.go`, `run_context.go`, and `runner.go` — a type change would be a much wider, riskier refactor. The `O(n·m)` dedup is harmless precisely because `n` now stays small.

Fixes #1054Reviewed-on: https://gitea.com/gitea/runner/pulls/1059
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
2026-07-01 18:50:34 +00:00

260 lines
7.6 KiB
Go

// Copyright 2022 The Gitea Authors. All rights reserved.
// Copyright 2022 The nektos/act Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package runner
import (
"context"
"errors"
"regexp"
"slices"
"strconv"
"strings"
"gitea.com/gitea/runner/act/common"
"gitea.com/gitea/runner/act/model"
)
func evaluateCompositeInputAndEnv(ctx context.Context, parent *RunContext, step actionStep) map[string]string {
env := make(map[string]string)
stepEnv := *step.getEnv()
for k, v := range stepEnv {
// do not set current inputs into composite action
// the required inputs are added in the second loop
if !strings.HasPrefix(k, "INPUT_") {
env[k] = v
}
}
ee := parent.NewStepExpressionEvaluator(ctx, step)
for inputID, input := range step.getActionModel().Inputs {
envKey := regexp.MustCompile("[^A-Z0-9-]").ReplaceAllString(strings.ToUpper(inputID), "_")
envKey = "INPUT_" + strings.ToUpper(envKey)
// lookup if key is defined in the step but the already
// evaluated value from the environment
_, defined := step.getStepModel().With[inputID]
if value, ok := stepEnv[envKey]; defined && ok {
env[envKey] = value
} else {
// defaults could contain expressions
env[envKey] = ee.Interpolate(ctx, input.Default)
}
}
gh := step.getGithubContext(ctx)
env["GITHUB_ACTION_REPOSITORY"] = gh.ActionRepository
env["GITHUB_ACTION_REF"] = gh.ActionRef
return env
}
func newCompositeRunContext(ctx context.Context, parent *RunContext, step actionStep, actionPath string) *RunContext {
env := evaluateCompositeInputAndEnv(ctx, parent, step)
// run with the global config but without secrets
configCopy := *(parent.Config)
configCopy.Secrets = nil
// create a run context for the composite action to run in
compositerc := &RunContext{
Name: parent.Name,
JobName: parent.JobName,
Run: &model.Run{
JobID: parent.Run.JobID,
Workflow: &model.Workflow{
Name: parent.Run.Workflow.Name,
Jobs: map[string]*model.Job{
parent.Run.JobID: {},
},
},
},
Config: &configCopy,
StepResults: map[string]*model.StepResult{},
JobContainer: parent.JobContainer,
ActionPath: actionPath,
Env: env,
GlobalEnv: parent.GlobalEnv,
Masks: parent.Masks,
ExtraPath: parent.ExtraPath,
Parent: parent,
EventJSON: parent.EventJSON,
}
compositerc.ExprEval = compositerc.NewExpressionEvaluator(ctx)
return compositerc
}
// appendUniqueMasks appends the masks from src to dst, skipping any mask that
// is already present in dst. This prevents the parent RunContext's Masks slice
// from growing exponentially when composite actions are nested or repeated,
// since each composite RunContext is seeded with its parent's masks.
func appendUniqueMasks(dst, src []string) []string {
for _, m := range src {
if !slices.Contains(dst, m) {
dst = append(dst, m)
}
}
return dst
}
func execAsComposite(step actionStep) common.Executor {
rc := step.getRunContext()
action := step.getActionModel()
return func(ctx context.Context) error {
compositeRC := step.getCompositeRunContext(ctx)
steps := step.getCompositeSteps()
if steps == nil || steps.main == nil {
return errors.New("missing steps in composite action")
}
ctx = WithCompositeLogger(ctx, &compositeRC.Masks)
err := steps.main(ctx)
// Map outputs from composite RunContext to job RunContext
eval := compositeRC.NewExpressionEvaluator(ctx)
for outputName, output := range action.Outputs {
rc.setOutput(ctx, map[string]string{
"name": outputName,
}, eval.Interpolate(ctx, output.Value))
}
// compositeRC.Masks is seeded with rc.Masks (see newCompositeRunContext)
// and may have additional masks appended while the composite action runs.
// Only append masks that are not already present, otherwise nested or
// repeated composite actions grow rc.Masks exponentially.
rc.Masks = appendUniqueMasks(rc.Masks, compositeRC.Masks)
rc.ExtraPath = compositeRC.ExtraPath
// compositeRC.Env is dirty, contains INPUT_ and merged step env, only rely on compositeRC.GlobalEnv
mergeIntoMap := mergeIntoMapCaseSensitive
if rc.JobContainer.IsEnvironmentCaseInsensitive() {
mergeIntoMap = mergeIntoMapCaseInsensitive
}
if rc.GlobalEnv == nil {
rc.GlobalEnv = map[string]string{}
}
mergeIntoMap(rc.GlobalEnv, compositeRC.GlobalEnv)
mergeIntoMap(rc.Env, compositeRC.GlobalEnv)
return err
}
}
type compositeSteps struct {
pre common.Executor
main common.Executor
post common.Executor
}
// Executor returns a pipeline executor for all the steps in the job
func (rc *RunContext) compositeExecutor(action *model.Action) *compositeSteps {
steps := make([]common.Executor, 0)
preSteps := make([]common.Executor, 0)
var postExecutor common.Executor
sf := &stepFactoryImpl{}
for i, step := range action.Runs.Steps {
if step.ID == "" {
step.ID = strconv.Itoa(i)
}
step.Number = i
// create a copy of the step, since this composite action could
// run multiple times and we might modify the instance
stepcopy := step
step, err := sf.newStep(&stepcopy, rc)
if err != nil {
return &compositeSteps{
main: common.NewErrorExecutor(err),
}
}
stepID := step.getStepModel().ID
stepPre := rc.newCompositeCommandExecutor(step.pre())
preSteps = append(preSteps, newCompositeStepLogExecutor(stepPre, stepID))
steps = append(steps, func(ctx context.Context) error {
ctx = WithCompositeStepLogger(ctx, stepID)
logger := common.Logger(ctx)
err := rc.newCompositeCommandExecutor(step.main())(ctx)
if err != nil {
logger.Errorf("%v", err)
common.SetJobError(ctx, err)
} else if ctx.Err() != nil {
logger.Errorf("%v", ctx.Err())
common.SetJobError(ctx, ctx.Err())
}
return nil
})
// run the post executor in reverse order
if postExecutor != nil {
stepPost := rc.newCompositeCommandExecutor(step.post())
postExecutor = newCompositeStepLogExecutor(stepPost.Finally(postExecutor), stepID)
} else {
stepPost := rc.newCompositeCommandExecutor(step.post())
postExecutor = newCompositeStepLogExecutor(stepPost, stepID)
}
}
steps = append(steps, common.JobError)
return &compositeSteps{
pre: func(ctx context.Context) error {
return common.NewPipelineExecutor(preSteps...)(common.WithJobErrorContainer(ctx))
},
main: func(ctx context.Context) error {
return common.NewPipelineExecutor(steps...)(common.WithJobErrorContainer(ctx))
},
post: postExecutor,
}
}
func (rc *RunContext) newCompositeCommandExecutor(executor common.Executor) common.Executor {
return func(ctx context.Context) error {
ctx = WithCompositeLogger(ctx, &rc.Masks)
// We need to inject a composite RunContext related command
// handler into the current running job container
// We need this, to support scoping commands to the composite action
// executing.
rawLogger := common.Logger(ctx).WithField("raw_output", true)
logWriter := common.NewLineWriter(rc.commandHandler(ctx), func(s string) bool {
if rc.Config.LogOutput {
rawLogger.Infof("%s", s)
} else {
rawLogger.Debugf("%s", s)
}
return true
})
oldout, olderr := rc.JobContainer.ReplaceLogWriter(logWriter, logWriter)
defer rc.JobContainer.ReplaceLogWriter(oldout, olderr)
return executor(ctx)
}
}
func newCompositeStepLogExecutor(runStep common.Executor, stepID string) common.Executor {
return func(ctx context.Context) error {
ctx = WithCompositeStepLogger(ctx, stepID)
logger := common.Logger(ctx)
err := runStep(ctx)
if err != nil {
logger.Errorf("%v", err)
common.SetJobError(ctx, err)
} else if ctx.Err() != nil {
logger.Errorf("%v", ctx.Err())
common.SetJobError(ctx, ctx.Err())
}
return nil
}
}