mirror of
https://github.com/go-gitea/gitea.git
synced 2026-10-01 20:59:45 +09:00
fix(actions): keep the caller's event and inputs in reusable workflow jobs (#39452)
Fixes https://github.com/go-gitea/gitea/issues/38705 Fixes https://gitea.com/gitea/runner/issues/1232 Jobs of a called workflow saw `gitea.event_name` as `workflow_call` and `gitea.event.inputs` replaced by the caller's `with:`, so a condition like `gitea.event_name == 'push'` never held in them, and a dispatched run's own inputs were lost there. They now keep the caller's trigger event and their `inputs` are the run's `workflow_dispatch` inputs overlaid with the caller's `with:`, as on GitHub. For example, with `workflow_dispatch` inputs `{target: prod, debug: true}` and caller's `with: {target: dev}`, the called workflow's `inputs` are `{target: dev, debug: true}`. A runner cannot resolve these inputs itself, so they are sent in a new `gitea_workflow_call` context entry, with the original event name and inputs for the runner to restore. For more details, see the runner PR: https://gitea.com/gitea/runner/pulls/1250 Co-authored-by: silverwind <me@silverwind.io>
This commit is contained in:
@@ -116,7 +116,7 @@ type ActionRunJob struct {
|
||||
// - JSON object : explicit mapping {alias: source_name}; names only, no values.
|
||||
// Only set when IsReusableCaller is true.
|
||||
CallSecrets string `xorm:"LONGTEXT"`
|
||||
// CallPayload is the JSON-encoded WorkflowCallPayload exposed to children as gitea.event.
|
||||
// CallPayload is the JSON-encoded WorkflowCallPayload.
|
||||
// Populated atomically with IsExpanded at the end of expandReusableWorkflowCaller.
|
||||
// Only set when IsReusableCaller is true.
|
||||
CallPayload string `xorm:"LONGTEXT"`
|
||||
|
||||
@@ -25,7 +25,6 @@ import (
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/optional"
|
||||
"gitea.dev/modules/setting"
|
||||
api "gitea.dev/modules/structs"
|
||||
)
|
||||
|
||||
type GiteaContext map[string]any
|
||||
@@ -113,31 +112,6 @@ func GenerateGiteaContext(ctx context.Context, run *actions_model.ActionRun, att
|
||||
if job != nil {
|
||||
gitContext["job"] = job.JobID
|
||||
gitContext["run_attempt"] = strconv.FormatInt(job.Attempt, 10)
|
||||
|
||||
if job.ParentJobID > 0 {
|
||||
// Inject the caller's resolved workflow_call inputs into gitea.event.inputs.
|
||||
// The rest of gitea.event stays as the caller's actual trigger event (push/pull_request/etc.)
|
||||
// to match GitHub's semantics (see https://docs.github.com/en/actions/reference/workflows-and-actions/reusing-workflow-configurations#github-context).
|
||||
// FIXME: If the run is triggered by "workflow_dispatch", the original inputs of "workflow_dispatch" will be overridden.
|
||||
// If necessary, the caller can send these values to the called workflow via `with:`.
|
||||
caller, err := actions_model.GetRunJobByRunAndID(ctx, job.RunID, job.ParentJobID)
|
||||
if err != nil {
|
||||
log.Error("GenerateGiteaContext: load caller job %d of job %d: %v", job.ParentJobID, job.ID, err)
|
||||
} else if caller.CallPayload != "" {
|
||||
var cp api.WorkflowCallPayload
|
||||
if err := json.Unmarshal([]byte(caller.CallPayload), &cp); err != nil {
|
||||
log.Error("GenerateGiteaContext: decode CallPayload of caller %d: %v", caller.ID, err)
|
||||
} else if cp.Inputs != nil {
|
||||
event["inputs"] = cp.Inputs
|
||||
}
|
||||
}
|
||||
|
||||
// Override gitea.event_name to "workflow_call", so that the runner-side `getEvaluatorInputs` can get inputs from event["inputs"].
|
||||
// https://gitea.com/gitea/runner/src/commit/0b9f251b6abb30d5f292a49cfe0c611f7c26d857/act/runner/expression.go#L509
|
||||
// FIXME: The trade-off is that `${{ gitea.event_name }}` inside a reusable workflow's child job reads "workflow_call"
|
||||
// instead of the caller's real trigger event name (push/pull_request/etc.) This is a small deviation from GitHub spec.
|
||||
gitContext["event_name"] = "workflow_call"
|
||||
}
|
||||
}
|
||||
|
||||
if attempt == nil {
|
||||
@@ -288,15 +262,14 @@ func computeReusableCallerOutputs(ctx context.Context, caller *actions_model.Act
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
inputs := map[string]any{}
|
||||
if caller.CallPayload != "" {
|
||||
var p api.WorkflowCallPayload
|
||||
if err := json.Unmarshal([]byte(caller.CallPayload), &p); err != nil {
|
||||
return nil, fmt.Errorf("decode caller payload: %w", err)
|
||||
}
|
||||
if p.Inputs != nil {
|
||||
inputs = p.Inputs
|
||||
}
|
||||
workflowCallInputs, err := decodeWorkflowCallInputs(caller)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
inputs, err := calledWorkflowInputs(ctx, caller.Run, caller, workflowCallInputs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// See `on.workflow_call.outputs.<output_id>.value` in https://docs.github.com/en/actions/reference/workflows-and-actions/contexts#context-availability
|
||||
|
||||
@@ -293,8 +293,10 @@ func TestComputeReusableCallerOutputs(t *testing.T) {
|
||||
assert.Equal(t, map[string]string{"result": "bar"}, out)
|
||||
})
|
||||
|
||||
t.Run("CallPayload inputs reachable in output expression", func(t *testing.T) {
|
||||
t.Run("CallPayload and dispatch inputs reachable in output expression", func(t *testing.T) {
|
||||
run := insertRun(t, "payload-out.yaml")
|
||||
run.Event, run.EventPayload = "workflow_dispatch", `{"inputs":{"target":"prod"}}`
|
||||
require.NoError(t, actions_model.UpdateRun(ctx, run, "event", "event_payload"))
|
||||
payload, err := json.Marshal(api.WorkflowCallPayload{
|
||||
Inputs: map[string]any{"env": "staging"},
|
||||
})
|
||||
@@ -307,11 +309,16 @@ func TestComputeReusableCallerOutputs(t *testing.T) {
|
||||
outputs:
|
||||
env:
|
||||
value: ${{ inputs.env }}
|
||||
target:
|
||||
value: ${{ inputs.target }}
|
||||
`, string(payload))
|
||||
caller.WorkflowPayload = []byte("on: {workflow_dispatch: {inputs: {target: {type: string}}}}\njobs:\n caller:\n uses: ./.gitea/workflows/callee.yml\n")
|
||||
_, err = actions_model.UpdateRunJob(ctx, caller, nil, "workflow_payload")
|
||||
require.NoError(t, err)
|
||||
|
||||
out, err := computeReusableCallerOutputs(ctx, caller, childrenByParentOfRun(t, run.ID))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, map[string]string{"env": "staging"}, out)
|
||||
assert.Equal(t, map[string]string{"env": "staging", "target": "prod"}, out)
|
||||
})
|
||||
|
||||
t.Run("nested caller outputs propagate to outer", func(t *testing.T) {
|
||||
|
||||
@@ -7,6 +7,8 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
"strings"
|
||||
|
||||
actions_model "gitea.dev/models/actions"
|
||||
actions_module "gitea.dev/modules/actions"
|
||||
@@ -53,7 +55,7 @@ func dispatchInputsForRunJobs(run *actions_model.ActionRun, jobs []*actions_mode
|
||||
|
||||
// getInputsForJob returns the `inputs.*` top-level expression context for a job's evaluation.
|
||||
// - For top-level jobs, it falls back to the run's dispatch inputs (empty for non-dispatch events)
|
||||
// - For reusable workflow children (and nested callers), this is the direct parent caller's CallPayload.Inputs
|
||||
// - For reusable workflow children (and nested callers), this is calledWorkflowInputs of the direct parent caller
|
||||
func getInputsForJob(ctx context.Context, run *actions_model.ActionRun, job *actions_model.ActionRunJob) (map[string]any, error) {
|
||||
if job.ParentJobID == 0 {
|
||||
return dispatchInputsForJob(run, job)
|
||||
@@ -63,20 +65,44 @@ func getInputsForJob(ctx context.Context, run *actions_model.ActionRun, job *act
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load caller job %d: %w", job.ParentJobID, err)
|
||||
}
|
||||
if caller.CallPayload == "" {
|
||||
// should not happen - a child job cannot reach this point if its caller's CallPayload hasn't been evaluated
|
||||
return map[string]any{}, nil
|
||||
workflowCallInputs, err := decodeWorkflowCallInputs(caller)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return calledWorkflowInputs(ctx, run, caller, workflowCallInputs)
|
||||
}
|
||||
|
||||
func decodeWorkflowCallInputs(caller *actions_model.ActionRunJob) (map[string]any, error) {
|
||||
var p api.WorkflowCallPayload
|
||||
if err := json.Unmarshal([]byte(caller.CallPayload), &p); err != nil {
|
||||
return nil, util.NewInvalidArgumentErrorf("decode caller %d payload: %v", caller.ID, err)
|
||||
}
|
||||
if p.Inputs == nil {
|
||||
return map[string]any{}, nil
|
||||
if caller.CallPayload != "" {
|
||||
if err := json.Unmarshal([]byte(caller.CallPayload), &p); err != nil {
|
||||
return nil, util.NewInvalidArgumentErrorf("decode caller %d payload: %v", caller.ID, err)
|
||||
}
|
||||
}
|
||||
return p.Inputs, nil
|
||||
}
|
||||
|
||||
// calledWorkflowInputs overlays the run's dispatch inputs with `workflowCallInputs`, as GitHub does.
|
||||
func calledWorkflowInputs(ctx context.Context, run *actions_model.ActionRun, caller *actions_model.ActionRunJob, workflowCallInputs map[string]any) (map[string]any, error) {
|
||||
top := caller
|
||||
for run.Event == "workflow_dispatch" && top.ParentJobID != 0 {
|
||||
parent, err := actions_model.GetRunJobByRunAndID(ctx, run.ID, top.ParentJobID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load caller job %d: %w", top.ParentJobID, err)
|
||||
}
|
||||
top = parent
|
||||
}
|
||||
inputs, err := dispatchInputsForJob(run, top)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for name, value := range workflowCallInputs {
|
||||
maps.DeleteFunc(inputs, func(key string, _ any) bool { return strings.EqualFold(key, name) }) // input names are case-insensitive
|
||||
inputs[name] = value
|
||||
}
|
||||
return inputs, nil
|
||||
}
|
||||
|
||||
// pullRequestTargetBaseSHA returns the base branch commit of a pull_request_target run, and whether the run is one.
|
||||
func pullRequestTargetBaseSHA(run *actions_model.ActionRun) (string, bool) {
|
||||
if run.TriggerEvent != actions_module.GithubEventPullRequestTarget {
|
||||
|
||||
@@ -7,6 +7,8 @@ import (
|
||||
"testing"
|
||||
|
||||
actions_model "gitea.dev/models/actions"
|
||||
"gitea.dev/models/db"
|
||||
"gitea.dev/models/unittest"
|
||||
actions_module "gitea.dev/modules/actions"
|
||||
"gitea.dev/modules/json"
|
||||
api "gitea.dev/modules/structs"
|
||||
@@ -33,6 +35,59 @@ func TestDispatchInputsForRunJobs(t *testing.T) {
|
||||
assert.Equal(t, true, inputs["deploy"])
|
||||
}
|
||||
|
||||
func TestReusableChildInputs(t *testing.T) {
|
||||
require.NoError(t, unittest.PrepareTestDatabase())
|
||||
ctx := t.Context()
|
||||
|
||||
const runID = 9801
|
||||
insertJob := func(jobID string, parentID int64, payload, callPayload string) *actions_model.ActionRunJob {
|
||||
job := &actions_model.ActionRunJob{RunID: runID, JobID: jobID, ParentJobID: parentID, WorkflowPayload: []byte(payload), CallPayload: callPayload}
|
||||
require.NoError(t, db.Insert(ctx, job))
|
||||
return job
|
||||
}
|
||||
caller := insertJob("caller", 0,
|
||||
"on: {workflow_dispatch: {inputs: {flag: {type: boolean}, Shared: {type: string}}}}\njobs:\n caller:\n uses: ./.gitea/workflows/mid.yml\n",
|
||||
`{"inputs":{"shared":"from-call","mid_only":"mid"}}`)
|
||||
mid := insertJob("mid", caller.ID, "", `{"inputs":{"env":"leaf"}}`)
|
||||
leaf := insertJob("leaf", mid.ID, "", "")
|
||||
|
||||
dispatchRun := &actions_model.ActionRun{ID: runID, Event: "workflow_dispatch", EventPayload: `{"inputs":{"flag":"true","Shared":"from-dispatch"}}`}
|
||||
pushRun := &actions_model.ActionRun{ID: runID, Event: "push", EventPayload: `{}`}
|
||||
|
||||
t.Run("dispatch inputs overlaid case-insensitively with the caller's with", func(t *testing.T) {
|
||||
inputs, err := getInputsForJob(ctx, dispatchRun, mid)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, map[string]any{"flag": true, "shared": "from-call", "mid_only": "mid"}, inputs)
|
||||
})
|
||||
|
||||
t.Run("task context of a nested child keeps the older runners' form and carries the original event", func(t *testing.T) {
|
||||
leaf.Run = dispatchRun
|
||||
gitCtx := GiteaContext{
|
||||
"event_name": "workflow_dispatch",
|
||||
"event": map[string]any{"inputs": map[string]any{"flag": "true", "Shared": "from-dispatch"}},
|
||||
}
|
||||
require.NoError(t, setCalledWorkflowContext(ctx, leaf, gitCtx))
|
||||
inputs := map[string]any{"flag": true, "Shared": "from-dispatch", "env": "leaf"}
|
||||
assert.Equal(t, "workflow_call", gitCtx["event_name"])
|
||||
assert.Equal(t, map[string]any{"inputs": inputs}, gitCtx["event"])
|
||||
assert.Equal(t, map[string]any{
|
||||
"original_event_name": "workflow_dispatch",
|
||||
"original_event_inputs": map[string]any{"flag": "true", "Shared": "from-dispatch"},
|
||||
"inputs": inputs,
|
||||
}, gitCtx["gitea_workflow_call"])
|
||||
})
|
||||
|
||||
t.Run("task context of a non-dispatch run", func(t *testing.T) {
|
||||
mid.Run = pushRun
|
||||
gitCtx := GiteaContext{"event_name": "push", "event": map[string]any{}}
|
||||
require.NoError(t, setCalledWorkflowContext(ctx, mid, gitCtx))
|
||||
inputs := map[string]any{"shared": "from-call", "mid_only": "mid"}
|
||||
assert.Equal(t, "workflow_call", gitCtx["event_name"])
|
||||
assert.Equal(t, map[string]any{"inputs": inputs}, gitCtx["event"])
|
||||
assert.Equal(t, map[string]any{"original_event_name": "push", "inputs": inputs}, gitCtx["gitea_workflow_call"])
|
||||
})
|
||||
}
|
||||
|
||||
func TestPullRequestTargetBaseSHA(t *testing.T) {
|
||||
prPayload := func(baseSHA string) string {
|
||||
payload, err := json.Marshal(api.PullRequestPayload{
|
||||
|
||||
@@ -272,6 +272,10 @@ func expandReusableWorkflowCaller(ctx context.Context, run *actions_model.Action
|
||||
return fmt.Errorf("caller %q inputs: %w", caller.JobID, err)
|
||||
}
|
||||
}
|
||||
jobInputs, err := calledWorkflowInputs(ctx, run, caller, workflowCallInputs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 7. Build CallPayload (persisted in step 9).
|
||||
callPayload, err := (&api.WorkflowCallPayload{
|
||||
@@ -303,7 +307,7 @@ func expandReusableWorkflowCaller(ctx context.Context, run *actions_model.Action
|
||||
}
|
||||
|
||||
// 9. We own the expansion: insert the direct children.
|
||||
if err := insertCallerChildren(ctx, run, attempt, caller, content, contentSourceRepoID, contentSourceCommitSHA, vars, workflowCallInputs); err != nil {
|
||||
if err := insertCallerChildren(ctx, run, attempt, caller, content, contentSourceRepoID, contentSourceCommitSHA, vars, jobInputs); err != nil {
|
||||
// On failure, undo the partial expansion so an error return always leaves the caller unexpanded and childless.
|
||||
return errors.Join(err, undoExpansion(ctx, caller))
|
||||
}
|
||||
@@ -335,13 +339,7 @@ func insertCallerChildren(ctx context.Context, run *actions_model.ActionRun, att
|
||||
}
|
||||
}
|
||||
|
||||
// Parse the called workflow with the caller's `inputs`
|
||||
gitCtx := GenerateGiteaContext(ctx, run, attempt, nil)
|
||||
if event, ok := gitCtx["event"].(map[string]any); ok {
|
||||
event["inputs"] = inputs
|
||||
}
|
||||
gitCtx["event_name"] = "workflow_call"
|
||||
|
||||
childWorkflows, err := jobparser.Parse(content,
|
||||
jobparser.WithVars(vars),
|
||||
jobparser.WithGitContext(gitCtx.ToGitHubContext()),
|
||||
|
||||
@@ -193,6 +193,11 @@ func generateTaskContext(ctx context.Context, t *actions_model.ActionTask) (*str
|
||||
}
|
||||
|
||||
gitCtx := GenerateGiteaContext(ctx, t.Job.Run, nil, t.Job)
|
||||
if t.Job.ParentJobID > 0 {
|
||||
if err := setCalledWorkflowContext(ctx, t.Job, gitCtx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
gitCtx["token"] = t.Token
|
||||
gitCtx["gitea_runtime_token"] = giteaRuntimeToken
|
||||
|
||||
@@ -213,3 +218,22 @@ func findTaskNeeds(ctx context.Context, taskJob *actions_model.ActionRunJob) (ma
|
||||
}
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
// setCalledWorkflowContext rewrites the context for older runners, newer runners undo it via `gitea_workflow_call`.
|
||||
func setCalledWorkflowContext(ctx context.Context, job *actions_model.ActionRunJob, gitCtx GiteaContext) error {
|
||||
inputs, err := getInputsForJob(ctx, job.Run, job)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
event, _ := gitCtx["event"].(map[string]any)
|
||||
workflowCall := map[string]any{"original_event_name": gitCtx["event_name"], "inputs": inputs}
|
||||
if eventInputs, ok := event["inputs"]; ok {
|
||||
workflowCall["original_event_inputs"] = eventInputs
|
||||
}
|
||||
gitCtx["gitea_workflow_call"] = workflowCall
|
||||
gitCtx["event_name"] = "workflow_call"
|
||||
if event != nil {
|
||||
event["inputs"] = inputs
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user