Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions ast.go
Original file line number Diff line number Diff line change
Expand Up @@ -425,6 +425,12 @@ const (
ExecKindAction ExecKind = iota
// ExecKindRun is kind for step to run shell script
ExecKindRun
// ExecKindWait is kind for step to wait for background steps ('wait' or 'wait-all')
ExecKindWait
// ExecKindCancel is kind for step to cancel background steps ('cancel')
ExecKindCancel
// ExecKindParallel is kind for step to run a group of steps in parallel ('parallel')
ExecKindParallel
)

// Exec is an interface how the step is executed. Step in workflow runs either an action or a script
Expand Down Expand Up @@ -480,6 +486,51 @@ func (e *ExecAction) Kind() ExecKind {
return ExecKindAction
}

// ExecWait is configuration of a step that waits for background steps to complete. It corresponds to
// the 'wait' and 'wait-all' steps.
// https://github.blog/changelog/2026-06-25-actions-steps-can-now-be-run-in-parallel/
type ExecWait struct {
// Names is the list of background step IDs to wait for, given by the 'wait' field. It is nil when
// 'wait-all' is used instead.
Names []*String
// All is true when the step waits for all preceding background steps via the 'wait-all' field.
All bool
// AllPos is the position of the 'wait-all' field. It is nil when 'wait' is used instead.
AllPos *Pos
}

// Kind returns kind of the step execution.
func (e *ExecWait) Kind() ExecKind {
return ExecKindWait
}

// ExecCancel is configuration of a step that cancels a running background step. It corresponds to the
// 'cancel' step.
// https://github.blog/changelog/2026-06-25-actions-steps-can-now-be-run-in-parallel/
type ExecCancel struct {
// Name is the ID of the background step to cancel, given by the 'cancel' field. The 'cancel' step
// targets a single background step by its ID.
Name *String
}

// Kind returns kind of the step execution.
func (e *ExecCancel) Kind() ExecKind {
return ExecKindCancel
}

// ExecParallel is configuration of a step that runs a group of steps in parallel. It corresponds to
// the 'parallel' step.
// https://github.blog/changelog/2026-06-25-actions-steps-can-now-be-run-in-parallel/
type ExecParallel struct {
// Steps is the group of steps to run in parallel, given by the 'parallel' field.
Steps []*Step
}

// Kind returns kind of the step execution.
func (e *ExecParallel) Kind() ExecKind {
return ExecKindParallel
}

// RawYAMLValueKind is kind of raw YAML values
type RawYAMLValueKind int

Expand Down Expand Up @@ -752,6 +803,10 @@ type Step struct {
ContinueOnError *Bool
// https://docs.github.com/en/actions/learn-github-actions/workflow-syntax-for-github-actions#jobsjob_idstepstimeout-minutes
TimeoutMinutes *Float
// Background is 'background' field. When it is true, the step runs asynchronously and the workflow
// immediately continues to the next step.
// https://github.blog/changelog/2026-06-25-actions-steps-can-now-be-run-in-parallel/
Background *Bool
// Pos is a position in source.
Pos *Pos
}
Expand Down
31 changes: 31 additions & 0 deletions docs/checks.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ List of checks:
- [pyflakes integration for `run:`](#check-pyflakes-integ)
- [Script injection by potentially untrusted inputs](#untrusted-inputs)
- [Job dependencies validation](#check-job-deps)
- [Parallel steps](#check-parallel-step-refs)
- [Matrix values](#check-matrix-values)
- [Webhook events validation](#check-webhook-events)
- [Workflow dispatch event validation](#check-workflow-dispatch-events)
Expand Down Expand Up @@ -1222,6 +1223,36 @@ test.yaml:8:3: job "bar" needs job "unknown" which does not exist in this workfl

[Playground](https://rhysd.github.io/actionlint/#eNqkjDsOAjEMRPucYrptyAXcwRFoEUUMRuEjexXb4vooS0VNNdLMvGdKWNN7eRg7FeBmNgNQkasTTtzGDof98by1I9XrhJJTI+urhXhsk4es/mWBOp8EuXTD0u9LAbiNX3PqU+2t/4k/AQAA//96DTh7)

<a id="check-parallel-step-refs"></a>
## Parallel steps

Example input:

```yaml
on: push
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Start server
id: server
run: echo 'start'
background: true
- run: echo 'tests'
- cancel: serverr
```

Output:

```
test.yaml:11:17: "serverr" is not the ID of a preceding background step. "wait" and "cancel" steps can only refer to an earlier step that has "background: true" [parallel-steps]
|
11 | - cancel: serverr
| ^~~~~~~
```

[Playground](https://rhysd.github.io/actionlint/#eNpczssNAjEMBND7VjG3nNKA26CCJGuxwOKs/KF+FD4R4mTJz6NxF8IRti3XXo0WwNl8TEBDLA+PGuKR9zLsReZ82PsKyJByZ8LJizqM9cH6IeCy0v9KQwjcto5kI5Km1NJuZ+0hK8E1eBb8RMYPlqa0Io33b4c+AwAA//+anjvx)

<a id="check-matrix-values"></a>
## Matrix values

Expand Down
1 change: 1 addition & 0 deletions linter.go
Original file line number Diff line number Diff line change
Expand Up @@ -561,6 +561,7 @@ func (l *Linter) check(
NewRuleRunnerLabel(),
NewRuleEvents(),
NewRuleJobNeeds(),
NewRuleParallelSteps(),
NewRuleAction(localActions),
NewRuleEnvVar(),
NewRuleID(),
Expand Down
122 changes: 120 additions & 2 deletions parse.go
Original file line number Diff line number Diff line change
Expand Up @@ -1145,7 +1145,7 @@ func (p *parser) parseStepExecAction(entries []workflowMappingEntry, isDocker bo
ret.Inputs[e.id] = &Input{e.key, p.parseString(e.val, true)}
}
}
case "id", "if", "name", "env", "continue-on-error", "timeout-minutes":
case "id", "if", "name", "env", "continue-on-error", "timeout-minutes", "background":
// do nothing
default:
p.unexpectedKey(e.key, "step to execute action", []string{
Expand All @@ -1155,6 +1155,7 @@ func (p *parser) parseStepExecAction(entries []workflowMappingEntry, isDocker bo
"env",
"continue-on-error",
"timeout-minutes",
"background",
"uses",
"with",
})
Expand All @@ -1177,7 +1178,7 @@ func (p *parser) parseStepExecRun(entries []workflowMappingEntry) *ExecRun {
ret.Shell = p.parseString(e.val, false)
case "working-directory":
ret.WorkingDirectory = p.parseString(e.val, false)
case "id", "if", "name", "env", "continue-on-error", "timeout-minutes":
case "id", "if", "name", "env", "continue-on-error", "timeout-minutes", "background":
// do nothing
default:
p.unexpectedKey(e.key, "step to run shell command", []string{
Expand All @@ -1187,6 +1188,7 @@ func (p *parser) parseStepExecRun(entries []workflowMappingEntry) *ExecRun {
"env",
"continue-on-error",
"timeout-minutes",
"background",
"run",
"shell",
"working-directory",
Expand All @@ -1198,6 +1200,104 @@ func (p *parser) parseStepExecRun(entries []workflowMappingEntry) *ExecRun {
return ret
}

// parseStepExecWait parses a 'wait' or 'wait-all' step that waits for background steps to complete.
// https://github.blog/changelog/2026-06-25-actions-steps-can-now-be-run-in-parallel/
func (p *parser) parseStepExecWait(entries []workflowMappingEntry) *ExecWait {
ret := &ExecWait{}
waitGiven := false

for _, e := range entries {
switch e.id {
case "wait":
waitGiven = true
ret.Names = p.parseStringOrStringSequence("wait", e.val, false, false)
case "wait-all":
// 'wait-all' waits for all background steps and takes no arguments.
if e.val.Tag != "!!null" {
p.error(e.val, "\"wait-all\" step takes no arguments")
}
ret.All = true
ret.AllPos = e.key.Pos
case "id", "if", "name", "env", "continue-on-error", "timeout-minutes":
// do nothing
default:
p.unexpectedKey(e.key, "step to wait for background steps", []string{
"id",
"if",
"name",
"env",
"continue-on-error",
"timeout-minutes",
"wait",
"wait-all",
})
}
}

// 'wait' and 'wait-all' are mutually exclusive: 'wait' targets specific
// background steps while 'wait-all' waits for all of them.
if waitGiven && ret.All {
p.errorAt(ret.AllPos, "\"wait\" and \"wait-all\" cannot be specified in the same step")
}

return ret
}

// parseStepExecCancel parses a 'cancel' step that cancels running background steps.
// https://github.blog/changelog/2026-06-25-actions-steps-can-now-be-run-in-parallel/
func (p *parser) parseStepExecCancel(entries []workflowMappingEntry) *ExecCancel {
ret := &ExecCancel{}

for _, e := range entries {
switch e.id {
case "cancel":
// The 'cancel' step targets a single background step by its ID (not a list).
ret.Name = p.parseString(e.val, false)
case "id", "if", "name", "env", "continue-on-error", "timeout-minutes":
// do nothing
default:
p.unexpectedKey(e.key, "step to cancel background steps", []string{
"id",
"if",
"name",
"env",
"continue-on-error",
"timeout-minutes",
"cancel",
})
}
}

return ret
}

// parseStepExecParallel parses a 'parallel' step that runs a group of steps in parallel.
// https://github.blog/changelog/2026-06-25-actions-steps-can-now-be-run-in-parallel/
func (p *parser) parseStepExecParallel(entries []workflowMappingEntry) *ExecParallel {
ret := &ExecParallel{}

for _, e := range entries {
switch e.id {
case "parallel":
ret.Steps = p.parseSteps(e.val)
case "id", "if", "name", "env", "continue-on-error", "timeout-minutes":
// do nothing
default:
p.unexpectedKey(e.key, "step to run steps in parallel", []string{
"id",
"if",
"name",
"env",
"continue-on-error",
"timeout-minutes",
"parallel",
})
}
}

return ret
}

// https://docs.github.com/en/actions/learn-github-actions/workflow-syntax-for-github-actions#jobsjob_idsteps
func (p *parser) parseStep(n *yaml.Node) *Step {
ret := &Step{Pos: posAt(n)}
Expand All @@ -1207,6 +1307,9 @@ func (p *parser) parseStep(n *yaml.Node) *Step {
isAction
isDocker
isRun
isWait
isCancel
isParallel
)

kind := isUnknown
Expand All @@ -1225,6 +1328,8 @@ func (p *parser) parseStep(n *yaml.Node) *Step {
ret.ContinueOnError = p.parseBool(e.val)
case "timeout-minutes":
ret.TimeoutMinutes = p.parseTimeoutMinutes(e.val)
case "background":
ret.Background = p.parseBool(e.val)
case "uses":
if strings.HasPrefix(e.val.Value, "docker://") {
kind = isDocker
Expand All @@ -1234,6 +1339,13 @@ func (p *parser) parseStep(n *yaml.Node) *Step {
case "run":
kind = isRun
// Note: Unexpected keys are checked in parseStepExecAction or parseStepExecRun later
case "wait", "wait-all":
kind = isWait
case "cancel":
kind = isCancel
case "parallel":
kind = isParallel
// Note: Unexpected keys are checked in the parseStepExec* functions later
}
}

Expand All @@ -1242,6 +1354,12 @@ func (p *parser) parseStep(n *yaml.Node) *Step {
ret.Exec = p.parseStepExecAction(entries, kind == isDocker)
case isRun:
ret.Exec = p.parseStepExecRun(entries)
case isWait:
ret.Exec = p.parseStepExecWait(entries)
case isCancel:
ret.Exec = p.parseStepExecCancel(entries)
case isParallel:
ret.Exec = p.parseStepExecParallel(entries)
default:
p.error(n, "step must run script with \"run\" section or run action with \"uses\" section")
}
Expand Down
10 changes: 10 additions & 0 deletions pass.go
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,16 @@ func (v *Visitor) visitStep(n *Step) error {
}
}

// Steps grouped in a 'parallel' step are visited as well so that rules are applied to them.
// https://github.blog/changelog/2026-06-25-actions-steps-can-now-be-run-in-parallel/
if e, ok := n.Exec.(*ExecParallel); ok {
for _, s := range e.Steps {
if err := v.visitStep(s); err != nil {
return err
}
}
}

if v.dbg != nil {
v.reportElapsedTime(fmt.Sprintf("VisitStep at %s", n.Pos), t)
}
Expand Down
Loading