From c4fc4d363b3b8afcb016354c1740e9cb5b65fc43 Mon Sep 17 00:00:00 2001 From: Mohamed Belkamel <39389636+darklight147@users.noreply.github.com> Date: Thu, 23 Jul 2026 14:42:21 +0100 Subject: [PATCH] fix(actions): make SingleWorkflow.Marshal round-trip multi-line run blocks (stop silent job stranding) (#38520) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem Jobs that call a reusable workflow (`uses:`) whose steps contain a `run: |` block that **starts with blank lines** never start — the child jobs stay `Blocked` forever, the run never finishes, and nothing is shown to the user (the error is only logged at DEBUG). The reusable is valid YAML and worked on 1.26. ## Root cause `jobparser` serializes each expanded job via `SingleWorkflow.SetJob()`, which uses `yaml.NewEncoder(...).SetIndent(2)`, but `SingleWorkflow.Marshal()` used `yaml.Marshal`, whose default indentation is **4**. Re-emitting a multi-line literal block scalar at a different indentation makes the encoder write a wrong explicit indentation indicator (`run: |4`) whose declared indent doesn't match the actual content indent. The stored `workflow_payload` is then unparseable: ``` run: |4 while ... ``` `jobparser.Parse` / `model.ReadWorkflow` (both go.yaml.in/yaml/v4) reject it: `did not find expected key`. This surfaces in `services/actions/job_emitter.go` `resolve()` → `updateConcurrencyEvaluationForJobWithNeeds` → `ParseJob`, where the error is swallowed at `log.Debug` and the job is left `Blocked`. Encoding at indent 4 triggers the bad indicator; indent 2 does not — matching the value already used by `SetJob`. ## Fix Encode `SingleWorkflow.Marshal()` with `SetIndent(2)` so both encoders agree and the serialized single workflow round-trips. Adds a regression test (`Parse → Marshal → Parse` on a `run:` block with leading blank lines) that fails before the change with `did not find expected key`. --------- Signed-off-by: quasimodo Co-authored-by: quasimodo Co-authored-by: Zettat123 --- modules/actions/jobparser/model.go | 17 +++++- modules/actions/jobparser/roundtrip_test.go | 64 +++++++++++++++++++++ 2 files changed, 80 insertions(+), 1 deletion(-) create mode 100644 modules/actions/jobparser/roundtrip_test.go diff --git a/modules/actions/jobparser/model.go b/modules/actions/jobparser/model.go index 5950feb5c8..1df8d89350 100644 --- a/modules/actions/jobparser/model.go +++ b/modules/actions/jobparser/model.go @@ -75,7 +75,22 @@ func (w *SingleWorkflow) SetJob(id string, job *Job) error { } func (w *SingleWorkflow) Marshal() ([]byte, error) { - return yaml.Marshal(w) + // 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) + if err := enc.Encode(w); err != nil { + return nil, err + } + if err := enc.Close(); err != nil { + return nil, err + } + return buf.Bytes(), nil } type Job struct { diff --git a/modules/actions/jobparser/roundtrip_test.go b/modules/actions/jobparser/roundtrip_test.go new file mode 100644 index 0000000000..ae4fa4dee2 --- /dev/null +++ b/modules/actions/jobparser/roundtrip_test.go @@ -0,0 +1,64 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package jobparser + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +// A step whose `run:` block starts with blank lines must still survive the +// Parse -> SingleWorkflow.Marshal -> Parse round-trip. Previously Marshal used a +// different indentation than SetJob, which made the encoder emit the block scalar +// with a wrong explicit indentation indicator (`run: |4`) that no longer parsed; +// the job then stayed silently blocked during concurrency evaluation. +func TestSingleWorkflowRoundTripRunBlockLeadingBlankLines(t *testing.T) { + const wf = `name: demo +on: + workflow_call: + inputs: + app_name: + type: string + required: true +jobs: + build: + name: build + env: + IMAGE_TAG: ${{ inputs.app_name }} + runs-on: ubuntu-latest + steps: + - if: ${{ inputs.app_name != '' }} + name: packages + run: | + + + echo start + echo done +` + sws, err := Parse([]byte(wf)) + require.NoError(t, err) + require.Len(t, sws, 1) + + // pin the original run block as the baseline + _, origJob := sws[0].Job() + require.Len(t, origJob.Steps, 1) + const wantRun = "\n\necho start\necho done\n" + require.Equal(t, wantRun, origJob.Steps[0].Run) + + payload, err := sws[0].Marshal() + require.NoError(t, err) + + // the serialized single workflow must be parseable again -- this is what the + // server does in EvaluateJobConcurrencyFillModel -> ParseJob. Before the fix + // Marshal emitted `run: |4`, which failed here and left the job blocked. + roundTripped, err := Parse(payload) + require.NoError(t, err, "serialized single workflow must round-trip; got payload:\n%s", payload) + require.Len(t, roundTripped, 1) + + // 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, gotJob.Steps[0].Run, "round-trip must preserve run content; got payload:\n%s", payload) +}