mirror of
https://codeberg.org/forgejo/forgejo.git
synced 2026-05-12 22:10:25 +00:00
feat: expand reusable workflow calls into their inner jobs (#10525)
Previously, Forgejo's behaviour for an Actions reusable workflow was to send the entire job to one specific Forgejo Runner based upon its required `runs-on` label, and that single Runner would then read the workflow file and perform all the jobs inside simultaneously, merging their log output into one output (#9768). This PR begins an implementation of expanding reusable workflows into their internal jobs. In this PR, the most basic support is implemented for expanding reusable workflows: - If a `runs-on` field is provided on the workflow, then the legacy behaviour of sending the reusable workflow to a runner is maintained. - If the `runs-on` field is omitted, then the job may be expanded, if: - If the `uses:` is a local path within the repo -- expanded - If the `uses:` is a path to another repo that is on the same Forgejo server -- expanded - If the `uses:` is a fully-qualified URL -- not expanded Because this is an "opt-in" implementation by omitting `runs-on`, and all existing capability is retained, I've **omitted some features** from this PR to make the scope small and manageable for review and testing. These features will be implemented after the initial support is landed: - Workflow input variables - Workflow secrets - Workflow output variables - "Incomplete" workflows which require multiple passes to evaluate -- any job within a reusable workflow where the `with`, `runs-on`, or `strategy.matrix` fields contain an output from another job with `${{ needs... }}` Although this implementation has restrictions with missing features, it is intended to fix #9768. Replaces PR #10448. ## Checklist The [contributor guide](https://forgejo.org/docs/next/contributor/) contains information that will be helpful to first time contributors. There also are a few [conditions for merging Pull Requests in Forgejo repositories](https://codeberg.org/forgejo/governance/src/branch/main/PullRequestsAgreement.md). You are also welcome to join the [Forgejo development chatroom](https://matrix.to/#/#forgejo-development:matrix.org). ### Tests - I added test coverage for Go changes... - [x] in their respective `*_test.go` for unit tests. - [ ] in the `tests/integration` directory if it involves interactions with a live Forgejo server. - I added test coverage for JavaScript changes... - [ ] in `web_src/js/*.test.js` if it can be unit tested. - [ ] in `tests/e2e/*.test.e2e.js` if it requires interactions with a live Forgejo server (see also the [developer guide for JavaScript testing](https://codeberg.org/forgejo/forgejo/src/branch/forgejo/tests/e2e/README.md#end-to-end-tests)). - end-to-end testing: https://code.forgejo.org/forgejo/end-to-end/pulls/1316 ### Documentation - [x] I created a pull request [to the documentation](https://codeberg.org/forgejo/docs) to explain to Forgejo users how to use this change. - https://codeberg.org/forgejo/docs/pulls/1648 - [ ] I did not document these changes and I do not expect someone else to do it. ### Release notes - [ ] I do not want this change to show in the release notes. - [x] I want the title to show in the release notes with a link to this pull request. - [ ] I want the content of the `release-notes/<pull request number>.md` to be be used for the release notes instead of the title. Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/10525 Reviewed-by: Andreas Ahlenstorf <aahlenst@noreply.codeberg.org> Co-authored-by: Mathieu Fenniak <mathieu@fenniak.net> Co-committed-by: Mathieu Fenniak <mathieu@fenniak.net>
This commit is contained in:
parent
a946887af6
commit
71623b1ab1
26 changed files with 938 additions and 15 deletions
|
|
@ -275,3 +275,13 @@ func (job *ActionRunJob) IsIncompleteRunsOn() (bool, *jobparser.IncompleteNeeds,
|
|||
}
|
||||
return jobWorkflow.IncompleteRunsOn, jobWorkflow.IncompleteRunsOnNeeds, jobWorkflow.IncompleteRunsOnMatrix, nil
|
||||
}
|
||||
|
||||
// Check whether this job is a caller of a reusable workflow -- in other words, the real work done in this job is in
|
||||
// spawned child jobs, not this job.
|
||||
func (job *ActionRunJob) IsWorkflowCallOuterJob() (bool, error) {
|
||||
jobWorkflow, err := job.decodeWorkflowPayload()
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failure decoding workflow payload: %w", err)
|
||||
}
|
||||
return jobWorkflow.Metadata.WorkflowCallID != "", nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -159,3 +159,40 @@ func TestActionRunJob_IsIncompleteRunsOn(t *testing.T) {
|
|||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestActionRunJob_IsWorkflowCallOuterJob(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
job ActionRunJob
|
||||
isWorkflowCallOuterJob bool
|
||||
errContains string
|
||||
}{
|
||||
{
|
||||
name: "normal workflow",
|
||||
job: ActionRunJob{WorkflowPayload: []byte("name: workflow")},
|
||||
isWorkflowCallOuterJob: false,
|
||||
},
|
||||
{
|
||||
name: "workflow_call outer job",
|
||||
job: ActionRunJob{WorkflowPayload: []byte("name: test\njobs:\n job:\n if: false\n__metadata:\n workflow_call_id: b5a9f46f1f2513d7777fde50b169d323a6519e349cc175484c947ac315a209ed\n")},
|
||||
isWorkflowCallOuterJob: true,
|
||||
},
|
||||
{
|
||||
name: "unparseable workflow",
|
||||
job: ActionRunJob{WorkflowPayload: []byte("name: []\nincomplete_runs_on: true")},
|
||||
errContains: "failure unmarshaling WorkflowPayload to SingleWorkflow: yaml: unmarshal errors",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
isWorkflowCallOuterJob, err := tt.job.IsWorkflowCallOuterJob()
|
||||
if tt.errContains != "" {
|
||||
assert.ErrorContains(t, err, tt.errContains)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tt.isWorkflowCallOuterJob, isWorkflowCallOuterJob)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -421,6 +421,44 @@ func CreateTaskForRunner(ctx context.Context, runner *ActionRunner) (*ActionTask
|
|||
return task, true, nil
|
||||
}
|
||||
|
||||
// Placeholder tasks are created when the status/content of an `ActionRunJob` is resolved by Forgejo without dispatch to
|
||||
// a runner, specifically in the case of a workflow call's outer job.
|
||||
func CreatePlaceholderTask(ctx context.Context, job *ActionRunJob, outputs map[string]string) (*ActionTask, error) {
|
||||
actionTask := &ActionTask{
|
||||
JobID: job.ID,
|
||||
Attempt: 1,
|
||||
Started: timeutil.TimeStampNow(),
|
||||
Stopped: timeutil.TimeStampNow(),
|
||||
Status: job.Status,
|
||||
RepoID: job.RepoID,
|
||||
OwnerID: job.OwnerID,
|
||||
CommitSHA: job.CommitSHA,
|
||||
IsForkPullRequest: job.IsForkPullRequest,
|
||||
}
|
||||
// token isn't used on a placeholder task, but generation is needed due to the unique constraint on field TokenHash
|
||||
actionTask.GenerateToken()
|
||||
|
||||
err := db.WithTx(ctx, func(ctx context.Context) error {
|
||||
_, err := db.GetEngine(ctx).Insert(actionTask)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failure inserting action_task: %w", err)
|
||||
}
|
||||
|
||||
for key, value := range outputs {
|
||||
err := InsertTaskOutputIfNotExist(ctx, actionTask.ID, key, value)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failure inserting action_task_output %q: %w", key, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return actionTask, nil
|
||||
}
|
||||
|
||||
func UpdateTask(ctx context.Context, task *ActionTask, cols ...string) error {
|
||||
sess := db.GetEngine(ctx).ID(task.ID)
|
||||
if len(cols) > 0 {
|
||||
|
|
|
|||
|
|
@ -46,3 +46,33 @@ func TestActionTask_GetTaskByJobAttempt(t *testing.T) {
|
|||
_, err = GetTaskByJobAttempt(t.Context(), 192, 100)
|
||||
assert.ErrorContains(t, err, "task with job_id 192 and attempt 100: resource does not exist")
|
||||
}
|
||||
|
||||
func TestActionTask_CreatePlaceholderTask(t *testing.T) {
|
||||
require.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
job := unittest.AssertExistsAndLoadBean(t, &ActionRunJob{ID: 396})
|
||||
assert.EqualValues(t, 0, job.TaskID)
|
||||
|
||||
task, err := CreatePlaceholderTask(t.Context(), job, map[string]string{"output1": "value1", "output2": "value2"})
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.NotEqualValues(t, 0, task.ID)
|
||||
assert.Equal(t, job.ID, task.JobID)
|
||||
assert.EqualValues(t, 1, task.Attempt)
|
||||
assert.NotEqualValues(t, 0, task.Started)
|
||||
assert.NotEqualValues(t, 0, task.Stopped)
|
||||
assert.Equal(t, job.Status, task.Status)
|
||||
assert.Equal(t, job.RepoID, task.RepoID)
|
||||
assert.Equal(t, job.OwnerID, task.OwnerID)
|
||||
assert.Equal(t, job.CommitSHA, task.CommitSHA)
|
||||
assert.Equal(t, job.IsForkPullRequest, task.IsForkPullRequest)
|
||||
|
||||
taskOutputs, err := FindTaskOutputByTaskID(t.Context(), task.ID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, taskOutputs, 2)
|
||||
finalOutputs := map[string]string{}
|
||||
for _, to := range taskOutputs {
|
||||
finalOutputs[to.OutputKey] = to.OutputValue
|
||||
}
|
||||
assert.Equal(t, map[string]string{"output1": "value1", "output2": "value2"}, finalOutputs)
|
||||
}
|
||||
|
|
|
|||
1
services/actions/TestExpandLocalReusableWorkflows/HEAD
Normal file
1
services/actions/TestExpandLocalReusableWorkflows/HEAD
Normal file
|
|
@ -0,0 +1 @@
|
|||
ref: refs/heads/main
|
||||
4
services/actions/TestExpandLocalReusableWorkflows/config
Normal file
4
services/actions/TestExpandLocalReusableWorkflows/config
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
[core]
|
||||
repositoryformatversion = 0
|
||||
filemode = true
|
||||
bare = true
|
||||
|
|
@ -0,0 +1 @@
|
|||
Unnamed repository; edit this file 'description' to name the repository.
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
# git ls-files --others --exclude-from=.git/info/exclude
|
||||
# Lines that start with '#' are comments.
|
||||
# For a project mostly in C, the following would be a good set of
|
||||
# exclude patterns (uncomment them if you want to use them):
|
||||
# *.[oa]
|
||||
# *~
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -0,0 +1 @@
|
|||
e3868ecb4f8b483fc0bdd422561bf0062a7df907
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
# Case w/ action_run_job.id = 601
|
||||
-
|
||||
id: 900
|
||||
title: "running workflow_dispatch run"
|
||||
owner_id: 2
|
||||
repo_id: 63
|
||||
workflow_id: "running.yaml"
|
||||
index: 4
|
||||
trigger_user_id: 2
|
||||
ref: "refs/heads/main"
|
||||
commit_sha: "97f29ee599c373c729132a5c46a046978311e0ee"
|
||||
trigger_event: "workflow_dispatch"
|
||||
is_fork_pull_request: 0
|
||||
status: 6 # running
|
||||
started: 1683636528
|
||||
created: 1683636108
|
||||
updated: 1683636626
|
||||
need_approval: 0
|
||||
approved_by: 0
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
# Case 600 -- workflow that is not a workflow call outer job
|
||||
-
|
||||
id: 600
|
||||
status: 1 # success
|
||||
started: 1683636528
|
||||
workflow_payload: |
|
||||
"on":
|
||||
push:
|
||||
jobs:
|
||||
produce-artifacts:
|
||||
name: produce-artifacts
|
||||
runs-on: docker
|
||||
steps:
|
||||
- run: echo "OK!"
|
||||
|
||||
# Case w/ action_run_job.id = 601 -- workflow call outer job exercising every available context in the `outputs`. Note
|
||||
# that jobparser translates `on.workflow_call.outputs` into the outer job's `job.<job_id>.outputs`, so the available
|
||||
# contexts should be considered as those in `on.workflow_call.outputs`.
|
||||
-
|
||||
id: 601
|
||||
run_id: 900
|
||||
status: 1 # success
|
||||
started: 1683636528
|
||||
needs: ["outer-job.inner-job"]
|
||||
workflow_payload: |
|
||||
"on":
|
||||
push:
|
||||
jobs:
|
||||
outer-job:
|
||||
name: outer-job
|
||||
if: false
|
||||
uses: ./.forgjeo/workflows/reusable.yml
|
||||
outputs:
|
||||
from_inner_job: ${{ needs['outer-job.inner-job'].outputs.my_output }}
|
||||
from_inner_job_result: ${{ needs['outer-job.inner-job'].result }}
|
||||
from_forgejo_ctx: ${{ forgejo.ref }}
|
||||
from_input_ctx: ${{ inputs.wc_input }}!
|
||||
from_vars_repo: ${{ vars.repo_var }}
|
||||
from_vars_org: ${{ vars.org_var }}
|
||||
from_vars_global: ${{ vars.global_var }}
|
||||
__metadata:
|
||||
workflow_call_inputs:
|
||||
wc_input: 'hello, world'
|
||||
workflow_call_id: b5a9f46f1f2513d7777fde50b169d323a6519e349cc175484c947ac315a209ed
|
||||
- # inner job of run 601
|
||||
id: 602
|
||||
run_id: 900
|
||||
status: 1 # success
|
||||
job_id: outer-job.inner-job
|
||||
task_id: 100
|
||||
|
|
@ -67,6 +67,7 @@ func checkJobsOfRun(ctx context.Context, runID int64) error {
|
|||
for _, job := range jobs {
|
||||
if status, ok := updates[job.ID]; ok {
|
||||
job.Status = status
|
||||
updateColumns := []string{"status"}
|
||||
|
||||
if status == actions_model.StatusWaiting {
|
||||
ignore, err := tryHandleIncompleteMatrix(ctx, job, jobs)
|
||||
|
|
@ -75,9 +76,16 @@ func checkJobsOfRun(ctx context.Context, runID int64) error {
|
|||
} else if ignore {
|
||||
continue
|
||||
}
|
||||
} else if status == actions_model.StatusSuccess || status == actions_model.StatusFailure {
|
||||
// Transition to these states can be triggered by workflow call outer jobs
|
||||
additionalColumns, err := tryHandleWorkflowCallOuterJob(ctx, job)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error in tryHandleWorkflowCallOuterJob: %w", err)
|
||||
}
|
||||
updateColumns = append(updateColumns, additionalColumns...)
|
||||
}
|
||||
|
||||
if n, err := UpdateRunJob(ctx, job, builder.Eq{"status": actions_model.StatusBlocked}, "status"); err != nil {
|
||||
if n, err := UpdateRunJob(ctx, job, builder.Eq{"status": actions_model.StatusBlocked}, updateColumns...); err != nil {
|
||||
return err
|
||||
} else if n != 1 {
|
||||
return fmt.Errorf("no affected for updating blocked job %v", job.ID)
|
||||
|
|
@ -155,23 +163,34 @@ func (r *jobStatusResolver) resolve() map[int64]actions_model.Status {
|
|||
}
|
||||
}
|
||||
if allDone {
|
||||
if allSucceed {
|
||||
ret[id] = actions_model.StatusWaiting
|
||||
} else {
|
||||
// Check if the job has an "if" condition
|
||||
hasIf := false
|
||||
if wfJobs, _ := jobparser.Parse(r.jobMap[id].WorkflowPayload, false); len(wfJobs) == 1 {
|
||||
_, wfJob := wfJobs[0].Job()
|
||||
hasIf = len(wfJob.If.Value) > 0
|
||||
if isWorkflowCallOuterJob, _ := r.jobMap[id].IsWorkflowCallOuterJob(); isWorkflowCallOuterJob {
|
||||
// If the dependent job was a workflow call outer job, then options aren't waiting/skipped, but rather
|
||||
// success/failure. checkJobsOfRun will do additional work in these cases to "finish" the workflow call
|
||||
// job as well.
|
||||
if allSucceed {
|
||||
ret[id] = actions_model.StatusSuccess
|
||||
} else {
|
||||
ret[id] = actions_model.StatusFailure
|
||||
}
|
||||
|
||||
if hasIf {
|
||||
// act_runner will check the "if" condition
|
||||
} else {
|
||||
if allSucceed {
|
||||
ret[id] = actions_model.StatusWaiting
|
||||
} else {
|
||||
// If the "if" condition is empty and not all dependent jobs completed successfully,
|
||||
// the job should be skipped.
|
||||
ret[id] = actions_model.StatusSkipped
|
||||
// Check if the job has an "if" condition
|
||||
hasIf := false
|
||||
if wfJobs, _ := jobparser.Parse(r.jobMap[id].WorkflowPayload, false); len(wfJobs) == 1 {
|
||||
_, wfJob := wfJobs[0].Job()
|
||||
hasIf = len(wfJob.If.Value) > 0
|
||||
}
|
||||
|
||||
if hasIf {
|
||||
// act_runner will check the "if" condition
|
||||
ret[id] = actions_model.StatusWaiting
|
||||
} else {
|
||||
// If the "if" condition is empty and not all dependent jobs completed successfully,
|
||||
// the job should be skipped.
|
||||
ret[id] = actions_model.StatusSkipped
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -355,3 +374,30 @@ func persistentIncompleteRunsOnError(job *actions_model.ActionRunJob, incomplete
|
|||
errorDetails = []any{job.JobID}
|
||||
return errorCode, errorDetails
|
||||
}
|
||||
|
||||
// When a workflow call outer job's dependencies are completed, `tryHandleWorkflowCallOuterJob` will complete the job
|
||||
// without actually executing it. It will not be dispatched it to a runner. There's no job execution logic, but we need
|
||||
// to update state of a few things -- particularly workflow outputs.
|
||||
//
|
||||
// A slice of additional columns for the caller to update on the passed-in `ActionRunJob` is returned, in addition to an
|
||||
// error.
|
||||
func tryHandleWorkflowCallOuterJob(ctx context.Context, job *actions_model.ActionRunJob) ([]string, error) {
|
||||
isWorkflowCallOuterJob, err := job.IsWorkflowCallOuterJob()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failure to identify workflow call outer job: %w", err)
|
||||
} else if !isWorkflowCallOuterJob {
|
||||
// Not an expected code path today, but if job status resolution changes in the future we might "try" to handle
|
||||
// a workflow call outer job while it's not really in that state. No work required.
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Insert a placeholder task; this will be used in the future to store computed outputs
|
||||
actionTask, err := actions_model.CreatePlaceholderTask(ctx, job, map[string]string{})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failure to insert placeholder task: %w", err)
|
||||
}
|
||||
|
||||
// Populate task_id and ask caller to update it in DB:
|
||||
job.TaskID = actionTask.ID
|
||||
return []string{"task_id"}, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -133,6 +133,48 @@ jobs:
|
|||
},
|
||||
want: map[int64]actions_model.Status{2: actions_model.StatusSkipped},
|
||||
},
|
||||
{
|
||||
name: "unblocked workflow call outer job with success",
|
||||
jobs: actions_model.ActionJobList{
|
||||
{ID: 1, JobID: "job1.innerjob1", Status: actions_model.StatusSuccess, Needs: []string{}},
|
||||
{ID: 2, JobID: "job1.innerjob2", Status: actions_model.StatusSuccess, Needs: []string{}},
|
||||
{ID: 3, JobID: "job1", Status: actions_model.StatusBlocked, Needs: []string{"job1.innerjob1", "job1.innerjob2"}, WorkflowPayload: []byte(
|
||||
`
|
||||
name: test
|
||||
on: push
|
||||
jobs:
|
||||
job2:
|
||||
if: false
|
||||
uses: ./.forgejo/workflows/reusable.yml
|
||||
__metadata:
|
||||
workflow_call_id: b5a9f46f1f2513d7777fde50b169d323a6519e349cc175484c947ac315a209ed
|
||||
`)},
|
||||
},
|
||||
want: map[int64]actions_model.Status{
|
||||
3: actions_model.StatusSuccess,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "unblocked workflow call outer job with internal failure",
|
||||
jobs: actions_model.ActionJobList{
|
||||
{ID: 1, JobID: "job1.innerjob1", Status: actions_model.StatusSuccess, Needs: []string{}},
|
||||
{ID: 2, JobID: "job1.innerjob2", Status: actions_model.StatusFailure, Needs: []string{}},
|
||||
{ID: 3, JobID: "job1", Status: actions_model.StatusBlocked, Needs: []string{"job1.innerjob1", "job1.innerjob2"}, WorkflowPayload: []byte(
|
||||
`
|
||||
name: test
|
||||
on: push
|
||||
jobs:
|
||||
job2:
|
||||
if: false
|
||||
uses: ./.forgejo/workflows/reusable.yml
|
||||
__metadata:
|
||||
workflow_call_id: b5a9f46f1f2513d7777fde50b169d323a6519e349cc175484c947ac315a209ed
|
||||
`)},
|
||||
},
|
||||
want: map[int64]actions_model.Status{
|
||||
3: actions_model.StatusFailure,
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
|
|
@ -382,3 +424,49 @@ func Test_tryHandleIncompleteMatrix(t *testing.T) {
|
|||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_tryHandleWorkflowCallOuterJob(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
runJobID int64
|
||||
updateFields []string
|
||||
outputs map[string]string
|
||||
}{
|
||||
{
|
||||
name: "not workflow call outer job",
|
||||
runJobID: 600,
|
||||
},
|
||||
{
|
||||
name: "outputs for every context",
|
||||
runJobID: 601,
|
||||
updateFields: []string{"task_id"},
|
||||
outputs: map[string]string{},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
defer unittest.OverrideFixtures("services/actions/Test_tryHandleWorkflowCallOuterJob")()
|
||||
require.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
outerJob := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: tt.runJobID})
|
||||
require.EqualValues(t, 0, outerJob.TaskID)
|
||||
|
||||
updateFields, err := tryHandleWorkflowCallOuterJob(t.Context(), outerJob)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tt.updateFields, updateFields)
|
||||
|
||||
if tt.updateFields != nil {
|
||||
// TaskID expected to be set by tryHandleWorkflowCallOuterJob
|
||||
require.NotEqualValues(t, 0, outerJob.TaskID)
|
||||
|
||||
taskOutputs, err := actions_model.FindTaskOutputByTaskID(t.Context(), outerJob.TaskID)
|
||||
require.NoError(t, err)
|
||||
outputMap := map[string]string{}
|
||||
for _, to := range taskOutputs {
|
||||
outputMap[to.OutputKey] = to.OutputValue
|
||||
}
|
||||
assert.Equal(t, tt.outputs, outputMap)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -420,6 +420,8 @@ func handleWorkflows(
|
|||
// `IncompleteMatrix` tagging for any jobs that require the inputs of other jobs.
|
||||
jobparser.WithJobOutputs(map[string]map[string]string{}),
|
||||
jobparser.SupportIncompleteRunsOn(),
|
||||
jobparser.ExpandLocalReusableWorkflows(expandLocalReusableWorkflows(commit)),
|
||||
jobparser.ExpandInstanceReusableWorkflows(expandInstanceReusableWorkflows(ctx)),
|
||||
)
|
||||
if err != nil {
|
||||
log.Info("jobparser.Parse: invalid workflow, setting job status to failed: %v", err)
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
package actions
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"slices"
|
||||
"testing"
|
||||
|
|
@ -17,9 +18,11 @@ import (
|
|||
actions_module "forgejo.org/modules/actions"
|
||||
"forgejo.org/modules/git"
|
||||
api "forgejo.org/modules/structs"
|
||||
"forgejo.org/modules/test"
|
||||
webhook_module "forgejo.org/modules/webhook"
|
||||
|
||||
"code.forgejo.org/forgejo/runner/v12/act/jobparser"
|
||||
"code.forgejo.org/forgejo/runner/v12/act/model"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
|
@ -353,3 +356,58 @@ func TestActionsNotifier_WorkflowDetection(t *testing.T) {
|
|||
assert.Equal(t, ".forgejo/workflows", runs[0].WorkflowDirectory)
|
||||
assert.Equal(t, "test.yml", runs[0].WorkflowID)
|
||||
}
|
||||
|
||||
// Verifies that the notifier_helper's `handleWorkflows` provides the local & remote reusable workflow expansion
|
||||
// routines to the jobparser, and that data flows into them accurately.
|
||||
func TestActionsNotifier_ExpandReusableWorkflow(t *testing.T) {
|
||||
require.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
var localReusableCalled []string
|
||||
var localReusableCalledGitCommit []*git.Commit
|
||||
defer test.MockVariableValue(&expandLocalReusableWorkflows,
|
||||
func(commit *git.Commit) jobparser.LocalWorkflowFetcher {
|
||||
return func(job *jobparser.Job, path string) ([]byte, error) {
|
||||
localReusableCalledGitCommit = append(localReusableCalledGitCommit, commit)
|
||||
localReusableCalled = append(localReusableCalled, path)
|
||||
return []byte("{ on: pull_request, jobs: { j1: { runs-on: debian-latest } } }"), nil
|
||||
}
|
||||
})()
|
||||
remoteReusableCalled := []*model.NonLocalReusableWorkflowReference{}
|
||||
defer test.MockVariableValue(&expandInstanceReusableWorkflows,
|
||||
func(ctx context.Context) jobparser.InstanceWorkflowFetcher {
|
||||
return func(job *jobparser.Job, ref *model.NonLocalReusableWorkflowReference) ([]byte, error) {
|
||||
remoteReusableCalled = append(remoteReusableCalled, ref)
|
||||
return []byte("{ on: pull_request, jobs: { j1: { runs-on: debian-latest } } }"), nil
|
||||
}
|
||||
})()
|
||||
|
||||
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 10})
|
||||
pr := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ID: 3})
|
||||
|
||||
dw := &actions_module.DetectedWorkflow{
|
||||
Content: []byte("{ on: pull_request, jobs: { j1: { uses: \"./.forgejo/workflows/reusable-path.yml\" }, j2: { uses: \"some-org/some-repo/.forgejo/workflows/reusable-path.yml@main\" }} }"),
|
||||
}
|
||||
testActionsNotifierPullRequest(t, repo, pr, dw, webhook_module.HookEventPullRequestSync)
|
||||
|
||||
runs, err := db.Find[actions_model.ActionRun](db.DefaultContext, actions_model.FindRunOptions{
|
||||
RepoID: repo.ID,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, runs, 1)
|
||||
run := runs[0]
|
||||
assert.EqualValues(t, 0, run.PreExecutionErrorCode, "pre execution error details: %#v", run.PreExecutionErrorDetails)
|
||||
|
||||
require.Len(t, localReusableCalled, 1, "localReusableCalled")
|
||||
require.Len(t, localReusableCalledGitCommit, 1, "localReusableCalledGitCommit")
|
||||
require.Len(t, remoteReusableCalled, 1, "remoteReusableCalled")
|
||||
|
||||
assert.Equal(t, "./.forgejo/workflows/reusable-path.yml", localReusableCalled[0])
|
||||
assert.Equal(t, "test", localReusableCalledGitCommit[0].CommitMessage)
|
||||
assert.Equal(t, &model.NonLocalReusableWorkflowReference{
|
||||
Org: "some-org",
|
||||
Repo: "some-repo",
|
||||
Filename: "reusable-path.yml",
|
||||
Ref: "main",
|
||||
GitPlatform: "forgejo",
|
||||
}, remoteReusableCalled[0])
|
||||
}
|
||||
|
|
|
|||
154
services/actions/reusable_workflows.go
Normal file
154
services/actions/reusable_workflows.go
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
// Copyright 2025 The Forgejo Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package actions
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
repo_model "forgejo.org/models/repo"
|
||||
user_model "forgejo.org/models/user"
|
||||
"forgejo.org/modules/git"
|
||||
"forgejo.org/modules/gitrepo"
|
||||
|
||||
"code.forgejo.org/forgejo/runner/v12/act/jobparser"
|
||||
"code.forgejo.org/forgejo/runner/v12/act/model"
|
||||
)
|
||||
|
||||
type CleanupFunc func()
|
||||
|
||||
// Evaluate whether we want to expand reusable workflows to their internal workflows. If the job has defined `runs-on`
|
||||
// labels, then we will not expand them -- this maintains the legacy behaviour from before reusable workflow expansion.
|
||||
// If `runs-on` is absent then we will attempt to expand the job.
|
||||
func expandForJob(job *jobparser.Job) bool {
|
||||
return len(job.RunsOn()) == 0
|
||||
}
|
||||
|
||||
// Provide a closure for `jobparser.ExpandLocalReusableWorkflows` which resolves reusable workflow references local to
|
||||
// the given commit. A reusable workflow reference is a job with a `uses: ./.forgejo/workflows/some-path.yaml`, and
|
||||
// resolving it involves reading the target file in the target commit and returning the file contents.
|
||||
//
|
||||
// See `expandForJob` for information about jobs that are exempt from expansion.
|
||||
var expandLocalReusableWorkflows = func(commit *git.Commit) jobparser.LocalWorkflowFetcher {
|
||||
return func(job *jobparser.Job, path string) ([]byte, error) {
|
||||
if !expandForJob(job) {
|
||||
return nil, jobparser.ErrUnsupportedReusableWorkflowFetch
|
||||
}
|
||||
|
||||
blob, err := commit.GetBlobByPath(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("expanding reusable workflow failed to access path %s: %w", path, err)
|
||||
}
|
||||
|
||||
reader, err := blob.DataAsync()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("expanding reusable workflow failed to read path %s: %w", path, err)
|
||||
}
|
||||
|
||||
content, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("expanding reusable workflow failed to read path %s: %w", path, err)
|
||||
}
|
||||
|
||||
return content, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Provide a closure for `jobparser.ExpandLocalReusableWorkflows` which resolves reusable workflow references local to
|
||||
// the given repo & commit SHA. This variation lazily opens the target git repo and reads the commit only when a local
|
||||
// reusable workflow is needed, and then caches the commit if multiple workflows need to be read. A cleanup function is
|
||||
// also returned that will close the open git repo, and should be `defer` executed.
|
||||
//
|
||||
// See `expandForJob` for information about jobs that are exempt from expansion.
|
||||
var lazyRepoExpandLocalReusableWorkflow = func(ctx context.Context, repoID int64, commitSHA string) (jobparser.LocalWorkflowFetcher, CleanupFunc) {
|
||||
// In the event that local reusable workflows (eg. `uses: ./.forgejo/workflows/reusable.yml`) are present, we'll
|
||||
// need to read the commit of the repo to resolve that reference. But most workflows don't do this, so save the
|
||||
// effort of opening the git repo and fetching the schedule's `CommitSHA` commit if it's not necessary by wrapping
|
||||
// that logic in a caching closure, `getGitCommit`.
|
||||
var innerFetcher jobparser.LocalWorkflowFetcher
|
||||
var gitRepo *git.Repository
|
||||
cleanupFunc := func() {
|
||||
if gitRepo != nil {
|
||||
gitRepo.Close()
|
||||
}
|
||||
}
|
||||
fetcher := func(job *jobparser.Job, path string) ([]byte, error) {
|
||||
if !expandForJob(job) {
|
||||
return nil, jobparser.ErrUnsupportedReusableWorkflowFetch
|
||||
}
|
||||
if innerFetcher != nil {
|
||||
content, err := innerFetcher(job, path)
|
||||
return content, err
|
||||
}
|
||||
repo, err := repo_model.GetRepositoryByID(ctx, repoID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("expanding reusable workflow failed to get repo: %w", err)
|
||||
}
|
||||
gitRepo, err = gitrepo.OpenRepository(ctx, repo) // ensure this keeps reference to the outer closure's `gitRepo`, not a local definition
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("expanding reusable workflow failed to open repo: %w", err)
|
||||
}
|
||||
commit, err := gitRepo.GetCommit(commitSHA)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("expanding reusable workflow failed to open commit %q on repo %s: %w", commitSHA, repo.FullName(), err)
|
||||
}
|
||||
innerFetcher = expandLocalReusableWorkflows(commit)
|
||||
content, err := innerFetcher(job, path)
|
||||
return content, err
|
||||
}
|
||||
return fetcher, cleanupFunc
|
||||
}
|
||||
|
||||
// Standard function for `jobparser.ExpandInstanceReusableWorkflows` which resolves reusable workflow references on the
|
||||
// same Forgejo instance, but in a specific repo. For example, `uses:
|
||||
// some-org/some-repo/.forgejo/workflows/some-path.yaml@ref`. Resolving it involves reading the target file in the
|
||||
// target repo & commit and returning the file contents.
|
||||
//
|
||||
// See `expandForJob` for information about jobs that are exempt from expansion.
|
||||
var expandInstanceReusableWorkflows = func(ctx context.Context) jobparser.InstanceWorkflowFetcher {
|
||||
return func(job *jobparser.Job, ref *model.NonLocalReusableWorkflowReference) ([]byte, error) {
|
||||
if !expandForJob(job) {
|
||||
return nil, jobparser.ErrUnsupportedReusableWorkflowFetch
|
||||
}
|
||||
|
||||
owner, err := user_model.GetUserByName(ctx, ref.Org)
|
||||
// Reusable workflows don't currently support access to any private repos -- that's implemented here as well,
|
||||
// although in the future it might be possible to use context information about the executing workflow to
|
||||
// broaden access (eg. repos within an org could access other repos within the same org, perhaps).
|
||||
if (err != nil && user_model.IsErrUserNotExist(err)) || !owner.Visibility.IsPublic() {
|
||||
// Same error message is returned for non-existing & non-visible to avoid information leak
|
||||
return nil, fmt.Errorf("expanding reusable workflow failed to access user %s: user does not exist", ref.Org)
|
||||
} else if err != nil {
|
||||
return nil, fmt.Errorf("expanding reusable workflow failed to access user %s: %w", ref.Org, err)
|
||||
}
|
||||
|
||||
repo, err := repo_model.GetRepositoryByName(ctx, owner.ID, ref.Repo)
|
||||
if (err != nil && repo_model.IsErrRepoNotExist(err)) || repo.IsPrivate {
|
||||
// Same error message is returned for non-existing & non-visible to avoid information leak
|
||||
return nil, fmt.Errorf("expanding reusable workflow failed to access repo %s: repo does not exist", ref.Repo)
|
||||
} else if err != nil {
|
||||
return nil, fmt.Errorf("expanding reusable workflow failed to access repo %s: %w", ref.Repo, err)
|
||||
}
|
||||
|
||||
gitRepo, err := gitrepo.OpenRepository(ctx, repo)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("expanding reusable workflow failed to open repo %s: %w", repo.FullName(), err)
|
||||
}
|
||||
defer gitRepo.Close()
|
||||
|
||||
commitID, err := gitRepo.GetRefCommitID(ref.Ref)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("expanding reusable workflow failed to resolve reference %q on repo %s: %w", ref.Ref, repo.FullName(), err)
|
||||
}
|
||||
|
||||
commit, err := gitRepo.GetCommit(commitID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("expanding reusable workflow failed to open commit %q on repo %s: %w", commitID, repo.FullName(), err)
|
||||
}
|
||||
|
||||
data, err := expandLocalReusableWorkflows(commit)(job, ref.FilePath())
|
||||
return data, err
|
||||
}
|
||||
}
|
||||
203
services/actions/reusable_workflows_test.go
Normal file
203
services/actions/reusable_workflows_test.go
Normal file
|
|
@ -0,0 +1,203 @@
|
|||
// Copyright 2025 The Forgejo Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package actions
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"forgejo.org/models/unittest"
|
||||
"forgejo.org/modules/git"
|
||||
"forgejo.org/modules/setting"
|
||||
|
||||
"code.forgejo.org/forgejo/runner/v12/act/jobparser"
|
||||
"code.forgejo.org/forgejo/runner/v12/act/model"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.yaml.in/yaml/v3"
|
||||
)
|
||||
|
||||
const testWorkflow string = `on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
example-string-required:
|
||||
required: true
|
||||
type: string
|
||||
|
||||
name: test
|
||||
jobs:
|
||||
job1:
|
||||
name: "job1 (local)"
|
||||
runs-on: ubuntu-slim
|
||||
steps:
|
||||
- name: Echo inputs
|
||||
run: |
|
||||
echo example-string-required="${{ inputs.example-string-required }}"
|
||||
|
||||
`
|
||||
|
||||
func TestExpandForJob(t *testing.T) {
|
||||
job := jobparser.Job{}
|
||||
|
||||
err := yaml.Unmarshal([]byte("{ name: job1 }"), &job)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, expandForJob(&job))
|
||||
|
||||
err = yaml.Unmarshal([]byte("{ name: job1, runs-on: ubuntu-latest }"), &job)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, expandForJob(&job))
|
||||
|
||||
err = yaml.Unmarshal([]byte("{ name: job1, runs-on: [x64, ubuntu-latest] }"), &job)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, expandForJob(&job))
|
||||
}
|
||||
|
||||
func TestExpandLocalReusableWorkflows(t *testing.T) {
|
||||
gitRepo, err := git.OpenRepository(git.DefaultContext, "./TestExpandLocalReusableWorkflows")
|
||||
require.NoError(t, err)
|
||||
defer gitRepo.Close()
|
||||
|
||||
commit, err := gitRepo.GetCommit("e3868ecb4f8b483fc0bdd422561bf0062a7df907")
|
||||
require.NoError(t, err)
|
||||
|
||||
fetcher := expandLocalReusableWorkflows(commit)
|
||||
require.NotNil(t, fetcher)
|
||||
|
||||
t.Run("successful fetch", func(t *testing.T) {
|
||||
content, err := fetcher(&jobparser.Job{}, "./.forgejo/workflows/reusable-1.yml")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, testWorkflow, string(content))
|
||||
})
|
||||
|
||||
t.Run("file not exist", func(t *testing.T) {
|
||||
_, err = fetcher(&jobparser.Job{}, "./forgejo/workflows/reusable-2.yml")
|
||||
require.ErrorContains(t, err, "expanding reusable workflow failed to access path ./forgejo/workflows/reusable-2.yml: object does not exist")
|
||||
})
|
||||
|
||||
t.Run("do not expand due to runs-on", func(t *testing.T) {
|
||||
jobWithRunsOn := jobparser.Job{}
|
||||
err = yaml.Unmarshal([]byte("{ name: job1, runs-on: ubuntu-latest }"), &jobWithRunsOn)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, expandForJob(&jobWithRunsOn))
|
||||
_, err = fetcher(&jobWithRunsOn, "./.forgejo/workflows/reusable-1.yml")
|
||||
require.ErrorIs(t, jobparser.ErrUnsupportedReusableWorkflowFetch, err)
|
||||
})
|
||||
}
|
||||
|
||||
func replaceTestRepo(t *testing.T, owner, repo, replacement string) {
|
||||
t.Helper()
|
||||
|
||||
// Copy the repository into the target path that `gitrepo.OpenRepository` will look for it.
|
||||
repoPath := filepath.Join(setting.RepoRootPath, strings.ToLower(owner), strings.ToLower(repo)+".git")
|
||||
err := os.RemoveAll(repoPath) // there's a default repo copied here by the fixture setup that we want to replace
|
||||
require.NoError(t, err)
|
||||
err = os.CopyFS(repoPath, os.DirFS(replacement))
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestLazyRepoExpandLocalReusableWorkflows(t *testing.T) {
|
||||
require.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
// Shouldn't need valid content if we never call the lazy evaluator
|
||||
lazy1, cleanup := lazyRepoExpandLocalReusableWorkflow(t.Context(), -123456, "this is not a valid commit SHA")
|
||||
assert.NotNil(t, lazy1)
|
||||
assert.NotNil(t, cleanup)
|
||||
cleanup()
|
||||
|
||||
replaceTestRepo(t, "user2", "repo1", "./TestExpandLocalReusableWorkflows")
|
||||
|
||||
lazy2, cleanup := lazyRepoExpandLocalReusableWorkflow(t.Context(), 1, "e3868ecb4f8b483fc0bdd422561bf0062a7df907")
|
||||
assert.NotNil(t, lazy2)
|
||||
assert.NotNil(t, cleanup)
|
||||
content, err := lazy2(&jobparser.Job{}, "./.forgejo/workflows/reusable-1.yml")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, testWorkflow, string(content))
|
||||
cleanup()
|
||||
}
|
||||
|
||||
func TestExpandInstanceReusableWorkflows(t *testing.T) {
|
||||
require.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
ref *model.NonLocalReusableWorkflowReference
|
||||
errIs error
|
||||
errorContains string
|
||||
repo string
|
||||
hasRunsOn bool
|
||||
}{
|
||||
{
|
||||
name: "hasRunsOn",
|
||||
hasRunsOn: true,
|
||||
ref: &model.NonLocalReusableWorkflowReference{},
|
||||
errIs: jobparser.ErrUnsupportedReusableWorkflowFetch,
|
||||
},
|
||||
{
|
||||
name: "non-existent owner",
|
||||
ref: &model.NonLocalReusableWorkflowReference{
|
||||
Org: "owner-does-not-exist",
|
||||
},
|
||||
errorContains: "owner-does-not-exist: user does not exist",
|
||||
},
|
||||
{
|
||||
name: "non-public owner",
|
||||
ref: &model.NonLocalReusableWorkflowReference{
|
||||
Org: "user33",
|
||||
},
|
||||
errorContains: "user33: user does not exist",
|
||||
},
|
||||
{
|
||||
name: "non-existent repo",
|
||||
ref: &model.NonLocalReusableWorkflowReference{
|
||||
Org: "user2",
|
||||
Repo: "repo10000",
|
||||
},
|
||||
errorContains: "repo10000: repo does not exist",
|
||||
},
|
||||
{
|
||||
name: "non-public repo",
|
||||
ref: &model.NonLocalReusableWorkflowReference{
|
||||
Org: "user2",
|
||||
Repo: "repo2",
|
||||
},
|
||||
errorContains: "repo2: repo does not exist",
|
||||
},
|
||||
{
|
||||
name: "public repo",
|
||||
ref: &model.NonLocalReusableWorkflowReference{
|
||||
Org: "user2",
|
||||
Repo: "repo1",
|
||||
GitPlatform: "forgejo",
|
||||
Filename: "reusable-1.yml",
|
||||
Ref: "main",
|
||||
},
|
||||
repo: "./TestExpandLocalReusableWorkflows",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if tt.repo != "" {
|
||||
replaceTestRepo(t, tt.ref.Org, tt.ref.Repo, tt.repo)
|
||||
}
|
||||
|
||||
job := jobparser.Job{}
|
||||
if tt.hasRunsOn {
|
||||
err := yaml.Unmarshal([]byte("{ name: job1, runs-on: ubuntu-latest }"), &job)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
fetcher := expandInstanceReusableWorkflows(t.Context())
|
||||
content, err := fetcher(&job, tt.ref)
|
||||
if tt.errIs != nil {
|
||||
require.ErrorIs(t, err, tt.errIs)
|
||||
} else if tt.errorContains != "" {
|
||||
require.ErrorContains(t, err, tt.errorContains)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, testWorkflow, string(content))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -169,6 +169,11 @@ func CreateScheduleTask(ctx context.Context, cron *actions_model.ActionSchedule)
|
|||
}
|
||||
}
|
||||
|
||||
// In the event that local reusable workflows (eg. `uses: ./.forgejo/workflows/reusable.yml`) are present, we'll
|
||||
// need to read the commit of the schedule to resolve that reference:
|
||||
expandLocalReusableWorkflow, expandCleanup := lazyRepoExpandLocalReusableWorkflow(ctx, cron.RepoID, cron.CommitSHA)
|
||||
defer expandCleanup()
|
||||
|
||||
// Parse the workflow specification from the cron schedule
|
||||
workflows, err := actions_module.JobParser(cron.Content,
|
||||
jobparser.WithVars(vars),
|
||||
|
|
@ -176,6 +181,8 @@ func CreateScheduleTask(ctx context.Context, cron *actions_model.ActionSchedule)
|
|||
// `IncompleteMatrix` tagging for any jobs that require the inputs of other jobs.
|
||||
jobparser.WithJobOutputs(map[string]map[string]string{}),
|
||||
jobparser.SupportIncompleteRunsOn(),
|
||||
jobparser.ExpandLocalReusableWorkflows(expandLocalReusableWorkflow),
|
||||
jobparser.ExpandInstanceReusableWorkflows(expandInstanceReusableWorkflows(ctx)),
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
package actions
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
actions_model "forgejo.org/models/actions"
|
||||
|
|
@ -11,9 +12,12 @@ import (
|
|||
repo_model "forgejo.org/models/repo"
|
||||
"forgejo.org/models/unit"
|
||||
"forgejo.org/models/unittest"
|
||||
"forgejo.org/modules/test"
|
||||
"forgejo.org/modules/timeutil"
|
||||
webhook_module "forgejo.org/modules/webhook"
|
||||
|
||||
"code.forgejo.org/forgejo/runner/v12/act/jobparser"
|
||||
"code.forgejo.org/forgejo/runner/v12/act/model"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
|
@ -390,3 +394,94 @@ jobs:
|
|||
// first inserted it is tagged w/ incomplete_runs_on
|
||||
assert.Contains(t, string(job.WorkflowPayload), "incomplete_runs_on: true")
|
||||
}
|
||||
|
||||
func TestServiceActions_ExpandReusableWorkflow(t *testing.T) {
|
||||
defer unittest.OverrideFixtures("services/actions/TestServiceActions_startTask")()
|
||||
require.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
type callArgs struct {
|
||||
repoID int64
|
||||
commitSHA string
|
||||
path string
|
||||
}
|
||||
var localReusableCalled []*callArgs
|
||||
var cleanupCallCount int
|
||||
defer test.MockVariableValue(&lazyRepoExpandLocalReusableWorkflow,
|
||||
func(ctx context.Context, repoID int64, commitSHA string) (jobparser.LocalWorkflowFetcher, CleanupFunc) {
|
||||
fetcher := func(job *jobparser.Job, path string) ([]byte, error) {
|
||||
localReusableCalled = append(localReusableCalled, &callArgs{repoID, commitSHA, path})
|
||||
return []byte("{ on: pull_request, jobs: { j1: { runs-on: debian-latest } } }"), nil
|
||||
}
|
||||
cleanup := func() {
|
||||
cleanupCallCount++
|
||||
}
|
||||
return fetcher, cleanup
|
||||
})()
|
||||
remoteReusableCalled := []*model.NonLocalReusableWorkflowReference{}
|
||||
defer test.MockVariableValue(&expandInstanceReusableWorkflows,
|
||||
func(ctx context.Context) jobparser.InstanceWorkflowFetcher {
|
||||
return func(job *jobparser.Job, ref *model.NonLocalReusableWorkflowReference) ([]byte, error) {
|
||||
remoteReusableCalled = append(remoteReusableCalled, ref)
|
||||
return []byte("{ on: pull_request, jobs: { j1: { runs-on: debian-latest } } }"), nil
|
||||
}
|
||||
})()
|
||||
|
||||
// Load fixtures that are corrupted and create one valid scheduled workflow
|
||||
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 4})
|
||||
|
||||
workflowID := "some.yml"
|
||||
schedules := []*actions_model.ActionSchedule{
|
||||
{
|
||||
Title: "scheduletitle1",
|
||||
RepoID: repo.ID,
|
||||
OwnerID: repo.OwnerID,
|
||||
WorkflowID: workflowID,
|
||||
TriggerUserID: repo.OwnerID,
|
||||
Ref: "branch",
|
||||
CommitSHA: "fakeSHA",
|
||||
Event: webhook_module.HookEventSchedule,
|
||||
EventPayload: "fakepayload",
|
||||
Specs: []string{"* * * * *"},
|
||||
Content: []byte(
|
||||
`
|
||||
jobs:
|
||||
job2:
|
||||
uses: ./.forgejo/workflows/reusable.yml
|
||||
job3:
|
||||
uses: some-org/some-repo/.forgejo/workflows/reusable-path.yml@main
|
||||
`),
|
||||
},
|
||||
}
|
||||
|
||||
require.Equal(t, 2, unittest.GetCount(t, actions_model.ActionScheduleSpec{}))
|
||||
require.NoError(t, actions_model.CreateScheduleTask(t.Context(), schedules))
|
||||
require.Equal(t, 3, unittest.GetCount(t, actions_model.ActionScheduleSpec{}))
|
||||
_, err := db.GetEngine(db.DefaultContext).Exec("UPDATE `action_schedule_spec` SET next = 1")
|
||||
require.NoError(t, err)
|
||||
|
||||
// After running startTasks an ActionRun row is created for the valid scheduled workflow
|
||||
require.Empty(t, unittest.GetCount(t, actions_model.ActionRun{WorkflowID: workflowID}))
|
||||
require.NoError(t, startTasks(t.Context()))
|
||||
require.NotEmpty(t, unittest.GetCount(t, actions_model.ActionRun{WorkflowID: workflowID}))
|
||||
|
||||
runs, err := db.Find[actions_model.ActionRun](db.DefaultContext, actions_model.FindRunOptions{
|
||||
WorkflowID: workflowID,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, runs, 1)
|
||||
run := runs[0]
|
||||
assert.EqualValues(t, 0, run.PreExecutionErrorCode, "pre execution error details: %#v", run.PreExecutionErrorDetails)
|
||||
|
||||
require.Len(t, localReusableCalled, 1, "localReusableCalled")
|
||||
require.Len(t, remoteReusableCalled, 1, "remoteReusableCalled")
|
||||
|
||||
assert.Equal(t, &callArgs{4, "fakeSHA", "./.forgejo/workflows/reusable.yml"}, localReusableCalled[0])
|
||||
assert.Equal(t, 2, cleanupCallCount, "cleanupCallCount")
|
||||
assert.Equal(t, &model.NonLocalReusableWorkflowReference{
|
||||
Org: "some-org",
|
||||
Repo: "some-repo",
|
||||
Filename: "reusable-path.yml",
|
||||
Ref: "main",
|
||||
GitPlatform: "forgejo",
|
||||
}, remoteReusableCalled[0])
|
||||
}
|
||||
|
|
|
|||
|
|
@ -181,6 +181,8 @@ func (entry *Workflow) Dispatch(ctx context.Context, inputGetter InputValueGette
|
|||
// `IncompleteMatrix` tagging for any jobs that require the inputs of other jobs.
|
||||
jobparser.WithJobOutputs(map[string]map[string]string{}),
|
||||
jobparser.SupportIncompleteRunsOn(),
|
||||
jobparser.ExpandLocalReusableWorkflows(expandLocalReusableWorkflows(entry.Commit)),
|
||||
jobparser.ExpandInstanceReusableWorkflows(expandInstanceReusableWorkflows(ctx)),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
|
|
|
|||
|
|
@ -1006,6 +1006,77 @@ func TestActionsWorkflowDispatchDynamicMatrix(t *testing.T) {
|
|||
})
|
||||
}
|
||||
|
||||
func TestActionsWorkflowDispatchReusableWorkflow(t *testing.T) {
|
||||
onApplicationRun(t, func(t *testing.T, u *url.URL) {
|
||||
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
||||
|
||||
// create the repo
|
||||
repo, sha, f := tests.CreateDeclarativeRepo(t, user2, "repo-workflow-dispatch",
|
||||
[]unit_model.Type{unit_model.TypeActions}, nil,
|
||||
[]*files_service.ChangeRepoFile{
|
||||
{
|
||||
Operation: "create",
|
||||
TreePath: ".forgejo/workflows/dispatch.yml",
|
||||
ContentReader: strings.NewReader(
|
||||
"name: test\n" +
|
||||
"on: [workflow_dispatch]\n" +
|
||||
"jobs:\n" +
|
||||
" test:\n" +
|
||||
" uses: ./.forgejo/workflows/reusable.yml\n",
|
||||
),
|
||||
},
|
||||
{
|
||||
Operation: "create",
|
||||
TreePath: ".forgejo/workflows/reusable.yml",
|
||||
ContentReader: strings.NewReader(
|
||||
"name: test\n" +
|
||||
"on: [workflow_call]\n" +
|
||||
"jobs:\n" +
|
||||
" inner:\n" +
|
||||
" runs-on: ubuntu-latest\n" +
|
||||
" steps:\n" +
|
||||
" - run: echo helloworld\n",
|
||||
),
|
||||
},
|
||||
},
|
||||
)
|
||||
defer f()
|
||||
|
||||
gitRepo, err := gitrepo.OpenRepository(db.DefaultContext, repo)
|
||||
require.NoError(t, err)
|
||||
defer gitRepo.Close()
|
||||
|
||||
workflow, err := actions_service.GetWorkflowFromCommit(gitRepo, "main", "dispatch.yml")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "refs/heads/main", workflow.Ref)
|
||||
assert.Equal(t, sha, workflow.Commit.ID.String())
|
||||
|
||||
inputGetter := func(key string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
run, _, err := workflow.Dispatch(db.DefaultContext, inputGetter, repo, user2)
|
||||
require.NoError(t, err)
|
||||
|
||||
var runJobs []*actions_model.ActionRunJob
|
||||
db.GetEngine(t.Context()).Where("run_id=?", run.ID).Find(&runJobs)
|
||||
assert.Len(t, runJobs, 2)
|
||||
|
||||
var parentJob *actions_model.ActionRunJob
|
||||
var childJob *actions_model.ActionRunJob
|
||||
for _, j := range runJobs {
|
||||
switch j.JobID {
|
||||
case "test":
|
||||
parentJob = j
|
||||
case "test.inner":
|
||||
childJob = j
|
||||
}
|
||||
}
|
||||
assert.NotNil(t, parentJob, "parentJob")
|
||||
assert.NotNil(t, childJob, "childJob")
|
||||
})
|
||||
}
|
||||
|
||||
func TestActionsWorkflowDispatchConcurrencyGroup(t *testing.T) {
|
||||
onApplicationRun(t, func(t *testing.T, u *url.URL) {
|
||||
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue