Skip to content

Commit ed4d7ea

Browse files
authored
fix: resolve YAML anchors and aliases in Actions workflows (#38984)
Workflows using YAML anchors are rejected as invalid, because a workflow is split into one document per job and an alias whose anchor lands in another job's document no longer resolves. Aliases are now expanded once, right after the workflow is parsed and before anything reads or splits it, bounded like GitHub's parser so nested aliases cannot expand without limit. Merge keys stay unsupported, as they are upstream. Fixes #38983 Signed-off-by: silverwind <me@silverwind.io>
1 parent 89b891b commit ed4d7ea

11 files changed

Lines changed: 240 additions & 38 deletions

File tree

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
// Copyright 2026 The Gitea Authors. All rights reserved.
2+
// SPDX-License-Identifier: MIT
3+
4+
package jobparser
5+
6+
import (
7+
"errors"
8+
"io"
9+
10+
"gitea.dev/actionslib/pkg/model"
11+
12+
"go.yaml.in/yaml/v4"
13+
)
14+
15+
// maxExpandedNodes bounds how many nodes alias expansion may create. go-yaml's own alias guard does
16+
// not cover us: it only counts while decoding into values, and a workflow is kept as raw yaml.Nodes.
17+
const maxExpandedNodes = 50000
18+
19+
var errTooManyYamlNodes = errors.New("maximum YAML nodes exceeded")
20+
21+
// ReadWorkflow decodes a workflow file with its aliases expanded. Callers inspect the workflow's
22+
// raw nodes by kind, and an alias is a kind none of them expect.
23+
func ReadWorkflow(content []byte) (*model.Workflow, error) {
24+
doc, err := resolveYamlAliases(content)
25+
if err != nil {
26+
return nil, err
27+
}
28+
return readWorkflowDoc(doc)
29+
}
30+
31+
func readWorkflowDoc(doc *yaml.Node) (*model.Workflow, error) {
32+
if doc.Kind == 0 {
33+
return nil, io.EOF // what a yaml decoder reports for an empty file
34+
}
35+
w := new(model.Workflow)
36+
return w, doc.Decode(w)
37+
}
38+
39+
// decodeResolved is yaml.Unmarshal with aliases expanded first.
40+
func decodeResolved(content []byte, out any) error {
41+
doc, err := resolveYamlAliases(content)
42+
if err != nil {
43+
return err
44+
}
45+
return decodeYamlDoc(doc, out)
46+
}
47+
48+
func decodeYamlDoc(doc *yaml.Node, out any) error {
49+
if doc.Kind == 0 {
50+
return nil // an empty document, as yaml.Unmarshal treats it
51+
}
52+
return doc.Decode(out)
53+
}
54+
55+
// resolveYamlAliases parses content and replaces every alias with a copy of the node its anchor names.
56+
func resolveYamlAliases(content []byte) (*yaml.Node, error) {
57+
doc := &yaml.Node{}
58+
if err := yaml.Unmarshal(content, doc); err != nil {
59+
return nil, err
60+
}
61+
budget := maxExpandedNodes
62+
return doc, expandAliases(doc, &budget)
63+
}
64+
65+
// expandAliases replaces node's alias descendants in place.
66+
func expandAliases(node *yaml.Node, budget *int) error {
67+
node.Anchor = "" // a name for a node, not part of the workflow: keep it out of the payloads
68+
if err := rejectMergeKeys(node); err != nil {
69+
return err
70+
}
71+
for i, child := range node.Content {
72+
if child.Kind != yaml.AliasNode {
73+
if err := expandAliases(child, budget); err != nil {
74+
return err
75+
}
76+
continue
77+
}
78+
copied, err := copyExpanded(child.Alias, budget)
79+
if err != nil {
80+
return err
81+
}
82+
node.Content[i] = copied
83+
}
84+
return nil
85+
}
86+
87+
// copyExpanded deep copies a node expandAliases already expanded and validated, since an anchor is
88+
// declared before the alias naming it. An anchor aliased from inside itself is the exception, and
89+
// recurses here until it exhausts budget.
90+
func copyExpanded(node *yaml.Node, budget *int) (*yaml.Node, error) {
91+
if *budget--; *budget < 0 {
92+
return nil, errTooManyYamlNodes
93+
}
94+
if node.Kind == yaml.AliasNode {
95+
return copyExpanded(node.Alias, budget)
96+
}
97+
98+
copied := *node
99+
copied.Content = make([]*yaml.Node, len(node.Content))
100+
for i, child := range node.Content {
101+
child, err := copyExpanded(child, budget)
102+
if err != nil {
103+
return nil, err
104+
}
105+
copied.Content[i] = child
106+
}
107+
return &copied, nil
108+
}
109+
110+
// rejectMergeKeys refuses `<<: *anchor`, same as GitHub does
111+
func rejectMergeKeys(node *yaml.Node) error {
112+
if node.Kind != yaml.MappingNode {
113+
return nil
114+
}
115+
for i := 0; i < len(node.Content)-1; i += 2 {
116+
if node.Content[i].Tag == "!!merge" {
117+
return errors.New("merge keys (`<<`) are not supported, alias the whole value instead")
118+
}
119+
}
120+
return nil
121+
}
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
// Copyright 2026 The Gitea Authors. All rights reserved.
2+
// SPDX-License-Identifier: MIT
3+
4+
package jobparser
5+
6+
import (
7+
"testing"
8+
9+
"github.com/stretchr/testify/assert"
10+
"github.com/stretchr/testify/require"
11+
)
12+
13+
func TestParseResolvesAliases(t *testing.T) {
14+
got, err := Parse([]byte(`on: push
15+
env: &common_env
16+
SHARED: "1"
17+
jobs:
18+
a:
19+
runs-on: linux
20+
env: *common_env
21+
steps: &common_steps [{run: echo hi}]
22+
b:
23+
runs-on: linux
24+
env: *common_env
25+
steps: *common_steps
26+
`))
27+
require.NoError(t, err)
28+
require.Len(t, got, 2)
29+
30+
for _, workflow := range got {
31+
_, job := workflow.Job()
32+
var env map[string]string
33+
require.NoError(t, job.Env.Decode(&env))
34+
assert.Equal(t, map[string]string{"SHARED": "1"}, env)
35+
require.Len(t, job.Steps, 1)
36+
37+
payload, err := workflow.Marshal()
38+
require.NoError(t, err)
39+
assert.NotContains(t, string(payload), "common_")
40+
}
41+
}
42+
43+
func TestParseRejectsAliases(t *testing.T) {
44+
job := func(body string) []byte {
45+
return []byte("on: push\njobs:\n a:\n runs-on: linux\n" + body)
46+
}
47+
48+
for _, tt := range []struct {
49+
name, wantErr string
50+
content []byte
51+
}{
52+
{
53+
name: "nested aliases exceed the node limit",
54+
content: []byte(`on: push
55+
x0: &x0 [1, 2, 3, 4, 5, 6, 7, 8, 9]
56+
x1: &x1 [*x0, *x0, *x0, *x0, *x0, *x0, *x0, *x0, *x0]
57+
x2: &x2 [*x1, *x1, *x1, *x1, *x1, *x1, *x1, *x1, *x1]
58+
x3: &x3 [*x2, *x2, *x2, *x2, *x2, *x2, *x2, *x2, *x2]
59+
x4: &x4 [*x3, *x3, *x3, *x3, *x3, *x3, *x3, *x3, *x3]
60+
jobs: {a: {runs-on: linux, steps: [{run: echo}]}}
61+
`),
62+
wantErr: "maximum YAML nodes exceeded",
63+
},
64+
{
65+
name: "anchor aliased from inside itself",
66+
content: job(" steps: &s [{run: echo}, *s]\n"),
67+
wantErr: "maximum YAML nodes exceeded",
68+
},
69+
{
70+
name: "merge key",
71+
content: job(" env: &e {X: \"1\"}\n container:\n image: alpine\n env:\n <<: *e\n"),
72+
wantErr: "merge keys (`<<`) are not supported",
73+
},
74+
{
75+
name: "alias before its anchor",
76+
content: job(" env: *e\n container: {image: alpine, env: &e {X: \"1\"}}\n"),
77+
wantErr: "unknown anchor 'e' referenced",
78+
},
79+
} {
80+
t.Run(tt.name, func(t *testing.T) {
81+
_, err := Parse(tt.content)
82+
require.ErrorContains(t, err, tt.wantErr)
83+
})
84+
}
85+
}

modules/actions/jobparser/jobparser.go

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44
package jobparser
55

66
import (
7-
"bytes"
87
"errors"
98
"fmt"
109
"slices"
@@ -43,7 +42,7 @@ func rawMatrixReadsNeeds(node *yaml.Node) bool {
4342
// a scalar), neither of which describes the one job the payload stands for.
4443
func ParseRawSingleWorkflow(payload []byte) (*SingleWorkflow, *Job, error) {
4544
swf := &SingleWorkflow{}
46-
if err := yaml.Unmarshal(payload, swf); err != nil {
45+
if err := decodeResolved(payload, swf); err != nil {
4746
return nil, nil, fmt.Errorf("unmarshal single workflow: %w", err)
4847
}
4948
id, job := swf.Job()
@@ -96,14 +95,21 @@ func expressionReadsContext(value, contextName string) bool {
9695
}
9796

9897
func Parse(content []byte, options ...ParseOption) ([]*SingleWorkflow, error) {
99-
origin, err := model.ReadWorkflow(bytes.NewReader(content))
98+
// The workflow is split into one document per job below, which would strand an alias whose
99+
// anchor lands in another one.
100+
doc, err := resolveYamlAliases(content)
100101
if err != nil {
101-
return nil, fmt.Errorf("model.ReadWorkflow: %w", err)
102+
return nil, fmt.Errorf("resolve aliases: %w", err)
103+
}
104+
105+
origin, err := readWorkflowDoc(doc)
106+
if err != nil {
107+
return nil, fmt.Errorf("read workflow: %w", err)
102108
}
103109

104110
workflow := &SingleWorkflow{}
105-
if err := yaml.Unmarshal(content, workflow); err != nil {
106-
return nil, fmt.Errorf("yaml.Unmarshal: %w", err)
111+
if err := decodeYamlDoc(doc, workflow); err != nil {
112+
return nil, fmt.Errorf("decode workflow: %w", err)
107113
}
108114

109115
pc := &parseContext{}
@@ -248,9 +254,6 @@ func validateMatrixFilters(job *model.Job) error {
248254
entries = value.Content
249255
}
250256
for _, entry := range entries {
251-
if entry.Kind == yaml.AliasNode {
252-
entry = entry.Alias
253-
}
254257
if entry.Kind != yaml.MappingNode {
255258
return fmt.Errorf("matrix %s must be a list of mappings", name)
256259
}

modules/actions/jobparser/model.go

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -259,9 +259,11 @@ func (evt *Event) Inputs() []WorkflowDispatchInput {
259259
}
260260

261261
func ReadWorkflowRawConcurrency(content []byte) (*model.RawConcurrency, error) {
262-
w := new(model.Workflow)
263-
err := yaml.NewDecoder(bytes.NewReader(content)).Decode(w)
264-
return w.RawConcurrency, err
262+
w, err := ReadWorkflow(content)
263+
if err != nil {
264+
return nil, err
265+
}
266+
return w.RawConcurrency, nil
265267
}
266268

267269
func EvaluateConcurrency(rc *model.RawConcurrency, jobID string, job *Job, gitCtx map[string]any, results map[string]*JobResult, vars map[string]string, inputs map[string]any) (string, bool, error) {

modules/actions/jobparser/workflow_call.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ func ParseWorkflowCallSpec(content []byte) (*WorkflowCallSpec, error) {
6363
var doc struct {
6464
On yaml.Node `yaml:"on"`
6565
}
66-
if err := yaml.Unmarshal(content, &doc); err != nil {
66+
if err := decodeResolved(content, &doc); err != nil {
6767
return nil, fmt.Errorf("parse workflow yaml: %w", err)
6868
}
6969

modules/actions/workflows.go

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44
package actions
55

66
import (
7-
"bytes"
87
"context"
98
"fmt"
109
"path"
@@ -121,7 +120,7 @@ func GetContentFromEntry(ctx context.Context, gitRepo *git.Repository, entry *gi
121120
}
122121

123122
func GetEventsFromContent(content []byte) ([]*jobparser.Event, error) {
124-
workflow, err := model.ReadWorkflow(bytes.NewReader(content))
123+
workflow, err := jobparser.ReadWorkflow(content)
125124
if err != nil {
126125
return nil, err
127126
}

routers/web/repo/actions/actions.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44
package actions
55

66
import (
7-
"bytes"
87
stdCtx "context"
98
"errors"
109
"fmt"
@@ -21,6 +20,7 @@ import (
2120
repo_model "gitea.dev/models/repo"
2221
"gitea.dev/models/unit"
2322
"gitea.dev/modules/actions"
23+
"gitea.dev/modules/actions/jobparser"
2424
"gitea.dev/modules/base"
2525
"gitea.dev/modules/container"
2626
"gitea.dev/modules/git"
@@ -220,7 +220,7 @@ func prepareWorkflowTemplate(ctx *context.Context, commit *git.Commit) (workflow
220220
ctx.ServerError("GetContentFromEntry", err)
221221
return nil, ""
222222
}
223-
wf, err := act_model.ReadWorkflow(bytes.NewReader(content))
223+
wf, err := jobparser.ReadWorkflow(content)
224224
if err != nil {
225225
workflow.ErrMsg = ctx.Locale.TrString("actions.runs.invalid_workflow_helper", err.Error())
226226
workflows = append(workflows, workflow)
@@ -390,7 +390,7 @@ func loadScopedWorkflowModel(ctx *context.Context, repo *repo_model.Repository,
390390
if content == nil {
391391
return nil // the workflow does not exist on the source's default branch
392392
}
393-
wf, err := act_model.ReadWorkflow(bytes.NewReader(content))
393+
wf, err := jobparser.ReadWorkflow(content)
394394
if err != nil {
395395
return nil
396396
}

services/actions/notifier_helper.go

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,11 @@
44
package actions
55

66
import (
7-
"bytes"
87
"context"
98
"fmt"
109
"slices"
1110
"strings"
1211

13-
"gitea.dev/actionslib/pkg/model"
1412
actions_model "gitea.dev/models/actions"
1513
"gitea.dev/models/db"
1614
issues_model "gitea.dev/models/issues"
@@ -20,6 +18,7 @@ import (
2018
unit_model "gitea.dev/models/unit"
2119
user_model "gitea.dev/models/user"
2220
actions_module "gitea.dev/modules/actions"
21+
"gitea.dev/modules/actions/jobparser"
2322
"gitea.dev/modules/container"
2423
"gitea.dev/modules/git"
2524
"gitea.dev/modules/json"
@@ -553,7 +552,7 @@ func handleSchedules(
553552
crons := make([]*actions_model.ActionSchedule, 0, len(detectedWorkflows))
554553
for _, dwf := range detectedWorkflows {
555554
// Check cron job condition. Only working in default branch
556-
workflow, err := model.ReadWorkflow(bytes.NewReader(dwf.Content))
555+
workflow, err := jobparser.ReadWorkflow(dwf.Content)
557556
if err != nil {
558557
log.Error("ReadWorkflow: %v", err)
559558
continue

services/actions/permission_parser.go

Lines changed: 5 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -40,17 +40,13 @@ func parseRawPermissionsExplicit(rawPerms *yaml.Node) *repo_model.ActionsTokenPe
4040
return nil
4141
}
4242

43-
// Unwrap DocumentNode and resolve AliasNode
43+
// Unwrap DocumentNode
4444
node := rawPerms
45-
for node.Kind == yaml.DocumentNode || node.Kind == yaml.AliasNode {
46-
if node.Kind == yaml.DocumentNode {
47-
if len(node.Content) == 0 {
48-
return nil
49-
}
50-
node = node.Content[0]
51-
} else {
52-
node = node.Alias
45+
for node.Kind == yaml.DocumentNode {
46+
if len(node.Content) == 0 {
47+
return nil
5348
}
49+
node = node.Content[0]
5450
}
5551

5652
if node.Kind == yaml.ScalarNode && node.Value == "" {

0 commit comments

Comments
 (0)