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 https://github.com/go-gitea/gitea/issues/38983
Signed-off-by: silverwind <me@silverwind.io>
This commit is contained in:
silverwind
2026-08-20 07:03:10 +02:00
committed by GitHub
parent 89b891b168
commit ed4d7ea08d
11 changed files with 240 additions and 38 deletions
+121
View File
@@ -0,0 +1,121 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package jobparser
import (
"errors"
"io"
"gitea.dev/actionslib/pkg/model"
"go.yaml.in/yaml/v4"
)
// maxExpandedNodes bounds how many nodes alias expansion may create. go-yaml's own alias guard does
// not cover us: it only counts while decoding into values, and a workflow is kept as raw yaml.Nodes.
const maxExpandedNodes = 50000
var errTooManyYamlNodes = errors.New("maximum YAML nodes exceeded")
// ReadWorkflow decodes a workflow file with its aliases expanded. Callers inspect the workflow's
// raw nodes by kind, and an alias is a kind none of them expect.
func ReadWorkflow(content []byte) (*model.Workflow, error) {
doc, err := resolveYamlAliases(content)
if err != nil {
return nil, err
}
return readWorkflowDoc(doc)
}
func readWorkflowDoc(doc *yaml.Node) (*model.Workflow, error) {
if doc.Kind == 0 {
return nil, io.EOF // what a yaml decoder reports for an empty file
}
w := new(model.Workflow)
return w, doc.Decode(w)
}
// decodeResolved is yaml.Unmarshal with aliases expanded first.
func decodeResolved(content []byte, out any) error {
doc, err := resolveYamlAliases(content)
if err != nil {
return err
}
return decodeYamlDoc(doc, out)
}
func decodeYamlDoc(doc *yaml.Node, out any) error {
if doc.Kind == 0 {
return nil // an empty document, as yaml.Unmarshal treats it
}
return doc.Decode(out)
}
// resolveYamlAliases parses content and replaces every alias with a copy of the node its anchor names.
func resolveYamlAliases(content []byte) (*yaml.Node, error) {
doc := &yaml.Node{}
if err := yaml.Unmarshal(content, doc); err != nil {
return nil, err
}
budget := maxExpandedNodes
return doc, expandAliases(doc, &budget)
}
// expandAliases replaces node's alias descendants in place.
func expandAliases(node *yaml.Node, budget *int) error {
node.Anchor = "" // a name for a node, not part of the workflow: keep it out of the payloads
if err := rejectMergeKeys(node); err != nil {
return err
}
for i, child := range node.Content {
if child.Kind != yaml.AliasNode {
if err := expandAliases(child, budget); err != nil {
return err
}
continue
}
copied, err := copyExpanded(child.Alias, budget)
if err != nil {
return err
}
node.Content[i] = copied
}
return nil
}
// copyExpanded deep copies a node expandAliases already expanded and validated, since an anchor is
// declared before the alias naming it. An anchor aliased from inside itself is the exception, and
// recurses here until it exhausts budget.
func copyExpanded(node *yaml.Node, budget *int) (*yaml.Node, error) {
if *budget--; *budget < 0 {
return nil, errTooManyYamlNodes
}
if node.Kind == yaml.AliasNode {
return copyExpanded(node.Alias, budget)
}
copied := *node
copied.Content = make([]*yaml.Node, len(node.Content))
for i, child := range node.Content {
child, err := copyExpanded(child, budget)
if err != nil {
return nil, err
}
copied.Content[i] = child
}
return &copied, nil
}
// rejectMergeKeys refuses `<<: *anchor`, same as GitHub does
func rejectMergeKeys(node *yaml.Node) error {
if node.Kind != yaml.MappingNode {
return nil
}
for i := 0; i < len(node.Content)-1; i += 2 {
if node.Content[i].Tag == "!!merge" {
return errors.New("merge keys (`<<`) are not supported, alias the whole value instead")
}
}
return nil
}
+85
View File
@@ -0,0 +1,85 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package jobparser
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestParseResolvesAliases(t *testing.T) {
got, err := Parse([]byte(`on: push
env: &common_env
SHARED: "1"
jobs:
a:
runs-on: linux
env: *common_env
steps: &common_steps [{run: echo hi}]
b:
runs-on: linux
env: *common_env
steps: *common_steps
`))
require.NoError(t, err)
require.Len(t, got, 2)
for _, workflow := range got {
_, job := workflow.Job()
var env map[string]string
require.NoError(t, job.Env.Decode(&env))
assert.Equal(t, map[string]string{"SHARED": "1"}, env)
require.Len(t, job.Steps, 1)
payload, err := workflow.Marshal()
require.NoError(t, err)
assert.NotContains(t, string(payload), "common_")
}
}
func TestParseRejectsAliases(t *testing.T) {
job := func(body string) []byte {
return []byte("on: push\njobs:\n a:\n runs-on: linux\n" + body)
}
for _, tt := range []struct {
name, wantErr string
content []byte
}{
{
name: "nested aliases exceed the node limit",
content: []byte(`on: push
x0: &x0 [1, 2, 3, 4, 5, 6, 7, 8, 9]
x1: &x1 [*x0, *x0, *x0, *x0, *x0, *x0, *x0, *x0, *x0]
x2: &x2 [*x1, *x1, *x1, *x1, *x1, *x1, *x1, *x1, *x1]
x3: &x3 [*x2, *x2, *x2, *x2, *x2, *x2, *x2, *x2, *x2]
x4: &x4 [*x3, *x3, *x3, *x3, *x3, *x3, *x3, *x3, *x3]
jobs: {a: {runs-on: linux, steps: [{run: echo}]}}
`),
wantErr: "maximum YAML nodes exceeded",
},
{
name: "anchor aliased from inside itself",
content: job(" steps: &s [{run: echo}, *s]\n"),
wantErr: "maximum YAML nodes exceeded",
},
{
name: "merge key",
content: job(" env: &e {X: \"1\"}\n container:\n image: alpine\n env:\n <<: *e\n"),
wantErr: "merge keys (`<<`) are not supported",
},
{
name: "alias before its anchor",
content: job(" env: *e\n container: {image: alpine, env: &e {X: \"1\"}}\n"),
wantErr: "unknown anchor 'e' referenced",
},
} {
t.Run(tt.name, func(t *testing.T) {
_, err := Parse(tt.content)
require.ErrorContains(t, err, tt.wantErr)
})
}
}
+12 -9
View File
@@ -4,7 +4,6 @@
package jobparser
import (
"bytes"
"errors"
"fmt"
"slices"
@@ -43,7 +42,7 @@ func rawMatrixReadsNeeds(node *yaml.Node) bool {
// a scalar), neither of which describes the one job the payload stands for.
func ParseRawSingleWorkflow(payload []byte) (*SingleWorkflow, *Job, error) {
swf := &SingleWorkflow{}
if err := yaml.Unmarshal(payload, swf); err != nil {
if err := decodeResolved(payload, swf); err != nil {
return nil, nil, fmt.Errorf("unmarshal single workflow: %w", err)
}
id, job := swf.Job()
@@ -96,14 +95,21 @@ func expressionReadsContext(value, contextName string) bool {
}
func Parse(content []byte, options ...ParseOption) ([]*SingleWorkflow, error) {
origin, err := model.ReadWorkflow(bytes.NewReader(content))
// The workflow is split into one document per job below, which would strand an alias whose
// anchor lands in another one.
doc, err := resolveYamlAliases(content)
if err != nil {
return nil, fmt.Errorf("model.ReadWorkflow: %w", err)
return nil, fmt.Errorf("resolve aliases: %w", err)
}
origin, err := readWorkflowDoc(doc)
if err != nil {
return nil, fmt.Errorf("read workflow: %w", err)
}
workflow := &SingleWorkflow{}
if err := yaml.Unmarshal(content, workflow); err != nil {
return nil, fmt.Errorf("yaml.Unmarshal: %w", err)
if err := decodeYamlDoc(doc, workflow); err != nil {
return nil, fmt.Errorf("decode workflow: %w", err)
}
pc := &parseContext{}
@@ -248,9 +254,6 @@ func validateMatrixFilters(job *model.Job) error {
entries = value.Content
}
for _, entry := range entries {
if entry.Kind == yaml.AliasNode {
entry = entry.Alias
}
if entry.Kind != yaml.MappingNode {
return fmt.Errorf("matrix %s must be a list of mappings", name)
}
+5 -3
View File
@@ -259,9 +259,11 @@ func (evt *Event) Inputs() []WorkflowDispatchInput {
}
func ReadWorkflowRawConcurrency(content []byte) (*model.RawConcurrency, error) {
w := new(model.Workflow)
err := yaml.NewDecoder(bytes.NewReader(content)).Decode(w)
return w.RawConcurrency, err
w, err := ReadWorkflow(content)
if err != nil {
return nil, err
}
return w.RawConcurrency, nil
}
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) {
+1 -1
View File
@@ -63,7 +63,7 @@ func ParseWorkflowCallSpec(content []byte) (*WorkflowCallSpec, error) {
var doc struct {
On yaml.Node `yaml:"on"`
}
if err := yaml.Unmarshal(content, &doc); err != nil {
if err := decodeResolved(content, &doc); err != nil {
return nil, fmt.Errorf("parse workflow yaml: %w", err)
}
+1 -2
View File
@@ -4,7 +4,6 @@
package actions
import (
"bytes"
"context"
"fmt"
"path"
@@ -121,7 +120,7 @@ func GetContentFromEntry(ctx context.Context, gitRepo *git.Repository, entry *gi
}
func GetEventsFromContent(content []byte) ([]*jobparser.Event, error) {
workflow, err := model.ReadWorkflow(bytes.NewReader(content))
workflow, err := jobparser.ReadWorkflow(content)
if err != nil {
return nil, err
}