diff --git a/services/actions/job_parser.go b/services/actions/job_parser.go new file mode 100644 index 0000000000..f55edcddad --- /dev/null +++ b/services/actions/job_parser.go @@ -0,0 +1,31 @@ +// Copyright 2025 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: GPL-3.0-or-later + +package actions + +import ( + "fmt" + + "github.com/nektos/act/pkg/jobparser" +) + +func jobParser(workflow []byte, options ...jobparser.ParseOption) ([]*jobparser.SingleWorkflow, error) { + singleWorkflows, err := jobparser.Parse(workflow, options...) + if err != nil { + return nil, err + } + nameToSingleWorkflows := make(map[string][]*jobparser.SingleWorkflow, len(singleWorkflows)) + duplicates := make(map[string]int, len(singleWorkflows)) + for _, singleWorkflow := range singleWorkflows { + id, job := singleWorkflow.Job() + nameToSingleWorkflows[job.Name] = append(nameToSingleWorkflows[job.Name], singleWorkflow) + if len(nameToSingleWorkflows[job.Name]) > 1 { + duplicates[job.Name]++ + job.Name = fmt.Sprintf("%s-%d", job.Name, duplicates[job.Name]) + if err := singleWorkflow.SetJob(id, job); err != nil { + return nil, err + } + } + } + return singleWorkflows, nil +} diff --git a/services/actions/job_parser_test.go b/services/actions/job_parser_test.go new file mode 100644 index 0000000000..9c1361d74e --- /dev/null +++ b/services/actions/job_parser_test.go @@ -0,0 +1,212 @@ +// Copyright 2025 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: GPL-3.0-or-later + +package actions + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestServiceActions_jobParser(t *testing.T) { + for _, testCase := range []struct { + name string + workflow string + singleWorkflows []string + }{ + { + name: "OneJobNoDuplicate", + workflow: ` +jobs: + job1: + runs-on: docker + steps: + - run: echo OK +`, + singleWorkflows: []string{ + `jobs: + job1: + name: job1 + runs-on: docker + steps: + - run: echo OK +`, + }, + }, + { + name: "MatrixTwoJobsWithSameJobName", + workflow: ` +name: test +jobs: + job1: + name: shadowdefaultmatrixgeneratednames + strategy: + matrix: + version: [1.17, 1.19] + runs-on: docker + steps: + - run: echo OK +`, + singleWorkflows: []string{ + `name: test +jobs: + job1: + name: shadowdefaultmatrixgeneratednames + runs-on: docker + steps: + - run: echo OK + strategy: + matrix: + version: + - 1.17 +`, + `name: test +jobs: + job1: + name: shadowdefaultmatrixgeneratednames-1 + runs-on: docker + steps: + - run: echo OK + strategy: + matrix: + version: + - 1.19 +`, + }, + }, + { + name: "MatrixTwoJobsWithMatrixGeneratedNames", + workflow: ` +name: test +jobs: + job1: + strategy: + matrix: + version: [1.17, 1.19] + runs-on: docker + steps: + - run: echo OK +`, + singleWorkflows: []string{ + `name: test +jobs: + job1: + name: job1 (1.17) + runs-on: docker + steps: + - run: echo OK + strategy: + matrix: + version: + - 1.17 +`, + `name: test +jobs: + job1: + name: job1 (1.19) + runs-on: docker + steps: + - run: echo OK + strategy: + matrix: + version: + - 1.19 +`, + }, + }, + { + name: "MatrixTwoJobsWithDistinctInterpolatedNames", + workflow: ` +name: test +jobs: + job1: + name: myname-${{ matrix.version }} + strategy: + matrix: + version: [1.17, 1.19] + runs-on: docker + steps: + - run: echo OK +`, + singleWorkflows: []string{ + `name: test +jobs: + job1: + name: myname-1.17 + runs-on: docker + steps: + - run: echo OK + strategy: + matrix: + version: + - 1.17 +`, + `name: test +jobs: + job1: + name: myname-1.19 + runs-on: docker + steps: + - run: echo OK + strategy: + matrix: + version: + - 1.19 +`, + }, + }, + { + name: "MatrixTwoJobsWithIdenticalInterpolatedNames", + workflow: ` +name: test +jobs: + job1: + name: myname-${{ matrix.typo }} + strategy: + matrix: + version: [1.17, 1.19] + runs-on: docker + steps: + - run: echo OK +`, + singleWorkflows: []string{ + `name: test +jobs: + job1: + name: myname- + runs-on: docker + steps: + - run: echo OK + strategy: + matrix: + version: + - 1.17 +`, + `name: test +jobs: + job1: + name: myname--1 + runs-on: docker + steps: + - run: echo OK + strategy: + matrix: + version: + - 1.19 +`, + }, + }, + } { + t.Run(testCase.name, func(t *testing.T) { + sw, err := jobParser([]byte(testCase.workflow)) + require.NoError(t, err) + for i, sw := range sw { + actual, err := sw.Marshal() + require.NoError(t, err) + assert.Equal(t, testCase.singleWorkflows[i], string(actual)) + } + }) + } +} diff --git a/services/actions/notifier_helper.go b/services/actions/notifier_helper.go index 9de0b75ac7..4ec077115b 100644 --- a/services/actions/notifier_helper.go +++ b/services/actions/notifier_helper.go @@ -364,7 +364,7 @@ func handleWorkflows( continue } - jobs, err := jobparser.Parse(dwf.Content, jobparser.WithVars(vars)) + jobs, err := jobParser(dwf.Content, jobparser.WithVars(vars)) if err != nil { log.Error("jobparser.Parse: %v", err) continue diff --git a/services/actions/schedule_tasks.go b/services/actions/schedule_tasks.go index f66a6ca092..c0bb4f3c64 100644 --- a/services/actions/schedule_tasks.go +++ b/services/actions/schedule_tasks.go @@ -139,7 +139,7 @@ func CreateScheduleTask(ctx context.Context, cron *actions_model.ActionSchedule) } // Parse the workflow specification from the cron schedule - workflows, err := jobparser.Parse(cron.Content, jobparser.WithVars(vars)) + workflows, err := jobParser(cron.Content, jobparser.WithVars(vars)) if err != nil { return err } diff --git a/services/actions/workflows.go b/services/actions/workflows.go index 7ec7c3abed..ca9c034b87 100644 --- a/services/actions/workflows.go +++ b/services/actions/workflows.go @@ -132,7 +132,7 @@ func (entry *Workflow) Dispatch(ctx context.Context, inputGetter InputValueGette return nil, nil, err } - jobs, err := jobparser.Parse(content, jobparser.WithVars(vars)) + jobs, err := jobParser(content, jobparser.WithVars(vars)) if err != nil { return nil, nil, err }