fix(actions): keep folded block scalars intact across serialization (#39787)

Folded (`>`) scalars in workflows gained blank lines and had spaces
turned into newlines every time Gitea re-serialized a job. This bumps
go-yaml to its main branch and drops our workarounds. The runner gets
the same change in https://gitea.com/gitea/runner/pulls/1321.

- go-yaml main fixes the accumulating blank lines
(https://github.com/yaml/go-yaml/pull/366) and the block scalar
indentation indicators (https://github.com/yaml/go-yaml/pull/414), so
`BlockSafeString` and the folded scalar requoting go away
- The job encoder no longer wraps lines, because go-yaml still wraps
more-indented lines of folded scalars and turns a space into a newline
- The fix applies to folded scalars anywhere in a workflow, including
workflow-level `env`

Fixes https://github.com/go-gitea/gitea/issues/39775
Co-authored-by: silverwind <me@silverwind.io>
This commit is contained in:
Lunny Xiao authored and GitHub committed 2026-10-16 22:53:37 +00:00
1 parent 078198d38d
commit c1ae4ec29e
7 files changed
+72 -59

No files matched your search

+23 -48
View File
@@ -4,7 +4,6 @@
package jobparser
import (
"bytes"
"errors"
"fmt"
"strings"
@@ -65,44 +64,31 @@ func (w *SingleWorkflow) SetJob(id string, job *Job) error {
m := map[string]*Job{
id: job,
}
var buf bytes.Buffer
encoder := yaml.NewEncoder(&buf)
encoder.SetIndent(2)
if err := encoder.Encode(m); err != nil {
out, err := marshalYAML(m)
if err != nil {
return err
}
encoder.Close()
node := yaml.Node{}
if err := yaml.Unmarshal(buf.Bytes(), &node); err != nil {
if err := yaml.Unmarshal(out, &node); err != nil {
return err
}
if len(node.Content) != 1 || node.Content[0].Kind != yaml.MappingNode {
return fmt.Errorf("can not set job: %s", buf.String())
return fmt.Errorf("can not set job: %s", out)
}
w.RawJobs = *node.Content[0]
return nil
}
func (w *SingleWorkflow) Marshal() ([]byte, error) {
// Encode with the same indentation SetJob uses (2). yaml.Marshal's default
// indentation (4) makes the encoder emit multi-line block scalars (e.g. a
// `run:` step that begins with blank lines) with a wrong explicit indentation
// indicator (`run: |4`) that then fails to re-parse, which silently strands
// the job during concurrency evaluation. Keeping both encoders at indent 2
// makes the serialized single workflow round-trip.
var buf bytes.Buffer
enc := yaml.NewEncoder(&buf)
enc.SetIndent(2)
payload := *w
payload.RunName = "" // already interpolated into the run title, a runner would parse it as a template again
if err := enc.Encode(&payload); err != nil {
return nil, err
}
if err := enc.Close(); err != nil {
return nil, err
}
return buf.Bytes(), nil
return marshalYAML(&payload)
}
// marshalYAML never wraps lines, go-yaml wraps more-indented lines of folded scalars until https://github.com/yaml/go-yaml/pull/441 is released.
func marshalYAML(v any) ([]byte, error) {
return yaml.Dump(v, yaml.WithV3Defaults(), yaml.WithIndent(2), yaml.WithLineWidth(-1))
}
type Job struct {
@@ -196,29 +182,18 @@ func (j *Job) DisplayName() string {
return util.EllipsisDisplayString(unescapeExpressions(j.Name), 255)
}
// BlockSafeString works around https://github.com/yaml/go-yaml/issues/399, quoting a value whose
// leading newline would cost a literal block scalar its indentation indicator.
type BlockSafeString string
func (s BlockSafeString) MarshalYAML() (any, error) {
if !strings.HasPrefix(string(s), "\n") {
return string(s), nil
}
return &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Style: yaml.DoubleQuotedStyle, Value: string(s)}, nil
}
type Step struct {
ID string `yaml:"id,omitempty"`
If yaml.Node `yaml:"if,omitempty"`
Name BlockSafeString `yaml:"name,omitempty"`
Uses string `yaml:"uses,omitempty"`
Run BlockSafeString `yaml:"run,omitempty"`
WorkingDirectory string `yaml:"working-directory,omitempty"`
Shell string `yaml:"shell,omitempty"`
Env yaml.Node `yaml:"env,omitempty"`
With yaml.Node `yaml:"with,omitempty"`
RawContinueOnError yaml.Node `yaml:"continue-on-error,omitempty"` // raw: the runner evaluates it with the steps context
TimeoutMinutes string `yaml:"timeout-minutes,omitempty"`
ID string `yaml:"id,omitempty"`
If yaml.Node `yaml:"if,omitempty"`
Name string `yaml:"name,omitempty"`
Uses string `yaml:"uses,omitempty"`
Run string `yaml:"run,omitempty"`
WorkingDirectory string `yaml:"working-directory,omitempty"`
Shell string `yaml:"shell,omitempty"`
Env yaml.Node `yaml:"env,omitempty"`
With yaml.Node `yaml:"with,omitempty"`
RawContinueOnError yaml.Node `yaml:"continue-on-error,omitempty"` // raw: the runner evaluates it with the steps context
TimeoutMinutes string `yaml:"timeout-minutes,omitempty"`
}
// UnmarshalYAML canonicalizes booleans like continue-on-error
@@ -240,9 +215,9 @@ func (s *Step) String() string {
}
return (&model.Step{
ID: s.ID,
Name: string(s.Name),
Name: s.Name,
Uses: s.Uses,
Run: string(s.Run),
Run: s.Run,
}).String()
}
+36 -2
View File
@@ -48,7 +48,7 @@ jobs:
_, origJob := sws[0].Job()
require.Len(t, origJob.Steps, 1)
const wantRun = "\n\necho start\necho done\n"
require.Equal(t, wantRun, string(origJob.Steps[0].Run))
require.Equal(t, wantRun, origJob.Steps[0].Run)
payload, err := sws[0].Marshal()
require.NoError(t, err)
@@ -63,7 +63,7 @@ jobs:
// the round-trip must preserve the run block byte-for-byte
_, gotJob := roundTripped[0].Job()
require.Len(t, gotJob.Steps, 1)
require.Equal(t, wantRun, string(gotJob.Steps[0].Run), "round-trip must preserve run content; got payload:\n%s", payload)
require.Equal(t, wantRun, gotJob.Steps[0].Run, "round-trip must preserve run content; got payload:\n%s", payload)
}
// Typing a step's continue-on-error as a bool used to reject the whole `jobs:` node.
@@ -104,3 +104,37 @@ jobs:
require.Equal(t, w, steps[i].RawContinueOnError, "step %d, payload:\n%s", i, payload)
}
}
// Folded (`>`) scalars in with/env used to gain blank lines and have spaces turned into newlines
// on every Marshal -> Parse cycle.
func TestSingleWorkflowRoundTripFoldedScalar(t *testing.T) {
const wf = `name: demo
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: ./.github/actions/build
with:
cfg: >
begin
first "indented" line with path dir/Installer Scripts/some long path/another long path/some file name.xzy,
second "indented" line
end
`
const want = "begin\n first \"indented\" line with path dir/Installer Scripts/some long path/another long path/some file name.xzy,\n second \"indented\" line\nend\n"
sws, err := Parse([]byte(wf))
require.NoError(t, err)
require.Len(t, sws, 1)
for range 3 {
payload, err := sws[0].Marshal()
require.NoError(t, err)
sws, err = Parse(payload)
require.NoError(t, err)
require.Len(t, sws, 1)
}
_, job := sws[0].Job()
var with map[string]string
require.NoError(t, job.Steps[0].With.Decode(&with))
require.Equal(t, want, with["cfg"])
}
@@ -7,5 +7,9 @@ jobs:
runs-on: ubuntu-latest
steps:
- id: extract_tag
name: "\nExtract tag for variant"
run: "\necho Test\n"
name: |-
Extract tag for variant
run: |
echo Test