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:
Mathieu Fenniak 2025-12-24 20:47:21 +01:00 committed by Mathieu Fenniak
parent a946887af6
commit 71623b1ab1
26 changed files with 938 additions and 15 deletions

View file

@ -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
}

View file

@ -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)
}
})
}
}

View file

@ -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 {

View file

@ -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)
}

View file

@ -0,0 +1 @@
ref: refs/heads/main

View file

@ -0,0 +1,4 @@
[core]
repositoryformatversion = 0
filemode = true
bare = true

View file

@ -0,0 +1 @@
Unnamed repository; edit this file 'description' to name the repository.

View file

@ -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]
# *~

View file

@ -0,0 +1 @@
e3868ecb4f8b483fc0bdd422561bf0062a7df907

View file

@ -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

View file

@ -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

View file

@ -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
}

View file

@ -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)
}
})
}
}

View file

@ -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)

View file

@ -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])
}

View 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
}
}

View 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))
}
})
}
}

View file

@ -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

View file

@ -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])
}

View file

@ -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

View file

@ -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})