feat: update gitea vendor
runner nix smoke / nix label and flake smoke (push) Failing after 1m28s

This commit is contained in:
2026-09-26 21:18:24 +00:00
parent c439c1b948
commit d9b2a4e787
3538 changed files with 116131 additions and 44340 deletions
@@ -0,0 +1,110 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package actions
import (
"context"
"errors"
"fmt"
actions_model "gitea.dev/models/actions"
"gitea.dev/models/db"
repo_model "gitea.dev/models/repo"
user_model "gitea.dev/models/user"
"gitea.dev/modules/container"
"gitea.dev/modules/log"
)
func ApproveRuns(ctx context.Context, repo *repo_model.Repository, doer *user_model.User, runIDs []int64) error {
updatedJobs := make([]*actions_model.ActionRunJob, 0)
cancelledConcurrencyJobs := make([]*actions_model.ActionRunJob, 0)
// Track runs whose reusable callers were just expanded so we can re-emit after the tx commits.
expandedCallerRunIDs := make(container.Set[int64])
err := db.WithTx(ctx, func(ctx context.Context) (err error) {
for _, runID := range runIDs {
run, err := actions_model.GetRunByRepoAndID(ctx, repo.ID, runID)
if err != nil {
return err
}
if !run.NeedApproval {
continue
}
run.NeedApproval = false
run.ApprovedBy = doer.ID
if err := actions_model.UpdateRun(ctx, run, "need_approval", "approved_by"); err != nil {
return err
}
jobs, err := actions_model.GetLatestAttemptJobsByRepoAndRunID(ctx, repo.ID, run.ID)
if err != nil {
return err
}
for _, job := range jobs {
// Skip jobs with `needs`: they stay blocked until their dependencies finish,
// at which point job_emitter will evaluate and start them.
if len(job.Needs) > 0 {
continue
}
var jobsToCancel []*actions_model.ActionRunJob
job.Status, jobsToCancel, err = PrepareToStartJobWithConcurrency(ctx, job)
if err != nil {
return err
}
cancelledConcurrencyJobs = append(cancelledConcurrencyJobs, jobsToCancel...)
if job.Status != actions_model.StatusWaiting {
continue
}
n, err := actions_model.UpdateRunJob(ctx, job, nil, "status")
if err != nil {
return err
}
if n == 0 {
continue
}
updatedJobs = append(updatedJobs, job)
// A top-level reusable caller was just unblocked by approval, expand it
if job.IsReusableCaller && !job.IsExpanded {
attempt, has, err := run.GetLatestAttempt(ctx)
if err != nil {
return fmt.Errorf("get latest attempt of run %d: %w", run.ID, err)
}
if !has {
return errors.New("run has no attempt")
}
vars, err := actions_model.GetVariablesOfRun(ctx, run)
if err != nil {
return err
}
if err := expandReusableWorkflowCaller(ctx, run, attempt, job, vars); err != nil {
return fmt.Errorf("expand caller %d on approval: %w", job.ID, err)
}
if err := actions_model.RefreshReusableCallerStatus(ctx, job); err != nil {
return fmt.Errorf("refresh caller %d status after approval-time expansion: %w", job.ID, err)
}
expandedCallerRunIDs.Add(run.ID)
}
}
}
return nil
})
if err != nil {
return err
}
// Re-emit AFTER the tx commits so the newly inserted callee rows transition Blocked -> Waiting.
for runID := range expandedCallerRunIDs {
if err := EmitJobsIfReadyByRun(runID); err != nil {
log.Error("emit run %d after approval-time caller expansion: %v", runID, err)
}
}
NotifyWorkflowJobsAndRunsStatusUpdate(ctx, updatedJobs)
NotifyWorkflowJobsAndRunsStatusUpdate(ctx, cancelledConcurrencyJobs)
EmitJobsIfReadyByJobs(cancelledConcurrencyJobs)
return nil
}
@@ -0,0 +1,67 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package actions
import (
"testing"
actions_model "gitea.dev/models/actions"
"gitea.dev/models/db"
repo_model "gitea.dev/models/repo"
"gitea.dev/models/unittest"
user_model "gitea.dev/models/user"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestApproveRuns(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
doer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
insertRun := func(index int64, status actions_model.Status, needApproval bool, approvedBy int64) *actions_model.ActionRun {
run := &actions_model.ActionRun{
Title: "approve-run", RepoID: repo.ID, OwnerID: repo.OwnerID, WorkflowID: "test.yaml", Index: index,
TriggerUserID: doer.ID, Ref: "refs/heads/main",
CommitSHA: "c2d72f548424103f01ee1dc02889c1e2bff816b0", Event: "push", TriggerEvent: "push",
Status: status, NeedApproval: needApproval, ApprovedBy: approvedBy,
}
require.NoError(t, db.Insert(t.Context(), run))
return run
}
insertJob := func(run *actions_model.ActionRun, status actions_model.Status) *actions_model.ActionRunJob {
job := &actions_model.ActionRunJob{
RunID: run.ID, RepoID: run.RepoID, OwnerID: run.OwnerID, CommitSHA: run.CommitSHA,
Name: "job1", Attempt: 1, JobID: "job1", Status: status,
RunsOn: []string{"ubuntu-latest"},
}
require.NoError(t, db.Insert(t.Context(), job))
return job
}
t.Run("approve blocked run", func(t *testing.T) {
run := insertRun(1001, actions_model.StatusBlocked, true, 0)
job := insertJob(run, actions_model.StatusBlocked)
require.NoError(t, ApproveRuns(t.Context(), repo, doer, []int64{run.ID}))
run = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: run.ID})
assert.False(t, run.NeedApproval)
assert.Equal(t, doer.ID, run.ApprovedBy)
assert.Equal(t, actions_model.StatusWaiting, unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: job.ID}).Status)
})
t.Run("re-approving an approved run is a no-op", func(t *testing.T) {
run := insertRun(1002, actions_model.StatusRunning, false, 4)
job := insertJob(run, actions_model.StatusRunning)
require.NoError(t, ApproveRuns(t.Context(), repo, doer, []int64{run.ID}))
run = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: run.ID})
assert.EqualValues(t, 4, run.ApprovedBy, "approver must not be overwritten")
assert.Equal(t, actions_model.StatusRunning, unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: job.ID}).Status, "started job must not be reset to waiting")
})
}
@@ -0,0 +1,65 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package actions
import (
"net/http"
"strings"
actions_model "gitea.dev/models/actions"
"gitea.dev/modules/httplib"
"gitea.dev/modules/log"
"gitea.dev/modules/setting"
"gitea.dev/modules/storage"
"gitea.dev/services/context"
)
// IsArtifactV4 detects whether the artifact is likely from v4.
// V4 backend stores the files as a single combined zip file per artifact, and ensures ContentEncoding contains a slash
// (otherwise this uses application/zip instead of the custom mime type), which is not the case for the old backend.
func IsArtifactV4(art *actions_model.ActionArtifact) bool {
return strings.Contains(art.ContentEncodingOrType, "/")
}
func GetArtifactV4ServeDirectURL(art *actions_model.ActionArtifact, method string) (string, error) {
contentType := art.ContentEncodingOrType
u, err := storage.ActionsArtifacts.ServeDirectURL(art.StoragePath, art.ArtifactPath, method, &storage.ServeDirectOptions{ContentType: contentType})
if err != nil {
return "", err
}
return u.String(), nil
}
func DownloadArtifactV4ServeDirect(ctx *context.Base, art *actions_model.ActionArtifact) bool {
if !setting.Actions.ArtifactStorage.ServeDirect() {
return false
}
u, err := GetArtifactV4ServeDirectURL(art, ctx.Req.Method)
if err != nil {
log.Error("GetArtifactV4ServeDirectURL: %v", err)
return false
}
ctx.Redirect(u, http.StatusFound)
return true
}
func DownloadArtifactV4ReadStorage(ctx *context.Base, art *actions_model.ActionArtifact) error {
f, err := storage.ActionsArtifacts.Open(art.StoragePath)
if err != nil {
return err
}
defer f.Close()
httplib.ServeUserContentByFile(ctx.Req, ctx.Resp, f, httplib.ServeHeaderOptions{
Filename: art.ArtifactPath,
ContentType: art.ContentEncodingOrType, // v4 guarantees that the field is Content-Type
})
return nil
}
func DownloadArtifactV4(ctx *context.Base, art *actions_model.ActionArtifact) error {
if DownloadArtifactV4ServeDirect(ctx, art) {
return nil
}
return DownloadArtifactV4ReadStorage(ctx, art)
}
@@ -10,9 +10,9 @@ import (
"strings"
"time"
"code.gitea.io/gitea/modules/json"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/setting"
"gitea.dev/modules/json"
"gitea.dev/modules/log"
"gitea.dev/modules/setting"
"github.com/golang-jwt/jwt/v5"
)
@@ -7,8 +7,8 @@ import (
"net/http"
"testing"
"code.gitea.io/gitea/modules/json"
"code.gitea.io/gitea/modules/setting"
"gitea.dev/modules/json"
"gitea.dev/modules/setting"
"github.com/golang-jwt/jwt/v5"
"github.com/stretchr/testify/assert"
@@ -9,14 +9,14 @@ import (
"fmt"
"time"
actions_model "code.gitea.io/gitea/models/actions"
"code.gitea.io/gitea/models/db"
actions_module "code.gitea.io/gitea/modules/actions"
"code.gitea.io/gitea/modules/container"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/storage"
"code.gitea.io/gitea/modules/timeutil"
actions_model "gitea.dev/models/actions"
"gitea.dev/models/db"
actions_module "gitea.dev/modules/actions"
"gitea.dev/modules/container"
"gitea.dev/modules/log"
"gitea.dev/modules/setting"
"gitea.dev/modules/storage"
"gitea.dev/modules/timeutil"
"xorm.io/builder"
)
@@ -144,7 +144,7 @@ func CleanupEphemeralRunners(ctx context.Context) error {
From(builder.Select("*").From("`action_runner`"), "`action_runner`"). // mysql needs this redundant subquery
Join("INNER", "`action_task`", "`action_task`.`runner_id` = `action_runner`.`id`").
Where(builder.Eq{"`action_runner`.`ephemeral`": true}).
And(builder.NotIn("`action_task`.`status`", actions_model.StatusWaiting, actions_model.StatusRunning, actions_model.StatusBlocked))
And(builder.NotIn("`action_task`.`status`", actions_model.StatusWaiting, actions_model.StatusRunning, actions_model.StatusBlocked, actions_model.StatusCancelling))
b := builder.Delete(builder.In("id", subQuery)).From("`action_runner`")
res, err := db.GetEngine(ctx).Exec(b)
if err != nil {
@@ -179,7 +179,7 @@ func DeleteRun(ctx context.Context, run *actions_model.ActionRun) error {
repoID := run.RepoID
jobs, err := actions_model.GetRunJobsByRunID(ctx, run.ID)
jobs, err := actions_model.GetAllRunJobsByRepoAndRunID(ctx, run.RepoID, run.ID)
if err != nil {
return err
}
@@ -207,6 +207,10 @@ func DeleteRun(ctx context.Context, run *actions_model.ActionRun) error {
RepoID: repoID,
ID: run.ID,
})
recordsToDelete = append(recordsToDelete, &actions_model.ActionRunAttempt{
RepoID: repoID,
RunID: run.ID,
})
recordsToDelete = append(recordsToDelete, &actions_model.ActionRunJob{
RepoID: repoID,
RunID: run.ID,
@@ -228,6 +232,10 @@ func DeleteRun(ctx context.Context, run *actions_model.ActionRun) error {
RepoID: repoID,
RunID: run.ID,
})
recordsToDelete = append(recordsToDelete, &actions_model.ActionRunJobSummary{
RepoID: repoID,
RunID: run.ID,
})
if err := db.WithTx(ctx, func(ctx context.Context) error {
// TODO: Deleting task records could break current ephemeral runner implementation. This is a temporary workaround suggested by ChristopherHX.
@@ -8,74 +8,55 @@ import (
"fmt"
"time"
actions_model "code.gitea.io/gitea/models/actions"
"code.gitea.io/gitea/models/db"
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/modules/actions"
"code.gitea.io/gitea/modules/container"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/timeutil"
"code.gitea.io/gitea/modules/util"
webhook_module "code.gitea.io/gitea/modules/webhook"
notify_service "code.gitea.io/gitea/services/notify"
actions_model "gitea.dev/models/actions"
"gitea.dev/models/db"
repo_model "gitea.dev/models/repo"
"gitea.dev/modules/actions"
"gitea.dev/modules/log"
"gitea.dev/modules/setting"
"gitea.dev/modules/timeutil"
"gitea.dev/modules/util"
webhook_module "gitea.dev/modules/webhook"
)
// StopZombieTasks stops the task which have running status, but haven't been updated for a long time
// StopZombieTasks stops tasks in running/cancelling status that haven't been updated for a long time
func StopZombieTasks(ctx context.Context) error {
return stopTasks(ctx, actions_model.FindTaskOptions{
Status: actions_model.StatusRunning,
return stopTasksByStatuses(ctx, actions_model.FindTaskOptions{
UpdatedBefore: timeutil.TimeStamp(time.Now().Add(-setting.Actions.ZombieTaskTimeout).Unix()),
})
}, actions_model.StatusRunning, actions_model.StatusCancelling)
}
// StopEndlessTasks stops the tasks which have running status and continuous updates, but don't end for a long time
// StopEndlessTasks stops running tasks with continuous updates that don't end for a long time.
// StatusRunning only: the threshold is the task's *start* time, so including StatusCancelling would kill a
// task mid post-cancel cleanup. StopZombieTasks covers a stalled one, keying off the last update instead.
func StopEndlessTasks(ctx context.Context) error {
return stopTasks(ctx, actions_model.FindTaskOptions{
Status: actions_model.StatusRunning,
return stopTasksByStatuses(ctx, actions_model.FindTaskOptions{
StartedBefore: timeutil.TimeStamp(time.Now().Add(-setting.Actions.EndlessTaskTimeout).Unix()),
})
}, actions_model.StatusRunning)
}
func notifyWorkflowJobStatusUpdate(ctx context.Context, jobs []*actions_model.ActionRunJob) {
if len(jobs) == 0 {
return
}
// The input jobs may belong to different runs, so track each affected run.
runs := make(map[int64]*actions_model.ActionRun, len(jobs))
for _, job := range jobs {
if err := job.LoadAttributes(ctx); err != nil {
log.Error("Failed to load job attributes: %v", err)
continue
}
CreateCommitStatusForRunJobs(ctx, job.Run, job)
notify_service.WorkflowJobStatusUpdate(ctx, job.Run.Repo, job.Run.TriggerUser, job, nil)
if _, ok := runs[job.RunID]; !ok {
runs[job.RunID] = job.Run
func stopTasksByStatuses(ctx context.Context, opts actions_model.FindTaskOptions, statuses ...actions_model.Status) error {
for _, status := range statuses {
optsByStatus := opts
optsByStatus.Status = status
if err := stopTasks(ctx, optsByStatus); err != nil {
return err
}
}
for _, run := range runs {
notify_service.WorkflowRunStatusUpdate(ctx, run.Repo, run.TriggerUser, run)
}
return nil
}
func CancelPreviousJobs(ctx context.Context, repoID int64, ref, workflowID string, event webhook_module.HookEventType) error {
jobs, err := actions_model.CancelPreviousJobs(ctx, repoID, ref, workflowID, event)
notifyWorkflowJobStatusUpdate(ctx, jobs)
if len(jobs) > 0 {
actions_model.UpdateRepoRunsNumbers(ctx, repoID)
}
NotifyWorkflowJobsAndRunsStatusUpdate(ctx, jobs)
EmitJobsIfReadyByJobs(jobs)
return err
}
func CleanRepoScheduleTasks(ctx context.Context, repo *repo_model.Repository) error {
jobs, err := actions_model.CleanRepoScheduleTasks(ctx, repo)
notifyWorkflowJobStatusUpdate(ctx, jobs)
if len(jobs) > 0 {
actions_model.UpdateRepoRunsNumbers(ctx, repo.ID)
}
NotifyWorkflowJobsAndRunsStatusUpdate(ctx, jobs)
EmitJobsIfReadyByJobs(jobs)
return err
}
@@ -90,61 +71,59 @@ func shouldBlockJobByConcurrency(ctx context.Context, job *actions_model.ActionR
return false, nil
}
runs, jobs, err := actions_model.GetConcurrentRunsAndJobs(ctx, job.RepoID, job.ConcurrencyGroup, []actions_model.Status{actions_model.StatusRunning})
attempts, jobs, err := actions_model.GetConcurrentRunAttemptsAndJobs(ctx, job.RepoID, job.ConcurrencyGroup, []actions_model.Status{actions_model.StatusRunning, actions_model.StatusCancelling})
if err != nil {
return false, fmt.Errorf("GetConcurrentRunsAndJobs: %w", err)
return false, fmt.Errorf("GetConcurrentRunAttemptsAndJobs: %w", err)
}
return len(runs) > 0 || len(jobs) > 0, nil
return len(attempts) > 0 || len(jobs) > 0, nil
}
// PrepareToStartJobWithConcurrency prepares a job to start by its evaluated concurrency group and cancelling previous jobs if necessary.
// It returns the new status of the job (either StatusBlocked or StatusWaiting) and any error encountered during the process.
func PrepareToStartJobWithConcurrency(ctx context.Context, job *actions_model.ActionRunJob) (actions_model.Status, error) {
// It returns the new status of the job (either StatusBlocked or StatusWaiting), any cancelled jobs, and any error encountered during the process.
func PrepareToStartJobWithConcurrency(ctx context.Context, job *actions_model.ActionRunJob) (actions_model.Status, []*actions_model.ActionRunJob, error) {
shouldBlock, err := shouldBlockJobByConcurrency(ctx, job)
if err != nil {
return actions_model.StatusBlocked, err
return actions_model.StatusBlocked, nil, err
}
// even if the current job is blocked, we still need to cancel previous "waiting/blocked" jobs in the same concurrency group
jobs, err := actions_model.CancelPreviousJobsByJobConcurrency(ctx, job)
if err != nil {
return actions_model.StatusBlocked, fmt.Errorf("CancelPreviousJobsByJobConcurrency: %w", err)
return actions_model.StatusBlocked, nil, fmt.Errorf("CancelPreviousJobsByJobConcurrency: %w", err)
}
notifyWorkflowJobStatusUpdate(ctx, jobs)
return util.Iif(shouldBlock, actions_model.StatusBlocked, actions_model.StatusWaiting), nil
return util.Iif(shouldBlock, actions_model.StatusBlocked, actions_model.StatusWaiting), jobs, nil
}
func shouldBlockRunByConcurrency(ctx context.Context, actionRun *actions_model.ActionRun) (bool, error) {
if actionRun.ConcurrencyGroup == "" || actionRun.ConcurrencyCancel {
func shouldBlockRunByConcurrency(ctx context.Context, attempt *actions_model.ActionRunAttempt) (bool, error) {
if attempt.ConcurrencyGroup == "" || attempt.ConcurrencyCancel {
return false, nil
}
runs, jobs, err := actions_model.GetConcurrentRunsAndJobs(ctx, actionRun.RepoID, actionRun.ConcurrencyGroup, []actions_model.Status{actions_model.StatusRunning})
attempts, jobs, err := actions_model.GetConcurrentRunAttemptsAndJobs(ctx, attempt.RepoID, attempt.ConcurrencyGroup, []actions_model.Status{actions_model.StatusRunning, actions_model.StatusCancelling})
if err != nil {
return false, fmt.Errorf("find concurrent runs and jobs: %w", err)
}
return len(runs) > 0 || len(jobs) > 0, nil
return len(attempts) > 0 || len(jobs) > 0, nil
}
// PrepareToStartRunWithConcurrency prepares a run to start by its evaluated concurrency group and cancelling previous jobs if necessary.
// It returns the new status of the run (either StatusBlocked or StatusWaiting) and any error encountered during the process.
func PrepareToStartRunWithConcurrency(ctx context.Context, run *actions_model.ActionRun) (actions_model.Status, error) {
shouldBlock, err := shouldBlockRunByConcurrency(ctx, run)
// PrepareToStartRunWithConcurrency prepares a run attempt to start by its evaluated concurrency group and cancelling previous jobs if necessary.
// It returns the new status of the run attempt (either StatusBlocked or StatusWaiting), any cancelled jobs, and any error encountered during the process.
func PrepareToStartRunWithConcurrency(ctx context.Context, attempt *actions_model.ActionRunAttempt) (actions_model.Status, []*actions_model.ActionRunJob, error) {
shouldBlock, err := shouldBlockRunByConcurrency(ctx, attempt)
if err != nil {
return actions_model.StatusBlocked, err
return actions_model.StatusBlocked, nil, err
}
// even if the current run is blocked, we still need to cancel previous "waiting/blocked" jobs in the same concurrency group
jobs, err := actions_model.CancelPreviousJobsByRunConcurrency(ctx, run)
jobs, err := actions_model.CancelPreviousJobsByRunConcurrency(ctx, attempt)
if err != nil {
return actions_model.StatusBlocked, fmt.Errorf("CancelPreviousJobsByRunConcurrency: %w", err)
return actions_model.StatusBlocked, nil, fmt.Errorf("CancelPreviousJobsByRunConcurrency: %w", err)
}
notifyWorkflowJobStatusUpdate(ctx, jobs)
return util.Iif(shouldBlock, actions_model.StatusBlocked, actions_model.StatusWaiting), nil
return util.Iif(shouldBlock, actions_model.StatusBlocked, actions_model.StatusWaiting), jobs, nil
}
func stopTasks(ctx context.Context, opts actions_model.FindTaskOptions) error {
@@ -156,7 +135,11 @@ func stopTasks(ctx context.Context, opts actions_model.FindTaskOptions) error {
jobs := make([]*actions_model.ActionRunJob, 0, len(tasks))
for _, task := range tasks {
if err := db.WithTx(ctx, func(ctx context.Context) error {
if err := actions_model.StopTask(ctx, task.ID, actions_model.StatusFailure); err != nil {
stopStatus := actions_model.StatusFailure
if task.Status == actions_model.StatusCancelling {
stopStatus = actions_model.StatusCancelled
}
if err := actions_model.StopTask(ctx, task.ID, stopStatus); err != nil {
return err
}
if err := task.LoadJob(ctx); err != nil {
@@ -182,17 +165,7 @@ func stopTasks(ctx context.Context, opts actions_model.FindTaskOptions) error {
remove()
}
notifyWorkflowJobStatusUpdate(ctx, jobs)
// Recompute counters post-commit for every repo whose runs may have flipped done-status.
reconcileRepos := make(container.Set[int64])
for _, job := range jobs {
reconcileRepos.Add(job.RepoID)
}
for repoID := range reconcileRepos {
actions_model.UpdateRepoRunsNumbers(ctx, repoID)
}
NotifyWorkflowJobsAndRunsStatusUpdate(ctx, jobs)
EmitJobsIfReadyByJobs(jobs)
return nil
@@ -200,62 +173,22 @@ func stopTasks(ctx context.Context, opts actions_model.FindTaskOptions) error {
// CancelAbandonedJobs cancels jobs that have not been picked by any runner for a long time
func CancelAbandonedJobs(ctx context.Context) error {
jobs, err := db.Find[actions_model.ActionRunJob](ctx, actions_model.FindRunJobOptions{
abandonedJobs, err := db.Find[actions_model.ActionRunJob](ctx, actions_model.FindRunJobOptions{
Statuses: []actions_model.Status{actions_model.StatusWaiting, actions_model.StatusBlocked},
UpdatedBefore: timeutil.TimeStampNow().AddDuration(-setting.Actions.AbandonedJobTimeout),
})
if err != nil {
log.Warn("find abandoned tasks: %v", err)
log.Warn("find abandoned jobs: %v", err)
return err
}
now := timeutil.TimeStampNow()
// Collect one job per run to send workflow run status update
updatedRuns := map[int64]*actions_model.ActionRunJob{}
updatedJobs := []*actions_model.ActionRunJob{}
updatedRepoIDs := make(container.Set[int64])
for _, job := range jobs {
job.Status = actions_model.StatusCancelled
job.Stopped = now
updated := false
if err := db.WithTx(ctx, func(ctx context.Context) error {
n, err := actions_model.UpdateRunJob(ctx, job, nil, "status", "stopped")
if err != nil {
return err
}
if err := job.LoadAttributes(ctx); err != nil {
return err
}
updated = n > 0
if updated && job.Run.Status.IsDone() {
updatedRuns[job.RunID] = job
updatedRepoIDs.Add(job.RepoID)
}
return nil
}); err != nil {
log.Warn("cancel abandoned job %v: %v", job.ID, err)
// go on
}
if job.Run == nil || job.Run.Repo == nil {
continue // error occurs during loading attributes, the following code that depends on "Run.Repo" will fail, so ignore and skip
}
CreateCommitStatusForRunJobs(ctx, job.Run, job)
if updated {
updatedJobs = append(updatedJobs, job)
notify_service.WorkflowJobStatusUpdate(ctx, job.Run.Repo, job.Run.TriggerUser, job, nil)
}
updatedJobs, err := actions_model.CancelJobs(ctx, abandonedJobs)
if err != nil {
log.Warn("cancel abandoned jobs: %v", err)
}
for _, job := range updatedRuns {
notify_service.WorkflowRunStatusUpdate(ctx, job.Run.Repo, job.Run.TriggerUser, job.Run)
}
NotifyWorkflowJobsAndRunsStatusUpdate(ctx, updatedJobs)
EmitJobsIfReadyByJobs(updatedJobs)
for repoID := range updatedRepoIDs {
actions_model.UpdateRepoRunsNumbers(ctx, repoID)
}
return nil
}
@@ -0,0 +1,143 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package actions
import (
"fmt"
"testing"
"time"
actions_model "gitea.dev/models/actions"
"gitea.dev/models/db"
"gitea.dev/models/unittest"
"gitea.dev/modules/setting"
"gitea.dev/modules/test"
"gitea.dev/modules/timeutil"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func createConflictingCancellingJob(t *testing.T, concurrencyGroup string, runIndex int64) *actions_model.ActionRunJob {
t.Helper()
run := &actions_model.ActionRun{
RepoID: 1,
OwnerID: 2,
TriggerUserID: 2,
WorkflowID: "test.yml",
Index: runIndex,
Ref: "refs/heads/main",
Status: actions_model.StatusBlocked,
}
require.NoError(t, db.Insert(t.Context(), run))
attempt := &actions_model.ActionRunAttempt{
RepoID: run.RepoID,
RunID: run.ID,
Attempt: 1,
TriggerUserID: run.TriggerUserID,
Status: actions_model.StatusBlocked,
ConcurrencyGroup: concurrencyGroup,
}
require.NoError(t, db.Insert(t.Context(), attempt))
job := &actions_model.ActionRunJob{
RunID: run.ID,
RunAttemptID: attempt.ID,
AttemptJobID: 1,
RepoID: run.RepoID,
OwnerID: run.OwnerID,
CommitSHA: "c2d72f548424103f01ee1dc02889c1e2bff816b0",
Name: "conflicting-cancelling-job",
JobID: "conflicting-cancelling-job",
Status: actions_model.StatusCancelling,
ConcurrencyGroup: concurrencyGroup,
}
require.NoError(t, db.Insert(t.Context(), job))
return job
}
func TestShouldBlockJobByConcurrency_CancellingJobBlocks(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
const concurrencyGroup = "test-cancelling-job-blocks"
createConflictingCancellingJob(t, concurrencyGroup, 9903)
job := &actions_model.ActionRunJob{
RepoID: 1,
RawConcurrency: concurrencyGroup,
IsConcurrencyEvaluated: true,
ConcurrencyGroup: concurrencyGroup,
}
shouldBlock, err := shouldBlockJobByConcurrency(t.Context(), job)
require.NoError(t, err)
assert.True(t, shouldBlock)
}
func TestShouldBlockRunByConcurrency_CancellingJobBlocks(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
const concurrencyGroup = "test-cancelling-run-blocks"
createConflictingCancellingJob(t, concurrencyGroup, 9904)
attempt := &actions_model.ActionRunAttempt{
RepoID: 1,
ConcurrencyGroup: concurrencyGroup,
}
shouldBlock, err := shouldBlockRunByConcurrency(t.Context(), attempt)
require.NoError(t, err)
assert.True(t, shouldBlock)
}
// TestStopEndlessTasksSkipsCancelling verifies that a task running its post-cancel cleanup is not
// force-stopped by the endless-task sweep just because the job started long ago.
func TestStopEndlessTasksSkipsCancelling(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
// StopEndlessTasks emits ready jobs onto the emitter queue, mock it
defer test.MockVariableValue(&EmitJobsIfReadyByRun, func(runID int64) error { return nil })()
// well past the endless-task threshold, keyed on the task's start time
longAgo := timeutil.TimeStamp(time.Now().Add(-2 * setting.Actions.EndlessTaskTimeout).Unix())
var seq int64
newTaskWithJob := func(status actions_model.Status) *actions_model.ActionTask {
seq++
run := &actions_model.ActionRun{
RepoID: 1, OwnerID: 2, TriggerUserID: 2, WorkflowID: "test.yml",
Index: 99500 + seq, Ref: "refs/heads/main", Status: actions_model.StatusRunning,
}
require.NoError(t, db.Insert(t.Context(), run))
attempt := &actions_model.ActionRunAttempt{
RepoID: run.RepoID, RunID: run.ID, Attempt: 1, TriggerUserID: run.TriggerUserID, Status: actions_model.StatusRunning,
}
require.NoError(t, db.Insert(t.Context(), attempt))
job := &actions_model.ActionRunJob{
RunID: run.ID, RunAttemptID: attempt.ID, AttemptJobID: 1, RepoID: run.RepoID, OwnerID: run.OwnerID,
CommitSHA: "c2d72f548424103f01ee1dc02889c1e2bff816b0", Name: "j", JobID: "j", Status: status,
}
require.NoError(t, db.Insert(t.Context(), job))
task := &actions_model.ActionTask{
JobID: job.ID, RepoID: run.RepoID, OwnerID: run.OwnerID,
CommitSHA: job.CommitSHA, Status: status, Started: longAgo,
TokenHash: fmt.Sprintf("endless-test-token-%d", seq), TokenSalt: "salt",
}
require.NoError(t, db.Insert(t.Context(), task))
return task
}
running := newTaskWithJob(actions_model.StatusRunning)
cancelling := newTaskWithJob(actions_model.StatusCancelling)
require.NoError(t, StopEndlessTasks(t.Context()))
runningAfter := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionTask{ID: running.ID})
cancellingAfter := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionTask{ID: cancelling.ID})
assert.Equal(t, actions_model.StatusFailure, runningAfter.Status, "long-running task should be force-stopped")
assert.Equal(t, actions_model.StatusCancelling, cancellingAfter.Status, "cancelling task should keep running its cleanup")
}
@@ -7,21 +7,23 @@ import (
"context"
"errors"
"fmt"
"path"
"strings"
"slices"
actions_model "code.gitea.io/gitea/models/actions"
"code.gitea.io/gitea/models/db"
git_model "code.gitea.io/gitea/models/git"
repo_model "code.gitea.io/gitea/models/repo"
user_model "code.gitea.io/gitea/models/user"
actions_module "code.gitea.io/gitea/modules/actions"
"code.gitea.io/gitea/modules/actions/jobparser"
"code.gitea.io/gitea/modules/commitstatus"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/util"
webhook_module "code.gitea.io/gitea/modules/webhook"
commitstatus_service "code.gitea.io/gitea/services/repository/commitstatus"
actions_model "gitea.dev/models/actions"
"gitea.dev/models/db"
git_model "gitea.dev/models/git"
repo_model "gitea.dev/models/repo"
user_model "gitea.dev/models/user"
actions_module "gitea.dev/modules/actions"
"gitea.dev/modules/actions/jobparser"
"gitea.dev/modules/commitstatus"
"gitea.dev/modules/glob"
"gitea.dev/modules/log"
api "gitea.dev/modules/structs"
"gitea.dev/modules/util"
webhook_module "gitea.dev/modules/webhook"
pull_service "gitea.dev/services/pull"
commitstatus_service "gitea.dev/services/repository/commitstatus"
)
// CreateCommitStatusForRunJobs creates a commit status for the given job if it has a supported event and related commit.
@@ -45,8 +47,14 @@ func CreateCommitStatusForRunJobs(ctx context.Context, run *actions_model.Action
return
}
// Compute the scoped source-repo prefix once per run; it is identical for every job.
var scopedPrefix string
if run.IsScopedRun {
scopedPrefix = actions_model.ScopedStatusContextPrefix(ctx, run.WorkflowRepoID)
}
for _, job := range jobs {
if err = createCommitStatus(ctx, run.Repo, event, commitID, run, job); err != nil {
if err = createCommitStatus(ctx, run.Repo, event, commitID, scopedPrefix, run, job); err != nil {
log.Error("Failed to create commit status for job %d: %v", job.ID, err)
}
}
@@ -136,23 +144,137 @@ func getCommitStatusEventNameAndCommitID(run *actions_model.ActionRun) (event, c
return event, commitID, nil
}
func createCommitStatus(ctx context.Context, repo *repo_model.Repository, event, commitID string, run *actions_model.ActionRun, job *actions_model.ActionRunJob) error {
// TODO: store workflow name as a field in ActionRun to avoid parsing
runName := path.Base(run.WorkflowID)
if wfs, err := jobparser.Parse(job.WorkflowPayload); err == nil && len(wfs) > 0 {
runName = wfs[0].Name
func createCommitStatus(ctx context.Context, repo *repo_model.Repository, event, commitID, scopedPrefix string, run *actions_model.ActionRun, job *actions_model.ActionRunJob) error {
displayName := actions_module.WorkflowDisplayName(run.WorkflowID, job.WorkflowPayload)
ctxName := actions_module.WorkflowStatusContextName(displayName, job.Name, event) // git_model.NewCommitStatus also trims spaces
if run.IsScopedRun {
// A scoped run is prefixed with its source repo (set off by a colon) so it stays distinct from a same-named repo-level workflow.
// scopedPrefix is computed once per run by the caller. The settings page derives the same string to preview expected checks.
ctxName = actions_module.ScopedWorkflowStatusContextName(scopedPrefix, displayName, job.Name, event)
}
ctxName := strings.TrimSpace(fmt.Sprintf("%s / %s (%s)", runName, job.Name, event)) // git_model.NewCommitStatus also trims spaces
state := toCommitStatus(job.Status)
targetURL := fmt.Sprintf("%s/jobs/%d", run.Link(), job.ID)
description := toCommitStatusDescription(job)
return createWorkflowCommitStatus(ctx, repo, commitID, ctxName, run.WorkflowID, toCommitStatus(job.Status), targetURL, toCommitStatusDescription(job))
}
// getAllRequiredStatusContextGlobs returns the compiled globs of every status-check context required in the repo:
// its protected branch rules' contexts and its required scoped workflows' patterns (see EffectiveRequiredContexts).
func getAllRequiredStatusContextGlobs(ctx context.Context, repo *repo_model.Repository) ([]glob.Glob, error) {
rules, err := git_model.FindRepoProtectedBranchRules(ctx, repo.ID)
if err != nil {
return nil, fmt.Errorf("FindRepoProtectedBranchRules: %w", err)
}
required, err := pull_service.EffectiveRequiredContexts(ctx, repo, rules...)
if err != nil {
return nil, fmt.Errorf("EffectiveRequiredContexts: %w", err)
}
globs := make([]glob.Glob, 0, len(required))
for _, c := range required {
if gp, err := glob.Compile(c); err != nil {
log.Error("glob.Compile %q: %v", c, err)
} else {
globs = append(globs, gp)
}
}
return globs, nil
}
// CreateSkippedCommitStatusForFilteredWorkflow posts a skipped commit status for each job of a
// workflow that matched the triggering event but was excluded by a branch/paths filter.
// This lets a required status check tied to that context be satisfied without the workflow running.
// Only contexts matching requiredGlobs are posted; a non-required context gets no skipped status.
// No ActionRun is created, so the status has no target URL (there is no run/job to link to).
// A non-empty scopedPrefix prefixes each context with its source repo, matching scoped runs.
//
// TODO: requiredGlobs over-approximates by including every protected branch rule's required contexts.
// If possible, the set should be narrowed to the required contexts of a specific branch protection rule (and the contexts from required scoped workflows).
// Currently, a `push` event must keep the repo-wide union since its future pull request base branch is unknown.
func CreateSkippedCommitStatusForFilteredWorkflow(ctx context.Context, repo *repo_model.Repository, event webhook_module.HookEventType, triggerEvent, workflowID string, content []byte, payload api.Payloader, scopedPrefix string, requiredGlobs []glob.Glob) error {
if len(requiredGlobs) == 0 {
return nil // nothing is required, so no skipped status is needed
}
// Derive the status event name and target commit from the payload.
// TODO: this mirrors getCommitStatusEventNameAndCommitID, which derives the same from a persisted run. Should merge the logic if possible.
var statusEvent, commitID string
switch event {
case webhook_module.HookEventPush:
if p, ok := payload.(*api.PushPayload); ok && p.HeadCommit != nil {
statusEvent, commitID = "push", p.HeadCommit.ID
}
case webhook_module.HookEventPullRequest,
webhook_module.HookEventPullRequestSync,
webhook_module.HookEventPullRequestAssign,
webhook_module.HookEventPullRequestLabel,
webhook_module.HookEventPullRequestReviewRequest,
webhook_module.HookEventPullRequestMilestone:
if p, ok := payload.(*api.PullRequestPayload); ok && p.PullRequest != nil && p.PullRequest.Head != nil {
statusEvent, commitID = "pull_request", p.PullRequest.Head.Sha
if triggerEvent == actions_module.GithubEventPullRequestTarget {
statusEvent = "pull_request_target"
}
}
}
if statusEvent == "" || commitID == "" {
return nil // unsupported event or missing commit id, nothing to post
}
workflows, err := jobparser.Parse(content)
if err != nil {
return fmt.Errorf("jobparser.Parse: %w", err)
}
displayName := actions_module.WorkflowDisplayName(workflowID, content)
for _, sw := range workflows {
_, job := sw.Job()
if job == nil {
continue
}
jobName := util.EllipsisDisplayString(job.Name, 255) // run creation truncates job names the same way
ctxName := actions_module.WorkflowStatusContextName(displayName, jobName, statusEvent)
if scopedPrefix != "" {
ctxName = actions_module.ScopedWorkflowStatusContextName(scopedPrefix, displayName, jobName, statusEvent)
}
// Only a required context needs the skipped status.
if !slices.ContainsFunc(requiredGlobs, func(gp glob.Glob) bool { return gp.Match(ctxName) }) {
continue
}
// "Skipped" mirrors toCommitStatusDescription for StatusSkipped.
if err := createWorkflowCommitStatus(ctx, repo, commitID, ctxName, workflowID, commitstatus.CommitStatusSkipped, "", "Skipped"); err != nil {
return err
}
}
return nil
}
// createWorkflowCommitStatus posts the commit status for one workflow-job context.
func createWorkflowCommitStatus(ctx context.Context, repo *repo_model.Repository, commitID, ctxName, workflowID string, state commitstatus.CommitStatusState, targetURL, description string) error {
// Mix the workflow file path into the hash so two workflow files that
// share the same `name:` and job name produce distinct commit statuses
// even though they render identically — matching GitHub's behavior
// (issue #35699).
ctxHash := git_model.HashCommitStatusContext(ctxName + "\x00" + workflowID)
// Pre-fix rows were hashed from Context alone. If a pre-existing row with
// the legacy hash is still the "latest" for this SHA, reuse that hash so
// the new row supersedes it; otherwise the old pending status would stay
// stuck forever (it lives in its own dedupe group). Only relevant for
// in-flight workflows at upgrade time.
legacyHash := git_model.HashCommitStatusContext(ctxName)
statuses, err := git_model.GetLatestCommitStatus(ctx, repo.ID, commitID, db.ListOptionsAll)
if err != nil {
return fmt.Errorf("GetLatestCommitStatus: %w", err)
}
for _, v := range statuses {
if v.Context == ctxName {
// Only adopt the legacy (Context-only) hash from a row the Actions user itself created before the
// #35699 fix, i.e. a pre-upgrade in-flight run. Adopting it from an external integration or the API
// would collapse two same-named workflows back into a single check.
if v.ContextHash == legacyHash && v.Context == ctxName && v.CreatorID == user_model.ActionsUserID {
ctxHash = legacyHash
break
}
}
for _, v := range statuses {
if v.ContextHash == ctxHash {
if v.State == state && v.TargetURL == targetURL && v.Description == description {
return nil
}
@@ -166,6 +288,7 @@ func createCommitStatus(ctx context.Context, repo *repo_model.Repository, event,
TargetURL: targetURL,
Description: description,
Context: ctxName,
ContextHash: ctxHash,
State: state,
CreatorID: creator.ID,
}
@@ -181,11 +304,13 @@ func toCommitStatusDescription(job *actions_model.ActionRunJob) string {
case actions_model.StatusFailure:
return fmt.Sprintf("Failing after %s", job.Duration())
case actions_model.StatusCancelled:
return "Has been cancelled"
return fmt.Sprintf("Canceled after %s", job.Duration())
case actions_model.StatusSkipped:
return "Has been skipped"
return "Skipped"
case actions_model.StatusRunning:
return "Has started running"
return "In progress"
case actions_model.StatusCancelling:
return "Canceling"
case actions_model.StatusWaiting:
return "Waiting to run"
case actions_model.StatusBlocked:
@@ -201,7 +326,7 @@ func toCommitStatus(status actions_model.Status) commitstatus.CommitStatusState
return commitstatus.CommitStatusSuccess
case actions_model.StatusFailure, actions_model.StatusCancelled:
return commitstatus.CommitStatusFailure
case actions_model.StatusWaiting, actions_model.StatusBlocked, actions_model.StatusRunning:
case actions_model.StatusWaiting, actions_model.StatusBlocked, actions_model.StatusRunning, actions_model.StatusCancelling:
return commitstatus.CommitStatusPending
case actions_model.StatusSkipped:
return commitstatus.CommitStatusSkipped
@@ -6,18 +6,44 @@ package actions
import (
"testing"
actions_model "code.gitea.io/gitea/models/actions"
"code.gitea.io/gitea/models/db"
git_model "code.gitea.io/gitea/models/git"
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unittest"
"code.gitea.io/gitea/modules/commitstatus"
"code.gitea.io/gitea/modules/gitrepo"
actions_model "gitea.dev/models/actions"
"gitea.dev/models/db"
git_model "gitea.dev/models/git"
repo_model "gitea.dev/models/repo"
"gitea.dev/models/unittest"
user_model "gitea.dev/models/user"
actions_module "gitea.dev/modules/actions"
"gitea.dev/modules/commitstatus"
"gitea.dev/modules/git"
"gitea.dev/modules/gitrepo"
"gitea.dev/modules/timeutil"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestCommitStatusDescription(t *testing.T) {
cases := []struct {
status actions_model.Status
started, stopped timeutil.TimeStamp
want string
}{
{actions_model.StatusSuccess, 100, 102, "Successful in 2s"},
{actions_model.StatusFailure, 100, 130, "Failing after 30s"},
{actions_model.StatusCancelled, 100, 145, "Canceled after 45s"},
{actions_model.StatusCancelling, 0, 0, "Canceling"},
{actions_model.StatusSkipped, 0, 0, "Skipped"},
{actions_model.StatusRunning, 0, 0, "In progress"},
{actions_model.StatusWaiting, 0, 0, "Waiting to run"},
{actions_model.StatusBlocked, 0, 0, "Blocked by required conditions"},
{actions_model.StatusUnknown, 0, 0, "Unknown status: 0"},
}
for _, tc := range cases {
job := &actions_model.ActionRunJob{Status: tc.status, Started: tc.started, Stopped: tc.stopped}
assert.Equal(t, tc.want, toCommitStatusDescription(job), tc.status.String())
}
}
func TestCreateCommitStatus_Dedupe(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
@@ -46,7 +72,7 @@ func TestCreateCommitStatus_Dedupe(t *testing.T) {
expectedContext := "status-dedupe-test.yaml / status-dedupe-job (push)"
expectedTargetURL := run.Link() + "/jobs/99002"
require.NoError(t, createCommitStatus(t.Context(), repo, "push", commit.ID.String(), run, job))
require.NoError(t, createCommitStatus(t.Context(), repo, "push", commit.ID.String(), "", run, job))
statuses := findCommitStatusesForContext(t, repo.ID, commit.ID.String(), expectedContext)
require.Len(t, statuses, 1)
@@ -55,26 +81,346 @@ func TestCreateCommitStatus_Dedupe(t *testing.T) {
assert.Equal(t, expectedTargetURL, statuses[0].TargetURL)
job.Status = actions_model.StatusRunning
require.NoError(t, createCommitStatus(t.Context(), repo, "push", commit.ID.String(), run, job))
require.NoError(t, createCommitStatus(t.Context(), repo, "push", commit.ID.String(), "", run, job))
statuses = findCommitStatusesForContext(t, repo.ID, commit.ID.String(), expectedContext)
require.Len(t, statuses, 2)
assert.Equal(t, "Waiting to run", statuses[0].Description)
assert.Equal(t, commitstatus.CommitStatusPending, statuses[1].State)
assert.Equal(t, "Has started running", statuses[1].Description)
assert.Equal(t, "In progress", statuses[1].Description)
assert.Equal(t, expectedTargetURL, statuses[1].TargetURL)
require.NoError(t, createCommitStatus(t.Context(), repo, "push", commit.ID.String(), run, job))
require.NoError(t, createCommitStatus(t.Context(), repo, "push", commit.ID.String(), "", run, job))
statuses = findCommitStatusesForContext(t, repo.ID, commit.ID.String(), expectedContext)
assert.Len(t, statuses, 2)
job.Status = actions_model.StatusSuccess
require.NoError(t, createCommitStatus(t.Context(), repo, "push", commit.ID.String(), run, job))
require.NoError(t, createCommitStatus(t.Context(), repo, "push", commit.ID.String(), "", run, job))
statuses = findCommitStatusesForContext(t, repo.ID, commit.ID.String(), expectedContext)
require.Len(t, statuses, 3)
assert.Equal(t, commitstatus.CommitStatusSuccess, statuses[2].State)
}
func TestGetCommitActionsStatusMap(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 4})
branch := unittest.AssertExistsAndLoadBean(t, &git_model.Branch{RepoID: repo.ID, Name: repo.DefaultBranch})
run := &actions_model.ActionRun{
RepoID: repo.ID, Repo: repo, OwnerID: repo.OwnerID, TriggerUserID: repo.OwnerID,
WorkflowID: "test.yaml", CommitSHA: branch.CommitID,
}
require.NoError(t, db.Insert(t.Context(), run))
cases := []struct {
jobName string
status actions_model.Status
}{
{"running-job", actions_model.StatusRunning},
{"waiting-job", actions_model.StatusWaiting},
{"unknown-job", actions_model.StatusUnknown},
}
for _, tc := range cases {
job := &actions_model.ActionRunJob{
RunID: run.ID, RepoID: repo.ID, OwnerID: repo.OwnerID, Name: tc.jobName, Status: tc.status,
}
require.NoError(t, db.Insert(t.Context(), job))
require.NoError(t, createCommitStatus(t.Context(), repo, "push", branch.CommitID, "", run, job))
}
statuses, err := git_model.GetLatestCommitStatus(t.Context(), repo.ID, branch.CommitID, db.ListOptionsAll)
require.NoError(t, err)
info := actions_module.GetCommitActionsStatusMap(t.Context(), statuses)
got := map[string]string{}
for _, s := range statuses {
got[s.Context] = info.IconStatus(s)
}
for _, tc := range cases {
key := "test.yaml / " + tc.jobName + " (push)"
want := tc.status.String()
assert.Equal(t, want, got[key], "icon status for %s", tc.jobName)
}
// Nil receiver returns "" without panicking — used by callers that skip enrichment.
var nilInfo actions_module.CommitActionsStatusMap
assert.Empty(t, nilInfo.IconStatus(statuses[0]))
}
// TestCreateCommitStatus_DistinctWorkflowFilesSameName covers issue #35699:
// two workflow files with the same `name:` and same job name must produce
// two distinct commit statuses, not be deduplicated into one.
func TestCreateCommitStatus_DistinctWorkflowFilesSameName(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 4})
branch := unittest.AssertExistsAndLoadBean(t, &git_model.Branch{RepoID: repo.ID, Name: repo.DefaultBranch})
payload := []byte(`
name: test-run
on: pull_request
jobs:
my-test:
runs-on: ubuntu-latest
steps:
- run: echo hi
`)
for _, spec := range []struct {
workflowID string
runID, jobID int64
}{
{"workflow1.yaml", 99101, 99201},
{"workflow2.yaml", 99102, 99202},
} {
run := &actions_model.ActionRun{
ID: spec.runID, Index: spec.runID, RepoID: repo.ID, Repo: repo, OwnerID: repo.OwnerID, TriggerUserID: repo.OwnerID,
WorkflowID: spec.workflowID, CommitSHA: branch.CommitID,
}
require.NoError(t, db.Insert(t.Context(), run))
job := &actions_model.ActionRunJob{
ID: spec.jobID, RunID: run.ID, RepoID: repo.ID, OwnerID: repo.OwnerID,
Name: "my-test", Status: actions_model.StatusWaiting,
WorkflowPayload: payload,
}
require.NoError(t, db.Insert(t.Context(), job))
require.NoError(t, createCommitStatus(t.Context(), repo, "pull_request", branch.CommitID, "", run, job))
}
statuses, err := git_model.GetLatestCommitStatus(t.Context(), repo.ID, branch.CommitID, db.ListOptionsAll)
require.NoError(t, err)
// Both workflow files should produce a row even though the display
// Context is identical — matching GitHub's behavior.
hashes := map[string]struct{}{}
targets := map[string]struct{}{}
for _, st := range statuses {
hashes[st.ContextHash] = struct{}{}
targets[st.TargetURL] = struct{}{}
assert.Equal(t, "test-run / my-test (pull_request)", st.Context)
}
assert.Len(t, hashes, 2, "expected distinct ContextHash per workflow file")
assert.Len(t, targets, 2, "expected distinct TargetURL per workflow file")
}
// TestCreateCommitStatus_LegacyHashRecovery covers the upgrade path: a pending
// status created before the fix (hashed from Context alone) must still be
// superseded by a follow-up event, instead of being orphaned in its own dedupe
// group while a new row accumulates under the new hash.
func TestCreateCommitStatus_LegacyHashRecovery(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 4})
branch := unittest.AssertExistsAndLoadBean(t, &git_model.Branch{RepoID: repo.ID, Name: repo.DefaultBranch})
ctxName := "legacy.yaml / my-job (push)"
legacyHash := git_model.HashCommitStatusContext(ctxName)
sha, err := git.NewIDFromString(branch.CommitID)
require.NoError(t, err)
// Pre-#35699 in-flight rows were posted by the Actions user with the Context-only hash.
creator := user_model.NewActionsUser()
require.NoError(t, git_model.NewCommitStatus(t.Context(), git_model.NewCommitStatusOptions{
Repo: repo,
Creator: creator,
SHA: sha,
CommitStatus: &git_model.CommitStatus{
State: commitstatus.CommitStatusPending,
Context: ctxName,
ContextHash: legacyHash,
TargetURL: "https://example.invalid/legacy",
Description: "Waiting to run",
},
}))
run := &actions_model.ActionRun{
ID: 99301, Index: 99301, RepoID: repo.ID, Repo: repo, OwnerID: repo.OwnerID, TriggerUserID: repo.OwnerID,
WorkflowID: "legacy.yaml", CommitSHA: branch.CommitID,
}
require.NoError(t, db.Insert(t.Context(), run))
job := &actions_model.ActionRunJob{
ID: 99302, RunID: run.ID, RepoID: repo.ID, OwnerID: repo.OwnerID,
Name: "my-job", Status: actions_model.StatusSuccess,
}
require.NoError(t, db.Insert(t.Context(), job))
require.NoError(t, createCommitStatus(t.Context(), repo, "push", branch.CommitID, "", run, job))
latest, err := git_model.GetLatestCommitStatus(t.Context(), repo.ID, branch.CommitID, db.ListOptionsAll)
require.NoError(t, err)
// The new row must reuse the legacy hash so GetLatestCommitStatus returns
// only one entry for this Context — the success, not the orphaned pending.
matches := 0
for _, s := range latest {
if s.Context == ctxName {
matches++
assert.Equal(t, legacyHash, s.ContextHash)
assert.Equal(t, commitstatus.CommitStatusSuccess, s.State)
}
}
assert.Equal(t, 1, matches)
}
// TestCreateCommitStatus_LegacyHashExternalNotAdopted: a status from a non-Actions creator sharing a
// workflow's Context must not pull the workflow into the legacy Context-only hash group.
func TestCreateCommitStatus_LegacyHashExternalNotAdopted(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 4})
branch := unittest.AssertExistsAndLoadBean(t, &git_model.Branch{RepoID: repo.ID, Name: repo.DefaultBranch})
workflowID := "external.yaml"
ctxName := "external.yaml / my-job (push)"
legacyHash := git_model.HashCommitStatusContext(ctxName)
distinctHash := git_model.HashCommitStatusContext(ctxName + "\x00" + workflowID)
sha, err := git.NewIDFromString(branch.CommitID)
require.NoError(t, err)
// An external status (posted by a real user, not the Actions user) sharing the same Context.
externalCreator := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
require.NoError(t, git_model.NewCommitStatus(t.Context(), git_model.NewCommitStatusOptions{
Repo: repo,
Creator: externalCreator,
SHA: sha,
CommitStatus: &git_model.CommitStatus{
State: commitstatus.CommitStatusSuccess,
Context: ctxName,
ContextHash: legacyHash,
TargetURL: "https://example.invalid/external",
Description: "external check",
},
}))
run := &actions_model.ActionRun{
ID: 99311, Index: 99311, RepoID: repo.ID, Repo: repo, OwnerID: repo.OwnerID, TriggerUserID: repo.OwnerID,
WorkflowID: workflowID, CommitSHA: branch.CommitID,
}
require.NoError(t, db.Insert(t.Context(), run))
job := &actions_model.ActionRunJob{
ID: 99312, RunID: run.ID, RepoID: repo.ID, OwnerID: repo.OwnerID,
Name: "my-job", Status: actions_model.StatusSuccess,
}
require.NoError(t, db.Insert(t.Context(), job))
require.NoError(t, createCommitStatus(t.Context(), repo, "push", branch.CommitID, "", run, job))
latest, err := git_model.GetLatestCommitStatus(t.Context(), repo.ID, branch.CommitID, db.ListOptionsAll)
require.NoError(t, err)
// The external status and the workflow status must coexist under distinct hashes.
var external, workflow *git_model.CommitStatus
for _, s := range latest {
switch s.ContextHash {
case legacyHash:
external = s
case distinctHash:
workflow = s
}
}
require.NotNil(t, external, "external status must be preserved under the legacy hash")
require.NotNil(t, workflow, "workflow status must use its own distinct hash, not the external legacy hash")
assert.Equal(t, "https://example.invalid/external", external.TargetURL)
}
// TestCreateCommitStatus_UnnamedWorkflowUsesFileName: a workflow with no
// non-blank `name:` uses the file name in the Context, not an empty
// "/ job (event)" — covers both an omitted and a whitespace-only name.
func TestCreateCommitStatus_UnnamedWorkflowUsesFileName(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 4})
branch := unittest.AssertExistsAndLoadBean(t, &git_model.Branch{RepoID: repo.ID, Name: repo.DefaultBranch})
for _, tc := range []struct {
workflowID string
runID, jobID int64
payload string
}{
{"unnamed.yaml", 99401, 99411, "on: push\n"},
{"blank.yaml", 99402, 99412, "name: \" \"\non: push\n"},
} {
run := &actions_model.ActionRun{
ID: tc.runID, Index: tc.runID, RepoID: repo.ID, Repo: repo, OwnerID: repo.OwnerID, TriggerUserID: repo.OwnerID,
WorkflowID: tc.workflowID, CommitSHA: branch.CommitID,
}
require.NoError(t, db.Insert(t.Context(), run))
job := &actions_model.ActionRunJob{
ID: tc.jobID, RunID: run.ID, RepoID: repo.ID, OwnerID: repo.OwnerID,
Name: "my-test", Status: actions_model.StatusWaiting,
WorkflowPayload: []byte(tc.payload + `jobs:
my-test:
runs-on: ubuntu-latest
steps:
- run: echo hi
`),
}
require.NoError(t, db.Insert(t.Context(), job))
require.NoError(t, createCommitStatus(t.Context(), repo, "push", branch.CommitID, "", run, job))
statuses := findCommitStatusesForContext(t, repo.ID, branch.CommitID, tc.workflowID+" / my-test (push)")
require.Len(t, statuses, 1)
assert.Equal(t, commitstatus.CommitStatusPending, statuses[0].State)
}
}
// TestCreateCommitStatus_ScopedSourcePrefix: a scoped run's commit status Context is prefixed with the source repo's full name,
// so it is distinct (display AND hash) from a same-named repo-level workflow.
func TestCreateCommitStatus_ScopedSourcePrefix(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
consumer := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 4})
source := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
branch := unittest.AssertExistsAndLoadBean(t, &git_model.Branch{RepoID: consumer.ID, Name: consumer.DefaultBranch})
payload := []byte(`name: ci
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- run: echo hi
`)
// A repo-level run and a scoped run share the same workflow name and job name;
// only the scoped one points its content source at another repo (WorkflowRepoID=source.ID, IsScopedRun=true).
for _, spec := range []struct {
runID, jobID int64
scoped bool
}{
{99501, 99511, false},
{99502, 99512, true},
} {
workflowRepoID := consumer.ID
if spec.scoped {
workflowRepoID = source.ID
}
run := &actions_model.ActionRun{
ID: spec.runID, Index: spec.runID, RepoID: consumer.ID, Repo: consumer, OwnerID: consumer.OwnerID, TriggerUserID: consumer.OwnerID,
WorkflowID: "ci.yaml", CommitSHA: branch.CommitID,
WorkflowRepoID: workflowRepoID, WorkflowCommitSHA: branch.CommitID, IsScopedRun: spec.scoped,
}
require.NoError(t, db.Insert(t.Context(), run))
job := &actions_model.ActionRunJob{
ID: spec.jobID, RunID: run.ID, RepoID: consumer.ID, OwnerID: consumer.OwnerID,
Name: "build", Status: actions_model.StatusWaiting, WorkflowPayload: payload,
}
require.NoError(t, db.Insert(t.Context(), job))
// mirror CreateCommitStatusForRunJobs: compute the scoped prefix once per run
scopedPrefix := ""
if run.IsScopedRun {
scopedPrefix = actions_model.ScopedStatusContextPrefix(t.Context(), run.WorkflowRepoID)
}
require.NoError(t, createCommitStatus(t.Context(), consumer, "push", branch.CommitID, scopedPrefix, run, job))
}
// repo-level Context is the bare "<display name> / <job> (<event>)"; the scoped one is the same but sets off the source repo with a colon,
// so the two stay distinct (and have different hashes) despite the same `name:`.
repoStatuses := findCommitStatusesForContext(t, consumer.ID, branch.CommitID, "ci / build (push)")
require.Len(t, repoStatuses, 1)
scopedStatuses := findCommitStatusesForContext(t, consumer.ID, branch.CommitID, source.FullName()+": ci / build (push)")
require.Len(t, scopedStatuses, 1)
assert.NotEqual(t, repoStatuses[0].ContextHash, scopedStatuses[0].ContextHash,
"scoped status must not collide with the same-named repo-level workflow")
}
func findCommitStatusesForContext(t *testing.T, repoID int64, sha, context string) []*git_model.CommitStatus {
t.Helper()
@@ -7,71 +7,47 @@ import (
"context"
"fmt"
actions_model "code.gitea.io/gitea/models/actions"
"code.gitea.io/gitea/modules/actions/jobparser"
"code.gitea.io/gitea/modules/json"
api "code.gitea.io/gitea/modules/structs"
actions_model "gitea.dev/models/actions"
"gitea.dev/modules/actions/jobparser"
"gitea.dev/modules/setting"
act_model "github.com/nektos/act/pkg/model"
act_model "gitea.com/gitea/runner/act/model"
"go.yaml.in/yaml/v4"
)
// EvaluateRunConcurrencyFillModel evaluates the expressions in a run-level (workflow) concurrency,
// and fills the run's model fields with `concurrency.group` and `concurrency.cancel-in-progress`.
// and fills the run attempt model with the evaluated `concurrency.group` and `concurrency.cancel-in-progress` values.
// Workflow-level concurrency doesn't depend on the job outputs, so it can always be evaluated if there is no syntax error.
// Callers must resolve `inputs`, there is no job in scope here to read `on: workflow_dispatch` from.
// See https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#concurrency
func EvaluateRunConcurrencyFillModel(ctx context.Context, run *actions_model.ActionRun, wfRawConcurrency *act_model.RawConcurrency, vars map[string]string, inputs map[string]any) error {
func EvaluateRunConcurrencyFillModel(ctx context.Context, run *actions_model.ActionRun, attempt *actions_model.ActionRunAttempt, wfRawConcurrency *act_model.RawConcurrency, vars map[string]string, inputs map[string]any) error {
if err := run.LoadAttributes(ctx); err != nil {
return fmt.Errorf("run LoadAttributes: %w", err)
}
actionsRunCtx := GenerateGiteaContext(run, nil)
actionsRunCtx := GenerateGiteaContext(ctx, run, attempt, nil)
jobResults := map[string]*jobparser.JobResult{"": {}}
if inputs == nil {
var err error
inputs, err = getInputsFromRun(run)
if err != nil {
return fmt.Errorf("get inputs: %w", err)
if run.Event == "workflow_dispatch" {
setting.PanicInDevOrTesting("run %d: workflow_dispatch inputs must be resolved by the caller", run.ID)
}
inputs = map[string]any{}
}
rawConcurrency, err := yaml.Marshal(wfRawConcurrency)
if err != nil {
return fmt.Errorf("marshal raw concurrency: %w", err)
}
run.RawConcurrency = string(rawConcurrency)
run.ConcurrencyGroup, run.ConcurrencyCancel, err = jobparser.EvaluateConcurrency(wfRawConcurrency, "", nil, actionsRunCtx, jobResults, vars, inputs)
var err error
attempt.ConcurrencyGroup, attempt.ConcurrencyCancel, err = jobparser.EvaluateConcurrency(wfRawConcurrency, "", nil, actionsRunCtx, jobResults, vars, inputs)
if err != nil {
return fmt.Errorf("evaluate concurrency: %w", err)
}
return nil
}
func findJobNeedsAndFillJobResults(ctx context.Context, job *actions_model.ActionRunJob) (map[string]*jobparser.JobResult, error) {
taskNeeds, err := FindTaskNeeds(ctx, job)
if err != nil {
return nil, fmt.Errorf("find task needs: %w", err)
}
jobResults := make(map[string]*jobparser.JobResult, len(taskNeeds))
for jobID, taskNeed := range taskNeeds {
jobResult := &jobparser.JobResult{
Result: taskNeed.Result.String(),
Outputs: taskNeed.Outputs,
}
jobResults[jobID] = jobResult
}
jobResults[job.JobID] = &jobparser.JobResult{
Needs: job.Needs,
}
return jobResults, nil
}
// EvaluateJobConcurrencyFillModel evaluates the expressions in a job-level concurrency,
// and fills the job's model fields with `concurrency.group` and `concurrency.cancel-in-progress`.
// Job-level concurrency may depend on other job's outputs (via `needs`): `concurrency.group: my-group-${{ needs.job1.outputs.out1 }}`
// If the needed jobs haven't been executed yet, this evaluation will also fail.
// See https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#jobsjob_idconcurrency
func EvaluateJobConcurrencyFillModel(ctx context.Context, run *actions_model.ActionRun, actionRunJob *actions_model.ActionRunJob, vars map[string]string, inputs map[string]any) error {
func EvaluateJobConcurrencyFillModel(ctx context.Context, run *actions_model.ActionRun, attempt *actions_model.ActionRunAttempt, actionRunJob *actions_model.ActionRunJob, vars map[string]string, inputs map[string]any) error {
if err := actionRunJob.LoadAttributes(ctx); err != nil {
return fmt.Errorf("job LoadAttributes: %w", err)
}
@@ -81,7 +57,7 @@ func EvaluateJobConcurrencyFillModel(ctx context.Context, run *actions_model.Act
return fmt.Errorf("unmarshal raw concurrency: %w", err)
}
actionsJobCtx := GenerateGiteaContext(run, actionRunJob)
actionsJobCtx := GenerateGiteaContext(ctx, run, attempt, actionRunJob)
jobResults, err := findJobNeedsAndFillJobResults(ctx, actionRunJob)
if err != nil {
@@ -90,7 +66,7 @@ func EvaluateJobConcurrencyFillModel(ctx context.Context, run *actions_model.Act
if inputs == nil {
var err error
inputs, err = getInputsFromRun(run)
inputs, err = getInputsForJob(ctx, run, actionRunJob)
if err != nil {
return fmt.Errorf("get inputs: %w", err)
}
@@ -108,14 +84,3 @@ func EvaluateJobConcurrencyFillModel(ctx context.Context, run *actions_model.Act
actionRunJob.IsConcurrencyEvaluated = true
return nil
}
func getInputsFromRun(run *actions_model.ActionRun) (map[string]any, error) {
if run.Event != "workflow_dispatch" {
return map[string]any{}, nil
}
var payload api.WorkflowDispatchPayload
if err := json.Unmarshal([]byte(run.EventPayload), &payload); err != nil {
return nil, err
}
return payload.Inputs, nil
}
+180 -27
View File
@@ -8,23 +8,32 @@ import (
"fmt"
"strconv"
actions_model "code.gitea.io/gitea/models/actions"
"code.gitea.io/gitea/models/db"
actions_module "code.gitea.io/gitea/modules/actions"
"code.gitea.io/gitea/modules/container"
"code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/modules/json"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/util"
actions_model "gitea.dev/models/actions"
"gitea.dev/models/db"
actions_module "gitea.dev/modules/actions"
"gitea.dev/modules/actions/jobparser"
"gitea.dev/modules/container"
"gitea.dev/modules/git"
"gitea.dev/modules/json"
"gitea.dev/modules/log"
"gitea.dev/modules/optional"
"gitea.dev/modules/setting"
api "gitea.dev/modules/structs"
"gitea.dev/modules/util"
"github.com/nektos/act/pkg/model"
"gitea.com/gitea/runner/act/model"
)
type GiteaContext map[string]any
// GenerateGiteaContext generate the gitea context without token and gitea_runtime_token
// job can be nil when generating a context for parsing workflow-level expressions
func GenerateGiteaContext(run *actions_model.ActionRun, job *actions_model.ActionRunJob) GiteaContext {
// GenerateGiteaContext generate the gitea context without token and gitea_runtime_token.
// attempt and job can be nil when generating a context for parsing workflow-level expressions.
//
// The run_attempt value is resolved with the following precedence:
// 1. attempt.Attempt - the explicit attempt argument, or run.GetLatestAttempt() as a fallback
// 2. job.Attempt - only used when neither an explicit nor latest attempt is available
// 3. "1" - when none of the above apply (first-run parse time, before the first attempt exists)
func GenerateGiteaContext(ctx context.Context, run *actions_model.ActionRun, attempt *actions_model.ActionRunAttempt, job *actions_model.ActionRunJob) GiteaContext {
event := map[string]any{}
_ = json.Unmarshal([]byte(run.EventPayload), &event)
@@ -40,7 +49,7 @@ func GenerateGiteaContext(run *actions_model.ActionRun, job *actions_model.Actio
// In GitHub's documentation, ref should be the branch or tag that triggered workflow. But when the TriggerEvent is pull_request_target,
// the ref will be the base branch.
if run.TriggerEvent == actions_module.GithubEventPullRequestTarget {
ref = git.BranchPrefix + pullPayload.PullRequest.Base.Name
ref = git.BranchPrefix + pullPayload.PullRequest.Base.Ref
sha = pullPayload.PullRequest.Base.Sha
}
}
@@ -89,8 +98,56 @@ func GenerateGiteaContext(run *actions_model.ActionRun, job *actions_model.Actio
if job != nil {
gitContext["job"] = job.JobID
gitContext["run_id"] = strconv.FormatInt(job.RunID, 10)
gitContext["run_attempt"] = strconv.FormatInt(job.Attempt, 10)
if job.ParentJobID > 0 {
// Inject the caller's resolved workflow_call inputs into gitea.event.inputs.
// The rest of gitea.event stays as the caller's actual trigger event (push/pull_request/etc.)
// to match GitHub's semantics (see https://docs.github.com/en/actions/reference/workflows-and-actions/reusing-workflow-configurations#github-context).
// FIXME: If the run is triggered by "workflow_dispatch", the original inputs of "workflow_dispatch" will be overridden.
// If necessary, the caller can send these values to the called workflow via `with:`.
caller, err := actions_model.GetRunJobByRunAndID(ctx, job.RunID, job.ParentJobID)
if err != nil {
log.Error("GenerateGiteaContext: load caller job %d of job %d: %v", job.ParentJobID, job.ID, err)
} else if caller.CallPayload != "" {
var cp api.WorkflowCallPayload
if err := json.Unmarshal([]byte(caller.CallPayload), &cp); err != nil {
log.Error("GenerateGiteaContext: decode CallPayload of caller %d: %v", caller.ID, err)
} else if cp.Inputs != nil {
event["inputs"] = cp.Inputs
}
}
// Override gitea.event_name to "workflow_call", so that the runner-side `getEvaluatorInputs` can get inputs from event["inputs"].
// https://gitea.com/gitea/runner/src/commit/0b9f251b6abb30d5f292a49cfe0c611f7c26d857/act/runner/expression.go#L509
// FIXME: The trade-off is that `${{ gitea.event_name }}` inside a reusable workflow's child job reads "workflow_call"
// instead of the caller's real trigger event name (push/pull_request/etc.) This is a small deviation from GitHub spec.
gitContext["event_name"] = "workflow_call"
}
}
if attempt == nil {
if latestAttempt, has, err := run.GetLatestAttempt(ctx); err == nil && has {
attempt = latestAttempt
}
}
if attempt != nil {
gitContext["run_attempt"] = strconv.FormatInt(attempt.Attempt, 10)
// Only the trigger user is needed for triggering_actor. attempt.LoadAttributes would also re-load
// the run this function already holds as a parameter, on the hot task-dispatch path.
if err := attempt.LoadTriggerUser(ctx); err != nil {
log.Error("GenerateGiteaContext: load trigger user of attempt %d: %v", attempt.ID, err)
}
if attempt.TriggerUser != nil {
gitContext["triggering_actor"] = attempt.TriggerUser.Name
}
}
// Fallback for first-run parse time: no job, no attempt (LatestAttemptID==0). github.run_attempt
// is 1-based per the documented contract, so emit "1" rather than leaving it empty.
if gitContext["run_attempt"] == "" {
gitContext["run_attempt"] = "1"
}
return gitContext
@@ -101,21 +158,36 @@ type TaskNeed struct {
Outputs map[string]string
}
// FindTaskNeeds finds the `needs` for the task by the task's job
// FindTaskNeeds finds the `needs` for the task by the task's job.
// Lookup is scoped to the same ParentJobID.
func FindTaskNeeds(ctx context.Context, job *actions_model.ActionRunJob) (map[string]*TaskNeed, error) {
if len(job.Needs) == 0 {
return nil, nil //nolint:nilnil // return nil when the job has no needs
}
needs := container.SetOf(job.Needs...)
jobs, err := db.Find[actions_model.ActionRunJob](ctx, actions_model.FindRunJobOptions{RunID: job.RunID})
// Scope to the same attempt. For legacy jobs RunAttemptID==0, which matches all other legacy jobs in the same run.
findOpts := actions_model.FindRunJobOptions{
RunID: job.RunID,
RunAttemptID: optional.Some(job.RunAttemptID),
}
jobs, err := db.Find[actions_model.ActionRunJob](ctx, findOpts)
if err != nil {
return nil, fmt.Errorf("FindRunJobs: %w", err)
}
jobIDJobs := make(map[string][]*actions_model.ActionRunJob)
for _, job := range jobs {
jobIDJobs[job.JobID] = append(jobIDJobs[job.JobID], job)
// childrenByParent indexes every job by its ParentJobID
childrenByParent := make(map[int64][]*actions_model.ActionRunJob)
for _, candidate := range jobs {
if candidate.ParentJobID != 0 {
childrenByParent[candidate.ParentJobID] = append(childrenByParent[candidate.ParentJobID], candidate)
}
// `needs` references are scope-bound: only candidates in the same caller scope match.
if candidate.ParentJobID == job.ParentJobID {
jobIDJobs[candidate.JobID] = append(jobIDJobs[candidate.JobID], candidate)
}
}
ret := make(map[string]*TaskNeed, len(needs))
@@ -124,18 +196,19 @@ func FindTaskNeeds(ctx context.Context, job *actions_model.ActionRunJob) (map[st
continue
}
var jobOutputs map[string]string
for _, job := range jobsWithSameID {
if job.TaskID == 0 || !job.Status.IsDone() {
// it shouldn't happen, or the job has been rerun
for _, candidate := range jobsWithSameID {
if !candidate.Status.IsDone() {
continue
}
got, err := actions_model.FindTaskOutputByTaskID(ctx, job.TaskID)
if err != nil {
return nil, fmt.Errorf("FindTaskOutputByTaskID: %w", err)
var outputs map[string]string
var err error
if candidate.IsReusableCaller {
outputs, err = computeReusableCallerOutputs(ctx, candidate, childrenByParent)
} else {
outputs, err = loadJobTaskOutputs(ctx, candidate)
}
outputs := make(map[string]string, len(got))
for _, v := range got {
outputs[v.OutputKey] = v.OutputValue
if err != nil {
return nil, err
}
if len(jobOutputs) == 0 {
jobOutputs = outputs
@@ -151,6 +224,86 @@ func FindTaskNeeds(ctx context.Context, job *actions_model.ActionRunJob) (map[st
return ret, nil
}
// computeReusableCallerOutputs returns the workflow_call outputs of a reusable caller by recursing into its child subtree.
func computeReusableCallerOutputs(ctx context.Context, caller *actions_model.ActionRunJob, childrenByParent map[int64][]*actions_model.ActionRunJob) (map[string]string, error) {
if !caller.IsExpanded {
// A caller that was never expanded (e.g. Skipped because its `if:` was false) has no workflow_call outputs, return early.
return map[string]string{}, nil
}
directChildren := childrenByParent[caller.ID]
if err := caller.LoadRun(ctx); err != nil {
return nil, err
}
wcSpec, err := jobparser.ParseWorkflowCallSpec(caller.ReusableWorkflowContent)
if err != nil {
return nil, err
}
if len(wcSpec.Outputs) == 0 {
return map[string]string{}, nil
}
// Per-job outputs over the children of this caller.
jobOutputs := make(jobparser.JobOutputs, len(directChildren))
for _, child := range directChildren {
var outs map[string]string
switch {
case child.IsReusableCaller:
outs, err = computeReusableCallerOutputs(ctx, child, childrenByParent)
default:
outs, err = loadJobTaskOutputs(ctx, child)
}
if err != nil {
return nil, err
}
if existing, ok := jobOutputs[child.JobID]; ok {
jobOutputs[child.JobID] = mergeTwoOutputs(outs, existing)
} else {
jobOutputs[child.JobID] = outs
}
}
// build contexts for evaluating outputs
if err := caller.Run.LoadAttributes(ctx); err != nil {
return nil, err
}
gitCtx := GenerateGiteaContext(ctx, caller.Run, nil, caller)
vars, err := actions_model.GetVariablesOfRun(ctx, caller.Run)
if err != nil {
return nil, err
}
inputs := map[string]any{}
if caller.CallPayload != "" {
var p api.WorkflowCallPayload
if err := json.Unmarshal([]byte(caller.CallPayload), &p); err != nil {
return nil, fmt.Errorf("decode caller payload: %w", err)
}
if p.Inputs != nil {
inputs = p.Inputs
}
}
return jobparser.EvaluateWorkflowCallOutputs(wcSpec, gitCtx.ToGitHubContext(), vars, inputs, jobOutputs)
}
// loadJobTaskOutputs returns the task-output map of `job`.
func loadJobTaskOutputs(ctx context.Context, job *actions_model.ActionRunJob) (map[string]string, error) {
tid := job.EffectiveTaskID()
if tid == 0 {
return map[string]string{}, nil
}
rows, err := actions_model.FindTaskOutputByTaskID(ctx, tid)
if err != nil {
return nil, fmt.Errorf("FindTaskOutputByTaskID: %w", err)
}
out := make(map[string]string, len(rows))
for _, r := range rows {
out[r.OutputKey] = r.OutputValue
}
return out, nil
}
// mergeTwoOutputs merges two outputs from two different ActionRunJobs
// Values with the same output name may be overridden. The user should ensure the output names are unique.
// See https://docs.github.com/en/actions/writing-workflows/workflow-syntax-for-github-actions#using-job-outputs-in-a-matrix-job
@@ -7,43 +7,50 @@ import (
"strconv"
"testing"
actions_model "code.gitea.io/gitea/models/actions"
"code.gitea.io/gitea/models/unittest"
actions_model "gitea.dev/models/actions"
"gitea.dev/models/db"
repo_model "gitea.dev/models/repo"
"gitea.dev/models/unittest"
user_model "gitea.dev/models/user"
actions_module "gitea.dev/modules/actions"
"gitea.dev/modules/json"
api "gitea.dev/modules/structs"
webhook_module "gitea.dev/modules/webhook"
act_model "github.com/nektos/act/pkg/model"
act_model "gitea.com/gitea/runner/act/model"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestEvaluateRunConcurrency_RunIDFallback(t *testing.T) {
// Unit-level check that EvaluateRunConcurrencyFillModel resolves
// github.run_id from run.ID. The full-flow regression — that run.ID is
// non-zero by the time evaluation happens — is in
// TestPrepareRunAndInsert_ExpressionsSeeRunID.
// Unit-level check that EvaluateRunConcurrencyFillModel resolves github.run_id from run.ID.
// The full-flow regression (run.ID non-zero by evaluation time) is TestPrepareRunAndInsert_ExpressionsSeeRunID.
assert.NoError(t, unittest.PrepareTestDatabase())
ctx := t.Context()
runA := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: 791})
runB := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: 792})
attemptA := &actions_model.ActionRunAttempt{RepoID: runA.RepoID, RunID: runA.ID, Attempt: 1}
attemptB := &actions_model.ActionRunAttempt{RepoID: runB.RepoID, RunID: runB.ID, Attempt: 1}
expr := &act_model.RawConcurrency{
Group: "${{ github.workflow }}-${{ github.head_ref || github.run_id }}",
CancelInProgress: "true",
CancelInProgress: "True",
}
assert.NoError(t, EvaluateRunConcurrencyFillModel(ctx, runA, expr, nil, nil))
assert.NoError(t, EvaluateRunConcurrencyFillModel(ctx, runB, expr, nil, nil))
assert.NoError(t, EvaluateRunConcurrencyFillModel(ctx, runA, attemptA, expr, nil, nil))
assert.NoError(t, EvaluateRunConcurrencyFillModel(ctx, runB, attemptB, expr, nil, nil))
assert.Contains(t, runA.ConcurrencyGroup, "791")
assert.Contains(t, runB.ConcurrencyGroup, "792")
assert.NotEqual(t, runA.ConcurrencyGroup, runB.ConcurrencyGroup)
assert.True(t, attemptA.ConcurrencyCancel)
assert.Contains(t, attemptA.ConcurrencyGroup, "791")
assert.Contains(t, attemptB.ConcurrencyGroup, "792")
assert.NotEqual(t, attemptA.ConcurrencyGroup, attemptB.ConcurrencyGroup)
}
func TestPrepareRunAndInsert_ExpressionsSeeRunID(t *testing.T) {
// Regression for the cross-branch concurrency leak: github.run_id must
// be available during BOTH jobparser.Parse (run-name) and workflow-level
// concurrency evaluation. Re-ordering db.Insert relative to either step
// would leave run.ID at 0 and break this test.
// Regression for the cross-branch concurrency leak: github.run_id must be available during both
// jobparser.Parse (run-name) and concurrency evaluation; inserting run after either leaves run.ID at 0.
assert.NoError(t, unittest.PrepareTestDatabase())
ctx := t.Context()
@@ -61,16 +68,18 @@ jobs:
`)
run := &actions_model.ActionRun{
Title: "before parse",
RepoID: 4,
OwnerID: 1,
WorkflowID: "expr-runid.yaml",
TriggerUserID: 1,
Ref: "refs/heads/master",
CommitSHA: "c2d72f548424103f01ee1dc02889c1e2bff816b0",
Event: "push",
TriggerEvent: "push",
EventPayload: "{}",
Title: "before parse",
RepoID: 4,
OwnerID: 1,
WorkflowID: "expr-runid.yaml",
TriggerUserID: 1,
Ref: "refs/heads/master",
CommitSHA: "c2d72f548424103f01ee1dc02889c1e2bff816b0",
Event: "push",
TriggerEvent: "push",
EventPayload: "{}",
WorkflowRepoID: 4,
WorkflowCommitSHA: "c2d72f548424103f01ee1dc02889c1e2bff816b0",
}
require.NoError(t, PrepareRunAndInsert(ctx, content, run, nil))
require.Positive(t, run.ID)
@@ -78,12 +87,228 @@ jobs:
persisted := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: run.ID})
runIDStr := strconv.FormatInt(run.ID, 10)
assert.Equal(t, "Run "+runIDStr, persisted.Title)
assert.Equal(t, "group-"+runIDStr, persisted.ConcurrencyGroup)
// ConcurrencyGroup lives on the latest attempt after migration v331.
require.Positive(t, persisted.LatestAttemptID)
attempt := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunAttempt{ID: persisted.LatestAttemptID})
assert.Equal(t, "group-"+runIDStr, attempt.ConcurrencyGroup)
// Rerun reads raw_concurrency from the DB to re-evaluate the group;
// see services/actions/rerun.go. Must survive the insert.
assert.NotEmpty(t, persisted.RawConcurrency)
}
func TestComputeReusableCallerOutputs(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
ctx := t.Context()
var nextRunIndex int64 = 9001
insertRun := func(t *testing.T, workflowID string) *actions_model.ActionRun {
t.Helper()
run := &actions_model.ActionRun{
Title: "reusable-out",
RepoID: 4,
Index: nextRunIndex,
OwnerID: 1,
WorkflowID: workflowID,
TriggerUserID: 1,
Ref: "refs/heads/master",
CommitSHA: "c2d72f548424103f01ee1dc02889c1e2bff816b0",
Event: "push",
TriggerEvent: "push",
EventPayload: "{}",
Status: actions_model.StatusSuccess,
}
nextRunIndex++
require.NoError(t, db.Insert(ctx, run))
return run
}
insertCaller := func(t *testing.T, run *actions_model.ActionRun, jobID string, parentID int64, content, callPayload string) *actions_model.ActionRunJob {
t.Helper()
job := &actions_model.ActionRunJob{
RunID: run.ID,
RepoID: run.RepoID,
OwnerID: run.OwnerID,
CommitSHA: run.CommitSHA,
Name: jobID,
JobID: jobID,
Attempt: 1,
Status: actions_model.StatusSuccess,
ParentJobID: parentID,
IsReusableCaller: true,
IsExpanded: true,
ReusableWorkflowContent: []byte(content),
CallPayload: callPayload,
}
require.NoError(t, db.Insert(ctx, job))
return job
}
// Each call to insertChildJobAndTask with non-empty outputs allocates a fresh TaskID
// so its action_task_output rows stay isolated per subtest.
var nextTaskID int64 = 90001
insertChildJobAndTask := func(t *testing.T, run *actions_model.ActionRun, jobID string, parentID int64, outputs map[string]string) *actions_model.ActionRunJob {
t.Helper()
var taskID int64
if len(outputs) > 0 {
taskID = nextTaskID
nextTaskID++
}
job := &actions_model.ActionRunJob{
RunID: run.ID,
RepoID: run.RepoID,
OwnerID: run.OwnerID,
CommitSHA: run.CommitSHA,
Name: jobID,
JobID: jobID,
Attempt: 1,
Status: actions_model.StatusSuccess,
ParentJobID: parentID,
TaskID: taskID,
}
require.NoError(t, db.Insert(ctx, job))
for k, v := range outputs {
require.NoError(t, db.Insert(ctx, &actions_model.ActionTaskOutput{
TaskID: taskID,
OutputKey: k,
OutputValue: v,
}))
}
return job
}
// childrenByParentOfRun returns the run's jobs indexed by ParentJobID, the shape computeReusableCallerOutputs expects.
childrenByParentOfRun := func(t *testing.T, runID int64) map[int64][]*actions_model.ActionRunJob {
t.Helper()
all, err := db.Find[actions_model.ActionRunJob](ctx, actions_model.FindRunJobOptions{RunID: runID})
require.NoError(t, err)
index := make(map[int64][]*actions_model.ActionRunJob)
for _, j := range all {
if j.ParentJobID != 0 {
index[j.ParentJobID] = append(index[j.ParentJobID], j)
}
}
return index
}
t.Run("returns empty when callee declares no outputs", func(t *testing.T) {
run := insertRun(t, "no-outputs.yaml")
caller := insertCaller(t, run, "caller", 0, `on:
workflow_call:
outputs: {}
`, "")
out, err := computeReusableCallerOutputs(ctx, caller, childrenByParentOfRun(t, run.ID))
require.NoError(t, err)
assert.Empty(t, out)
})
t.Run("unexpanded (skipped) caller yields empty outputs without error", func(t *testing.T) {
run := insertRun(t, "skipped-caller.yaml")
// A reusable caller skipped before expansion: IsExpanded=false, empty ReusableWorkflowContent, no children.
caller := &actions_model.ActionRunJob{
RunID: run.ID,
RepoID: run.RepoID,
OwnerID: run.OwnerID,
CommitSHA: run.CommitSHA,
Name: "caller",
JobID: "caller",
Attempt: 1,
Status: actions_model.StatusSkipped,
IsReusableCaller: true,
IsExpanded: false,
}
require.NoError(t, db.Insert(ctx, caller))
out, err := computeReusableCallerOutputs(ctx, caller, childrenByParentOfRun(t, run.ID))
require.NoError(t, err)
assert.Empty(t, out)
})
t.Run("literal output value passes through", func(t *testing.T) {
run := insertRun(t, "literal-out.yaml")
caller := insertCaller(t, run, "caller", 0, `on:
workflow_call:
outputs:
hello:
value: world
`, "")
out, err := computeReusableCallerOutputs(ctx, caller, childrenByParentOfRun(t, run.ID))
require.NoError(t, err)
assert.Equal(t, map[string]string{"hello": "world"}, out)
})
t.Run("output expression reads child task outputs", func(t *testing.T) {
run := insertRun(t, "child-out.yaml")
caller := insertCaller(t, run, "caller", 0, `on:
workflow_call:
outputs:
result:
value: ${{ jobs.child.outputs.foo }}
`, "")
insertChildJobAndTask(t, run, "child", caller.ID, map[string]string{"foo": "bar"})
out, err := computeReusableCallerOutputs(ctx, caller, childrenByParentOfRun(t, run.ID))
require.NoError(t, err)
assert.Equal(t, map[string]string{"result": "bar"}, out)
})
t.Run("CallPayload inputs reachable in output expression", func(t *testing.T) {
run := insertRun(t, "payload-out.yaml")
payload, err := json.Marshal(api.WorkflowCallPayload{
Inputs: map[string]any{"env": "staging"},
})
require.NoError(t, err)
caller := insertCaller(t, run, "caller", 0, `on:
workflow_call:
inputs:
env:
type: string
outputs:
env:
value: ${{ inputs.env }}
`, string(payload))
out, err := computeReusableCallerOutputs(ctx, caller, childrenByParentOfRun(t, run.ID))
require.NoError(t, err)
assert.Equal(t, map[string]string{"env": "staging"}, out)
})
t.Run("nested caller outputs propagate to outer", func(t *testing.T) {
run := insertRun(t, "nested-out.yaml")
outer := insertCaller(t, run, "outer", 0, `on:
workflow_call:
outputs:
bubbled:
value: ${{ jobs.inner.outputs.up }}
`, "")
inner := insertCaller(t, run, "inner", outer.ID, `on:
workflow_call:
outputs:
up:
value: ${{ jobs.leaf.outputs.foo }}
`, "")
insertChildJobAndTask(t, run, "leaf", inner.ID, map[string]string{"foo": "bubble-value"})
out, err := computeReusableCallerOutputs(ctx, outer, childrenByParentOfRun(t, run.ID))
require.NoError(t, err)
assert.Equal(t, map[string]string{"bubbled": "bubble-value"}, out)
})
t.Run("matrix children with same JobID prefer non-empty values", func(t *testing.T) {
run := insertRun(t, "matrix-out.yaml")
caller := insertCaller(t, run, "caller", 0, `on:
workflow_call:
outputs:
foo:
value: ${{ jobs.matrix.outputs.foo }}
`, "")
insertChildJobAndTask(t, run, "matrix", caller.ID, map[string]string{"foo": ""})
insertChildJobAndTask(t, run, "matrix", caller.ID, map[string]string{"foo": "filled"})
out, err := computeReusableCallerOutputs(ctx, caller, childrenByParentOfRun(t, run.ID))
require.NoError(t, err)
assert.Equal(t, map[string]string{"foo": "filled"}, out)
})
}
func TestFindTaskNeeds(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
@@ -98,3 +323,71 @@ func TestFindTaskNeeds(t *testing.T) {
assert.Equal(t, "abc", ret["job1"].Outputs["output_a"])
assert.Equal(t, "bbb", ret["job1"].Outputs["output_b"])
}
func TestGenerateGiteaContextPullRequestTarget(t *testing.T) {
payload := api.PullRequestPayload{
PullRequest: &api.PullRequest{
Base: &api.PRBranchInfo{
Name: "owner:main",
Ref: "main",
Sha: "1234567890abcdef",
},
Head: &api.PRBranchInfo{
Name: "fork:feature",
Ref: "feature",
Sha: "fedcba0987654321",
},
},
}
payloadBytes, err := json.Marshal(payload)
assert.NoError(t, err)
run := &actions_model.ActionRun{
Event: webhook_module.HookEventPullRequest,
TriggerEvent: string(actions_module.GithubEventPullRequestTarget),
EventPayload: string(payloadBytes),
TriggerUser: &user_model.User{Name: "test-user"},
Repo: &repo_model.Repository{Name: "test-repo", OwnerName: "test-owner"},
}
giteaCtx := GenerateGiteaContext(t.Context(), run, nil, nil)
assert.Equal(t, "refs/heads/main", giteaCtx["ref"])
assert.Equal(t, "main", giteaCtx["ref_name"])
}
// TestGenerateGiteaContext_NilAttempt verifies that, with no explicit attempt,
// use GetLatestAttempt to load the latest attempt and resolve attempt-related context variables.
func TestGenerateGiteaContext_NilAttempt(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 4})
require.NoError(t, repo.LoadOwner(t.Context()))
actor := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}) // initiated the run
triggerer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) // initiated the latest attempt
run := &actions_model.ActionRun{
RepoID: repo.ID, Repo: repo, OwnerID: repo.OwnerID,
TriggerUserID: actor.ID, TriggerUser: actor,
WorkflowID: "test.yml", Index: 99600, Ref: "refs/heads/main",
CommitSHA: "c2d72f548424103f01ee1dc02889c1e2bff816b0", TriggerEvent: "push",
Status: actions_model.StatusRunning,
}
require.NoError(t, db.Insert(t.Context(), run))
attempt := &actions_model.ActionRunAttempt{
RepoID: repo.ID, RunID: run.ID, Attempt: 3, TriggerUserID: triggerer.ID, Status: actions_model.StatusRunning,
}
require.NoError(t, db.Insert(t.Context(), attempt))
run.LatestAttemptID = attempt.ID
job := &actions_model.ActionRunJob{
RunID: run.ID, RunAttemptID: attempt.ID, AttemptJobID: 1, RepoID: repo.ID, OwnerID: repo.OwnerID,
Name: "j", JobID: "j", Attempt: attempt.Attempt, Status: actions_model.StatusRunning,
}
require.NoError(t, db.Insert(t.Context(), job))
// attempt == nil forces the fallback lookup via run.GetLatestAttempt.
gitCtx := GenerateGiteaContext(t.Context(), run, nil, job)
assert.Equal(t, actor.Name, gitCtx["actor"])
assert.Equal(t, triggerer.Name, gitCtx["triggering_actor"])
assert.Equal(t, "3", gitCtx["run_attempt"])
}
@@ -0,0 +1,137 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package actions
import (
"context"
"fmt"
actions_model "gitea.dev/models/actions"
actions_module "gitea.dev/modules/actions"
"gitea.dev/modules/actions/jobparser"
"gitea.dev/modules/json"
"gitea.dev/modules/log"
api "gitea.dev/modules/structs"
"gitea.dev/modules/util"
)
// dispatchInputsForJob types a top-level job's `inputs.*` from EventPayload, empty for other events.
func dispatchInputsForJob(run *actions_model.ActionRun, job *actions_model.ActionRunJob) (map[string]any, error) {
if run.Event != "workflow_dispatch" {
return map[string]any{}, nil
}
var payload api.WorkflowDispatchPayload
if err := json.Unmarshal([]byte(run.EventPayload), &payload); err != nil {
return nil, err
}
if payload.Inputs == nil {
payload.Inputs = map[string]any{} // nil reads as "unresolved" in EvaluateRunConcurrencyFillModel
}
parsedWorkflows, err := jobparser.Parse(job.WorkflowPayload)
if err != nil {
return nil, util.NewInvalidArgumentErrorf("parse job %d workflow payload: %v", job.ID, err)
}
if len(parsedWorkflows) != 1 {
return nil, util.NewInvalidArgumentErrorf("job %d workflow payload: not single workflow", job.ID)
}
dispatch := parsedWorkflows[0].WorkflowDispatchConfig()
if dispatch == nil { // without it the values would silently stay untyped
return nil, util.NewInvalidArgumentErrorf("job %d payload declares no workflow_dispatch", job.ID)
}
coerceDispatchInputTypes(dispatch, payload.Inputs)
return payload.Inputs, nil
}
// dispatchInputsForRunJobs answers for the whole run, off any top-level job's workflow header.
func dispatchInputsForRunJobs(run *actions_model.ActionRun, jobs []*actions_model.ActionRunJob) (map[string]any, error) {
for _, job := range jobs {
if job.ParentJobID == 0 {
return dispatchInputsForJob(run, job)
}
}
return nil, fmt.Errorf("run %d: no top-level job to read the workflow_dispatch declaration from", run.ID)
}
// getInputsForJob returns the `inputs.*` top-level expression context for a job's evaluation.
// - For top-level jobs, it falls back to the run's dispatch inputs (empty for non-dispatch events)
// - For reusable workflow children (and nested callers), this is the direct parent caller's CallPayload.Inputs
func getInputsForJob(ctx context.Context, run *actions_model.ActionRun, job *actions_model.ActionRunJob) (map[string]any, error) {
if job.ParentJobID == 0 {
return dispatchInputsForJob(run, job)
}
caller, err := actions_model.GetRunJobByRunAndID(ctx, run.ID, job.ParentJobID)
if err != nil {
return nil, fmt.Errorf("load caller job %d: %w", job.ParentJobID, err)
}
if caller.CallPayload == "" {
// should not happen - a child job cannot reach this point if its caller's CallPayload hasn't been evaluated
return map[string]any{}, nil
}
var p api.WorkflowCallPayload
if err := json.Unmarshal([]byte(caller.CallPayload), &p); err != nil {
return nil, fmt.Errorf("decode caller %d payload: %w", caller.ID, err)
}
if p.Inputs == nil {
return map[string]any{}, nil
}
return p.Inputs, nil
}
// pullRequestTargetBaseSHA returns the base branch commit of a pull_request_target run, and whether the run is one.
func pullRequestTargetBaseSHA(run *actions_model.ActionRun) (string, bool) {
if run.TriggerEvent != actions_module.GithubEventPullRequestTarget {
return "", false
}
payload, err := run.GetPullRequestEventPayload()
if err != nil {
log.Error("run %d: get pull request event payload: %v", run.ID, err)
return "", false
}
if payload.PullRequest == nil || payload.PullRequest.Base == nil || payload.PullRequest.Base.Sha == "" {
return "", false
}
return payload.PullRequest.Base.Sha, true
}
// evaluateJobIf evaluates a job's `if:`
func evaluateJobIf(ctx context.Context, run *actions_model.ActionRun, attempt *actions_model.ActionRunAttempt, job *actions_model.ActionRunJob, vars map[string]string, allNeedsSucceed bool) (bool, error) {
parsedJob, err := job.ParseJob()
if err != nil {
return false, err
}
// Empty `if:` reduces to implicit `success()` - true iff every need finished as Success.
if len(parsedJob.If.Value) == 0 {
return allNeedsSucceed, nil
}
jobResults, err := findJobNeedsAndFillJobResults(ctx, job)
if err != nil {
return false, err
}
inputs, err := getInputsForJob(ctx, run, job)
if err != nil {
return false, err
}
gitCtx := GenerateGiteaContext(ctx, run, attempt, job)
return jobparser.EvaluateJobIfExpression(job.JobID, parsedJob, gitCtx, jobResults, vars, inputs)
}
func findJobNeedsAndFillJobResults(ctx context.Context, job *actions_model.ActionRunJob) (map[string]*jobparser.JobResult, error) {
taskNeeds, err := FindTaskNeeds(ctx, job)
if err != nil {
return nil, fmt.Errorf("find task needs: %w", err)
}
jobResults := make(map[string]*jobparser.JobResult, len(taskNeeds))
for jobID, taskNeed := range taskNeeds {
jobResult := &jobparser.JobResult{
Result: taskNeed.Result.String(),
Outputs: taskNeed.Outputs,
}
jobResults[jobID] = jobResult
}
jobResults[job.JobID] = &jobparser.JobResult{
Needs: job.Needs,
}
return jobResults, nil
}
@@ -0,0 +1,90 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package actions
import (
"testing"
actions_model "gitea.dev/models/actions"
actions_module "gitea.dev/modules/actions"
"gitea.dev/modules/json"
api "gitea.dev/modules/structs"
webhook_module "gitea.dev/modules/webhook"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestDispatchInputsForRunJobs(t *testing.T) {
// a child carries the callee's `on: workflow_call`, so only a top-level job answers for the run
run := &actions_model.ActionRun{Event: "workflow_dispatch", EventPayload: `{"inputs":{"deploy":"true"}}`}
job := &actions_model.ActionRunJob{
ID: 1, JobID: "deploy",
WorkflowPayload: []byte("on: {workflow_dispatch: {inputs: {deploy: {type: boolean}}}}\njobs:\n deploy:\n steps: [{run: echo}]\n"),
}
child := &actions_model.ActionRunJob{
ID: 2, JobID: "called", ParentJobID: job.ID,
WorkflowPayload: []byte("on: workflow_call\njobs:\n called:\n steps: [{run: echo}]\n"),
}
inputs, err := dispatchInputsForRunJobs(run, []*actions_model.ActionRunJob{child, job})
require.NoError(t, err)
assert.Equal(t, true, inputs["deploy"])
}
func TestPullRequestTargetBaseSHA(t *testing.T) {
prPayload := func(baseSHA string) string {
payload, err := json.Marshal(api.PullRequestPayload{
PullRequest: &api.PullRequest{
Base: &api.PRBranchInfo{Sha: baseSHA},
},
})
require.NoError(t, err)
return string(payload)
}
t.Run("pull_request_target with base SHA", func(t *testing.T) {
run := &actions_model.ActionRun{
Event: webhook_module.HookEventPullRequest,
TriggerEvent: actions_module.GithubEventPullRequestTarget,
EventPayload: prPayload("base-sha"),
}
got, ok := pullRequestTargetBaseSHA(run)
assert.True(t, ok)
assert.Equal(t, "base-sha", got)
})
t.Run("non pull_request_target trigger", func(t *testing.T) {
run := &actions_model.ActionRun{
Event: webhook_module.HookEventPullRequest,
TriggerEvent: actions_module.GithubEventPullRequest,
EventPayload: prPayload("base-sha"),
}
got, ok := pullRequestTargetBaseSHA(run)
assert.False(t, ok)
assert.Empty(t, got)
})
t.Run("missing base SHA", func(t *testing.T) {
run := &actions_model.ActionRun{
Event: webhook_module.HookEventPullRequest,
TriggerEvent: actions_module.GithubEventPullRequestTarget,
EventPayload: prPayload(""),
}
got, ok := pullRequestTargetBaseSHA(run)
assert.False(t, ok)
assert.Empty(t, got)
})
t.Run("invalid payload", func(t *testing.T) {
run := &actions_model.ActionRun{
Event: webhook_module.HookEventPullRequest,
TriggerEvent: actions_module.GithubEventPullRequestTarget,
EventPayload: "{",
}
got, ok := pullRequestTargetBaseSHA(run)
assert.False(t, ok)
assert.Empty(t, got)
})
}
@@ -10,13 +10,13 @@ import (
"os"
"strings"
actions_model "code.gitea.io/gitea/models/actions"
"code.gitea.io/gitea/modules/graceful"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/queue"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/util"
notify_service "code.gitea.io/gitea/services/notify"
actions_model "gitea.dev/models/actions"
"gitea.dev/modules/graceful"
"gitea.dev/modules/log"
"gitea.dev/modules/queue"
"gitea.dev/modules/setting"
"gitea.dev/modules/util"
notify_service "gitea.dev/services/notify"
)
func initGlobalRunnerToken(ctx context.Context) error {
@@ -7,10 +7,10 @@ import (
"os"
"testing"
actions_model "code.gitea.io/gitea/models/actions"
"code.gitea.io/gitea/models/db"
"code.gitea.io/gitea/models/unittest"
"code.gitea.io/gitea/modules/util"
actions_model "gitea.dev/models/actions"
"gitea.dev/models/db"
"gitea.dev/models/unittest"
"gitea.dev/modules/util"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -35,7 +35,7 @@ func TestInitToken(t *testing.T) {
})
t.Run("EnvToken", func(t *testing.T) {
tokenValue, _ := util.CryptoRandomString(32)
tokenValue := util.CryptoRandomString(32)
t.Setenv("GITEA_RUNNER_REGISTRATION_TOKEN", tokenValue)
t.Setenv("GITEA_RUNNER_REGISTRATION_TOKEN_FILE", "")
err := initGlobalRunnerToken(t.Context())
@@ -52,7 +52,7 @@ func TestInitToken(t *testing.T) {
})
t.Run("EnvFileToken", func(t *testing.T) {
tokenValue, _ := util.CryptoRandomString(32)
tokenValue := util.CryptoRandomString(32)
f := t.TempDir() + "/token"
_ = os.WriteFile(f, []byte(tokenValue), 0o644)
t.Setenv("GITEA_RUNNER_REGISTRATION_TOKEN", "")
@@ -3,7 +3,7 @@
package actions
import "code.gitea.io/gitea/services/context"
import "gitea.dev/services/context"
// API for actions of a repository or organization
type API interface {
@@ -8,15 +8,15 @@ import (
"errors"
"fmt"
actions_model "code.gitea.io/gitea/models/actions"
"code.gitea.io/gitea/models/db"
"code.gitea.io/gitea/modules/container"
"code.gitea.io/gitea/modules/graceful"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/queue"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/util"
notify_service "code.gitea.io/gitea/services/notify"
actions_model "gitea.dev/models/actions"
"gitea.dev/models/db"
"gitea.dev/modules/container"
"gitea.dev/modules/graceful"
"gitea.dev/modules/log"
"gitea.dev/modules/queue"
"gitea.dev/modules/setting"
"gitea.dev/modules/timeutil"
"gitea.dev/modules/util"
"xorm.io/builder"
)
@@ -27,7 +27,7 @@ type jobUpdate struct {
RunID int64
}
func EmitJobsIfReadyByRun(runID int64) error {
var EmitJobsIfReadyByRun = func(runID int64) error {
err := jobEmitterQueue.Push(&jobUpdate{
RunID: runID,
})
@@ -70,36 +70,48 @@ func checkJobsByRunID(ctx context.Context, runID int64) error {
if err != nil {
return fmt.Errorf("get action run: %w", err)
}
var jobs, updatedJobs []*actions_model.ActionRunJob
var result jobsCheckResult
if err := db.WithTx(ctx, func(ctx context.Context) error {
// check jobs of the current run
if js, ujs, err := checkJobsOfRun(ctx, run); err != nil {
r, err := checkJobsOfCurrentRunAttempt(ctx, run)
if err != nil {
return err
} else {
jobs = append(jobs, js...)
updatedJobs = append(updatedJobs, ujs...)
}
if js, ujs, err := checkRunConcurrency(ctx, run); err != nil {
result.merge(r)
r, err = checkRunConcurrency(ctx, run)
if err != nil {
return err
} else {
jobs = append(jobs, js...)
updatedJobs = append(updatedJobs, ujs...)
}
result.merge(r)
return nil
}); err != nil {
return err
}
CreateCommitStatusForRunJobs(ctx, run, jobs...)
for _, job := range updatedJobs {
_ = job.LoadAttributes(ctx)
notify_service.WorkflowJobStatusUpdate(ctx, job.Run.Repo, job.Run.TriggerUser, job, nil)
// Re-emit AFTER the transaction commits; doing this inside WithTx would deadlock under
// immediate-mode queues (the inline handler reopens checkJobsByRunID and asks for a
// nested writer transaction while the outer one is still open).
emitted := make(container.Set[int64])
for _, rid := range result.RunIDsToReEmit {
if !emitted.Add(rid) {
continue
}
if err := EmitJobsIfReadyByRun(rid); err != nil {
log.Error("re-emit run %d: %v", rid, err)
}
}
NotifyWorkflowJobsAndRunsStatusUpdate(ctx, result.CancelledJobs)
EmitJobsIfReadyByJobs(result.CancelledJobs)
if err := createCommitStatusesForJobsByRun(ctx, result.Jobs); err != nil {
return err
}
NotifyWorkflowJobsStatusUpdate(ctx, result.UpdatedJobs...)
runJobs := make(map[int64][]*actions_model.ActionRunJob)
for _, job := range jobs {
for _, job := range result.Jobs {
runJobs[job.RunID] = append(runJobs[job.RunID], job)
}
runUpdatedJobs := make(map[int64][]*actions_model.ActionRunJob)
for _, uj := range updatedJobs {
for _, uj := range result.UpdatedJobs {
runUpdatedJobs[uj.RunID] = append(runUpdatedJobs[uj.RunID], uj)
}
for runID, js := range runJobs {
@@ -114,71 +126,106 @@ func checkJobsByRunID(ctx context.Context, runID int64) error {
}
}
if runUpdated {
NotifyWorkflowRunStatusUpdateWithReload(ctx, js[0])
NotifyWorkflowRunStatusUpdateWithReload(ctx, js[0].RepoID, js[0].RunID)
}
}
return nil
}
// findBlockedRunByConcurrency finds the blocked concurrent run in a repo and returns `nil, nil` when there is no blocked run.
func findBlockedRunByConcurrency(ctx context.Context, repoID int64, concurrencyGroup string) (*actions_model.ActionRun, error) {
if concurrencyGroup == "" {
return nil, nil //nolint:nilnil // return nil to indicate that no blocked run exists
}
cRuns, cJobs, err := actions_model.GetConcurrentRunsAndJobs(ctx, repoID, concurrencyGroup, []actions_model.Status{actions_model.StatusBlocked})
if err != nil {
return nil, fmt.Errorf("find concurrent runs and jobs: %w", err)
func createCommitStatusesForJobsByRun(ctx context.Context, jobs []*actions_model.ActionRunJob) error {
runJobs := make(map[int64][]*actions_model.ActionRunJob)
for _, job := range jobs {
runJobs[job.RunID] = append(runJobs[job.RunID], job)
}
// There can be at most one blocked run or job
var concurrentRun *actions_model.ActionRun
if len(cRuns) > 0 {
concurrentRun = cRuns[0]
} else if len(cJobs) > 0 {
jobRun, exist, err := db.GetByID[actions_model.ActionRun](ctx, cJobs[0].RunID)
if !exist {
return nil, fmt.Errorf("run %d does not exist", cJobs[0].RunID)
}
for jobRunID, jobList := range runJobs {
run, err := actions_model.GetRunByRepoAndID(ctx, jobList[0].RepoID, jobRunID)
if err != nil {
return nil, fmt.Errorf("get run by job %d: %w", cJobs[0].ID, err)
return fmt.Errorf("get action run %d: %w", jobRunID, err)
}
concurrentRun = jobRun
CreateCommitStatusForRunJobs(ctx, run, jobList...)
}
return concurrentRun, nil
return nil
}
func checkRunConcurrency(ctx context.Context, run *actions_model.ActionRun) (jobs, updatedJobs []*actions_model.ActionRunJob, err error) {
// findConcurrencyWaiterToWake returns a run (other than excludeRunID) blocked on the group that can be woken now
func findConcurrencyWaiterToWake(ctx context.Context, repoID, excludeRunID int64, concurrencyGroup string) (int64, error) {
if concurrencyGroup == "" {
return 0, nil
}
// The slot should be free before any waiter can proceed.
holderAttempts, holderJobs, err := actions_model.GetConcurrentRunAttemptsAndJobs(ctx, repoID, concurrencyGroup, []actions_model.Status{actions_model.StatusRunning, actions_model.StatusCancelling})
if err != nil {
return 0, fmt.Errorf("find concurrency-group holders: %w", err)
}
if len(holderAttempts) > 0 || len(holderJobs) > 0 {
return 0, nil
}
cAttempts, cJobs, err := actions_model.GetConcurrentRunAttemptsAndJobs(ctx, repoID, concurrencyGroup, []actions_model.Status{actions_model.StatusBlocked})
if err != nil {
return 0, fmt.Errorf("find blocked concurrent runs: %w", err)
}
for _, a := range cAttempts {
if a.RunID != excludeRunID {
return a.RunID, nil
}
}
for _, j := range cJobs {
if j.RunID != excludeRunID {
return j.RunID, nil
}
}
return 0, nil
}
// checkRunConcurrency wakes a run blocked by concurrency that may become runnable now that
// the current run's activity may have freed a workflow-level or job-level concurrency group.
func checkRunConcurrency(ctx context.Context, run *actions_model.ActionRun) (*jobsCheckResult, error) {
result := &jobsCheckResult{}
checkedConcurrencyGroup := make(container.Set[string])
collect := func(concurrencyGroup string) error {
concurrentRun, err := findBlockedRunByConcurrency(ctx, run.RepoID, concurrencyGroup)
if err != nil {
return fmt.Errorf("find blocked run by concurrency: %w", err)
}
if concurrentRun != nil && !concurrentRun.NeedApproval {
js, ujs, err := checkJobsOfRun(ctx, concurrentRun)
if err != nil {
return err
}
jobs = append(jobs, js...)
updatedJobs = append(updatedJobs, ujs...)
}
checkedConcurrencyGroup.Add(concurrencyGroup)
// Exclude run.ID: this run's own jobs are resolved by checkJobsOfCurrentRunAttempt, no need to re-emit.
concurrentRunID, err := findConcurrencyWaiterToWake(ctx, run.RepoID, run.ID, concurrencyGroup)
if err != nil {
return err
}
if concurrentRunID == 0 {
return nil
}
concurrentRun, err := actions_model.GetRunByRepoAndID(ctx, run.RepoID, concurrentRunID)
if err != nil {
return fmt.Errorf("get concurrent run %d: %w", concurrentRunID, err)
}
// A run awaiting approval is not advanced by concurrency; ApproveRuns emits it once approved.
if concurrentRun.NeedApproval {
return nil
}
result.RunIDsToReEmit = append(result.RunIDsToReEmit, concurrentRunID)
return nil
}
// check run (workflow-level) concurrency
if run.ConcurrencyGroup != "" {
if err := collect(run.ConcurrencyGroup); err != nil {
return nil, nil, err
runConcurrencyGroup, _, err := run.GetEffectiveConcurrency(ctx)
if err != nil {
return nil, fmt.Errorf("GetEffectiveConcurrency: %w", err)
}
if runConcurrencyGroup != "" {
if err := collect(runConcurrencyGroup); err != nil {
return nil, err
}
}
// check job concurrency
runJobs, err := db.Find[actions_model.ActionRunJob](ctx, actions_model.FindRunJobOptions{RunID: run.ID})
runJobs, err := actions_model.GetLatestAttemptJobsByRepoAndRunID(ctx, run.RepoID, run.ID)
if err != nil {
return nil, nil, fmt.Errorf("find run %d jobs: %w", run.ID, err)
return nil, fmt.Errorf("find run %d jobs: %w", run.ID, err)
}
for _, job := range runJobs {
if !job.Status.IsDone() {
@@ -188,83 +235,138 @@ func checkRunConcurrency(ctx context.Context, run *actions_model.ActionRun) (job
continue
}
if err := collect(job.ConcurrencyGroup); err != nil {
return nil, nil, err
return nil, err
}
}
return jobs, updatedJobs, nil
return result, nil
}
func checkJobsOfRun(ctx context.Context, run *actions_model.ActionRun) (jobs, updatedJobs []*actions_model.ActionRunJob, err error) {
jobs, err = db.Find[actions_model.ActionRunJob](ctx, actions_model.FindRunJobOptions{RunID: run.ID})
if err != nil {
return nil, nil, err
// checkJobsOfCurrentRunAttempt resolves blocked jobs of the run's latest attempt.
func checkJobsOfCurrentRunAttempt(ctx context.Context, run *actions_model.ActionRun) (*jobsCheckResult, error) {
// Approval is the only transition allowed to release an approval-pending run.
if run.NeedApproval {
return &jobsCheckResult{}, nil
}
jobs, err := actions_model.GetRunJobsByRunAndAttemptID(ctx, run.ID, run.LatestAttemptID)
if err != nil {
return nil, err
}
result := &jobsCheckResult{Jobs: jobs}
var attempt *actions_model.ActionRunAttempt
if run.LatestAttemptID > 0 {
attempt, err = actions_model.GetRunAttemptByRepoAndID(ctx, run.RepoID, run.LatestAttemptID)
if err != nil {
return nil, err
}
}
// The resolver below only considers needs and job-level concurrency, so a run blocked
// solely by run-level concurrency would have its jobs unblocked here. checkRunConcurrency
// re-evaluates when the holding run finishes.
if run.Status.IsBlocked() {
shouldBlock, err := shouldBlockRunByConcurrency(ctx, run)
if run.Status.IsBlocked() && attempt != nil {
shouldBlock, err := shouldBlockRunByConcurrency(ctx, attempt)
if err != nil {
return nil, nil, fmt.Errorf("shouldBlockRunByConcurrency: %w", err)
return nil, fmt.Errorf("shouldBlockRunByConcurrency: %w", err)
}
if shouldBlock {
return jobs, nil, nil
return result, nil
}
}
vars, err := actions_model.GetVariablesOfRun(ctx, run)
if err != nil {
return nil, nil, err
return nil, err
}
resolver := newJobStatusResolver(jobs, vars)
expandedAnyCaller := false
if err = db.WithTx(ctx, func(ctx context.Context) error {
for _, job := range jobs {
job.Run = run
}
updates := newJobStatusResolver(jobs, vars).Resolve(ctx)
updates := resolver.Resolve(ctx)
for _, job := range jobs {
if status, ok := updates[job.ID]; ok {
job.Status = status
if n, err := actions_model.UpdateRunJob(ctx, job, builder.Eq{"status": actions_model.StatusBlocked}, "status"); err != nil {
return err
} else if n != 1 {
return fmt.Errorf("no affected for updating blocked job %v", job.ID)
}
updatedJobs = append(updatedJobs, job)
status, ok := updates[job.ID]
if !ok {
continue
}
if job.IsReusableCaller {
switch status {
case actions_model.StatusWaiting:
if err := expandReusableWorkflowCaller(ctx, run, attempt, job, vars); err != nil {
// Terminal expansion failure (an invalid/unresolvable reusable workflow): fail this caller.
log.Warn("caller %d cannot be expanded: %v", job.ID, err)
job.Status = actions_model.StatusFailure
job.Stopped = timeutil.TimeStampNow()
if n, uerr := actions_model.UpdateRunJob(ctx, job, builder.Eq{"status": actions_model.StatusBlocked, "is_expanded": false}, "status", "stopped"); uerr != nil {
return fmt.Errorf("mark unexpandable caller %d failed: %w", job.ID, uerr)
} else if n == 1 {
log.Warn("unexpandable caller %d has been marked as failed", job.ID)
result.UpdatedJobs = append(result.UpdatedJobs, job)
// Re-emit so the failed caller's dependents get resolved on the next pass.
expandedAnyCaller = true
} else {
// A concurrent writer advanced the caller; restore the in-memory state.
log.Warn("unexpandable caller %d has been advanced by a concurrent writer, not marking it failed", job.ID)
job.Status = actions_model.StatusBlocked
job.Stopped = 0
}
} else {
expandedAnyCaller = true
}
case actions_model.StatusSkipped:
job.Status = actions_model.StatusSkipped
if _, err := actions_model.UpdateRunJob(ctx, job, nil, "status"); err != nil {
return err
}
}
continue
}
// Non-caller: standard status update.
job.Status = status
if n, err := actions_model.UpdateRunJob(ctx, job, builder.Eq{"status": actions_model.StatusBlocked}, "status"); err != nil {
return err
} else if n != 1 {
return fmt.Errorf("no affected for updating blocked job %v", job.ID)
}
result.UpdatedJobs = append(result.UpdatedJobs, job)
}
return nil
}); err != nil {
return nil, nil, err
return nil, err
}
return jobs, updatedJobs, nil
}
func NotifyWorkflowRunStatusUpdateWithReload(ctx context.Context, job *actions_model.ActionRunJob) {
job.Run = nil
if err := job.LoadAttributes(ctx); err != nil {
log.Error("LoadAttributes: %v", err)
return
if expandedAnyCaller {
result.RunIDsToReEmit = append(result.RunIDsToReEmit, run.ID)
}
notify_service.WorkflowRunStatusUpdate(ctx, job.Run.Repo, job.Run.TriggerUser, job.Run)
// Recomputes the repository's num_action_runs / num_closed_action_runs counters since the run's status changed
actions_model.UpdateRepoRunsNumbers(ctx, job.RepoID)
result.CancelledJobs = resolver.cancelledJobs
return result, nil
}
type jobStatusResolver struct {
statuses map[int64]actions_model.Status
needs map[int64][]int64
jobMap map[int64]*actions_model.ActionRunJob
vars map[string]string
statuses map[int64]actions_model.Status
needs map[int64][]int64
jobMap map[int64]*actions_model.ActionRunJob
vars map[string]string
cancelledJobs []*actions_model.ActionRunJob
}
func newJobStatusResolver(jobs actions_model.ActionJobList, vars map[string]string) *jobStatusResolver {
idToJobs := make(map[string][]*actions_model.ActionRunJob, len(jobs))
// Scope-aware: needs are resolved within the same ParentJobID scope so the same
// JobID in different reusable workflow calls does not cross-link.
scopedIDToJobs := make(map[int64]map[string][]*actions_model.ActionRunJob)
jobMap := make(map[int64]*actions_model.ActionRunJob)
for _, job := range jobs {
idToJobs[job.JobID] = append(idToJobs[job.JobID], job)
scope := scopedIDToJobs[job.ParentJobID]
if scope == nil {
scope = make(map[string][]*actions_model.ActionRunJob)
scopedIDToJobs[job.ParentJobID] = scope
}
scope[job.JobID] = append(scope[job.JobID], job)
jobMap[job.ID] = job
}
@@ -272,8 +374,9 @@ func newJobStatusResolver(jobs actions_model.ActionJobList, vars map[string]stri
needs := make(map[int64][]int64, len(jobs))
for _, job := range jobs {
statuses[job.ID] = job.Status
scope := scopedIDToJobs[job.ParentJobID]
for _, need := range job.Needs {
for _, v := range idToJobs[need] {
for _, v := range scope[need] {
needs[job.ID] = append(needs[job.ID], v.ID)
}
}
@@ -308,6 +411,11 @@ func (r *jobStatusResolver) resolveCheckNeeds(id int64) (allDone, allSucceed boo
if !needStatus.IsDone() {
allDone = false
}
// A failed need with continue-on-error:true is treated as success, matching AggregateJobStatus,
// so a downstream job with an implicit `success()` is not skipped.
if needJob := r.jobMap[need]; needJob != nil && needJob.ContinueOnError && needStatus == actions_model.StatusFailure {
continue
}
if needStatus.In(actions_model.StatusFailure, actions_model.StatusCancelled, actions_model.StatusSkipped) {
allSucceed = false
}
@@ -315,14 +423,6 @@ func (r *jobStatusResolver) resolveCheckNeeds(id int64) (allDone, allSucceed boo
return allDone, allSucceed
}
func (r *jobStatusResolver) resolveJobHasIfCondition(actionRunJob *actions_model.ActionRunJob) (hasIf bool) {
// FIXME evaluate this on the server side
if job, err := actionRunJob.ParseJob(); err == nil {
return len(job.If.Value) > 0
}
return hasIf
}
func (r *jobStatusResolver) resolve(ctx context.Context) map[int64]actions_model.Status {
ret := map[int64]actions_model.Status{}
for id, status := range r.statuses {
@@ -330,6 +430,12 @@ func (r *jobStatusResolver) resolve(ctx context.Context) map[int64]actions_model
if status != actions_model.StatusBlocked {
continue
}
// A child of a caller cannot start until the caller has become "ready" (children inserted, CallPayload populated).
if actionRunJob.ParentJobID > 0 {
if parent, ok := r.jobMap[actionRunJob.ParentJobID]; ok && !parent.IsExpanded {
continue
}
}
allDone, allSucceed := r.resolveCheckNeeds(id)
if !allDone {
continue
@@ -340,25 +446,26 @@ func (r *jobStatusResolver) resolve(ctx context.Context) map[int64]actions_model
if err != nil {
// The err can be caused by different cases: database error, or syntax error, or the needed jobs haven't completed
// At the moment there is no way to distinguish them.
// Actually, for most cases, the error is caused by "syntax error" / "the needed jobs haven't completed (skipped?)"
// TODO: if workflow or concurrency expression has syntax error, there should be a user error message, need to show it to end users
log.Debug("updateConcurrencyEvaluationForJobWithNeeds failed, this job will stay blocked: job: %d, err: %v", id, err)
continue
}
shouldStartJob := true
if !allSucceed {
// Not all dependent jobs completed successfully:
// * if the job has "if" condition, it can be started, then the act_runner will evaluate the "if" condition.
// * otherwise, the job should be skipped.
shouldStartJob = r.resolveJobHasIfCondition(actionRunJob)
shouldStartJob, err := evaluateJobIf(ctx, actionRunJob.Run, nil, actionRunJob, r.vars, allSucceed)
if err != nil {
// TODO: surface deterministic expression errors to users by failing the job with a message.
log.Error("evaluateJobIf failed, job will stay blocked: job: %d, err: %v", id, err)
continue
}
newStatus := util.Iif(shouldStartJob, actions_model.StatusWaiting, actions_model.StatusSkipped)
if newStatus == actions_model.StatusWaiting {
newStatus, err = PrepareToStartJobWithConcurrency(ctx, actionRunJob)
var cancelledJobs []*actions_model.ActionRunJob
newStatus, cancelledJobs, err = PrepareToStartJobWithConcurrency(ctx, actionRunJob)
if err != nil {
log.Error("ShouldBlockJobByConcurrency failed, this job will stay blocked: job: %d, err: %v", id, err)
} else {
r.cancelledJobs = append(r.cancelledJobs, cancelledJobs...)
}
}
@@ -374,8 +481,16 @@ func updateConcurrencyEvaluationForJobWithNeeds(ctx context.Context, actionRunJo
return nil // for testing purpose only, no repo, no evaluation
}
err := EvaluateJobConcurrencyFillModel(ctx, actionRunJob.Run, actionRunJob, vars, nil)
if err != nil {
// Legacy jobs (created before migration v331) have RunAttemptID=0 and no attempt record.
var attempt *actions_model.ActionRunAttempt
if actionRunJob.RunAttemptID > 0 {
var err error
attempt, err = actions_model.GetRunAttemptByRepoAndID(ctx, actionRunJob.RepoID, actionRunJob.RunAttemptID)
if err != nil {
return fmt.Errorf("GetRunAttemptByRepoAndID: %w", err)
}
}
if err := EvaluateJobConcurrencyFillModel(ctx, actionRunJob.Run, attempt, actionRunJob, vars, nil); err != nil {
return fmt.Errorf("evaluate job concurrency: %w", err)
}
@@ -384,3 +499,23 @@ func updateConcurrencyEvaluationForJobWithNeeds(ctx context.Context, actionRunJo
}
return nil
}
// jobsCheckResult bundles the output of the per-run job-check helpers.
type jobsCheckResult struct {
// Jobs are all jobs of the run's latest attempt that were inspected.
Jobs []*actions_model.ActionRunJob
// UpdatedJobs are jobs whose status was transitioned out of Blocked in this pass.
UpdatedJobs []*actions_model.ActionRunJob
// CancelledJobs are jobs cancelled by job-level concurrency while preparing to start.
CancelledJobs []*actions_model.ActionRunJob
// RunIDsToReEmit are runs that need another resolver pass in their own transaction.
RunIDsToReEmit []int64
}
// merge appends another result's contents into r in place.
func (r *jobsCheckResult) merge(other *jobsCheckResult) {
r.Jobs = append(r.Jobs, other.Jobs...)
r.UpdatedJobs = append(r.UpdatedJobs, other.UpdatedJobs...)
r.CancelledJobs = append(r.CancelledJobs, other.CancelledJobs...)
r.RunIDsToReEmit = append(r.RunIDsToReEmit, other.RunIDsToReEmit...)
}
@@ -4,11 +4,15 @@
package actions
import (
"fmt"
"testing"
actions_model "code.gitea.io/gitea/models/actions"
"code.gitea.io/gitea/models/db"
"code.gitea.io/gitea/models/unittest"
actions_model "gitea.dev/models/actions"
"gitea.dev/models/db"
repo_model "gitea.dev/models/repo"
"gitea.dev/models/unittest"
user_model "gitea.dev/models/user"
"gitea.dev/modules/util"
"github.com/stretchr/testify/assert"
)
@@ -16,6 +20,7 @@ import (
func Test_jobStatusResolver_Resolve(t *testing.T) {
tests := []struct {
name string
run *actions_model.ActionRun // defaults to stubRun
jobs actions_model.ActionJobList
want map[int64]actions_model.Status
}{
@@ -128,11 +133,96 @@ jobs:
},
want: map[int64]actions_model.Status{2: actions_model.StatusSkipped},
},
{
name: "`if` is empty and a failed need has continue-on-error",
jobs: actions_model.ActionJobList{
{ID: 1, JobID: "job1", Status: actions_model.StatusFailure, ContinueOnError: true, Needs: []string{}},
{ID: 2, JobID: "job2", Status: actions_model.StatusBlocked, Needs: []string{"job1"}, WorkflowPayload: []byte(
`
name: test
on: push
jobs:
job2:
runs-on: ubuntu-latest
needs: job1
steps:
- run: echo "should run, job1 failure is masked by continue-on-error"
`)},
},
want: map[int64]actions_model.Status{2: actions_model.StatusWaiting},
},
{
// a needs-gated job is evaluated server-side, so a mistyped input silently leaves it blocked
name: "`if` compares a workflow_dispatch boolean input",
run: &actions_model.ActionRun{
TriggerUser: &user_model.User{}, Repo: &repo_model.Repository{},
Event: "workflow_dispatch",
EventPayload: `{"inputs":{"deploy":"true"}}`,
},
jobs: actions_model.ActionJobList{
{ID: 1, JobID: "job1", Status: actions_model.StatusSuccess, Needs: []string{}},
{ID: 2, JobID: "job2", Status: actions_model.StatusBlocked, Needs: []string{"job1"}, WorkflowPayload: []byte(
`
on:
workflow_dispatch:
inputs:
deploy:
type: boolean
jobs:
job2:
runs-on: ubuntu-latest
needs: job1
if: ${{ inputs.deploy == true && github.event.inputs.deploy == 'true' }}
steps:
- run: echo
`)},
},
want: map[int64]actions_model.Status{2: actions_model.StatusWaiting},
},
}
for _, tt := range tests {
assert.NoError(t, unittest.PrepareTestDatabase())
ctx := t.Context()
stubRun := &actions_model.ActionRun{TriggerUser: &user_model.User{}, Repo: &repo_model.Repository{}}
for i, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Each subtest gets a unique RunID / RunAttemptID so jobs from different subtests don't bleed into each other's FindTaskNeeds queries
runID := int64(9001 + i)
attemptID := int64(9001 + i)
run := util.IfZero(tt.run, stubRun)
// Insert each test job (letting the DB assign IDs) and remember the testID -> dbID mapping so we can translate the expected map.
idMap := make(map[int64]int64, len(tt.jobs))
for _, j := range tt.jobs {
origID := j.ID
j.ID = 0
j.RunID = runID
j.RunAttemptID = attemptID
j.Run = run
// The resolver evaluates Blocked jobs via evaluateJobIf, which needs a valid YAML payload;
// supply a minimal one when the case didn't.
if j.Status == actions_model.StatusBlocked && len(j.WorkflowPayload) == 0 {
j.WorkflowPayload = fmt.Appendf(nil, `name: test
on: push
jobs:
%s:
runs-on: ubuntu-latest
steps:
- run: echo
`, j.JobID)
}
assert.NoError(t, db.Insert(ctx, j))
idMap[origID] = j.ID
}
want := make(map[int64]actions_model.Status, len(tt.want))
for k, v := range tt.want {
want[idMap[k]] = v
}
r := newJobStatusResolver(tt.jobs, nil)
assert.Equal(t, tt.want, r.Resolve(t.Context()))
assert.Equal(t, want, r.Resolve(ctx))
})
}
}
@@ -144,23 +234,36 @@ func Test_checkRunConcurrency_NoDuplicateConcurrencyGroupCheck(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
ctx := t.Context()
// Run A: the triggering run with a concurrency group.
// Run A: the triggering run of attempt A. It is done, so it no longer holds "test-cg", which is what lets checkRunConcurrency wake the blocked waiter.
runA := &actions_model.ActionRun{
RepoID: 4,
OwnerID: 1,
TriggerUserID: 1,
WorkflowID: "test.yml",
Index: 9901,
Ref: "refs/heads/main",
Status: actions_model.StatusRunning,
ConcurrencyGroup: "test-cg",
RepoID: 4,
OwnerID: 1,
TriggerUserID: 1,
WorkflowID: "test.yml",
Index: 9901,
Ref: "refs/heads/main",
Status: actions_model.StatusSuccess,
}
assert.NoError(t, db.Insert(ctx, runA))
// Attempt A: a done attempt of run A with concurrency group "test-cg"
runAAttempt := &actions_model.ActionRunAttempt{
RepoID: 4,
RunID: runA.ID,
Attempt: 1,
Status: actions_model.StatusSuccess,
ConcurrencyGroup: "test-cg",
}
assert.NoError(t, db.Insert(ctx, runAAttempt))
_, err := db.Exec(t.Context(), "UPDATE `action_run` SET latest_attempt_id = ? WHERE id = ?", runAAttempt.ID, runA.ID)
assert.NoError(t, err)
// A done job for run A with the same ConcurrencyGroup.
// This triggers the job-level concurrency check in checkRunConcurrency.
jobADone := &actions_model.ActionRunJob{
RunID: runA.ID,
RunAttemptID: runAAttempt.ID,
AttemptJobID: 1,
RepoID: 4,
OwnerID: 1,
JobID: "job1",
@@ -170,68 +273,97 @@ func Test_checkRunConcurrency_NoDuplicateConcurrencyGroupCheck(t *testing.T) {
}
assert.NoError(t, db.Insert(ctx, jobADone))
// Blocked run B competing for the same concurrency group.
// Run B: a run blocked by concurrency
runB := &actions_model.ActionRun{
RepoID: 4,
OwnerID: 1,
TriggerUserID: 1,
WorkflowID: "test.yml",
Index: 9902,
Ref: "refs/heads/main",
Status: actions_model.StatusBlocked,
ConcurrencyGroup: "test-cg",
RepoID: 4,
OwnerID: 1,
TriggerUserID: 1,
WorkflowID: "test.yml",
Index: 9902,
Ref: "refs/heads/main",
Status: actions_model.StatusBlocked,
}
assert.NoError(t, db.Insert(ctx, runB))
// Attempt B: an blocked attempt of run B
runBAttempt := &actions_model.ActionRunAttempt{
RepoID: 4,
RunID: runB.ID,
Attempt: 1,
Status: actions_model.StatusBlocked,
ConcurrencyGroup: "test-cg",
}
assert.NoError(t, db.Insert(ctx, runBAttempt))
_, err = db.Exec(t.Context(), "UPDATE `action_run` SET latest_attempt_id = ? WHERE id = ?", runBAttempt.ID, runB.ID)
assert.NoError(t, err)
// A blocked job belonging to run B (no job-level concurrency group).
jobBBlocked := &actions_model.ActionRunJob{
RunID: runB.ID,
RepoID: 4,
OwnerID: 1,
JobID: "job1",
Name: "job1",
Status: actions_model.StatusBlocked,
RunID: runB.ID,
RunAttemptID: runBAttempt.ID,
AttemptJobID: 1,
RepoID: 4,
OwnerID: 1,
JobID: "job1",
Name: "job1",
Status: actions_model.StatusBlocked,
}
assert.NoError(t, db.Insert(ctx, jobBBlocked))
jobs, _, err := checkRunConcurrency(ctx, runA)
runA, _, _ = db.GetByID[actions_model.ActionRun](t.Context(), runA.ID)
result, err := checkRunConcurrency(ctx, runA)
assert.NoError(t, err)
if assert.Len(t, jobs, 1) {
assert.Equal(t, jobBBlocked.ID, jobs[0].ID)
// "test-cg" is free, so the single blocked waiter (run B) is collected for re-emit.
if assert.Len(t, result.RunIDsToReEmit, 1) {
assert.Equal(t, runB.ID, result.RunIDsToReEmit[0])
}
assert.Empty(t, result.Jobs)
}
// Test_checkJobsOfRun_RunLevelConcurrencyKeepsJobsBlocked verifies that
// Test_checkJobsOfCurrentRunAttempt_RunLevelConcurrencyKeepsJobsBlocked verifies that
// the resolver does not transition a job out of Blocked while another run still holds
// the workflow-level concurrency group. Regression for #37446.
func Test_checkJobsOfRun_RunLevelConcurrencyKeepsJobsBlocked(t *testing.T) {
func Test_checkJobsOfCurrentRunAttempt_RunLevelConcurrencyKeepsJobsBlocked(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
ctx := t.Context()
const group = "test-run-level-concurrency-keeps-blocked"
// Holder run: Running run in the concurrency group.
// Holder run: Running attempt in the concurrency group.
holderRun := &actions_model.ActionRun{
RepoID: 4, OwnerID: 1, TriggerUserID: 1,
WorkflowID: "test.yml", Index: 9911, Ref: "refs/heads/main",
Status: actions_model.StatusRunning,
ConcurrencyGroup: group,
Status: actions_model.StatusRunning,
}
assert.NoError(t, db.Insert(ctx, holderRun))
holderAttempt := &actions_model.ActionRunAttempt{
RepoID: 4, RunID: holderRun.ID, Attempt: 1,
Status: actions_model.StatusRunning, ConcurrencyGroup: group,
}
assert.NoError(t, db.Insert(ctx, holderAttempt))
_, err := db.Exec(ctx, "UPDATE `action_run` SET latest_attempt_id = ? WHERE id = ?", holderAttempt.ID, holderRun.ID)
assert.NoError(t, err)
// Blocked run: Blocked run in the same group, with one Blocked job that has
// Blocked run: Blocked attempt in the same group, with one Blocked job that has
// no needs and no job-level concurrency. Without the run-level guard in
// checkJobsOfRun, the resolver would transition this job to Waiting.
// checkJobsOfCurrentRunAttempt, the resolver would transition this job to Waiting.
blockedRun := &actions_model.ActionRun{
RepoID: 4, OwnerID: 1, TriggerUserID: 1,
WorkflowID: "test.yml", Index: 9912, Ref: "refs/heads/main",
Status: actions_model.StatusBlocked,
ConcurrencyGroup: group,
Status: actions_model.StatusBlocked,
}
assert.NoError(t, db.Insert(ctx, blockedRun))
blockedAttempt := &actions_model.ActionRunAttempt{
RepoID: 4, RunID: blockedRun.ID, Attempt: 1,
Status: actions_model.StatusBlocked, ConcurrencyGroup: group,
}
assert.NoError(t, db.Insert(ctx, blockedAttempt))
_, err = db.Exec(ctx, "UPDATE `action_run` SET latest_attempt_id = ? WHERE id = ?", blockedAttempt.ID, blockedRun.ID)
assert.NoError(t, err)
blockedRun.LatestAttemptID = blockedAttempt.ID
blockedJob := &actions_model.ActionRunJob{
RunID: blockedRun.ID,
RunID: blockedRun.ID, RunAttemptID: blockedAttempt.ID, AttemptJobID: 1,
RepoID: 4, OwnerID: 1, JobID: "job1", Name: "job1",
Status: actions_model.StatusBlocked,
WorkflowPayload: []byte(`
@@ -246,10 +378,118 @@ jobs:
}
assert.NoError(t, db.Insert(ctx, blockedJob))
_, updated, err := checkJobsOfRun(ctx, blockedRun)
result, err := checkJobsOfCurrentRunAttempt(ctx, blockedRun)
assert.NoError(t, err)
assert.Empty(t, updated)
assert.Empty(t, result.UpdatedJobs)
refreshed := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: blockedJob.ID})
assert.Equal(t, actions_model.StatusBlocked, refreshed.Status)
}
func Test_checkJobsOfCurrentRunAttempt_NeedApprovalKeepsJobsBlocked(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
ctx := t.Context()
run := &actions_model.ActionRun{
RepoID: 4, OwnerID: 1, TriggerUserID: 1,
WorkflowID: "test.yml", Index: 9913, Ref: "refs/heads/main",
Status: actions_model.StatusBlocked, NeedApproval: true,
}
assert.NoError(t, db.Insert(ctx, run))
attempt := &actions_model.ActionRunAttempt{
RepoID: 4, RunID: run.ID, Attempt: 1, Status: actions_model.StatusBlocked,
}
assert.NoError(t, db.Insert(ctx, attempt))
_, err := db.Exec(ctx, "UPDATE `action_run` SET latest_attempt_id = ? WHERE id = ?", attempt.ID, run.ID)
assert.NoError(t, err)
run.LatestAttemptID = attempt.ID
job := &actions_model.ActionRunJob{
RunID: run.ID, RunAttemptID: attempt.ID, AttemptJobID: 1,
RepoID: 4, OwnerID: 1, JobID: "job1", Name: "job1", Status: actions_model.StatusBlocked,
}
assert.NoError(t, db.Insert(ctx, job))
result, err := checkJobsOfCurrentRunAttempt(ctx, run)
assert.NoError(t, err)
assert.Empty(t, result.UpdatedJobs)
assert.Equal(t, actions_model.StatusBlocked, unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: job.ID}).Status)
}
// Test_checkRunConcurrency_HeldGroupDoesNotWake verifies that only an unoccupied concurrency group can wake up a blocked run/job.
func Test_checkRunConcurrency_HeldGroupDoesNotWake(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
ctx := t.Context()
// Run A holds "test-cg": its attempt is still running.
runA := &actions_model.ActionRun{
RepoID: 4, OwnerID: 1, TriggerUserID: 1, WorkflowID: "test.yml",
Index: 9911, Ref: "refs/heads/main", Status: actions_model.StatusRunning,
}
assert.NoError(t, db.Insert(ctx, runA))
runAAttempt := &actions_model.ActionRunAttempt{
RepoID: 4, RunID: runA.ID, Attempt: 1, Status: actions_model.StatusRunning, ConcurrencyGroup: "test-cg",
}
assert.NoError(t, db.Insert(ctx, runAAttempt))
_, err := db.Exec(ctx, "UPDATE `action_run` SET latest_attempt_id = ? WHERE id = ?", runAAttempt.ID, runA.ID)
assert.NoError(t, err)
// Run B is blocked on the same group.
runB := &actions_model.ActionRun{
RepoID: 4, OwnerID: 1, TriggerUserID: 1, WorkflowID: "test.yml",
Index: 9912, Ref: "refs/heads/main", Status: actions_model.StatusBlocked,
}
assert.NoError(t, db.Insert(ctx, runB))
runBAttempt := &actions_model.ActionRunAttempt{
RepoID: 4, RunID: runB.ID, Attempt: 1, Status: actions_model.StatusBlocked, ConcurrencyGroup: "test-cg",
}
assert.NoError(t, db.Insert(ctx, runBAttempt))
_, err = db.Exec(ctx, "UPDATE `action_run` SET latest_attempt_id = ? WHERE id = ?", runBAttempt.ID, runB.ID)
assert.NoError(t, err)
runA, _, _ = db.GetByID[actions_model.ActionRun](ctx, runA.ID)
result, err := checkRunConcurrency(ctx, runA)
assert.NoError(t, err)
// The group is held by run A, so run B must not be woken; A will wake it when it releases the group.
assert.Empty(t, result.RunIDsToReEmit)
}
// Test_findConcurrencyWaiterToWake covers the finder's contract: it skips the run being processed (excludeRunID),
// returns another blocked waiter when the group is free, and returns 0 while the group is still held.
func Test_findConcurrencyWaiterToWake(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
ctx := t.Context()
const repoID int64 = 4
seed := func(index int64, group string, status actions_model.Status) *actions_model.ActionRun {
run := &actions_model.ActionRun{
RepoID: repoID, OwnerID: 1, TriggerUserID: 1, WorkflowID: "test.yml",
Index: index, Ref: "refs/heads/main", Status: status,
}
assert.NoError(t, db.Insert(ctx, run))
assert.NoError(t, db.Insert(ctx, &actions_model.ActionRunAttempt{
RepoID: repoID, RunID: run.ID, Attempt: 1, Status: status, ConcurrencyGroup: group,
}))
return run
}
// Free group "excl-cg" with two blocked runs: excluding self returns the other waiter, not self.
self := seed(99701, "excl-cg", actions_model.StatusBlocked)
other := seed(99702, "excl-cg", actions_model.StatusBlocked)
id, err := findConcurrencyWaiterToWake(ctx, repoID, self.ID, "excl-cg")
assert.NoError(t, err)
assert.Equal(t, other.ID, id)
// Free group "solo-cg" with only self blocked: excluding it leaves no waiter.
solo := seed(99703, "solo-cg", actions_model.StatusBlocked)
id, err = findConcurrencyWaiterToWake(ctx, repoID, solo.ID, "solo-cg")
assert.NoError(t, err)
assert.Equal(t, int64(0), id)
// Held group "held-cg" (a running holder) has a blocked waiter, but nothing is woken while held.
seed(99704, "held-cg", actions_model.StatusRunning)
seed(99705, "held-cg", actions_model.StatusBlocked)
id, err = findConcurrencyWaiterToWake(ctx, repoID, 0, "held-cg")
assert.NoError(t, err)
assert.Equal(t, int64(0), id)
}
@@ -7,24 +7,23 @@ import (
"context"
"errors"
actions_model "code.gitea.io/gitea/models/actions"
issues_model "code.gitea.io/gitea/models/issues"
"code.gitea.io/gitea/models/organization"
packages_model "code.gitea.io/gitea/models/packages"
perm_model "code.gitea.io/gitea/models/perm"
access_model "code.gitea.io/gitea/models/perm/access"
repo_model "code.gitea.io/gitea/models/repo"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/modules/gitrepo"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/repository"
"code.gitea.io/gitea/modules/setting"
api "code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/modules/util"
webhook_module "code.gitea.io/gitea/modules/webhook"
"code.gitea.io/gitea/services/convert"
notify_service "code.gitea.io/gitea/services/notify"
actions_model "gitea.dev/models/actions"
issues_model "gitea.dev/models/issues"
"gitea.dev/models/organization"
packages_model "gitea.dev/models/packages"
perm_model "gitea.dev/models/perm"
access_model "gitea.dev/models/perm/access"
repo_model "gitea.dev/models/repo"
user_model "gitea.dev/models/user"
"gitea.dev/modules/git"
"gitea.dev/modules/log"
"gitea.dev/modules/repository"
"gitea.dev/modules/setting"
api "gitea.dev/modules/structs"
"gitea.dev/modules/util"
webhook_module "gitea.dev/modules/webhook"
"gitea.dev/services/convert"
notify_service "gitea.dev/services/notify"
)
type actionsNotifier struct {
@@ -460,8 +459,7 @@ func (n *actionsNotifier) PullRequestReview(ctx context.Context, pr *issues_mode
return
}
newNotifyInput(review.Issue.Repo, review.Reviewer, reviewHookType).
WithRef(review.CommitID).
newPullRequestReviewNotifyInput(review.Issue.Repo, review.Reviewer, reviewHookType, review.CommitID, pr).
WithPayload(&api.PullRequestPayload{
Action: api.HookIssueReviewed,
Index: review.Issue.Index,
@@ -687,7 +685,7 @@ func (n *actionsNotifier) AutoMergePullRequest(ctx context.Context, doer *user_m
n.MergePullRequest(ctx, doer, pr)
}
func (n *actionsNotifier) PullRequestSynchronized(ctx context.Context, doer *user_model.User, pr *issues_model.PullRequest) {
func (n *actionsNotifier) PullRequestSynchronized(ctx context.Context, doer *user_model.User, pr *issues_model.PullRequest, before, after string) {
ctx = withMethod(ctx, "PullRequestSynchronized")
if err := pr.LoadIssue(ctx); err != nil {
@@ -703,6 +701,8 @@ func (n *actionsNotifier) PullRequestSynchronized(ctx context.Context, doer *use
newNotifyInput(pr.Issue.Repo, doer, webhook_module.HookEventPullRequestSync).
WithPayload(&api.PullRequestPayload{
Action: api.HookIssueSynchronized,
Before: before,
After: after,
Index: pr.Issue.Index,
PullRequest: convert.ToAPIPullRequest(ctx, pr, nil),
Repository: convert.ToRepo(ctx, pr.Issue.Repo, access_model.Permission{AccessMode: perm_model.AccessModeNone}),
@@ -800,22 +800,19 @@ func (n *actionsNotifier) WorkflowRunStatusUpdate(ctx context.Context, repo *rep
status := convert.ToWorkflowRunAction(run.Status)
gitRepo, err := gitrepo.OpenRepository(ctx, repo)
convertedWorkflow, err := convert.ResolveActionWorkflowForRun(ctx, repo, run)
if err != nil {
log.Error("OpenRepository: %v", err)
if errors.Is(err, util.ErrNotExist) {
// The workflow definition is gone (e.g. a scoped source repo/file was deleted, or the file no longer exists at the recorded commit), skip
log.Debug("WorkflowRunStatusUpdate: workflow %q for run %d not found: %v", run.WorkflowID, run.ID, err)
return
}
log.Error("WorkflowRunStatusUpdate resolve workflow: %v", err)
return
}
defer gitRepo.Close()
convertedWorkflow, err := convert.GetActionWorkflowByRef(ctx, gitRepo, repo, run.WorkflowID, git.RefName(run.Ref))
if err != nil && errors.Is(err, util.ErrNotExist) {
convertedWorkflow, err = convert.GetActionWorkflow(ctx, gitRepo, repo, run.WorkflowID)
}
if err != nil {
log.Error("GetActionWorkflow: %v", err)
return
}
convertedRun, err := convert.ToActionWorkflowRun(ctx, repo, run)
run.Repo = repo
convertedRun, err := convert.ToActionWorkflowRun(ctx, run, nil, false)
if err != nil {
log.Error("ToActionWorkflowRun: %v", err)
return
@@ -4,31 +4,30 @@
package actions
import (
"bytes"
"context"
"fmt"
"slices"
"strings"
actions_model "code.gitea.io/gitea/models/actions"
"code.gitea.io/gitea/models/db"
issues_model "code.gitea.io/gitea/models/issues"
packages_model "code.gitea.io/gitea/models/packages"
access_model "code.gitea.io/gitea/models/perm/access"
repo_model "code.gitea.io/gitea/models/repo"
unit_model "code.gitea.io/gitea/models/unit"
user_model "code.gitea.io/gitea/models/user"
actions_module "code.gitea.io/gitea/modules/actions"
"code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/modules/gitrepo"
"code.gitea.io/gitea/modules/json"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/setting"
api "code.gitea.io/gitea/modules/structs"
webhook_module "code.gitea.io/gitea/modules/webhook"
"code.gitea.io/gitea/services/convert"
"github.com/nektos/act/pkg/model"
actions_model "gitea.dev/models/actions"
"gitea.dev/models/db"
issues_model "gitea.dev/models/issues"
packages_model "gitea.dev/models/packages"
access_model "gitea.dev/models/perm/access"
repo_model "gitea.dev/models/repo"
unit_model "gitea.dev/models/unit"
user_model "gitea.dev/models/user"
actions_module "gitea.dev/modules/actions"
"gitea.dev/modules/actions/jobparser"
"gitea.dev/modules/container"
"gitea.dev/modules/git"
"gitea.dev/modules/gitrepo"
"gitea.dev/modules/json"
"gitea.dev/modules/log"
"gitea.dev/modules/setting"
api "gitea.dev/modules/structs"
webhook_module "gitea.dev/modules/webhook"
"gitea.dev/services/convert"
)
type methodCtxKeyType struct{}
@@ -84,6 +83,12 @@ func newNotifyInputForSchedules(repo *repo_model.Repository) *notifyInput {
return newNotifyInput(repo, user_model.NewActionsUser(), webhook_module.HookEventSchedule)
}
func newPullRequestReviewNotifyInput(repo *repo_model.Repository, reviewer *user_model.User, event webhook_module.HookEventType, commitID string, pr *issues_model.PullRequest) *notifyInput {
return newNotifyInput(repo, reviewer, event).
WithRef(commitID).
WithPullRequest(pr)
}
func (input *notifyInput) WithDoer(doer *user_model.User) *notifyInput {
input.Doer = doer
return input
@@ -182,8 +187,9 @@ func notify(ctx context.Context, input *notifyInput) error {
}
var detectedWorkflows []*actions_module.DetectedWorkflow
var filteredWorkflows []*actions_module.DetectedWorkflow
actionsConfig := input.Repo.MustGetUnit(ctx, unit_model.TypeActions).ActionsConfig()
workflows, schedules, err := actions_module.DetectWorkflows(gitRepo, commit,
workflows, schedules, filtered, err := actions_module.DetectWorkflows(gitRepo, commit,
input.Event,
input.Payload,
shouldDetectSchedules,
@@ -211,6 +217,17 @@ func notify(ctx context.Context, input *notifyInput) error {
}
}
for _, wf := range filtered {
if actionsConfig.IsWorkflowDisabled(wf.EntryName) {
log.Trace("repo %s has disable workflows %s", input.Repo.RelativePath(), wf.EntryName)
continue
}
if wf.TriggerEvent.Name != actions_module.GithubEventPullRequestTarget {
filteredWorkflows = append(filteredWorkflows, wf)
}
}
if input.PullRequest != nil {
// detect pull_request_target workflows
baseRef := git.BranchPrefix + input.PullRequest.BaseBranch
@@ -218,7 +235,7 @@ func notify(ctx context.Context, input *notifyInput) error {
if err != nil {
return fmt.Errorf("gitRepo.GetCommit: %w", err)
}
baseWorkflows, _, err := actions_module.DetectWorkflows(gitRepo, baseCommit, input.Event, input.Payload, false)
baseWorkflows, _, baseFiltered, err := actions_module.DetectWorkflows(gitRepo, baseCommit, input.Event, input.Payload, false)
if err != nil {
return fmt.Errorf("DetectWorkflows: %w", err)
}
@@ -226,11 +243,24 @@ func notify(ctx context.Context, input *notifyInput) error {
log.Trace("repo %s with commit %s couldn't find pull_request_target workflows", input.Repo.RelativePath(), baseCommit.ID)
} else {
for _, wf := range baseWorkflows {
if actionsConfig.IsWorkflowDisabled(wf.EntryName) {
log.Trace("repo %s has disable workflows %s", input.Repo.RelativePath(), wf.EntryName)
continue
}
if wf.TriggerEvent.Name == actions_module.GithubEventPullRequestTarget {
detectedWorkflows = append(detectedWorkflows, wf)
}
}
}
for _, wf := range baseFiltered {
if actionsConfig.IsWorkflowDisabled(wf.EntryName) {
log.Trace("repo %s has disable workflows %s", input.Repo.RelativePath(), wf.EntryName)
continue
}
if wf.TriggerEvent.Name == actions_module.GithubEventPullRequestTarget {
filteredWorkflows = append(filteredWorkflows, wf)
}
}
}
if shouldDetectSchedules {
@@ -239,7 +269,13 @@ func notify(ctx context.Context, input *notifyInput) error {
}
}
return handleWorkflows(ctx, detectedWorkflows, commit, input, ref)
if err := handleWorkflows(ctx, detectedWorkflows, commit, input, ref); err != nil {
return err
}
handleFilteredWorkflows(ctx, input, filteredWorkflows)
return detectAndHandleScopedWorkflows(ctx, input, ref, gitRepo, commit)
}
func skipWorkflows(ctx context.Context, input *notifyInput, commit *git.Commit) bool {
@@ -256,7 +292,7 @@ func skipWorkflows(ctx context.Context, input *notifyInput, commit *git.Commit)
log.Debug("repo %s: skipped run for pr %v because of %s string", input.Repo.RelativePath(), input.PullRequest.Issue.ID, s)
return true
}
if strings.Contains(commit.CommitMessage, s) {
if strings.Contains(commit.MessageRaw, s) {
log.Debug("repo %s with commit %s: skipped run because of %s string", input.Repo.RelativePath(), commit.ID, s)
return true
}
@@ -303,55 +339,99 @@ func handleWorkflows(
return fmt.Errorf("json.Marshal: %w", err)
}
isForkPullRequest := false
if pr := input.PullRequest; pr != nil {
switch pr.Flow {
case issues_model.PullRequestFlowGithub:
isForkPullRequest = pr.IsFromFork()
case issues_model.PullRequestFlowAGit:
// There is no fork concept in agit flow, anyone with read permission can push refs/for/<target-branch>/<topic-branch> to the repo.
// So we can treat it as a fork pull request because it may be from an untrusted user
isForkPullRequest = true
default:
// unknown flow, assume it's a fork pull request to be safe
isForkPullRequest = true
}
}
isForkPullRequest := isForkPullRequestInput(input)
for _, dwf := range detectedWorkflows {
run := &actions_model.ActionRun{
Title: commit.Summary(),
RepoID: input.Repo.ID,
Repo: input.Repo,
OwnerID: input.Repo.OwnerID,
WorkflowID: dwf.EntryName,
TriggerUserID: input.Doer.ID,
TriggerUser: input.Doer,
Ref: ref.String(),
CommitSHA: commit.ID.String(),
IsForkPullRequest: isForkPullRequest,
Event: input.Event,
EventPayload: string(p),
TriggerEvent: dwf.TriggerEvent.Name,
Status: actions_model.StatusWaiting,
}
need, err := ifNeedApproval(ctx, run, input.Repo, input.Doer)
if err != nil {
log.Error("check if need approval for repo %d with user %d: %v", input.Repo.ID, input.Doer.ID, err)
continue
}
run.NeedApproval = need
if err := PrepareRunAndInsert(ctx, dwf.Content, run, nil); err != nil {
log.Error("PrepareRunAndInsert: %v", err)
// repo-level run: the workflow content is this repo at dwf.SourceCommitSHA
if err := buildApproveAndInsertRun(ctx, input, ref, commit, string(p), isForkPullRequest, dwf, input.Repo.ID, false); err != nil {
log.Error("repo %s: %v", input.Repo.RelativePath(), err)
continue
}
}
return nil
}
// buildApproveAndInsertRun assembles an ActionRun for a detected workflow, runs the
// fork-PR approval gate, and inserts it. Repo-level and scoped runs share this path so
// run construction and the approval flow have a single implementation that can't drift.
// workflowRepoID and dwf.SourceCommitSHA point at the repo+commit the workflow content comes
// from (the repo itself for repo-level runs, the source repo for scoped runs).
func buildApproveAndInsertRun(
ctx context.Context,
input *notifyInput,
ref git.RefName,
commit *git.Commit,
payload string,
isForkPullRequest bool,
dwf *actions_module.DetectedWorkflow,
workflowRepoID int64,
isScopedRun bool,
) error {
if dwf.SourceCommitSHA == "" {
// unreachable in the normal flow; catches a test case that builds a DetectedWorkflow without it
setting.PanicInDevOrTesting("workflow %q has no source commit", dwf.EntryName)
}
run := &actions_model.ActionRun{
Title: commit.MessageTitle(),
RepoID: input.Repo.ID,
Repo: input.Repo,
OwnerID: input.Repo.OwnerID,
WorkflowID: dwf.EntryName,
TriggerUserID: input.Doer.ID,
TriggerUser: input.Doer,
Ref: ref.String(),
CommitSHA: commit.ID.String(),
IsForkPullRequest: isForkPullRequest,
Event: input.Event,
EventPayload: payload,
TriggerEvent: dwf.TriggerEvent.Name,
Status: actions_model.StatusWaiting,
WorkflowRepoID: workflowRepoID,
WorkflowCommitSHA: dwf.SourceCommitSHA,
IsScopedRun: isScopedRun,
}
need, err := ifNeedApproval(ctx, run, input.Repo, input.Doer)
if err != nil {
return fmt.Errorf("check if need approval for user %d: %w", input.Doer.ID, err)
}
run.NeedApproval = need
if err := PrepareRunAndInsert(ctx, dwf.Content, run, nil); err != nil {
return fmt.Errorf("PrepareRunAndInsert: %w", err)
}
return nil
}
// handleFilteredWorkflows posts a skipped commit status for each filtered-out workflow whose context is a required status check;
// a non-required one posts nothing, so it cannot leak into a pull request.
func handleFilteredWorkflows(ctx context.Context, input *notifyInput, filteredWorkflows []*actions_module.DetectedWorkflow) {
if len(filteredWorkflows) == 0 {
return
}
requiredGlobs, err := getAllRequiredStatusContextGlobs(ctx, input.Repo)
if err != nil {
log.Error("repo %s: required status contexts: %v", input.Repo.RelativePath(), err)
return
}
if len(requiredGlobs) == 0 {
return
}
for _, dwf := range filteredWorkflows {
if !shouldCreateSkippedCommitStatusForFilteredWorkflow(input, dwf) {
continue
}
if err := CreateSkippedCommitStatusForFilteredWorkflow(ctx, input.Repo, input.Event, dwf.TriggerEvent.Name, dwf.EntryName, dwf.Content, input.Payload, "", requiredGlobs); err != nil {
log.Error("repo %s: skipped commit status for workflow %s: %v", input.Repo.RelativePath(), dwf.EntryName, err)
continue
}
}
}
func shouldCreateSkippedCommitStatusForFilteredWorkflow(input *notifyInput, workflow *actions_module.DetectedWorkflow) bool {
return !isForkPullRequestInput(input) || workflow.TriggerEvent.Name == actions_module.GithubEventPullRequestTarget
}
func newNotifyInputFromIssue(issue *issues_model.Issue, event webhook_module.HookEventType) *notifyInput {
return newNotifyInput(issue.Repo, issue.Poster, event)
}
@@ -399,6 +479,24 @@ func notifyPackage(ctx context.Context, sender *user_model.User, pd *packages_mo
}
func ifNeedApproval(ctx context.Context, run *actions_model.ActionRun, repo *repo_model.Repository, user *user_model.User) (bool, error) {
canWrite := func(ctx context.Context, repo *repo_model.Repository, user *user_model.User) (bool, error) {
perm, err := access_model.GetDoerRepoPermission(ctx, repo, user)
if err != nil {
return false, err
}
return perm.CanWrite(unit_model.TypeActions), nil
}
return ifNeedApprovalWith(ctx, run, repo, user, canWrite, issues_model.HasMergedPullRequestInRepo)
}
func ifNeedApprovalWith(
ctx context.Context,
run *actions_model.ActionRun,
repo *repo_model.Repository,
user *user_model.User,
canWriteActions func(context.Context, *repo_model.Repository, *user_model.User) (bool, error),
hasMergedPR func(context.Context, int64, int64) (bool, error),
) (bool, error) {
// 1. don't need approval if it's not a fork PR
// 2. don't need approval if the event is `pull_request_target` since the workflow will run in the context of base branch
// see https://docs.github.com/en/actions/managing-workflow-runs/approving-workflow-runs-from-public-forks#about-workflow-runs-from-public-forks
@@ -413,27 +511,24 @@ func ifNeedApproval(ctx context.Context, run *actions_model.ActionRun, repo *rep
}
// don't need approval if the user can write
if perm, err := access_model.GetDoerRepoPermission(ctx, repo, user); err != nil {
if ok, err := canWriteActions(ctx, repo, user); err != nil {
return false, fmt.Errorf("GetDoerRepoPermission: %w", err)
} else if perm.CanWrite(unit_model.TypeActions) {
} else if ok {
log.Trace("do not need approval because user %d can write", user.ID)
return false, nil
}
// don't need approval if the user has been approved before
if count, err := db.Count[actions_model.ActionRun](ctx, actions_model.FindRunOptions{
RepoID: repo.ID,
TriggerUserID: user.ID,
Approved: true,
}); err != nil {
return false, fmt.Errorf("CountRuns: %w", err)
} else if count > 0 {
log.Trace("do not need approval because user %d has been approved before", user.ID)
// trust the user only after a merged PR — matching GitHub Actions. Approving one
// fork PR's run must not implicitly trust later fork PRs that replace the workflow.
if merged, err := hasMergedPR(ctx, repo.ID, user.ID); err != nil {
return false, fmt.Errorf("HasMergedPullRequestInRepo: %w", err)
} else if merged {
log.Trace("do not need approval because user %d has a merged pull request in repo %d", user.ID, repo.ID)
return false, nil
}
// otherwise, need approval
log.Trace("need approval because it's the first time user %d triggered actions", user.ID)
log.Trace("need approval because user %d has no merged pull request in repo %d", user.ID, repo.ID)
return true, nil
}
@@ -471,7 +566,7 @@ func handleSchedules(
crons := make([]*actions_model.ActionSchedule, 0, len(detectedWorkflows))
for _, dwf := range detectedWorkflows {
// Check cron job condition. Only working in default branch
workflow, err := model.ReadWorkflow(bytes.NewReader(dwf.Content))
workflow, err := jobparser.ReadWorkflow(dwf.Content)
if err != nil {
log.Error("ReadWorkflow: %v", err)
continue
@@ -483,7 +578,7 @@ func handleSchedules(
}
run := &actions_model.ActionSchedule{
Title: commit.Summary(),
Title: commit.MessageTitle(),
RepoID: input.Repo.ID,
Repo: input.Repo,
OwnerID: input.Repo.OwnerID,
@@ -536,3 +631,136 @@ func DetectAndHandleSchedules(ctx context.Context, repo *repo_model.Repository)
return handleSchedules(ctx, scheduleWorkflows, commit, notifyInput, git.RefNameFromBranch(repo.DefaultBranch))
}
// isForkPullRequestInput reports whether the run should be treated as a fork pull request.
func isForkPullRequestInput(input *notifyInput) bool {
pr := input.PullRequest
if pr == nil {
return false
}
switch pr.Flow {
case issues_model.PullRequestFlowGithub:
return pr.IsFromFork()
case issues_model.PullRequestFlowAGit:
// There is no fork concept in agit flow, anyone with read permission can push refs/for/<target-branch>/<topic-branch> to the repo.
// So we can treat it as a fork pull request because it may be from an untrusted user
return true
default:
// unknown flow, assume it's a fork pull request to be safe
return true
}
}
// detectAndHandleScopedWorkflows detects scoped workflows registered for the consuming repo
func detectAndHandleScopedWorkflows(
ctx context.Context,
input *notifyInput,
ref git.RefName,
consumerGitRepo *git.Repository,
consumerCommit *git.Commit,
) error {
// TODO: support workflow_run and schedule
if input.Event == webhook_module.HookEventWorkflowRun || input.Event == webhook_module.HookEventSchedule {
return nil
}
sources, err := actions_model.GetEffectiveScopedWorkflowSources(ctx, input.Repo.OwnerID)
if err != nil {
return fmt.Errorf("GetEffectiveScopedWorkflowSources: %w", err)
}
if len(sources) == 0 {
return nil
}
p, err := json.Marshal(input.Payload)
if err != nil {
return fmt.Errorf("json.Marshal: %w", err)
}
isForkPullRequest := isForkPullRequestInput(input)
actionsConfig := input.Repo.MustGetUnit(ctx, unit_model.TypeActions).ActionsConfig()
// A filtered-out scoped workflow only posts a skipped status when its context is a required check.
requiredGlobs, err := getAllRequiredStatusContextGlobs(ctx, input.Repo)
if err != nil {
log.Error("scoped workflows: required status contexts for %s: %v", input.Repo.RelativePath(), err)
}
// The same source repo may be registered at both the owner and instance level; dedup
// the IDs and batch-load them in one query instead of one round-trip per source.
seen := make(container.Set[int64], len(sources))
for _, source := range sources {
seen.Add(source.SourceRepoID)
}
sourceRepoIDs := seen.Values()
sourceRepos, err := repo_model.GetRepositoriesMapByIDs(ctx, sourceRepoIDs)
if err != nil {
return fmt.Errorf("GetRepositoriesMapByIDs: %w", err)
}
for _, sourceRepoID := range sourceRepoIDs {
sourceRepo := sourceRepos[sourceRepoID]
if sourceRepo == nil {
// don't abort the other effective sources for this event
log.Error("scoped workflows: source repo %d for consumer %s not found", sourceRepoID, input.Repo.RelativePath())
continue
}
if sourceRepo.IsEmpty {
continue
}
detected, filtered, err := detectScopedWorkflowsForSource(ctx, input, consumerGitRepo, consumerCommit, sourceRepo)
if err != nil {
log.Error("scoped workflows: source %d for consumer %s: %v", sourceRepoID, input.Repo.RelativePath(), err)
continue
}
for _, dwf := range detected {
// A consuming repo can opt out of a non-required scoped workflow.
// A required workflow (marked required at any effective level) can never be opted out.
if actions_model.ScopedWorkflowOptedOut(actionsConfig, sources, sourceRepo.ID, dwf.EntryName) {
continue
}
if err := buildApproveAndInsertRun(ctx, input, ref, consumerCommit, string(p), isForkPullRequest, dwf, sourceRepo.ID, true); err != nil {
log.Error("scoped workflows: source %s workflow %s: %v", sourceRepo.RelativePath(), dwf.EntryName, err)
continue
}
}
// A filtered-out scoped workflow posts a skipped commit status for its required-check contexts.
if len(filtered) > 0 && len(requiredGlobs) > 0 {
scopedPrefix := actions_model.ScopedStatusContextPrefix(ctx, sourceRepo.ID)
for _, dwf := range filtered {
if actions_model.ScopedWorkflowOptedOut(actionsConfig, sources, sourceRepo.ID, dwf.EntryName) {
continue
}
if err := CreateSkippedCommitStatusForFilteredWorkflow(ctx, input.Repo, input.Event, dwf.TriggerEvent.Name, dwf.EntryName, dwf.Content, input.Payload, scopedPrefix, requiredGlobs); err != nil {
log.Error("scoped workflows: skipped commit status for source %s workflow %s: %v", sourceRepo.RelativePath(), dwf.EntryName, err)
continue
}
}
}
}
return nil
}
// detectScopedWorkflowsForSource detects the scoped workflows from the source repo at its default branch.
// detected are the workflows to run; filtered matched the event but were excluded by a branch/paths
// filter, and later post a skipped commit status only for a required-check context.
func detectScopedWorkflowsForSource(
ctx context.Context,
input *notifyInput,
consumerGitRepo *git.Repository,
consumerCommit *git.Commit,
sourceRepo *repo_model.Repository,
) (detected, filtered []*actions_module.DetectedWorkflow, err error) {
// scoped workflow content is always taken from the source repo's default branch; the parse is cached per (source, default-branch SHA) and reused across consuming repos/events
sourceCommitSHA, parsed, err := LoadParsedScopedWorkflows(ctx, sourceRepo)
if err != nil {
return nil, nil, err
}
detected, filtered = actions_module.MatchScopedWorkflows(parsed, sourceCommitSHA, consumerGitRepo, consumerCommit, input.Event, input.Payload)
return detected, filtered, nil
}
@@ -0,0 +1,126 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package actions
import (
"context"
"errors"
"testing"
actions_model "gitea.dev/models/actions"
issues_model "gitea.dev/models/issues"
repo_model "gitea.dev/models/repo"
user_model "gitea.dev/models/user"
actions_module "gitea.dev/modules/actions"
"gitea.dev/modules/actions/jobparser"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestIfNeedApproval(t *testing.T) {
alwaysWrite := func(_ context.Context, _ *repo_model.Repository, _ *user_model.User) (bool, error) {
return true, nil
}
neverWrite := func(_ context.Context, _ *repo_model.Repository, _ *user_model.User) (bool, error) {
return false, nil
}
hasMerged := func(_ context.Context, _, _ int64) (bool, error) { return true, nil }
noMerged := func(_ context.Context, _, _ int64) (bool, error) { return false, nil }
errPerm := errors.New("perm error")
errMerge := errors.New("merge error")
forkRun := &actions_model.ActionRun{IsForkPullRequest: true, TriggerEvent: actions_module.GithubEventPullRequest}
nonForkRun := &actions_model.ActionRun{IsForkPullRequest: false, TriggerEvent: actions_module.GithubEventPullRequest}
prTargetRun := &actions_model.ActionRun{IsForkPullRequest: true, TriggerEvent: actions_module.GithubEventPullRequestTarget}
repo := &repo_model.Repository{ID: 1}
normalUser := &user_model.User{ID: 10}
restrictedUser := &user_model.User{ID: 11, IsRestricted: true}
t.Run("not a fork PR never needs approval", func(t *testing.T) {
need, err := ifNeedApprovalWith(t.Context(), nonForkRun, repo, normalUser, alwaysWrite, hasMerged)
require.NoError(t, err)
assert.False(t, need)
})
t.Run("pull_request_target never needs approval even when fork", func(t *testing.T) {
need, err := ifNeedApprovalWith(t.Context(), prTargetRun, repo, normalUser, alwaysWrite, hasMerged)
require.NoError(t, err)
assert.False(t, need)
})
t.Run("restricted user always needs approval", func(t *testing.T) {
need, err := ifNeedApprovalWith(t.Context(), forkRun, repo, restrictedUser, alwaysWrite, hasMerged)
require.NoError(t, err)
assert.True(t, need)
})
t.Run("fork PR with write permission does not need approval", func(t *testing.T) {
need, err := ifNeedApprovalWith(t.Context(), forkRun, repo, normalUser, alwaysWrite, noMerged)
require.NoError(t, err)
assert.False(t, need)
})
t.Run("fork PR with merged PR but no write permission does not need approval", func(t *testing.T) {
need, err := ifNeedApprovalWith(t.Context(), forkRun, repo, normalUser, neverWrite, hasMerged)
require.NoError(t, err)
assert.False(t, need)
})
t.Run("fork PR with no write and no merged PR needs approval", func(t *testing.T) {
need, err := ifNeedApprovalWith(t.Context(), forkRun, repo, normalUser, neverWrite, noMerged)
require.NoError(t, err)
assert.True(t, need)
})
t.Run("canWriteActions error is propagated", func(t *testing.T) {
failWrite := func(_ context.Context, _ *repo_model.Repository, _ *user_model.User) (bool, error) {
return false, errPerm
}
_, err := ifNeedApprovalWith(t.Context(), forkRun, repo, normalUser, failWrite, noMerged)
require.ErrorIs(t, err, errPerm)
})
t.Run("hasMergedPR error is propagated", func(t *testing.T) {
failMerge := func(_ context.Context, _, _ int64) (bool, error) { return false, errMerge }
_, err := ifNeedApprovalWith(t.Context(), forkRun, repo, normalUser, neverWrite, failMerge)
require.ErrorIs(t, err, errMerge)
})
t.Run("restricted user skips permission check entirely", func(t *testing.T) {
// The perm and merge functions must not be called for a restricted user.
called := false
trackWrite := func(_ context.Context, _ *repo_model.Repository, _ *user_model.User) (bool, error) {
called = true
return true, nil
}
need, err := ifNeedApprovalWith(t.Context(), forkRun, repo, restrictedUser, trackWrite, noMerged)
require.NoError(t, err)
assert.True(t, need)
assert.False(t, called, "permission check must not run for restricted user")
})
}
func TestFilteredWorkflowCommitStatusForForkPullRequest(t *testing.T) {
forkPR := &issues_model.PullRequest{
Flow: issues_model.PullRequestFlowGithub,
BaseRepoID: 1,
HeadRepoID: 2,
}
input := newPullRequestReviewNotifyInput(&repo_model.Repository{ID: 1}, &user_model.User{ID: 2}, actions_module.GithubEventPullRequest, "refs/pull/1/head", forkPR)
assert.True(t, isForkPullRequestInput(input))
assert.Equal(t, "refs/pull/1/head", input.Ref.String())
assert.False(t, shouldCreateSkippedCommitStatusForFilteredWorkflow(input, &actions_module.DetectedWorkflow{
TriggerEvent: &jobparser.Event{Name: actions_module.GithubEventPullRequest},
}))
assert.True(t, shouldCreateSkippedCommitStatusForFilteredWorkflow(input, &actions_module.DetectedWorkflow{
TriggerEvent: &jobparser.Event{Name: actions_module.GithubEventPullRequestTarget},
}))
assert.True(t, shouldCreateSkippedCommitStatusForFilteredWorkflow(newNotifyInput(&repo_model.Repository{ID: 1}, &user_model.User{ID: 2}, actions_module.GithubEventPullRequest), &actions_module.DetectedWorkflow{
TriggerEvent: &jobparser.Event{Name: actions_module.GithubEventPullRequest},
}))
}
@@ -0,0 +1,147 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package actions
import (
"context"
actions_model "gitea.dev/models/actions"
"gitea.dev/modules/log"
notify_service "gitea.dev/services/notify"
)
// NotifyWorkflowJobsAndRunsStatusUpdate notifies status changes for a batch of jobs and the runs they affect.
// Use it when a workflow operation updates multiple jobs and runs.
func NotifyWorkflowJobsAndRunsStatusUpdate(ctx context.Context, jobs []*actions_model.ActionRunJob) {
if len(jobs) == 0 {
return
}
// The input jobs may belong to different runs, so track each affected run ID
// and reload it later to avoid notifying with stale aggregate status.
runRepoIDs := make(map[int64]int64, len(jobs))
jobsByRunID := make(map[int64][]*actions_model.ActionRunJob)
for _, job := range jobs {
if err := job.LoadAttributes(ctx); err != nil {
log.Error("Failed to load job attributes: %v", err)
continue
}
CreateCommitStatusForRunJobs(ctx, job.Run, job)
runRepoIDs[job.RunID] = job.RepoID
if _, ok := jobsByRunID[job.RunID]; !ok {
jobsByRunID[job.RunID] = make([]*actions_model.ActionRunJob, 0)
}
jobsByRunID[job.RunID] = append(jobsByRunID[job.RunID], job)
}
for runID, repoID := range runRepoIDs {
NotifyWorkflowRunStatusUpdateWithReload(ctx, repoID, runID)
}
for _, jobs := range jobsByRunID {
NotifyWorkflowJobsStatusUpdate(ctx, jobs...)
}
}
// NotifyWorkflowRunStatusUpdateWithReload reloads the run before notifying its status update.
// Use it when only repo/run IDs are available or when the in-memory run may be stale after job updates.
func NotifyWorkflowRunStatusUpdateWithReload(ctx context.Context, repoID, runID int64) {
run, err := actions_model.GetRunByRepoAndID(ctx, repoID, runID)
if err != nil {
log.Error("GetRunByRepoAndID: %v", err)
return
}
NotifyWorkflowRunStatusUpdate(ctx, run)
}
// NotifyWorkflowRunStatusUpdate notifies a run status update using the latest attempt trigger user when available.
// Use it for run-level notifications when the caller already has the run model loaded.
func NotifyWorkflowRunStatusUpdate(ctx context.Context, run *actions_model.ActionRun) {
if err := run.LoadAttributes(ctx); err != nil {
log.Error("run.LoadAttributes: %v", err)
return
}
triggerUser := run.TriggerUser
if run.LatestAttemptID > 0 {
attempt, err := actions_model.GetRunAttemptByRepoAndID(ctx, run.RepoID, run.LatestAttemptID)
if err != nil {
log.Error("GetRunAttemptByRepoAndID: %v", err)
return
}
if err := attempt.LoadAttributes(ctx); err != nil {
log.Error("attempt.LoadAttributes: %v", err)
return
}
triggerUser = attempt.TriggerUser
}
notify_service.WorkflowRunStatusUpdate(ctx, run.Repo, triggerUser, run)
// Recomputes the repository's num_action_runs / num_closed_action_runs counters since the run's status changed
actions_model.UpdateRepoRunsNumbers(ctx, run.RepoID)
}
// NotifyWorkflowJobsStatusUpdate notifies status updates for jobs without task.
// Use it for batch or single-job notifications after state changes.
func NotifyWorkflowJobsStatusUpdate(ctx context.Context, jobs ...*actions_model.ActionRunJob) {
jobsByAttempt := make(map[int64][]*actions_model.ActionRunJob)
for _, job := range jobs {
if _, ok := jobsByAttempt[job.RunAttemptID]; !ok {
jobsByAttempt[job.RunAttemptID] = make([]*actions_model.ActionRunJob, 0)
}
jobsByAttempt[job.RunAttemptID] = append(jobsByAttempt[job.RunAttemptID], job)
}
for attemptID, js := range jobsByAttempt {
if attemptID == 0 {
for _, job := range js {
if err := job.LoadAttributes(ctx); err != nil {
log.Error("job.LoadAttributes: %v", err)
continue
}
notify_service.WorkflowJobStatusUpdate(ctx, job.Run.Repo, job.Run.TriggerUser, job, nil)
}
continue
}
attempt, err := actions_model.GetRunAttemptByRepoAndID(ctx, js[0].RepoID, attemptID)
if err != nil {
log.Error("GetRunAttemptByRepoAndID: %v", err)
continue
}
if err := attempt.LoadAttributes(ctx); err != nil {
log.Error("attempt.LoadAttributes: %v", err)
continue
}
for _, job := range js {
notify_service.WorkflowJobStatusUpdate(ctx, attempt.Run.Repo, attempt.TriggerUser, job, nil)
}
}
}
// NotifyWorkflowJobStatusUpdateWithTask notifies a single job status update when a concrete task is available.
// Use it for runner/task lifecycle callbacks so the notification includes the originating task context.
func NotifyWorkflowJobStatusUpdateWithTask(ctx context.Context, job *actions_model.ActionRunJob, task *actions_model.ActionTask) {
if job.RunAttemptID == 0 {
if err := job.LoadAttributes(ctx); err != nil {
log.Error("job.LoadAttributes: %v", err)
return
}
notify_service.WorkflowJobStatusUpdate(ctx, job.Run.Repo, job.Run.TriggerUser, job, task)
return
}
attempt, err := actions_model.GetRunAttemptByRepoAndID(ctx, job.RepoID, job.RunAttemptID)
if err != nil {
log.Error("GetRunAttemptByRepoAndID: %v", err)
return
}
if err := attempt.LoadAttributes(ctx); err != nil {
log.Error("attempt.LoadAttributes: %v", err)
return
}
notify_service.WorkflowJobStatusUpdate(ctx, attempt.Run.Repo, attempt.TriggerUser, job, task)
}
@@ -4,11 +4,11 @@
package actions
import (
"code.gitea.io/gitea/models/perm"
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unit"
"code.gitea.io/gitea/modules/actions/jobparser"
"code.gitea.io/gitea/modules/setting"
"gitea.dev/models/perm"
repo_model "gitea.dev/models/repo"
"gitea.dev/models/unit"
"gitea.dev/modules/actions/jobparser"
"gitea.dev/modules/setting"
"go.yaml.in/yaml/v4"
)
@@ -40,17 +40,13 @@ func parseRawPermissionsExplicit(rawPerms *yaml.Node) *repo_model.ActionsTokenPe
return nil
}
// Unwrap DocumentNode and resolve AliasNode
// Unwrap DocumentNode
node := rawPerms
for node.Kind == yaml.DocumentNode || node.Kind == yaml.AliasNode {
if node.Kind == yaml.DocumentNode {
if len(node.Content) == 0 {
return nil
}
node = node.Content[0]
} else {
node = node.Alias
for node.Kind == yaml.DocumentNode {
if len(node.Content) == 0 {
return nil
}
node = node.Content[0]
}
if node.Kind == yaml.ScalarNode && node.Value == "" {
@@ -6,10 +6,10 @@ package actions
import (
"testing"
"code.gitea.io/gitea/models/perm"
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unit"
"code.gitea.io/gitea/modules/actions/jobparser"
"gitea.dev/models/perm"
repo_model "gitea.dev/models/repo"
"gitea.dev/models/unit"
"gitea.dev/modules/actions/jobparser"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
+547 -165
View File
@@ -6,215 +6,597 @@ package actions
import (
"context"
"fmt"
"slices"
actions_model "code.gitea.io/gitea/models/actions"
"code.gitea.io/gitea/models/db"
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unit"
"code.gitea.io/gitea/modules/container"
"code.gitea.io/gitea/modules/util"
notify_service "code.gitea.io/gitea/services/notify"
actions_model "gitea.dev/models/actions"
"gitea.dev/models/db"
repo_model "gitea.dev/models/repo"
"gitea.dev/models/unit"
user_model "gitea.dev/models/user"
"gitea.dev/modules/container"
"gitea.dev/modules/log"
"gitea.dev/modules/setting"
"gitea.dev/modules/util"
"github.com/nektos/act/pkg/model"
"gitea.com/gitea/runner/act/model"
"go.yaml.in/yaml/v4"
"xorm.io/builder"
)
// GetFailedRerunJobs returns all failed jobs and their downstream dependent jobs that need to be rerun
func GetFailedRerunJobs(allJobs []*actions_model.ActionRunJob) []*actions_model.ActionRunJob {
rerunJobIDSet := make(container.Set[int64])
// GetFailedJobsForRerun returns the failed or cancelled jobs in a run.
func GetFailedJobsForRerun(allJobs []*actions_model.ActionRunJob) []*actions_model.ActionRunJob {
var jobsToRerun []*actions_model.ActionRunJob
for _, job := range allJobs {
if job.Status == actions_model.StatusFailure || job.Status == actions_model.StatusCancelled {
for _, j := range GetAllRerunJobs(job, allJobs) {
if !rerunJobIDSet.Contains(j.ID) {
rerunJobIDSet.Add(j.ID)
jobsToRerun = append(jobsToRerun, j)
}
}
jobsToRerun = append(jobsToRerun, job)
}
}
return jobsToRerun
}
// GetAllRerunJobs returns the target job and all jobs that transitively depend on it.
// Downstream jobs are included regardless of their current status.
func GetAllRerunJobs(job *actions_model.ActionRunJob, allJobs []*actions_model.ActionRunJob) []*actions_model.ActionRunJob {
rerunJobs := []*actions_model.ActionRunJob{job}
rerunJobsIDSet := make(container.Set[string])
rerunJobsIDSet.Add(job.JobID)
// RerunWorkflowRunJobs reruns the given jobs of a workflow run.
// An empty jobsToRerun means rerunning the whole run. Otherwise jobsToRerun contains only the user-requested target jobs;
// downstream dependent jobs are expanded internally while building the rerun plan.
//
// The three stages below (legacy backfill, plan build, plan exec) deliberately run in separate DB transactions
// rather than one big outer transaction:
// - execRerunPlan performs slow work (loading variables, YAML unmarshal, concurrency expression evaluation)
// before opening its own transaction, so the tx stays focused on inserts/updates.
// (Exception: reusable workflow caller expansion runs inside the tx, see expandReusableWorkflowCaller's doc.)
// - The legacy backfill is idempotent-friendly: if it succeeds but a later stage fails, a subsequent rerun
// will observe run.LatestAttemptID != 0 and skip the backfill, continuing naturally. No data corruption
// or stuck state results from partial progress.
//
// Fast validations that can catch failures early (workflow disabled, run not done, etc.) are therefore
// pushed into validateRerun so we rarely enter createOriginalAttemptForLegacyRun only to fail afterwards.
func RerunWorkflowRunJobs(ctx context.Context, repo *repo_model.Repository, run *actions_model.ActionRun, triggerUser *user_model.User, jobsToRerun []*actions_model.ActionRunJob) (*actions_model.ActionRunAttempt, error) {
if err := validateRerun(ctx, run, repo, triggerUser, jobsToRerun); err != nil {
return nil, err
}
for {
found := false
for _, j := range allJobs {
if rerunJobsIDSet.Contains(j.JobID) {
if run.LatestAttemptID == 0 {
if err := createOriginalAttemptForLegacyRun(ctx, run); err != nil {
return nil, fmt.Errorf("create attempt for legacy run: %w", err)
}
}
plan, err := buildRerunPlan(ctx, run, triggerUser, jobsToRerun)
if err != nil {
return nil, err
}
return execRerunPlan(ctx, plan)
}
func validateRerun(ctx context.Context, run *actions_model.ActionRun, repo *repo_model.Repository, triggerUser *user_model.User, jobsToRerun []*actions_model.ActionRunJob) error {
if !run.Status.IsDone() {
return util.NewInvalidArgumentErrorf("this workflow run is not done")
}
if repo == nil {
return util.NewInvalidArgumentErrorf("repo is required")
}
if run.RepoID != repo.ID {
return util.NewInvalidArgumentErrorf("run %d does not belong to repo %d", run.ID, repo.ID)
}
for _, job := range jobsToRerun {
if job.RunID != run.ID {
return util.NewInvalidArgumentErrorf("job %d does not belong to workflow run %d", job.ID, run.ID)
}
}
if triggerUser == nil {
return util.NewInvalidArgumentErrorf("trigger user is required")
}
cfgUnit := repo.MustGetUnit(ctx, unit.TypeActions)
cfg := cfgUnit.ActionsConfig()
if run.IsScopedRun {
// a required scoped workflow can never be opted out, so a stale disabled flag must not block rerun
optedOut, err := actions_model.IsScopedWorkflowOptedOut(ctx, cfg, repo.OwnerID, run.WorkflowRepoID, run.WorkflowID)
if err != nil {
return err
}
if optedOut {
return util.NewInvalidArgumentErrorf("scoped workflow %s is disabled", run.WorkflowID)
}
} else if cfg.IsWorkflowDisabled(run.WorkflowID) {
return util.NewInvalidArgumentErrorf("workflow %s is disabled", run.WorkflowID)
}
// Legacy runs (LatestAttemptID == 0) conceptually have only attempt 1, so they can never be at the cap.
// For non-legacy runs, look up the latest attempt and reject when its number is already at the configured cap.
if run.LatestAttemptID > 0 {
latestAttempt, has, err := run.GetLatestAttempt(ctx)
if err != nil {
return fmt.Errorf("GetLatestAttempt: %w", err)
}
if has && latestAttempt.Attempt >= setting.Actions.MaxRerunAttempts {
return util.NewInvalidArgumentErrorf("workflow run has reached the maximum of %d attempts", setting.Actions.MaxRerunAttempts)
}
}
return nil
}
// rerunPlan is a read-only snapshot of the inputs needed to execute a rerun.
// It holds no to-be-persisted entities and no intermediate evaluation results;
// execRerunPlan constructs and evaluates the new ActionRunAttempt itself.
type rerunPlan struct {
run *actions_model.ActionRun
templateAttempt *actions_model.ActionRunAttempt
templateJobs actions_model.ActionJobList
triggerUser *user_model.User
// rerunAttemptJobIDs holds the AttemptJobIDs of jobs that will actually be re-run in the new attempt.
// If a job here is a reusable caller, the whole subtree under it will be re-run.
rerunAttemptJobIDs container.Set[int64]
// ancestorAttemptJobIDs holds the AttemptJobIDs of reusable caller jobs that have only some of their descendants being re-run:
// the caller itself is NOT re-run as a whole, it stays pass-through and its non-rerun children stay pass-through too.
ancestorAttemptJobIDs container.Set[int64]
// skipCloneTemplateJobIDs holds the template-attempt DB row IDs of descendants of any reusable caller in rerunAttemptJobIDs.
// These jobs should not be cloned, since the caller's lazy expansion will re-insert them fresh.
skipCloneTemplateJobIDs container.Set[int64]
}
// buildRerunPlan constructs a rerunPlan for the given workflow run without writing to the database.
// jobsToRerun contains only the user-requested target jobs. An empty jobsToRerun means the entire run should be rerun.
// It loads the latest attempt as a template and expands jobsToRerun to include all transitive downstream dependents.
// The construction of new-attempt and concurrency evaluation are deferred to execRerunPlan so that the plan remains a pure input snapshot.
func buildRerunPlan(ctx context.Context, run *actions_model.ActionRun, triggerUser *user_model.User, jobsToRerun []*actions_model.ActionRunJob) (*rerunPlan, error) {
if err := run.LoadAttributes(ctx); err != nil {
return nil, err
}
templateAttempt, hasTemplateAttempt, err := run.GetLatestAttempt(ctx)
if err != nil {
return nil, err
}
if !hasTemplateAttempt {
return nil, util.NewNotExistErrorf("latest attempt not found")
}
templateJobs, err := actions_model.GetRunJobsByRunAndAttemptID(ctx, run.ID, templateAttempt.ID)
if err != nil {
return nil, fmt.Errorf("load template jobs: %w", err)
}
if len(templateJobs) == 0 {
return nil, util.NewNotExistErrorf("no template jobs")
}
plan := &rerunPlan{
run: run,
templateAttempt: templateAttempt,
templateJobs: templateJobs,
triggerUser: triggerUser,
}
if err := plan.expandRerunJobIDs(jobsToRerun); err != nil {
return nil, err
}
plan.skipCloneTemplateJobIDs = plan.collectResetCallerDescendants()
return plan, nil
}
// execRerunPlan executes the rerun plan built by buildRerunPlan.
// It loads run variables, constructs the new ActionRunAttempt and evaluates run-level concurrency (all outside the transaction to keep the tx short).
// Inside a single database transaction it then inserts the new attempt, clones all template jobs, evaluates job-level concurrency for rerun jobs,
// and updates the run's latest_attempt_id.
// Jobs not in the rerun set are cloned as pass-through: their status is preserved and SourceTaskID points to the original task so the UI can still display their results.
// The attempt's final status is derived only from the rerun jobs, not the pass-through jobs.
// Notifications and commit statuses are sent after the transaction commits.
func execRerunPlan(ctx context.Context, plan *rerunPlan) (*actions_model.ActionRunAttempt, error) {
vars, err := actions_model.GetVariablesOfRun(ctx, plan.run)
if err != nil {
return nil, fmt.Errorf("get run %d variables: %w", plan.run.ID, err)
}
newAttempt := &actions_model.ActionRunAttempt{
RepoID: plan.run.RepoID,
RunID: plan.run.ID,
Attempt: plan.templateAttempt.Attempt + 1,
TriggerUserID: plan.triggerUser.ID,
Status: actions_model.StatusWaiting,
}
if plan.run.RawConcurrency != "" {
var rawConcurrency model.RawConcurrency
if err := yaml.Unmarshal([]byte(plan.run.RawConcurrency), &rawConcurrency); err != nil {
return nil, fmt.Errorf("unmarshal raw concurrency: %w", err)
}
inputs, err := dispatchInputsForRunJobs(plan.run, plan.templateJobs)
if err != nil {
return nil, err
}
if err := EvaluateRunConcurrencyFillModel(ctx, plan.run, newAttempt, &rawConcurrency, vars, inputs); err != nil {
return nil, err
}
}
var newJobs, newJobsToRerun actions_model.ActionJobList
var cancelledConcurrencyJobs []*actions_model.ActionRunJob
var hasWaitingCallerJobs bool
err = db.WithTx(ctx, func(ctx context.Context) error {
newAttemptStatus, jobsToCancel, err := PrepareToStartRunWithConcurrency(ctx, newAttempt)
if err != nil {
return err
}
cancelledConcurrencyJobs = append(cancelledConcurrencyJobs, jobsToCancel...)
newAttempt.Status = newAttemptStatus
shouldBlock := newAttemptStatus == actions_model.StatusBlocked
if err := db.Insert(ctx, newAttempt); err != nil {
if _, getErr := actions_model.GetRunAttemptByRunIDAndAttemptNum(ctx, plan.run.ID, newAttempt.Attempt); getErr == nil {
return util.NewAlreadyExistErrorf("workflow run attempt %d for run %d already exists", newAttempt.Attempt, plan.run.ID)
}
return err
}
plan.run.LatestAttemptID = newAttempt.ID
if err := actions_model.UpdateRun(ctx, plan.run, "latest_attempt_id"); err != nil {
return err
}
hasWaitingJobs := false
newJobs = make(actions_model.ActionJobList, 0, len(plan.templateJobs))
newJobsToRerun = make(actions_model.ActionJobList, 0, len(plan.rerunAttemptJobIDs))
// templateIDToNewID maps each template-attempt job's DB ID to its newly-inserted clone's DB ID
templateIDToNewID := make(map[int64]int64, len(plan.templateJobs))
for _, templateJob := range plan.templateJobs {
// descendants of a reset reusable caller are not cloned at all, the caller will re-insert them
if plan.skipCloneTemplateJobIDs.Contains(templateJob.ID) {
continue
}
for _, need := range j.Needs {
if rerunJobsIDSet.Contains(need) {
found = true
rerunJobs = append(rerunJobs, j)
rerunJobsIDSet.Add(j.JobID)
break
newJob := cloneRunJobForAttempt(templateJob, newAttempt)
// Remap ParentJobID from template attempts's DB ID -> new attempt's DB ID.
if templateJob.ParentJobID != 0 {
newParentID, ok := templateIDToNewID[templateJob.ParentJobID]
if !ok {
return fmt.Errorf("clone order violation: parent job %d not yet cloned for child %d",
templateJob.ParentJobID, templateJob.ID)
}
newJob.ParentJobID = newParentID
}
if plan.rerunAttemptJobIDs.Contains(templateJob.AttemptJobID) {
shouldBlockJob := shouldBlock || plan.hasRerunDependency(templateJob)
newJob.Status = util.Iif(shouldBlockJob, actions_model.StatusBlocked, actions_model.StatusWaiting)
newJob.TaskID = 0
newJob.SourceTaskID = 0
newJob.Started = 0
newJob.Stopped = 0
newJob.ConcurrencyGroup = ""
newJob.ConcurrencyCancel = false
newJob.IsConcurrencyEvaluated = false
if templateJob.IsReusableCaller {
newJob.IsExpanded = false
newJob.CallPayload = ""
}
if newJob.RawConcurrency != "" && !shouldBlockJob {
if err := EvaluateJobConcurrencyFillModel(ctx, plan.run, newAttempt, newJob, vars, nil); err != nil {
return fmt.Errorf("evaluate job concurrency: %w", err)
}
newJob.Status, jobsToCancel, err = PrepareToStartJobWithConcurrency(ctx, newJob)
if err != nil {
return fmt.Errorf("prepare to start job with concurrency: %w", err)
}
cancelledConcurrencyJobs = append(cancelledConcurrencyJobs, jobsToCancel...)
}
newJobsToRerun = append(newJobsToRerun, newJob)
} else {
newJob.TaskID = 0
newJob.SourceTaskID = templateJob.EffectiveTaskID()
isAncestor := plan.ancestorAttemptJobIDs.Contains(templateJob.AttemptJobID)
newJob.Started = util.Iif(isAncestor, 0, templateJob.Started)
newJob.Stopped = util.Iif(isAncestor, 0, templateJob.Stopped)
}
if err := db.Insert(ctx, newJob); err != nil {
return err
}
templateIDToNewID[templateJob.ID] = newJob.ID
// expand reusable caller
if newJob.IsReusableCaller && newJob.Status == actions_model.StatusWaiting && !newJob.IsExpanded {
if err := expandReusableWorkflowCaller(ctx, plan.run, newAttempt, newJob, vars); err != nil {
return fmt.Errorf("inline trigger caller %d ready: %w", newJob.ID, err)
}
// refresh the caller status
if err := actions_model.RefreshReusableCallerStatus(ctx, newJob); err != nil {
return fmt.Errorf("refresh caller %d status: %w", newJob.ID, err)
}
hasWaitingCallerJobs = true
}
// A reusable caller is never dispatched to a runner, so it must not drive the task-version bump.
hasWaitingJobs = hasWaitingJobs || (newJob.Status == actions_model.StatusWaiting && !newJob.IsReusableCaller)
newJobs = append(newJobs, newJob)
}
// Refresh each ancestor's status from its now-fresh children.
// `newJobs` is appended top-down (caller before its children), so we walk it in reverse to refresh the deepest ancestor first.
for _, ancestor := range slices.Backward(newJobs) {
if !ancestor.IsReusableCaller || !plan.ancestorAttemptJobIDs.Contains(ancestor.AttemptJobID) {
continue
}
if err := actions_model.RefreshReusableCallerStatus(ctx, ancestor); err != nil {
return fmt.Errorf("refresh ancestor caller %d status: %w", ancestor.ID, err)
}
}
if !found {
break
newAttempt.Status = actions_model.AggregateJobStatus(newJobsToRerun)
if err := actions_model.UpdateRunAttempt(ctx, newAttempt, "status"); err != nil {
return err
}
}
return rerunJobs
}
if hasWaitingJobs {
if err := actions_model.IncreaseTaskVersion(ctx, plan.run.OwnerID, plan.run.RepoID); err != nil {
return err
}
}
// prepareRunRerun validates the run, resets its state, handles concurrency, persists the
// updated run, and fires a status-update notification.
// It returns isRunBlocked (true when the run itself is held by a concurrency group).
func prepareRunRerun(ctx context.Context, repo *repo_model.Repository, run *actions_model.ActionRun, jobs []*actions_model.ActionRunJob) (isRunBlocked bool, err error) {
if !run.Status.IsDone() {
return false, util.NewInvalidArgumentErrorf("this workflow run is not done")
}
cfgUnit := repo.MustGetUnit(ctx, unit.TypeActions)
// Rerun is not allowed when workflow is disabled.
cfg := cfgUnit.ActionsConfig()
if cfg.IsWorkflowDisabled(run.WorkflowID) {
return false, util.NewInvalidArgumentErrorf("workflow %s is disabled", run.WorkflowID)
}
// Reset run's timestamps and status.
run.PreviousDuration = run.Duration()
run.Started = 0
run.Stopped = 0
run.Status = actions_model.StatusWaiting
vars, err := actions_model.GetVariablesOfRun(ctx, run)
return nil
})
if err != nil {
return false, fmt.Errorf("get run %d variables: %w", run.ID, err)
return nil, err
}
if run.RawConcurrency != "" {
var rawConcurrency model.RawConcurrency
if err := yaml.Unmarshal([]byte(run.RawConcurrency), &rawConcurrency); err != nil {
return false, fmt.Errorf("unmarshal raw concurrency: %w", err)
}
if err := plan.run.LoadAttributes(ctx); err != nil {
return nil, err
}
if err := EvaluateRunConcurrencyFillModel(ctx, run, &rawConcurrency, vars, nil); err != nil {
return false, err
}
NotifyWorkflowJobsAndRunsStatusUpdate(ctx, cancelledConcurrencyJobs)
EmitJobsIfReadyByJobs(cancelledConcurrencyJobs)
run.Status, err = PrepareToStartRunWithConcurrency(ctx, run)
if err != nil {
return false, err
CreateCommitStatusForRunJobs(ctx, plan.run, newJobs...)
NotifyWorkflowJobsAndRunsStatusUpdate(ctx, newJobsToRerun)
// Post-commit kick for expanded callers: let job_emitter resolve its child jobs
if hasWaitingCallerJobs {
if err := EmitJobsIfReadyByRun(plan.run.ID); err != nil {
log.Error("emit run %d after rerun: %v", plan.run.ID, err)
}
}
if err := actions_model.UpdateRun(ctx, run, "started", "stopped", "previous_duration", "status", "concurrency_group", "concurrency_cancel"); err != nil {
return false, err
}
if err := run.LoadAttributes(ctx); err != nil {
return false, err
}
for _, job := range jobs {
job.Run = run
}
// Recomputes the repository's num_action_runs / num_closed_action_runs counters since the run's status changed
actions_model.UpdateRepoRunsNumbers(ctx, run.RepoID)
notify_service.WorkflowRunStatusUpdate(ctx, run.Repo, run.TriggerUser, run)
return run.Status == actions_model.StatusBlocked, nil
return newAttempt, nil
}
// RerunWorkflowRunJobs reruns the given jobs of a workflow run.
// jobsToRerun must include all jobs to be rerun (the target job and its transitively dependent jobs).
// A job is blocked (waiting for dependencies) if the run itself is blocked or if any of its
// needs are also being rerun.
func RerunWorkflowRunJobs(ctx context.Context, repo *repo_model.Repository, run *actions_model.ActionRun, jobsToRerun []*actions_model.ActionRunJob) error {
// expandRerunJobIDs computes rerunAttemptJobIDs and ancestorAttemptJobIDs from the user-selected jobsToRerun.
func (p *rerunPlan) expandRerunJobIDs(jobsToRerun []*actions_model.ActionRunJob) error {
// Empty jobsToRerun: rerun the whole latest attempt
if len(jobsToRerun) == 0 {
all := make(container.Set[int64], len(p.templateJobs))
for _, job := range p.templateJobs {
all.Add(job.AttemptJobID)
}
p.rerunAttemptJobIDs = all
p.ancestorAttemptJobIDs = make(container.Set[int64])
return nil
}
isRunBlocked, err := prepareRunRerun(ctx, repo, run, jobsToRerun)
if err != nil {
return err
}
rerunJobIDs := make(container.Set[string])
for _, j := range jobsToRerun {
rerunJobIDs.Add(j.JobID)
byID := make(map[int64]*actions_model.ActionRunJob, len(p.templateJobs))
byAttemptJobID := make(map[int64]*actions_model.ActionRunJob, len(p.templateJobs))
for _, job := range p.templateJobs {
byID[job.ID] = job
byAttemptJobID[job.AttemptJobID] = job
}
for _, job := range jobsToRerun {
shouldBlockJob := isRunBlocked
if !shouldBlockJob {
for _, need := range job.Needs {
if rerunJobIDs.Contains(need) {
shouldBlockJob = true
break
}
if _, ok := byID[job.ID]; !ok {
return util.NewInvalidArgumentErrorf("job %q does not exist in the latest attempt", job.JobID)
}
}
rerunSet := make(container.Set[int64])
ancestorSet := make(container.Set[int64])
queue := make([]*actions_model.ActionRunJob, 0, len(jobsToRerun))
for _, job := range jobsToRerun {
j := byID[job.ID]
rerunSet.Add(j.AttemptJobID)
queue = append(queue, j)
}
for len(queue) > 0 {
cur := queue[0]
queue = queue[1:]
// same-scope downstream: siblings whose Needs reference cur.JobID join the rerun set
for _, candidate := range p.templateJobs {
if candidate.ParentJobID != cur.ParentJobID {
continue
}
if rerunSet.Contains(candidate.AttemptJobID) || ancestorSet.Contains(candidate.AttemptJobID) {
continue
}
if !slices.Contains(candidate.Needs, cur.JobID) {
continue
}
rerunSet.Add(candidate.AttemptJobID)
queue = append(queue, candidate)
}
// escalate to parent caller as an ancestor so its own siblings get checked next round
if cur.ParentJobID == 0 {
continue
}
parent, ok := byID[cur.ParentJobID]
if !ok {
continue
}
if rerunSet.Contains(parent.AttemptJobID) || ancestorSet.Contains(parent.AttemptJobID) {
continue
}
ancestorSet.Add(parent.AttemptJobID)
queue = append(queue, parent)
}
// remove entries whose parent-caller chain already has a rerunSet member
for atID := range ancestorSet {
cur := byAttemptJobID[atID]
for cur.ParentJobID != 0 {
parent, ok := byID[cur.ParentJobID]
if !ok {
break
}
if rerunSet.Contains(parent.AttemptJobID) {
delete(ancestorSet, atID)
break
}
cur = parent
}
}
p.rerunAttemptJobIDs = rerunSet
p.ancestorAttemptJobIDs = ancestorSet
return nil
}
// hasRerunDependency reports whether `job` has a needs-reference that points to a job which is itself being rerun (in rerunAttemptJobIDs)
// or is an ancestor caller whose subtree is being rerun (in ancestorAttemptJobIDs).
// Either case means `job` should start in Blocked status.
func (p *rerunPlan) hasRerunDependency(job *actions_model.ActionRunJob) bool {
if len(job.Needs) == 0 {
return false
}
needSet := container.SetOf(job.Needs...)
for _, sibling := range p.templateJobs {
if sibling.ParentJobID != job.ParentJobID {
continue
}
if !needSet.Contains(sibling.JobID) {
continue
}
if p.rerunAttemptJobIDs.Contains(sibling.AttemptJobID) || p.ancestorAttemptJobIDs.Contains(sibling.AttemptJobID) {
return true
}
}
return false
}
// collectResetCallerDescendants walks p.templateJobs and returns the DB IDs of every transitive descendant of any reusable caller whose AttemptJobID is in p.rerunAttemptJobIDs.
// These descendants must NOT be cloned by execRerunPlan: the reset caller will re-insert them with template-matched AttemptJobIDs.
func (p *rerunPlan) collectResetCallerDescendants() container.Set[int64] {
out := make(container.Set[int64])
for _, tj := range p.templateJobs {
if !tj.IsReusableCaller || !p.rerunAttemptJobIDs.Contains(tj.AttemptJobID) {
continue
}
// If this caller's row ID is already in `out`, it means an outer caller has already covered its whole subtree.
// Skip the redundant walk.
if out.Contains(tj.ID) {
continue
}
for _, child := range actions_model.CollectAllDescendantJobs(tj, p.templateJobs) {
out.Add(child.ID)
}
}
return out
}
func cloneRunJobForAttempt(templateJob *actions_model.ActionRunJob, attempt *actions_model.ActionRunAttempt) *actions_model.ActionRunJob {
return &actions_model.ActionRunJob{
RunID: templateJob.RunID,
RunAttemptID: attempt.ID,
RepoID: templateJob.RepoID,
OwnerID: templateJob.OwnerID,
CommitSHA: templateJob.CommitSHA,
IsForkPullRequest: templateJob.IsForkPullRequest,
Name: templateJob.Name,
Attempt: attempt.Attempt,
WorkflowPayload: slices.Clone(templateJob.WorkflowPayload),
JobID: templateJob.JobID,
AttemptJobID: templateJob.AttemptJobID,
Needs: slices.Clone(templateJob.Needs),
RunsOn: slices.Clone(templateJob.RunsOn),
ContinueOnError: templateJob.ContinueOnError,
Status: templateJob.Status,
RawConcurrency: templateJob.RawConcurrency,
IsConcurrencyEvaluated: templateJob.IsConcurrencyEvaluated,
ConcurrencyGroup: templateJob.ConcurrencyGroup,
ConcurrencyCancel: templateJob.ConcurrencyCancel,
TokenPermissions: templateJob.TokenPermissions,
// reusable workflow fields
IsReusableCaller: templateJob.IsReusableCaller,
CallUses: templateJob.CallUses,
ReusableWorkflowContent: slices.Clone(templateJob.ReusableWorkflowContent),
CallSecrets: templateJob.CallSecrets,
CallPayload: templateJob.CallPayload,
IsExpanded: templateJob.IsExpanded,
ParentJobID: templateJob.ParentJobID, // remapped by execRerunPlan
WorkflowSourceRepoID: templateJob.WorkflowSourceRepoID,
WorkflowSourceCommitSHA: templateJob.WorkflowSourceCommitSHA,
}
}
// createOriginalAttemptForLegacyRun creates a real attempt=1 for a legacy run and updates the existing legacy jobs and artifacts in place
// so the original execution becomes attempt-aware before the rerun plan is built and all subsequent logic can use real attempts.
// Tasks are not modified: they reference jobs by JobID, so updating jobs implicitly carries the new attempt linkage.
func createOriginalAttemptForLegacyRun(ctx context.Context, run *actions_model.ActionRun) error {
return db.WithTx(ctx, func(ctx context.Context) error {
jobs, err := actions_model.GetRunJobsByRunAndAttemptID(ctx, run.ID, 0)
if err != nil {
return fmt.Errorf("load legacy run jobs: %w", err)
}
if len(jobs) == 0 {
return fmt.Errorf("run %d has no jobs", run.ID)
}
originalAttempt := &actions_model.ActionRunAttempt{
RepoID: run.RepoID,
RunID: run.ID,
Attempt: 1,
TriggerUserID: run.TriggerUserID,
// Legacy concurrency fields on ActionRun are intentionally NOT backfilled onto this original attempt.
// They only matter while a run is actively being scheduled, and backfilling them for completed legacy runs
// would add migration/runtime cost without changing any future concurrency behavior.
Status: run.Status,
Created: run.Created,
Started: run.Started,
Stopped: run.Stopped,
}
// Use NoAutoTime so xorm does not overwrite Created with the current time on insert.
if _, err := db.GetEngine(ctx).NoAutoTime().Insert(originalAttempt); err != nil {
if _, getErr := actions_model.GetRunAttemptByRunIDAndAttemptNum(ctx, run.ID, originalAttempt.Attempt); getErr == nil {
return util.NewAlreadyExistErrorf("workflow run attempt %d for run %d already exists", originalAttempt.Attempt, run.ID)
}
return err
}
// backfill attempt related fields for jobs
for i, job := range jobs {
job.RunAttemptID = originalAttempt.ID
job.Attempt = originalAttempt.Attempt
job.AttemptJobID = int64(i + 1)
if _, err := db.GetEngine(ctx).ID(job.ID).Cols("run_attempt_id", "attempt", "attempt_job_id").Update(job); err != nil {
return fmt.Errorf("backfill legacy run jobs: %w", err)
}
}
if err := rerunWorkflowJob(ctx, job, shouldBlockJob); err != nil {
return err
}
}
return nil
}
func rerunWorkflowJob(ctx context.Context, job *actions_model.ActionRunJob, shouldBlock bool) error {
status := job.Status
if !status.IsDone() {
return nil
}
job.TaskID = 0
job.Status = util.Iif(shouldBlock, actions_model.StatusBlocked, actions_model.StatusWaiting)
job.Started = 0
job.Stopped = 0
job.ConcurrencyGroup = ""
job.ConcurrencyCancel = false
job.IsConcurrencyEvaluated = false
if err := job.LoadRun(ctx); err != nil {
return err
}
if err := job.Run.LoadAttributes(ctx); err != nil {
return err
}
vars, err := actions_model.GetVariablesOfRun(ctx, job.Run)
if err != nil {
return fmt.Errorf("get run %d variables: %w", job.Run.ID, err)
}
if job.RawConcurrency != "" && !shouldBlock {
if err := EvaluateJobConcurrencyFillModel(ctx, job.Run, job, vars, nil); err != nil {
return fmt.Errorf("evaluate job concurrency: %w", err)
}
job.Status, err = PrepareToStartJobWithConcurrency(ctx, job)
if err != nil {
return err
// backfill "run_attempt_id" field for artifacts
if _, err := db.GetEngine(ctx).
Where("run_id=? AND run_attempt_id=0", run.ID).
Cols("run_attempt_id").
Update(&actions_model.ActionArtifact{RunAttemptID: originalAttempt.ID}); err != nil {
return fmt.Errorf("backfill legacy artifacts: %w", err)
}
}
if err := db.WithTx(ctx, func(ctx context.Context) error {
updateCols := []string{"task_id", "status", "started", "stopped", "concurrency_group", "concurrency_cancel", "is_concurrency_evaluated"}
_, err := actions_model.UpdateRunJob(ctx, job, builder.Eq{"status": status}, updateCols...)
return err
}); err != nil {
return err
}
CreateCommitStatusForRunJobs(ctx, job.Run, job)
notify_service.WorkflowJobStatusUpdate(ctx, job.Run.Repo, job.Run.TriggerUser, job, nil)
return nil
// update "latest_attempt_id" for the run
run.LatestAttemptID = originalAttempt.ID
return actions_model.UpdateRun(ctx, run, "latest_attempt_id")
})
}
@@ -4,54 +4,18 @@
package actions
import (
"context"
"testing"
actions_model "code.gitea.io/gitea/models/actions"
"code.gitea.io/gitea/modules/util"
actions_model "gitea.dev/models/actions"
user_model "gitea.dev/models/user"
"gitea.dev/modules/container"
"gitea.dev/modules/util"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestGetAllRerunJobs(t *testing.T) {
job1 := &actions_model.ActionRunJob{JobID: "job1"}
job2 := &actions_model.ActionRunJob{JobID: "job2", Needs: []string{"job1"}}
job3 := &actions_model.ActionRunJob{JobID: "job3", Needs: []string{"job2"}}
job4 := &actions_model.ActionRunJob{JobID: "job4", Needs: []string{"job2", "job3"}}
jobs := []*actions_model.ActionRunJob{job1, job2, job3, job4}
testCases := []struct {
job *actions_model.ActionRunJob
rerunJobs []*actions_model.ActionRunJob
}{
{
job1,
[]*actions_model.ActionRunJob{job1, job2, job3, job4},
},
{
job2,
[]*actions_model.ActionRunJob{job2, job3, job4},
},
{
job3,
[]*actions_model.ActionRunJob{job3, job4},
},
{
job4,
[]*actions_model.ActionRunJob{job4},
},
}
for _, tc := range testCases {
rerunJobs := GetAllRerunJobs(tc.job, jobs)
assert.ElementsMatch(t, tc.rerunJobs, rerunJobs)
}
}
func TestGetFailedRerunJobs(t *testing.T) {
// IDs must be non-zero to distinguish jobs in the dedup set.
func TestGetFailedJobsForRerun(t *testing.T) {
makeJob := func(id int64, jobID string, status actions_model.Status, needs ...string) *actions_model.ActionRunJob {
return &actions_model.ActionRunJob{ID: id, JobID: jobID, Status: status, Needs: needs}
}
@@ -61,7 +25,7 @@ func TestGetFailedRerunJobs(t *testing.T) {
makeJob(1, "job1", actions_model.StatusSuccess),
makeJob(2, "job2", actions_model.StatusSkipped, "job1"),
}
assert.Empty(t, GetFailedRerunJobs(jobs))
assert.Empty(t, GetFailedJobsForRerun(jobs))
})
t.Run("single failed job with no dependents", func(t *testing.T) {
@@ -69,56 +33,66 @@ func TestGetFailedRerunJobs(t *testing.T) {
job2 := makeJob(2, "job2", actions_model.StatusSuccess)
jobs := []*actions_model.ActionRunJob{job1, job2}
result := GetFailedRerunJobs(jobs)
result := GetFailedJobsForRerun(jobs)
assert.ElementsMatch(t, []*actions_model.ActionRunJob{job1}, result)
})
t.Run("failed job pulls in downstream dependents", func(t *testing.T) {
// job1 failed; job2 depends on job1 (skipped); job3 depends on job2 (skipped)
t.Run("failed job does not pull in downstream dependents", func(t *testing.T) {
job1 := makeJob(1, "job1", actions_model.StatusFailure)
job2 := makeJob(2, "job2", actions_model.StatusSkipped, "job1")
job3 := makeJob(3, "job3", actions_model.StatusSkipped, "job2")
job4 := makeJob(4, "job4", actions_model.StatusSuccess) // unrelated, must not appear
jobs := []*actions_model.ActionRunJob{job1, job2, job3, job4}
result := GetFailedRerunJobs(jobs)
assert.ElementsMatch(t, []*actions_model.ActionRunJob{job1, job2, job3}, result)
result := GetFailedJobsForRerun(jobs)
assert.ElementsMatch(t, []*actions_model.ActionRunJob{job1}, result)
})
t.Run("multiple independent failed jobs each pull in their own dependents", func(t *testing.T) {
// job1 failed -> job3 depends on job1
// job2 failed -> job4 depends on job2
t.Run("multiple failed jobs are returned directly", func(t *testing.T) {
job1 := makeJob(1, "job1", actions_model.StatusFailure)
job2 := makeJob(2, "job2", actions_model.StatusFailure)
job3 := makeJob(3, "job3", actions_model.StatusSkipped, "job1")
job4 := makeJob(4, "job4", actions_model.StatusSkipped, "job2")
jobs := []*actions_model.ActionRunJob{job1, job2, job3, job4}
result := GetFailedRerunJobs(jobs)
assert.ElementsMatch(t, []*actions_model.ActionRunJob{job1, job2, job3, job4}, result)
result := GetFailedJobsForRerun(jobs)
assert.ElementsMatch(t, []*actions_model.ActionRunJob{job1, job2}, result)
})
t.Run("shared downstream dependent is not duplicated", func(t *testing.T) {
// job1 and job2 both failed; job3 depends on both
t.Run("shared downstream dependent is not included", func(t *testing.T) {
job1 := makeJob(1, "job1", actions_model.StatusFailure)
job2 := makeJob(2, "job2", actions_model.StatusFailure)
job3 := makeJob(3, "job3", actions_model.StatusSkipped, "job1", "job2")
jobs := []*actions_model.ActionRunJob{job1, job2, job3}
result := GetFailedRerunJobs(jobs)
assert.ElementsMatch(t, []*actions_model.ActionRunJob{job1, job2, job3}, result)
assert.Len(t, result, 3) // job3 must appear exactly once
result := GetFailedJobsForRerun(jobs)
assert.ElementsMatch(t, []*actions_model.ActionRunJob{job1, job2}, result)
assert.Len(t, result, 2)
})
t.Run("successful downstream job of a failed job is still included", func(t *testing.T) {
// job1 failed; job2 succeeded but depends on job1 — downstream is always rerun
// regardless of its own status (GetAllRerunJobs includes all transitive dependents)
t.Run("successful downstream job of a failed job is not included", func(t *testing.T) {
job1 := makeJob(1, "job1", actions_model.StatusFailure)
job2 := makeJob(2, "job2", actions_model.StatusSuccess, "job1")
jobs := []*actions_model.ActionRunJob{job1, job2}
result := GetFailedRerunJobs(jobs)
assert.ElementsMatch(t, []*actions_model.ActionRunJob{job1, job2}, result)
result := GetFailedJobsForRerun(jobs)
assert.ElementsMatch(t, []*actions_model.ActionRunJob{job1}, result)
})
}
func TestCloneRunJobForAttempt(t *testing.T) {
attempt := &actions_model.ActionRunAttempt{ID: 42, Attempt: 2}
t.Run("preserves continue-on-error", func(t *testing.T) {
template := &actions_model.ActionRunJob{ContinueOnError: true, Status: actions_model.StatusFailure}
clone := cloneRunJobForAttempt(template, attempt)
assert.True(t, clone.ContinueOnError)
})
t.Run("defaults to false when template has it unset", func(t *testing.T) {
template := &actions_model.ActionRunJob{ContinueOnError: false}
clone := cloneRunJobForAttempt(template, attempt)
assert.False(t, clone.ContinueOnError)
})
}
@@ -129,7 +103,7 @@ func TestRerunValidation(t *testing.T) {
jobs := []*actions_model.ActionRunJob{
{ID: 1, JobID: "job1"},
}
err := RerunWorkflowRunJobs(context.Background(), nil, runningRun, jobs)
_, err := RerunWorkflowRunJobs(t.Context(), nil, runningRun, &user_model.User{ID: 1}, jobs)
require.Error(t, err)
assert.ErrorIs(t, err, util.ErrInvalidArgument)
})
@@ -138,8 +112,247 @@ func TestRerunValidation(t *testing.T) {
jobs := []*actions_model.ActionRunJob{
{ID: 1, JobID: "job1", Status: actions_model.StatusFailure},
}
err := RerunWorkflowRunJobs(context.Background(), nil, runningRun, GetFailedRerunJobs(jobs))
_, err := RerunWorkflowRunJobs(t.Context(), nil, runningRun, &user_model.User{ID: 1}, GetFailedJobsForRerun(jobs))
require.Error(t, err)
assert.ErrorIs(t, err, util.ErrInvalidArgument)
})
}
func TestRerunPlan(t *testing.T) {
// "verify" appears in two scopes (inner caller under deploy, and top-level) so scope-blind matching would fail here.
// build id=101, attemptJobID=1
// test id=102, attemptJobID=2, needs=[build]
// deploy id=103, attemptJobID=3, caller
// ├── validate id=104, attemptJobID=4, parent=103
// ├── push id=105, attemptJobID=5, parent=103, needs=[validate]
// ├── verify id=106, attemptJobID=6, parent=103, caller, needs=[push]
// │ ├── smoke-test id=107, attemptJobID=7, parent=106
// │ └── cleanup id=108, attemptJobID=8, parent=106, needs=[smoke-test]
// └── finish-deploy id=109, attemptJobID=9, parent=103, needs=[verify]
// verify id=110, attemptJobID=10, needs=[deploy] (top-level, same JobID)
buildJob := templateJob(101, 1, "build", 0, false)
testJob := templateJob(102, 2, "test", 0, false, "build")
deployJob := templateJob(103, 3, "deploy", 0, true)
validateJob := templateJob(104, 4, "validate", 103, false)
pushJob := templateJob(105, 5, "push", 103, false, "validate")
verifyInnerJob := templateJob(106, 6, "verify", 103, true, "push")
smokeTestJob := templateJob(107, 7, "smoke-test", 106, false)
cleanupJob := templateJob(108, 8, "cleanup", 106, false, "smoke-test")
finishDeployJob := templateJob(109, 9, "finish-deploy", 103, false, "verify")
verifyTopJob := templateJob(110, 10, "verify", 0, false, "deploy")
jobs := []*actions_model.ActionRunJob{
buildJob, testJob, deployJob, validateJob, pushJob,
verifyInnerJob, smokeTestJob, cleanupJob,
finishDeployJob, verifyTopJob,
}
t.Run("ExpandRerunJobIDs", func(t *testing.T) {
t.Run("empty jobsToRerun reruns every template job, no ancestors", func(t *testing.T) {
plan := &rerunPlan{templateJobs: jobs}
require.NoError(t, plan.expandRerunJobIDs(nil))
assert.ElementsMatch(t, attemptJobIDsOf(jobs...), plan.rerunAttemptJobIDs.Values())
assert.Empty(t, plan.ancestorAttemptJobIDs)
})
t.Run("same-scope downstream BFS pulls in dependents", func(t *testing.T) {
// a -> b -> c (chain), d unrelated.
a := templateJob(101, 1, "a", 0, false)
b := templateJob(102, 2, "b", 0, false, "a")
c := templateJob(103, 3, "c", 0, false, "b")
d := templateJob(104, 4, "d", 0, false)
plan := &rerunPlan{templateJobs: []*actions_model.ActionRunJob{a, b, c, d}}
require.NoError(t, plan.expandRerunJobIDs([]*actions_model.ActionRunJob{a}))
assert.ElementsMatch(t, attemptJobIDsOf(a, b, c), plan.rerunAttemptJobIDs.Values())
assert.Empty(t, plan.ancestorAttemptJobIDs)
})
t.Run("rerun a deep child escalates across reusable scopes", func(t *testing.T) {
plan := &rerunPlan{templateJobs: jobs}
require.NoError(t, plan.expandRerunJobIDs([]*actions_model.ActionRunJob{smokeTestJob}))
// rerun: smoke-test (selected), cleanup (same-scope downstream),
// finish-deploy (deploy-scope sibling of inner verify ancestor),
// top-level verify (top-scope sibling of deploy ancestor).
assert.ElementsMatch(t,
attemptJobIDsOf(smokeTestJob, cleanupJob, finishDeployJob, verifyTopJob),
plan.rerunAttemptJobIDs.Values())
// ancestors: inner verify and deploy
assert.ElementsMatch(t, attemptJobIDsOf(verifyInnerJob, deployJob), plan.ancestorAttemptJobIDs.Values())
})
t.Run("rerun a top-level caller resets only itself and same-scope dependents", func(t *testing.T) {
plan := &rerunPlan{templateJobs: jobs}
require.NoError(t, plan.expandRerunJobIDs([]*actions_model.ActionRunJob{deployJob}))
// rerun: deploy (selected) + top-level verify (needs:[deploy]).
assert.ElementsMatch(t, attemptJobIDsOf(deployJob, verifyTopJob), plan.rerunAttemptJobIDs.Values())
// deploy is top-level so no ancestors.
assert.Empty(t, plan.ancestorAttemptJobIDs)
})
t.Run("rerun a nested caller escalates one level", func(t *testing.T) {
plan := &rerunPlan{templateJobs: jobs}
require.NoError(t, plan.expandRerunJobIDs([]*actions_model.ActionRunJob{verifyInnerJob}))
// inner verify (selected) -> finish-deploy (deploy-scope dep) -> top-level verify (top-scope dep of deploy).
assert.ElementsMatch(t,
attemptJobIDsOf(verifyInnerJob, finishDeployJob, verifyTopJob),
plan.rerunAttemptJobIDs.Values())
// deploy is the only ancestor (one level up from inner verify).
assert.ElementsMatch(t, attemptJobIDsOf(deployJob), plan.ancestorAttemptJobIDs.Values())
})
t.Run("selecting one same-name job leaves the other-scope same-name job alone", func(t *testing.T) {
// Selecting the top-level "verify" must not pull in the same-named inner one or its descendants.
plan := &rerunPlan{templateJobs: jobs}
require.NoError(t, plan.expandRerunJobIDs([]*actions_model.ActionRunJob{verifyTopJob}))
// Only the top-level verify is rerun.
assert.ElementsMatch(t, attemptJobIDsOf(verifyTopJob), plan.rerunAttemptJobIDs.Values())
assert.Empty(t, plan.ancestorAttemptJobIDs)
})
t.Run("a caller is rerun when a sibling it needs is selected", func(t *testing.T) {
plan := &rerunPlan{templateJobs: jobs}
require.NoError(t, plan.expandRerunJobIDs([]*actions_model.ActionRunJob{pushJob}))
assert.ElementsMatch(t,
attemptJobIDsOf(pushJob, verifyInnerJob, finishDeployJob, verifyTopJob),
plan.rerunAttemptJobIDs.Values())
assert.ElementsMatch(t, attemptJobIDsOf(deployJob), plan.ancestorAttemptJobIDs.Values())
// Confirm the downstream effect: verify(inner) is a reset caller, so its children's DB row IDs are marked for skip-clone.
assert.ElementsMatch(t, rowIDsOf(smokeTestJob, cleanupJob), plan.collectResetCallerDescendants().Values())
})
t.Run("multiple selections converge", func(t *testing.T) {
plan := &rerunPlan{templateJobs: jobs}
require.NoError(t, plan.expandRerunJobIDs([]*actions_model.ActionRunJob{deployJob, smokeTestJob}))
assert.ElementsMatch(t, attemptJobIDsOf(deployJob, smokeTestJob, cleanupJob, finishDeployJob, verifyTopJob), plan.rerunAttemptJobIDs.Values())
assert.Empty(t, plan.ancestorAttemptJobIDs)
assert.ElementsMatch(t,
rowIDsOf(validateJob, pushJob, verifyInnerJob, smokeTestJob, cleanupJob, finishDeployJob),
plan.collectResetCallerDescendants().Values())
})
})
t.Run("CollectResetCallerDescendants", func(t *testing.T) {
planWith := func(rerunJobs ...*actions_model.ActionRunJob) *rerunPlan {
set := make(container.Set[int64])
for _, j := range rerunJobs {
set.Add(j.AttemptJobID)
}
return &rerunPlan{templateJobs: jobs, rerunAttemptJobIDs: set}
}
t.Run("non-caller in reset set is ignored", func(t *testing.T) {
assert.Empty(t, planWith(smokeTestJob).collectResetCallerDescendants())
})
t.Run("caller in reset set returns transitive descendants", func(t *testing.T) {
out := planWith(deployJob).collectResetCallerDescendants()
assert.ElementsMatch(t,
rowIDsOf(validateJob, pushJob, verifyInnerJob, smokeTestJob, cleanupJob, finishDeployJob),
out.Values())
})
t.Run("multiple reset callers union their descendants", func(t *testing.T) {
out := planWith(deployJob, verifyInnerJob).collectResetCallerDescendants()
assert.ElementsMatch(t,
rowIDsOf(validateJob, pushJob, verifyInnerJob, smokeTestJob, cleanupJob, finishDeployJob),
out.Values())
})
t.Run("nested-only reset returns just the nested subtree", func(t *testing.T) {
out := planWith(verifyInnerJob).collectResetCallerDescendants()
assert.ElementsMatch(t, rowIDsOf(smokeTestJob, cleanupJob), out.Values())
})
})
t.Run("HasRerunDependency", func(t *testing.T) {
t.Run("no needs returns false", func(t *testing.T) {
plan := &rerunPlan{
templateJobs: []*actions_model.ActionRunJob{buildJob},
rerunAttemptJobIDs: make(container.Set[int64]),
ancestorAttemptJobIDs: make(container.Set[int64]),
}
assert.False(t, plan.hasRerunDependency(buildJob))
})
t.Run("dependency in rerun set returns true", func(t *testing.T) {
plan := &rerunPlan{
templateJobs: jobs,
rerunAttemptJobIDs: container.SetOf(smokeTestJob.AttemptJobID),
ancestorAttemptJobIDs: make(container.Set[int64]),
}
// cleanup `needs: [smoke-test]`, both in inner verify scope.
assert.True(t, plan.hasRerunDependency(cleanupJob))
})
t.Run("dependency in ancestor set returns true", func(t *testing.T) {
plan := &rerunPlan{
templateJobs: jobs,
rerunAttemptJobIDs: container.SetOf(attemptJobIDsOf(smokeTestJob, cleanupJob)...),
ancestorAttemptJobIDs: container.SetOf(verifyInnerJob.AttemptJobID),
}
assert.True(t, plan.hasRerunDependency(finishDeployJob))
})
t.Run("dependency on unrelated sibling returns false", func(t *testing.T) {
plan := &rerunPlan{
templateJobs: jobs,
rerunAttemptJobIDs: container.SetOf(smokeTestJob.AttemptJobID),
ancestorAttemptJobIDs: make(container.Set[int64]),
}
assert.False(t, plan.hasRerunDependency(pushJob))
})
t.Run("scope-bound: same JobID in another scope does not match", func(t *testing.T) {
plan := &rerunPlan{
templateJobs: jobs,
rerunAttemptJobIDs: container.SetOf(verifyTopJob.AttemptJobID),
ancestorAttemptJobIDs: make(container.Set[int64]),
}
assert.False(t, plan.hasRerunDependency(finishDeployJob))
// Sanity: swap to the inner verify and the same target now sees it.
plan.rerunAttemptJobIDs = container.SetOf(verifyInnerJob.AttemptJobID)
assert.True(t, plan.hasRerunDependency(finishDeployJob))
})
})
}
// templateJob is a small constructor for fixture jobs used by the rerunPlan unit tests.
func templateJob(id, attemptJobID int64, jobID string, parentID int64, isCaller bool, needs ...string) *actions_model.ActionRunJob {
return &actions_model.ActionRunJob{
ID: id,
AttemptJobID: attemptJobID,
JobID: jobID,
ParentJobID: parentID,
IsReusableCaller: isCaller,
Needs: needs,
}
}
func attemptJobIDsOf(jobs ...*actions_model.ActionRunJob) []int64 {
out := make([]int64, len(jobs))
for i, j := range jobs {
out[i] = j.AttemptJobID
}
return out
}
func rowIDsOf(jobs ...*actions_model.ActionRunJob) []int64 {
out := make([]int64, len(jobs))
for i, j := range jobs {
out[i] = j.ID
}
return out
}
@@ -0,0 +1,430 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package actions
import (
"context"
"errors"
"fmt"
"strings"
actions_model "gitea.dev/models/actions"
"gitea.dev/models/db"
perm_model "gitea.dev/models/perm"
access_model "gitea.dev/models/perm/access"
repo_model "gitea.dev/models/repo"
actions_module "gitea.dev/modules/actions"
"gitea.dev/modules/actions/jobparser"
"gitea.dev/modules/container"
"gitea.dev/modules/gitrepo"
"gitea.dev/modules/httplib"
"gitea.dev/modules/json"
"gitea.dev/modules/log"
"gitea.dev/modules/setting"
api "gitea.dev/modules/structs"
"gitea.dev/modules/util"
"gitea.dev/services/convert"
"xorm.io/builder"
)
// MaxReusableCallLevels caps how deep a reusable workflow can nest:
// a top-level caller may have at most MaxReusableCallLevels nested callers below it.
const MaxReusableCallLevels = 9
// checkRunJobLimit rejects an expansion that would push the attempt over actions_model.MaxJobNumPerRun.
// checkCallerChain bounds nesting *depth*, but a reusable graph also fans out in *breadth*: without a
// cumulative cap a tiny set of files can drive exponential job-row insertion and exhaust the database.
func checkRunJobLimit(ctx context.Context, runID, attemptID int64, adding int) error {
existing, err := actions_model.CountRunJobsByRunAndAttemptID(ctx, runID, attemptID)
if err != nil {
return fmt.Errorf("count existing jobs of run %d attempt %d: %w", runID, attemptID, err)
}
if existing+int64(adding) > actions_model.MaxJobNumPerRun {
return fmt.Errorf("workflow run exceeds the maximum of %d jobs", actions_model.MaxJobNumPerRun)
}
return nil
}
// loadReusableWorkflowSource resolves the workflow file referenced by a caller's `uses:` and returns its raw bytes,
// along with the (repo_id, commit_sha) the file was loaded from.
func loadReusableWorkflowSource(ctx context.Context, run *actions_model.ActionRun, caller *actions_model.ActionRunJob, ref *jobparser.UsesRef) (content []byte, sourceRepoID int64, sourceCommitSHA string, err error) {
if err := run.LoadAttributes(ctx); err != nil {
return nil, 0, "", err
}
switch ref.Kind {
case jobparser.UsesKindLocalSameRepo:
// `./` is resolved against the workflow file containing the `uses:` - i.e. the caller's own source repo + commit.
callerRepo, err := repo_model.GetRepositoryByID(ctx, caller.WorkflowSourceRepoID)
if err != nil {
return nil, 0, "", fmt.Errorf("look up caller source repo %d: %w", caller.WorkflowSourceRepoID, err)
}
sourceCommitSHA := resolveSameRepoWorkflowSourceCommit(run, caller)
if sourceCommitSHA != caller.WorkflowSourceCommitSHA {
log.Warn("run %d (pull_request_target) records workflow source commit %s, resolving %q at base commit %s instead", run.ID, caller.WorkflowSourceCommitSHA, ref.Path, sourceCommitSHA)
}
bytes, resolvedSHA, err := readWorkflowFromRepo(ctx, callerRepo, sourceCommitSHA, ref.Path)
if err != nil {
return nil, 0, "", err
}
return bytes, callerRepo.ID, resolvedSHA, nil
case jobparser.UsesKindLocalCrossRepo:
repo, err := repo_model.GetRepositoryByOwnerAndName(ctx, ref.Owner, ref.Repo)
if err != nil {
return nil, 0, "", fmt.Errorf("look up cross-repo workflow source %q: %w", ref.Owner+"/"+ref.Repo, err)
}
ok, err := access_model.CanReadWorkflowCrossRepo(ctx, repo, run)
if err != nil {
return nil, 0, "", err
}
if !ok {
if run.IsScopedRun {
// A scoped workflow's cross-repo "uses:" is resolved with the consuming repo's read permission,
// so the referenced repo must be readable by every consumer. Make that explicit in the failure.
return nil, 0, "", fmt.Errorf("no permission to read reusable workflow %s/%s: a scoped workflow's cross-repo \"uses:\" is resolved with the consuming repository %q read permission", ref.Owner, ref.Repo, run.Repo.RelativePath())
}
return nil, 0, "", fmt.Errorf("no permission to read reusable workflow from %s/%s", ref.Owner, ref.Repo)
}
bytes, resolvedSHA, err := readWorkflowFromRepo(ctx, repo, ref.Ref, ref.Path)
if err != nil {
return nil, 0, "", err
}
return bytes, repo.ID, resolvedSHA, nil
}
return nil, 0, "", fmt.Errorf("unsupported uses kind %d", ref.Kind)
}
// resolveSameRepoWorkflowSourceCommit returns the commit to read a same-repo reusable workflow from.
// pull_request_target runs must resolve local `uses:` at the PR base commit, not a stored head SHA.
func resolveSameRepoWorkflowSourceCommit(run *actions_model.ActionRun, caller *actions_model.ActionRunJob) string {
// only a SHA copied from the run row can be the polluted head one; a SHA resolved from a `uses:` ref is right by construction
if run.IsScopedRun || caller.WorkflowSourceRepoID != run.RepoID || caller.WorkflowSourceCommitSHA != run.WorkflowCommitSHA {
return caller.WorkflowSourceCommitSHA
}
if baseSHA, ok := pullRequestTargetBaseSHA(run); ok && baseSHA != caller.WorkflowSourceCommitSHA {
return baseSHA
}
return caller.WorkflowSourceCommitSHA
}
// readWorkflowFromRepo loads a workflow file from `repo` at `refOrSHA` and returns its content plus the resolved commit SHA.
func readWorkflowFromRepo(ctx context.Context, repo *repo_model.Repository, refOrSHA, path string) ([]byte, string, error) {
gitRepo, err := gitrepo.OpenRepository(ctx, repo)
if err != nil {
return nil, "", fmt.Errorf("open repo %s: %w", repo.FullName(), err)
}
defer gitRepo.Close()
commit, err := gitRepo.GetCommit(refOrSHA)
if err != nil {
return nil, "", fmt.Errorf("get commit %q in %s: %w", refOrSHA, repo.FullName(), err)
}
str, err := commit.GetFileContent(path, 1024*1024)
if err != nil {
return nil, "", fmt.Errorf("read %s@%s:%s: %w", repo.FullName(), refOrSHA, path, err)
}
return []byte(str), commit.ID.String(), nil
}
// checkCallerChain walks `caller`'s ancestor chain (via ParentJobID) and:
// - rejects cycles (caller.CallUses appearing in any ancestor's CallUses)
// - enforces MaxReusableCallLevels on the number of ancestors above `caller`
//
// Cycle detection is intentionally *syntactic* (string equality on CallUses), not semantic.
// So `owner/repo/lib.yml@v1` and `owner/repo/lib.yml@refs/heads/v1` resolving to the same commit are NOT treated as the same node.
// Going semantic (Owner, Repo, Path, ResolvedSHA tuples) would require extra git reads.
func checkCallerChain(ctx context.Context, caller *actions_model.ActionRunJob) error {
if caller.ParentJobID == 0 {
return nil // top-level caller: depth 0, no ancestors to walk
}
visited := make(container.Set[string])
visited.Add(caller.CallUses)
depth := 0
current := caller
for current.ParentJobID != 0 {
next, err := actions_model.GetRunJobByRunAndID(ctx, current.RunID, current.ParentJobID)
if err != nil {
return fmt.Errorf("walk caller chain: %w", err)
}
current = next
depth++
if depth > MaxReusableCallLevels {
return fmt.Errorf("reusable workflow call exceeds the maximum nesting level of %d at %q", MaxReusableCallLevels, caller.CallUses)
}
if current.IsReusableCaller && current.CallUses != "" {
if visited.Contains(current.CallUses) {
return fmt.Errorf("reusable workflow call cycle detected: %q", current.CallUses)
}
visited.Add(current.CallUses)
}
}
return nil
}
// expandReusableWorkflowCaller loads and parses the target reusable workflow and inserts the caller's direct child jobs.
// It expands only ONE level: a child that is itself a reusable caller is inserted Blocked and expanded later by a subsequent resolver pass.
// It does NOT schedule a follow-up resolver pass; the caller of this function is responsible for emitting.
//
// All call sites (PrepareRunAndInsert, execRerunPlan, checkJobsOfCurrentRunAttempt, ApproveRuns) invoke this inside their enclosing write transaction,
// because the caller row update and the child-row inserts must commit atomically.
// Be aware this is not cheap inside a tx: it does a git read, YAML parsing, and `${{ }}` expression evaluation.
// None of the call sites is hot: each caller is expanded once per attempt.
func expandReusableWorkflowCaller(ctx context.Context, run *actions_model.ActionRun, attempt *actions_model.ActionRunAttempt, caller *actions_model.ActionRunJob, vars map[string]string) error {
// Already expanded by an earlier call, skip
if caller.IsExpanded {
return nil
}
// 1. Cycle + depth check via the ParentJobID chain.
if err := checkCallerChain(ctx, caller); err != nil {
return err
}
// 2. Parse the caller's own job (Uses, With, RawSecrets) from its WorkflowPayload.
parsedJob, err := caller.ParseJob()
if err != nil {
return fmt.Errorf("parse caller job %d: %w", caller.ID, err)
}
// 3. Resolve `uses` and load called-workflow source.
ref, err := ResolveUses(ctx, parsedJob.Uses)
if err != nil {
return fmt.Errorf("resolve uses %q: %w", parsedJob.Uses, err)
}
content, contentSourceRepoID, contentSourceCommitSHA, err := loadReusableWorkflowSource(ctx, run, caller, ref)
if err != nil {
return err
}
// 4. Parse the called workflow's spec (used by both secret validation and input evaluation).
wcSpec, err := jobparser.ParseWorkflowCallSpec(content)
if err != nil {
return fmt.Errorf("parse called workflow spec: %w", err)
}
// 5. Resolve caller's `secrets:` and validate it against the callee's schema.
inherit, secretsMap, err := jobparser.ParseCallerSecrets(parsedJob.RawSecrets)
if err != nil {
return fmt.Errorf("caller secrets %q: %w", caller.JobID, err)
}
// Under `secrets: inherit` the caller forwards all of its own secrets verbatim and does NOT name them individually,
// so required-secret presence cannot be verified at expansion time and a missing required secret will surface at job runtime.
// This matches GitHub Actions' behavior.
if !inherit {
if err := jobparser.ValidateCallerSecrets(wcSpec, secretsMap); err != nil {
return fmt.Errorf("caller %q secrets: %w", caller.JobID, err)
}
}
switch {
case inherit:
caller.CallSecrets = jobparser.SecretsInherit
case len(secretsMap) > 0:
mapBytes, err := json.Marshal(secretsMap)
if err != nil {
return fmt.Errorf("marshal caller secret map: %w", err)
}
caller.CallSecrets = string(mapBytes)
}
caller.ReusableWorkflowContent = content
// 6. Evaluate caller's `with:`, then match against the callee schema.
workflowCallInputs := map[string]any{}
if len(wcSpec.Inputs) > 0 {
jobResults, err := findJobNeedsAndFillJobResults(ctx, caller)
if err != nil {
return fmt.Errorf("find caller needs: %w", err)
}
parentInputs, err := getInputsForJob(ctx, run, caller)
if err != nil {
return err
}
callerGitCtx := GenerateGiteaContext(ctx, run, attempt, caller)
evaluated, err := jobparser.EvaluateCallerWith(
caller.JobID, parsedJob,
callerGitCtx, jobResults, vars, parentInputs,
)
if err != nil {
return fmt.Errorf("evaluate caller with: %w", err)
}
workflowCallInputs, err = jobparser.MatchCallerInputsAgainstSpec(wcSpec, evaluated)
if err != nil {
return fmt.Errorf("caller %q inputs: %w", caller.JobID, err)
}
}
// 7. Build CallPayload (persisted in step 9).
callPayload, err := (&api.WorkflowCallPayload{
Workflow: run.WorkflowID,
Ref: run.Ref,
Repository: convert.ToRepo(ctx, run.Repo, access_model.Permission{AccessMode: perm_model.AccessModeNone}),
Sender: convert.ToUserWithAccessMode(ctx, run.TriggerUser, perm_model.AccessModeNone),
Inputs: workflowCallInputs,
}).JSONPayload()
if err != nil {
return fmt.Errorf("build call payload: %w", err)
}
// 8. Claim the expansion by flipping is_expanded false->true BEFORE inserting any children.
// Two concurrent expanders serialize on this row: exactly one winner matches (n==1) and owns the expansion.
// Children are only ever inserted by the claim winner, so no duplicate child rows can arise.
caller.IsExpanded = true
n, err := actions_model.UpdateRunJob(ctx, caller, builder.And(
builder.Eq{"is_expanded": false},
builder.In("status", actions_model.StatusBlocked, actions_model.StatusWaiting),
), "is_expanded")
if err != nil {
caller.IsExpanded = false // the claim was not established
return fmt.Errorf("claim caller %d expansion: %w", caller.ID, err)
}
if n == 0 {
// Another writer won the expansion, or the caller has been moved to a terminal status (e.g. failed/cancelled).
return nil
}
// 9. We own the expansion: insert the direct children.
if err := insertCallerChildren(ctx, run, attempt, caller, content, contentSourceRepoID, contentSourceCommitSHA, vars, workflowCallInputs); err != nil {
// On failure, undo the partial expansion so an error return always leaves the caller unexpanded and childless.
return errors.Join(err, undoExpansion(ctx, caller))
}
// 10. Persist the remaining caller metadata (the row is already ours via the claim above).
caller.CallPayload = string(callPayload)
if _, err := actions_model.UpdateRunJob(ctx, caller, nil, "call_secrets", "reusable_workflow_content", "call_payload"); err != nil {
return errors.Join(fmt.Errorf("persist caller %d expansion metadata: %w", caller.ID, err), undoExpansion(ctx, caller))
}
return nil
}
// insertCallerChildren parses the called workflow with the caller's resolved inputs and inserts each parsed job.
func insertCallerChildren(ctx context.Context, run *actions_model.ActionRun, attempt *actions_model.ActionRunAttempt, caller *actions_model.ActionRunJob, content []byte, sourceRepoID int64, sourceCommitSHA string, vars map[string]string, inputs map[string]any) error {
// Parse the called workflow with the caller's `inputs`
gitCtx := GenerateGiteaContext(ctx, run, attempt, nil)
if event, ok := gitCtx["event"].(map[string]any); ok {
event["inputs"] = inputs
}
gitCtx["event_name"] = "workflow_call"
childWorkflows, err := jobparser.Parse(content,
jobparser.WithVars(vars),
jobparser.WithGitContext(gitCtx.ToGitHubContext()),
jobparser.WithInputs(inputs),
)
if err != nil {
return fmt.Errorf("parse called workflow for caller %d: %w", caller.ID, err)
}
if len(childWorkflows) == 0 {
return fmt.Errorf("called workflow for caller %d (uses %q) has no jobs", caller.ID, caller.CallUses)
}
if err := checkRunJobLimit(ctx, run.ID, attempt.ID, len(childWorkflows)); err != nil {
return err
}
priorChildren, err := actions_model.GetPriorAttemptChildrenByParent(ctx, run.ID, attempt.ID, caller.AttemptJobID)
if err != nil {
return fmt.Errorf("lookup prior-attempt children of caller %d: %w", caller.ID, err)
}
for _, sw := range childWorkflows {
jobID, parsedChild := sw.Job()
if parsedChild == nil {
continue
}
needs := parsedChild.Needs()
if err := sw.SetJob(jobID, parsedChild.EraseNeeds()); err != nil {
return err
}
payload, err := sw.Marshal()
if err != nil {
return fmt.Errorf("marshal child %q under caller %d: %w", jobID, caller.ID, err)
}
parsedChild.Name = util.EllipsisDisplayString(parsedChild.Name, 255)
// AttemptJobID: prefer a prior-attempt match by (JobID, Name) and fall back to a fresh allocator value for newly-appearing logical jobs.
// The two-level key disambiguates matrix instances (same JobID, different Names) and distinct jobs that legally share the same Name (different JobIDs).
var attemptJobID int64
if priorChild, ok := priorChildren[jobID][parsedChild.Name]; ok {
attemptJobID = priorChild.AttemptJobID
} else {
attemptJobID, err = actions_model.GetNextAttemptJobID(ctx, run.ID)
if err != nil {
return fmt.Errorf("alloc attempt_job_id for child %q: %w", jobID, err)
}
}
child := &actions_model.ActionRunJob{
RunID: run.ID,
RunAttemptID: attempt.ID,
RepoID: run.RepoID,
OwnerID: run.OwnerID,
CommitSHA: run.CommitSHA,
IsForkPullRequest: run.IsForkPullRequest,
Name: parsedChild.Name,
Attempt: attempt.Attempt,
WorkflowPayload: payload,
JobID: jobID,
AttemptJobID: attemptJobID,
Needs: needs,
RunsOn: parsedChild.RunsOn(),
ContinueOnError: parsedChild.GetContinueOnError(),
Status: actions_model.StatusBlocked,
ParentJobID: caller.ID,
WorkflowSourceRepoID: sourceRepoID,
WorkflowSourceCommitSHA: sourceCommitSHA,
}
if perms := ExtractJobPermissionsFromWorkflow(sw, parsedChild); perms != nil {
child.TokenPermissions = perms
}
if parsedChild.Uses != "" {
child.IsReusableCaller = true
child.CallUses = parsedChild.Uses
}
if err := db.Insert(ctx, child); err != nil {
return fmt.Errorf("insert child %q under caller %d: %w", jobID, caller.ID, err)
}
}
return nil
}
// ResolveUses normalizes and parses a reusable workflow `uses:` value.
// It first rewrites an absolute URL pointing to this instance into the cross-repo form (rejecting external URLs),
// then validates the syntax via jobparser.ParseUses.
func ResolveUses(ctx context.Context, uses string) (*jobparser.UsesRef, error) {
// Rewrite a local-instance URL to the equivalent cross-repo form "owner/repo/.gitea/workflows/file.yml@ref".
if strings.HasPrefix(uses, "http://") || strings.HasPrefix(uses, "https://") {
// ParseGiteaSiteURL returns nil for URLs that do not belong to this instance.
gsu := httplib.ParseGiteaSiteURL(ctx, uses)
if gsu == nil {
return nil, fmt.Errorf("unsupported reusable workflow URL %q: an absolute URL must point to this Gitea instance (%s)", uses, setting.AppURL)
}
// RoutePath is the instance-relative path (AppSubURL already stripped), e.g. "/owner/repo/.gitea/workflows/file.yml@ref".
uses = strings.TrimPrefix(gsu.RoutePath, "/")
}
ref, err := jobparser.ParseUses(uses)
if err != nil {
return nil, err
}
// jobparser only validates syntax; enforce the (instance-configurable) directory allowlist here.
if !actions_module.IsWorkflowOrScopedWorkflow(ref.Path) {
return nil, fmt.Errorf(`"uses:" path %q must be under a configured workflow directory (WORKFLOW_DIRS or SCOPED_WORKFLOW_DIRS)`, ref.Path)
}
return ref, nil
}
// undoExpansion rolls back a partial expansion owned by the current transaction:
// it removes the inserted children and releases the is_expanded claim itself.
func undoExpansion(ctx context.Context, caller *actions_model.ActionRunJob) error {
if err := actions_model.DeleteDirectChildJobsByParent(ctx, caller); err != nil {
return fmt.Errorf("delete children of caller %d: %w", caller.ID, err)
}
caller.IsExpanded = false
if _, err := actions_model.UpdateRunJob(ctx, caller, nil, "is_expanded"); err != nil {
return fmt.Errorf("release caller %d expansion claim: %w", caller.ID, err)
}
return nil
}
@@ -0,0 +1,355 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package actions
import (
"fmt"
"testing"
actions_model "gitea.dev/models/actions"
"gitea.dev/models/db"
"gitea.dev/models/unittest"
actions_module "gitea.dev/modules/actions"
"gitea.dev/modules/actions/jobparser"
"gitea.dev/modules/json"
"gitea.dev/modules/setting"
api "gitea.dev/modules/structs"
"gitea.dev/modules/test"
webhook_module "gitea.dev/modules/webhook"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestCheckCallerChain_Cycle(t *testing.T) {
t.Run("DirectCycle", func(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
// A -> A: leaf's CallUses matches its direct parent's.
chain := buildCallerChain(t,
"./.gitea/workflows/a.yml",
"./.gitea/workflows/a.yml",
)
err := checkCallerChain(t.Context(), chain[len(chain)-1])
assert.ErrorContains(t, err, "cycle detected")
})
t.Run("IndirectCycle", func(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
// A -> B -> A: leaf's CallUses matches its grandparent's.
chain := buildCallerChain(t,
"./.gitea/workflows/a.yml",
"./.gitea/workflows/b.yml",
"./.gitea/workflows/a.yml",
)
err := checkCallerChain(t.Context(), chain[len(chain)-1])
assert.ErrorContains(t, err, "cycle detected")
})
t.Run("NoCycle", func(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
// Sanity: linear chain with distinct CallUses must not trip cycle detection.
chain := buildCallerChain(t,
"./.gitea/workflows/a.yml",
"./.gitea/workflows/b.yml",
"./.gitea/workflows/c.yml",
)
require.NoError(t, checkCallerChain(t.Context(), chain[len(chain)-1]))
})
}
func TestCheckCallerChain_DepthLimit(t *testing.T) {
// top + MaxReusableCallLevels nested callers is the longest accepted; one more exceeds the limit.
makeDistinctUses := func(n int) []string {
out := make([]string, n)
for i := range out {
out[i] = fmt.Sprintf("./.gitea/workflows/level%d.yml", i)
}
return out
}
t.Run("ExactlyAtLimit", func(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
chain := buildCallerChain(t, makeDistinctUses(MaxReusableCallLevels+1)...)
require.NoError(t, checkCallerChain(t.Context(), chain[len(chain)-1]))
})
t.Run("OneOverLimit", func(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
chain := buildCallerChain(t, makeDistinctUses(MaxReusableCallLevels+2)...)
err := checkCallerChain(t.Context(), chain[len(chain)-1])
assert.ErrorContains(t, err, "exceeds the maximum nesting level")
})
}
// buildCallerChain inserts a linear chain of reusable caller jobs in a single run+attempt.
// callerUses[0] is the top-level caller (ParentJobID=0); each subsequent caller is inserted as a child of the previous one.
// Returns the inserted jobs in order (index 0 = top, last = leaf).
func buildCallerChain(t *testing.T, callerUses ...string) []*actions_model.ActionRunJob {
t.Helper()
require.NotEmpty(t, callerUses)
ctx := t.Context()
run := &actions_model.ActionRun{
Title: "caller-chain-test",
RepoID: 4,
OwnerID: 1,
Index: 9601,
WorkflowID: "test.yaml",
TriggerUserID: 1,
Ref: "refs/heads/master",
CommitSHA: "c2d72f548424103f01ee1dc02889c1e2bff816b0",
Event: "push",
TriggerEvent: "push",
EventPayload: "{}",
Status: actions_model.StatusRunning,
}
require.NoError(t, db.Insert(ctx, run))
attempt := &actions_model.ActionRunAttempt{
RepoID: run.RepoID,
RunID: run.ID,
Attempt: 1,
TriggerUserID: 1,
Status: actions_model.StatusRunning,
}
require.NoError(t, db.Insert(ctx, attempt))
jobs := make([]*actions_model.ActionRunJob, 0, len(callerUses))
parentID := int64(0)
for i, uses := range callerUses {
job := &actions_model.ActionRunJob{
RunID: run.ID,
RunAttemptID: attempt.ID,
RepoID: run.RepoID,
OwnerID: run.OwnerID,
CommitSHA: run.CommitSHA,
Name: fmt.Sprintf("caller-%d", i),
JobID: fmt.Sprintf("caller-%d", i),
Attempt: 1,
Status: actions_model.StatusBlocked,
AttemptJobID: int64(i + 1),
IsReusableCaller: true,
CallUses: uses,
ParentJobID: parentID,
}
require.NoError(t, db.Insert(ctx, job))
jobs = append(jobs, job)
parentID = job.ID
}
return jobs
}
func TestResolveUses(t *testing.T) {
defer test.MockVariableValue(&setting.AppURL, "https://gitea.example.com/sub/")()
defer test.MockVariableValue(&setting.AppSubURL, "/sub")()
defer test.MockVariableValue(&setting.Actions.WorkflowDirs, []string{".gitea/workflows", ".github/workflows"})()
defer test.MockVariableValue(&setting.Actions.ScopedWorkflowDirs, []string{".gitea/scoped_workflows"})()
ctx := t.Context()
t.Run("LocalForms", func(t *testing.T) {
// Same-repo and cross-repo forms are not URLs and are parsed as-is.
ref, err := ResolveUses(ctx, "./.gitea/workflows/build.yml")
require.NoError(t, err)
assert.Equal(t, jobparser.UsesRef{Kind: jobparser.UsesKindLocalSameRepo, Path: ".gitea/workflows/build.yml"}, *ref)
ref, err = ResolveUses(ctx, "owner/repo/.gitea/workflows/build.yml@v1")
require.NoError(t, err)
assert.Equal(t, jobparser.UsesRef{Kind: jobparser.UsesKindLocalCrossRepo, Owner: "owner", Repo: "repo", Path: ".gitea/workflows/build.yml", Ref: "v1"}, *ref)
})
t.Run("DirectoryAllowlist", func(t *testing.T) {
// SCOPED_WORKFLOW_DIRS is allowed (local and cross-repo).
ref, err := ResolveUses(ctx, "./.gitea/scoped_workflows/lib.yml")
require.NoError(t, err)
assert.Equal(t, ".gitea/scoped_workflows/lib.yml", ref.Path)
ref, err = ResolveUses(ctx, "owner/repo/.gitea/scoped_workflows/lib.yml@v1")
require.NoError(t, err)
assert.Equal(t, ".gitea/scoped_workflows/lib.yml", ref.Path)
// A directory that is neither WORKFLOW_DIRS nor SCOPED_WORKFLOW_DIRS parses but is rejected by the allowlist.
_, err = ResolveUses(ctx, "./not-workflows/build.yml")
require.Error(t, err)
_, err = ResolveUses(ctx, "owner/repo/lib/build.yml@v1")
require.Error(t, err)
})
t.Run("ConfigurableWorkflowDirs", func(t *testing.T) {
// A non-default WORKFLOW_DIRS is honored (the hardcoded ".gitea/workflows" is no longer special).
defer test.MockVariableValue(&setting.Actions.WorkflowDirs, []string{".gitea/ci"})()
ref, err := ResolveUses(ctx, "./.gitea/ci/build.yml")
require.NoError(t, err)
assert.Equal(t, ".gitea/ci/build.yml", ref.Path)
_, err = ResolveUses(ctx, "./.gitea/workflows/build.yml") // no longer a configured dir
require.Error(t, err)
})
t.Run("LocalInstanceURL", func(t *testing.T) {
// An absolute URL on this instance (incl. AppSubURL) resolves to the equivalent cross-repo ref.
ref, err := ResolveUses(ctx, "https://gitea.example.com/sub/owner/repo/.gitea/workflows/ci.yml@refs/heads/main")
require.NoError(t, err)
assert.Equal(t, jobparser.UsesRef{Kind: jobparser.UsesKindLocalCrossRepo, Owner: "owner", Repo: "repo", Path: ".gitea/workflows/ci.yml", Ref: "refs/heads/main"}, *ref)
})
t.Run("InvalidSyntax", func(t *testing.T) {
for _, in := range []string{
"owner/.gitea/workflows/foo.yml", // missing repo segment
"owner/repo/.gitea/workflows/foo.yml", // missing @ref
"https://gitea.example.com/sub/repo/.gitea/workflows/ci.yml@refs/heads/main", // local absolute URL but missing owner
"not a valid uses at all",
} {
_, err := ResolveUses(ctx, in)
require.Error(t, err, "in = %s", in)
}
})
t.Run("ForeignURL", func(t *testing.T) {
_, err := ResolveUses(ctx, "https://other.gitea-example.com/owner/repo/.gitea/workflows/ci.yaml@v1")
assert.ErrorContains(t, err, "must point to this Gitea instance")
})
}
func TestCheckRunJobLimit(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
const (
runID = 900100
attemptA = 910001
attemptB = 910002
)
seed := func(attemptID int64, n int) {
for i := range n {
name := fmt.Sprintf("job-%d-%d", attemptID, i)
require.NoError(t, db.Insert(t.Context(), &actions_model.ActionRunJob{
RunID: runID,
RunAttemptID: attemptID,
RepoID: 1,
OwnerID: 1,
CommitSHA: "abcdef",
Name: name,
JobID: name,
AttemptJobID: attemptID*1000 + int64(i),
Status: actions_model.StatusBlocked,
}))
}
}
seed(attemptA, 5)
seed(attemptB, 3) // a different attempt of the same run must not count toward attempt A
limit := actions_model.MaxJobNumPerRun
// attempt A already holds 5 jobs: filling up to the cap is allowed, one more is rejected.
require.NoError(t, checkRunJobLimit(t.Context(), runID, attemptA, limit-5))
require.ErrorContains(t, checkRunJobLimit(t.Context(), runID, attemptA, limit-4), "maximum")
require.ErrorContains(t, checkRunJobLimit(t.Context(), runID, attemptA, limit), "maximum")
// the count is scoped to the attempt: attempt B only holds 3 jobs, so attempt A's 5 must not leak in.
require.NoError(t, checkRunJobLimit(t.Context(), runID, attemptB, limit-3))
require.ErrorContains(t, checkRunJobLimit(t.Context(), runID, attemptB, limit-2), "maximum")
}
func TestUndoExpansion(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
ctx := t.Context()
// A claimed caller with two children inserted by the aborted expansion, plus a sibling that must survive.
caller := &actions_model.ActionRunJob{
RunID: 991, RepoID: 4, OwnerID: 1, JobID: "caller", Name: "caller",
Status: actions_model.StatusBlocked, IsReusableCaller: true, IsExpanded: true,
}
require.NoError(t, db.Insert(ctx, caller))
for _, jobID := range []string{"child1", "child2"} {
require.NoError(t, db.Insert(ctx, &actions_model.ActionRunJob{
RunID: 991, RepoID: 4, OwnerID: 1, JobID: jobID, Name: jobID,
Status: actions_model.StatusBlocked, ParentJobID: caller.ID,
}))
}
sibling := &actions_model.ActionRunJob{
RunID: 991, RepoID: 4, OwnerID: 1, JobID: "sibling", Name: "sibling",
Status: actions_model.StatusBlocked,
}
require.NoError(t, db.Insert(ctx, sibling))
require.NoError(t, undoExpansion(ctx, caller))
assert.Equal(t, 0, unittest.GetCount(t, &actions_model.ActionRunJob{ParentJobID: caller.ID}))
assert.False(t, caller.IsExpanded)
refreshed := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: caller.ID})
assert.False(t, refreshed.IsExpanded)
unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: sibling.ID})
}
func TestResolveSameRepoWorkflowSourceCommit(t *testing.T) {
prtRun := func(baseSHA string) *actions_model.ActionRun {
payload, err := json.Marshal(api.PullRequestPayload{
PullRequest: &api.PullRequest{
Base: &api.PRBranchInfo{Sha: baseSHA},
},
})
require.NoError(t, err)
// a run recorded before the fix points at the PR head commit
return &actions_model.ActionRun{
ID: 42,
RepoID: 1,
Event: webhook_module.HookEventPullRequest,
TriggerEvent: actions_module.GithubEventPullRequestTarget,
EventPayload: string(payload),
WorkflowCommitSHA: "head-sha",
}
}
pushRun := &actions_model.ActionRun{
RepoID: 1,
TriggerEvent: "push",
WorkflowCommitSHA: "head-sha",
}
caller := func(sourceRepoID int64, sourceCommitSHA string) *actions_model.ActionRunJob {
return &actions_model.ActionRunJob{WorkflowSourceRepoID: sourceRepoID, WorkflowSourceCommitSHA: sourceCommitSHA}
}
t.Run("pull_request_target pins to base commit", func(t *testing.T) {
got := resolveSameRepoWorkflowSourceCommit(prtRun("base-sha"), caller(1, "head-sha"))
assert.Equal(t, "base-sha", got)
})
t.Run("legacy nested caller (with head-sha) pins to base commit", func(t *testing.T) {
nested := caller(1, "head-sha")
nested.ParentJobID = 99
got := resolveSameRepoWorkflowSourceCommit(prtRun("base-sha"), nested)
assert.Equal(t, "base-sha", got)
})
t.Run("pull_request_target keeps stored SHA when already base", func(t *testing.T) {
run := prtRun("base-sha")
run.WorkflowCommitSHA = "base-sha"
got := resolveSameRepoWorkflowSourceCommit(run, caller(1, "base-sha"))
assert.Equal(t, "base-sha", got)
})
t.Run("non pull_request_target keeps stored SHA", func(t *testing.T) {
got := resolveSameRepoWorkflowSourceCommit(pushRun, caller(1, "head-sha"))
assert.Equal(t, "head-sha", got)
})
t.Run("scoped run keeps stored SHA", func(t *testing.T) {
run := prtRun("base-sha")
run.IsScopedRun = true
got := resolveSameRepoWorkflowSourceCommit(run, caller(1, "head-sha"))
assert.Equal(t, "head-sha", got)
})
t.Run("cross-repo caller keeps stored SHA", func(t *testing.T) {
got := resolveSameRepoWorkflowSourceCommit(prtRun("base-sha"), caller(2, "head-sha"))
assert.Equal(t, "head-sha", got)
})
t.Run("caller resolved from a uses: ref keeps its own SHA", func(t *testing.T) {
nested := caller(1, "tag-v1-sha")
nested.ParentJobID = 99
got := resolveSameRepoWorkflowSourceCommit(prtRun("base-sha"), nested)
assert.Equal(t, "tag-v1-sha", got)
})
}
+201 -92
View File
@@ -7,13 +7,13 @@ import (
"context"
"fmt"
actions_model "code.gitea.io/gitea/models/actions"
"code.gitea.io/gitea/models/db"
"code.gitea.io/gitea/modules/actions/jobparser"
"code.gitea.io/gitea/modules/util"
notify_service "code.gitea.io/gitea/services/notify"
actions_model "gitea.dev/models/actions"
"gitea.dev/models/db"
"gitea.dev/modules/actions/jobparser"
"gitea.dev/modules/log"
"gitea.dev/modules/util"
act_model "github.com/nektos/act/pkg/model"
act_model "gitea.com/gitea/runner/act/model"
"go.yaml.in/yaml/v4"
)
@@ -21,6 +21,10 @@ import (
// It parses the workflow content, evaluates concurrency if needed, and inserts the run and its jobs into the database.
// The title will be cut off at 255 characters if it's longer than 255 characters.
func PrepareRunAndInsert(ctx context.Context, content []byte, run *actions_model.ActionRun, inputsWithDefaults map[string]any) error {
if run.WorkflowRepoID == 0 {
return fmt.Errorf("WorkflowRepoID must be set before insert (repo %d, workflow %q)", run.RepoID, run.WorkflowID)
}
if err := run.LoadAttributes(ctx); err != nil {
return fmt.Errorf("LoadAttributes: %w", err)
}
@@ -47,13 +51,7 @@ func PrepareRunAndInsert(ctx context.Context, content []byte, run *actions_model
CreateCommitStatusForRunJobs(ctx, run, allJobs...)
notify_service.WorkflowRunStatusUpdate(ctx, run.Repo, run.TriggerUser, run)
for _, job := range allJobs {
notify_service.WorkflowJobStatusUpdate(ctx, run.Repo, run.TriggerUser, job, nil)
}
// Recomputes the repository's num_action_runs / num_closed_action_runs counters since a new run is created
actions_model.UpdateRepoRunsNumbers(ctx, run.RepoID)
NotifyWorkflowJobsAndRunsStatusUpdate(ctx, allJobs)
return nil
}
@@ -61,7 +59,9 @@ func PrepareRunAndInsert(ctx context.Context, content []byte, run *actions_model
// InsertRun inserts a run
// The title will be cut off at 255 characters if it's longer than 255 characters.
func InsertRun(ctx context.Context, run *actions_model.ActionRun, content []byte, vars map[string]string, inputs map[string]any, wfRawConcurrency *act_model.RawConcurrency) error {
return db.WithTx(ctx, func(ctx context.Context) error {
var cancelledConcurrencyJobs []*actions_model.ActionRunJob
var needPostCommitEmit bool
if err := db.WithTx(ctx, func(ctx context.Context) error {
index, err := db.GetNextResourceIndex(ctx, "action_run_index", run.RepoID)
if err != nil {
return err
@@ -70,6 +70,14 @@ func InsertRun(ctx context.Context, run *actions_model.ActionRun, content []byte
run.Title = util.EllipsisDisplayString(run.Title, 255)
run.Status = actions_model.StatusWaiting
if wfRawConcurrency != nil {
rawConcurrency, err := yaml.Marshal(wfRawConcurrency)
if err != nil {
return fmt.Errorf("marshal raw concurrency: %w", err)
}
run.RawConcurrency = string(rawConcurrency)
}
// Insert before parsing jobs or evaluating workflow-level concurrency
// so that run.ID is populated. Expressions referencing github.run_id —
// in run-name, job names, runs-on, or a workflow-level concurrency
@@ -79,102 +87,66 @@ func InsertRun(ctx context.Context, run *actions_model.ActionRun, content []byte
return err
}
giteaCtx := GenerateGiteaContext(run, nil)
runAttempt := &actions_model.ActionRunAttempt{
RepoID: run.RepoID,
RunID: run.ID,
Attempt: 1,
TriggerUserID: run.TriggerUserID,
Status: actions_model.StatusWaiting,
}
if wfRawConcurrency != nil {
if err := EvaluateRunConcurrencyFillModel(ctx, run, runAttempt, wfRawConcurrency, vars, inputs); err != nil {
return fmt.Errorf("EvaluateRunConcurrencyFillModel: %w", err)
}
// check run (workflow-level) concurrency
var jobsToCancel []*actions_model.ActionRunJob
runAttempt.Status, jobsToCancel, err = PrepareToStartRunWithConcurrency(ctx, runAttempt)
if err != nil {
return err
}
cancelledConcurrencyJobs = append(cancelledConcurrencyJobs, jobsToCancel...)
}
if err := db.Insert(ctx, runAttempt); err != nil {
return err
}
run.LatestAttemptID = runAttempt.ID
giteaCtx := GenerateGiteaContext(ctx, run, runAttempt, nil)
jobs, err := jobparser.Parse(content, jobparser.WithVars(vars), jobparser.WithGitContext(giteaCtx.ToGitHubContext()), jobparser.WithInputs(inputs))
if err != nil {
return fmt.Errorf("parse workflow: %w", err)
}
titleChanged := len(jobs) > 0 && jobs[0].RunName != ""
if titleChanged {
run.Title = util.EllipsisDisplayString(jobs[0].RunName, 255)
}
if wfRawConcurrency != nil {
if err := EvaluateRunConcurrencyFillModel(ctx, run, wfRawConcurrency, vars, inputs); err != nil {
return fmt.Errorf("EvaluateRunConcurrencyFillModel: %w", err)
}
run.Status, err = PrepareToStartRunWithConcurrency(ctx, run)
if err != nil {
return err
}
cols := []string{"latest_attempt_id"}
if titleChanged {
cols = append(cols, "title")
}
if err := actions_model.UpdateRun(ctx, run, cols...); err != nil {
return err
}
runJobs := make([]*actions_model.ActionRunJob, 0, len(jobs))
var hasWaitingJobs bool
for _, v := range jobs {
id, job := v.Job()
needs := job.Needs()
if err := v.SetJob(id, job.EraseNeeds()); err != nil {
runJob, jobsToCancel, jobNeedsPostCommitEmit, err := insertRunJob(ctx, run, runAttempt, v, vars, inputs)
if err != nil {
return err
}
payload, _ := v.Marshal()
shouldBlockJob := len(needs) > 0 || run.NeedApproval || run.Status == actions_model.StatusBlocked
job.Name = util.EllipsisDisplayString(job.Name, 255)
runJob := &actions_model.ActionRunJob{
RunID: run.ID,
RepoID: run.RepoID,
OwnerID: run.OwnerID,
CommitSHA: run.CommitSHA,
IsForkPullRequest: run.IsForkPullRequest,
Name: job.Name,
WorkflowPayload: payload,
JobID: id,
Needs: needs,
RunsOn: job.RunsOn(),
Status: util.Iif(shouldBlockJob, actions_model.StatusBlocked, actions_model.StatusWaiting),
}
// Parse workflow/job permissions (no clamping here)
if perms := ExtractJobPermissionsFromWorkflow(v, job); perms != nil {
runJob.TokenPermissions = perms
}
// check job concurrency
if job.RawConcurrency != nil {
rawConcurrency, err := yaml.Marshal(job.RawConcurrency)
if err != nil {
return fmt.Errorf("marshal raw concurrency: %w", err)
}
runJob.RawConcurrency = string(rawConcurrency)
// do not evaluate job concurrency when it requires `needs`, the jobs with `needs` will be evaluated later by job emitter
if len(needs) == 0 {
err = EvaluateJobConcurrencyFillModel(ctx, run, runJob, vars, inputs)
if err != nil {
return fmt.Errorf("evaluate job concurrency: %w", err)
}
}
// If a job needs other jobs ("needs" is not empty), its status is set to StatusBlocked at the entry of the loop
// No need to check job concurrency for a blocked job (it will be checked by job emitter later)
if runJob.Status == actions_model.StatusWaiting {
runJob.Status, err = PrepareToStartJobWithConcurrency(ctx, runJob)
if err != nil {
return fmt.Errorf("prepare to start job with concurrency: %w", err)
}
}
}
hasWaitingJobs = hasWaitingJobs || runJob.Status == actions_model.StatusWaiting
if err := db.Insert(ctx, runJob); err != nil {
return err
}
cancelledConcurrencyJobs = append(cancelledConcurrencyJobs, jobsToCancel...)
needPostCommitEmit = needPostCommitEmit || jobNeedsPostCommitEmit
// A reusable caller is never dispatched to a runner, so it must not drive the task-version bump.
hasWaitingJobs = hasWaitingJobs || (runJob.Status == actions_model.StatusWaiting && !runJob.IsReusableCaller)
runJobs = append(runJobs, runJob)
}
run.Status = actions_model.AggregateJobStatus(runJobs)
cols := []string{"status"}
if titleChanged {
cols = append(cols, "title")
}
if wfRawConcurrency != nil {
cols = append(cols, "raw_concurrency", "concurrency_group", "concurrency_cancel")
}
if err := actions_model.UpdateRun(ctx, run, cols...); err != nil {
runAttempt.Status = actions_model.AggregateJobStatus(runJobs)
if err := actions_model.UpdateRunAttempt(ctx, runAttempt, "status"); err != nil {
return err
}
@@ -186,5 +158,142 @@ func InsertRun(ctx context.Context, run *actions_model.ActionRun, content []byte
}
return nil
})
}); err != nil {
return err
}
NotifyWorkflowJobsAndRunsStatusUpdate(ctx, cancelledConcurrencyJobs)
EmitJobsIfReadyByJobs(cancelledConcurrencyJobs)
// Post-commit kick: let the job emitter resolve jobs if needed
if needPostCommitEmit {
if err := EmitJobsIfReadyByRun(run.ID); err != nil {
log.Error("emit run %d after InsertRun: %v", run.ID, err)
}
}
return nil
}
// insertRunJob builds a single run job from a parsed workflow job, evaluates its
// job-level concurrency, inserts it, and — for a ready no-needs reusable caller —
// inline-expands (or skips) it. It returns the inserted job, any jobs cancelled by
// job concurrency, and whether a post-commit emitter pass is needed to resolve the
// caller's dependents.
func insertRunJob(ctx context.Context, run *actions_model.ActionRun, runAttempt *actions_model.ActionRunAttempt, workflowJob *jobparser.SingleWorkflow, vars map[string]string, inputs map[string]any) (*actions_model.ActionRunJob, []*actions_model.ActionRunJob, bool, error) {
id, job := workflowJob.Job()
needs := job.Needs()
if err := workflowJob.SetJob(id, job.EraseNeeds()); err != nil {
return nil, nil, false, err
}
payload, _ := workflowJob.Marshal()
isReusableWorkflowCaller := job.Uses != ""
shouldBlockJob := runAttempt.Status == actions_model.StatusBlocked || len(needs) > 0 || run.NeedApproval
attemptJobID, err := actions_model.GetNextAttemptJobID(ctx, run.ID)
if err != nil {
return nil, nil, false, fmt.Errorf("alloc attempt_job_id: %w", err)
}
job.Name = util.EllipsisDisplayString(job.Name, 255)
runJob := &actions_model.ActionRunJob{
RunID: run.ID,
RunAttemptID: runAttempt.ID,
RepoID: run.RepoID,
OwnerID: run.OwnerID,
CommitSHA: run.CommitSHA,
IsForkPullRequest: run.IsForkPullRequest,
Name: job.Name,
Attempt: runAttempt.Attempt,
WorkflowPayload: payload,
JobID: id,
AttemptJobID: attemptJobID,
Needs: needs,
RunsOn: job.RunsOn(),
Status: util.Iif(shouldBlockJob, actions_model.StatusBlocked, actions_model.StatusWaiting),
WorkflowSourceRepoID: run.WorkflowRepoID,
WorkflowSourceCommitSHA: run.WorkflowCommitSHA,
ContinueOnError: job.GetContinueOnError(),
}
// Parse workflow/job permissions (no clamping here)
if perms := ExtractJobPermissionsFromWorkflow(workflowJob, job); perms != nil {
runJob.TokenPermissions = perms
}
if isReusableWorkflowCaller {
runJob.IsReusableCaller = true
runJob.CallUses = job.Uses
}
var cancelledConcurrencyJobs []*actions_model.ActionRunJob
// check job concurrency
if job.RawConcurrency != nil {
rawConcurrency, err := yaml.Marshal(job.RawConcurrency)
if err != nil {
return nil, nil, false, fmt.Errorf("marshal raw concurrency: %w", err)
}
runJob.RawConcurrency = string(rawConcurrency)
// do not evaluate job concurrency when it requires `needs`, the jobs with `needs` will be evaluated later by job emitter
if len(needs) == 0 {
if err := EvaluateJobConcurrencyFillModel(ctx, run, runAttempt, runJob, vars, inputs); err != nil {
return nil, nil, false, fmt.Errorf("evaluate job concurrency: %w", err)
}
}
// If a job needs other jobs ("needs" is not empty), its status is set to StatusBlocked at the entry of the loop
// No need to check job concurrency for a blocked job (it will be checked by job emitter later)
if runJob.Status == actions_model.StatusWaiting {
var jobsToCancel []*actions_model.ActionRunJob
runJob.Status, jobsToCancel, err = PrepareToStartJobWithConcurrency(ctx, runJob)
if err != nil {
return nil, nil, false, fmt.Errorf("prepare to start job with concurrency: %w", err)
}
cancelledConcurrencyJobs = append(cancelledConcurrencyJobs, jobsToCancel...)
}
}
if err := db.Insert(ctx, runJob); err != nil {
return nil, nil, false, err
}
// expand reusable caller
var needPostCommitEmit bool
if isReusableWorkflowCaller && runJob.Status == actions_model.StatusWaiting {
if err := processInlineReusableCaller(ctx, run, runAttempt, runJob, vars); err != nil {
return nil, nil, false, err
}
// A processed caller always needs a resolver pass:
// - if the caller is expanded, resolve its children jobs;
// - if the caller is skipped, propagate its state to its dependents
needPostCommitEmit = true
}
return runJob, cancelledConcurrencyJobs, needPostCommitEmit, nil
}
// processInlineReusableCaller evaluates a no-needs reusable caller's own `if:` and
// either inline-expands it into child jobs or marks it skipped.
// (A caller with needs is Blocked and gets its `if:` evaluated by the job emitter instead.)
func processInlineReusableCaller(ctx context.Context, run *actions_model.ActionRun, runAttempt *actions_model.ActionRunAttempt, caller *actions_model.ActionRunJob, vars map[string]string) error {
shouldStart, err := evaluateJobIf(ctx, run, runAttempt, caller, vars, true)
if err != nil {
return fmt.Errorf("evaluate caller %d if: %w", caller.ID, err)
}
if shouldStart {
if err := expandReusableWorkflowCaller(ctx, run, runAttempt, caller, vars); err != nil {
return fmt.Errorf("inline trigger caller %d ready: %w", caller.ID, err)
}
// refresh the caller status
if err := actions_model.RefreshReusableCallerStatus(ctx, caller); err != nil {
return fmt.Errorf("refresh caller %d status: %w", caller.ID, err)
}
return nil
}
caller.Status = actions_model.StatusSkipped
if _, err := actions_model.UpdateRunJob(ctx, caller, nil, "status"); err != nil {
return fmt.Errorf("skip caller %d: %w", caller.ID, err)
}
return nil
}
@@ -6,16 +6,22 @@ package actions
import (
"context"
"fmt"
"maps"
"time"
actions_model "code.gitea.io/gitea/models/actions"
"code.gitea.io/gitea/models/db"
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unit"
"code.gitea.io/gitea/modules/json"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/timeutil"
webhook_module "code.gitea.io/gitea/modules/webhook"
actions_model "gitea.dev/models/actions"
"gitea.dev/models/db"
"gitea.dev/models/organization"
perm_model "gitea.dev/models/perm"
access_model "gitea.dev/models/perm/access"
repo_model "gitea.dev/models/repo"
"gitea.dev/models/unit"
user_model "gitea.dev/models/user"
"gitea.dev/modules/json"
"gitea.dev/modules/log"
"gitea.dev/modules/timeutil"
webhook_module "gitea.dev/modules/webhook"
"gitea.dev/services/convert"
)
// StartScheduleTasks start the task
@@ -102,7 +108,19 @@ func startTasks(ctx context.Context) error {
// It creates an action run based on the schedule, inserts it into the database, and creates commit statuses for each job.
func CreateScheduleTask(ctx context.Context, spec *actions_model.ActionScheduleSpec) error {
cron := spec.Schedule
eventPayload := withScheduleInEventPayload(cron.EventPayload, spec.Spec)
// Scheduled runs carry no webhook payload; synthesize what github.event.* expects.
if err := spec.Repo.LoadOwner(ctx); err != nil {
return fmt.Errorf("LoadOwner: %w", err)
}
fields := map[string]any{
"repository": convert.ToRepo(ctx, spec.Repo, access_model.Permission{AccessMode: perm_model.AccessModeRead}),
"sender": convert.ToUser(ctx, user_model.NewActionsUser(), nil),
}
if spec.Repo.Owner.IsOrganization() {
fields["organization"] = convert.ToOrganization(ctx, organization.OrgFromUser(spec.Repo.Owner))
}
eventPayload := withScheduleInEventPayload(cron.EventPayload, spec.Spec, fields)
// Create a new action run based on the schedule
run := &actions_model.ActionRun{
@@ -118,6 +136,9 @@ func CreateScheduleTask(ctx context.Context, spec *actions_model.ActionScheduleS
TriggerEvent: string(webhook_module.HookEventSchedule),
ScheduleID: cron.ID,
Status: actions_model.StatusWaiting,
// schedule runs the repo's own workflow at the recorded commit
WorkflowRepoID: cron.RepoID,
WorkflowCommitSHA: cron.CommitSHA,
}
// FIXME cron.Content might be outdated if the workflow file has been changed.
@@ -131,7 +152,7 @@ func CreateScheduleTask(ctx context.Context, spec *actions_model.ActionScheduleS
return nil
}
func withScheduleInEventPayload(eventPayload, schedule string) string {
func withScheduleInEventPayload(eventPayload, schedule string, fields map[string]any) string {
if schedule == "" {
return eventPayload
}
@@ -150,6 +171,7 @@ func withScheduleInEventPayload(eventPayload, schedule string) string {
event = map[string]any{}
}
maps.Copy(event, fields)
event["schedule"] = schedule
updatedPayload, err := json.Marshal(event)
if err != nil {
@@ -6,7 +6,8 @@ package actions
import (
"testing"
"code.gitea.io/gitea/modules/json"
"gitea.dev/modules/json"
api "gitea.dev/modules/structs"
"github.com/stretchr/testify/assert"
)
@@ -14,7 +15,7 @@ import (
func TestWithScheduleInEventPayload(t *testing.T) {
t.Run("adds schedule to existing payload", func(t *testing.T) {
payload := `{"ref":"refs/heads/main"}`
updated := withScheduleInEventPayload(payload, "*/5 * * * *")
updated := withScheduleInEventPayload(payload, "*/5 * * * *", nil)
event := map[string]any{}
assert.NoError(t, json.Unmarshal([]byte(updated), &event))
@@ -23,7 +24,7 @@ func TestWithScheduleInEventPayload(t *testing.T) {
})
t.Run("adds schedule to null payload", func(t *testing.T) {
updated := withScheduleInEventPayload("null", "37 12 5 1 2")
updated := withScheduleInEventPayload("null", "37 12 5 1 2", nil)
event := map[string]any{}
assert.NoError(t, json.Unmarshal([]byte(updated), &event))
@@ -31,22 +32,37 @@ func TestWithScheduleInEventPayload(t *testing.T) {
})
t.Run("adds schedule to empty payload", func(t *testing.T) {
updated := withScheduleInEventPayload("", "37 12 5 1 2")
updated := withScheduleInEventPayload("", "37 12 5 1 2", nil)
event := map[string]any{}
assert.NoError(t, json.Unmarshal([]byte(updated), &event))
assert.Equal(t, "37 12 5 1 2", event["schedule"])
})
t.Run("adds schedule with repository, sender, organization", func(t *testing.T) {
updated := withScheduleInEventPayload("null", "@weekly", map[string]any{
"repository": &api.Repository{Name: "test-repo"},
"sender": &api.User{UserName: "test-user"},
"organization": &api.Organization{Name: "test-org"},
})
event := map[string]any{}
assert.NoError(t, json.Unmarshal([]byte(updated), &event))
assert.Equal(t, "@weekly", event["schedule"])
assert.Equal(t, "test-repo", event["repository"].(map[string]any)["name"])
assert.Equal(t, "test-user", event["sender"].(map[string]any)["login"])
assert.Equal(t, "test-org", event["organization"].(map[string]any)["name"])
})
t.Run("keeps payload when schedule empty", func(t *testing.T) {
payload := `{"ref":"refs/heads/main"}`
updated := withScheduleInEventPayload(payload, "")
updated := withScheduleInEventPayload(payload, "", nil)
assert.Equal(t, payload, updated)
})
t.Run("keeps payload when malformed JSON", func(t *testing.T) {
payload := `not a json object`
updated := withScheduleInEventPayload(payload, "*/5 * * * *")
updated := withScheduleInEventPayload(payload, "*/5 * * * *", nil)
assert.Equal(t, payload, updated)
})
}
@@ -0,0 +1,84 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package actions
import (
"context"
"fmt"
git_model "gitea.dev/models/git"
repo_model "gitea.dev/models/repo"
actions_module "gitea.dev/modules/actions"
"gitea.dev/modules/gitrepo"
"gitea.dev/modules/log"
lru "github.com/hashicorp/golang-lru/v2"
)
// cachedScopedWorkflows is one source repo's parsed scoped workflows together with the default-branch SHA they were parsed at.
type cachedScopedWorkflows struct {
sha string
parsed []*actions_module.ParsedScopedWorkflow
}
// scopedWorkflowCache caches each scoped-workflow source repo's parsed workflows, keyed by source repo ID.
// There is exactly one entry per source: a default-branch update is detected by SHA mismatch and overwrites the entry, so stale parses never accumulate.
var scopedWorkflowCache *lru.Cache[int64, *cachedScopedWorkflows]
const defaultScopedWorkflowCacheSize = 1024
func init() {
c, err := lru.New[int64, *cachedScopedWorkflows](defaultScopedWorkflowCacheSize)
if err != nil {
log.Fatal("failed to new scopedWorkflowCache, err: %v", err)
}
scopedWorkflowCache = c
}
// LoadParsedScopedWorkflows returns the source repo's parsed scoped workflows at its current default-branch HEAD.
func LoadParsedScopedWorkflows(ctx context.Context, sourceRepo *repo_model.Repository) (sha string, parsed []*actions_module.ParsedScopedWorkflow, err error) {
branch, err := git_model.GetBranch(ctx, sourceRepo.ID, sourceRepo.DefaultBranch)
if err != nil {
return "", nil, fmt.Errorf("get source default branch: %w", err)
}
sha = branch.CommitID
if v, ok := scopedWorkflowCache.Get(sourceRepo.ID); ok && v.sha == sha {
// cache hit at the current default-branch HEAD
return sha, v.parsed, nil
}
// cache miss: open the source repo at the exact SHA we keyed on
sourceGitRepo, err := gitrepo.OpenRepository(ctx, sourceRepo)
if err != nil {
return "", nil, fmt.Errorf("open source repo: %w", err)
}
defer sourceGitRepo.Close()
sourceCommit, err := sourceGitRepo.GetCommit(sha)
if err != nil {
return "", nil, fmt.Errorf("get source commit %s: %w", sha, err)
}
parsed, err = actions_module.ParseScopedWorkflows(sourceCommit)
if err != nil {
return "", nil, err
}
// overwrite this source's single entry (a stale entry from a previous HEAD is replaced, not accumulated)
scopedWorkflowCache.Add(sourceRepo.ID, &cachedScopedWorkflows{sha: sha, parsed: parsed})
return sha, parsed, nil
}
// ScopedWorkflowContent returns one scoped workflow's raw content (by entry name) at the source repo's current default-branch HEAD, or nil if no such workflow exists there.
func ScopedWorkflowContent(ctx context.Context, sourceRepo *repo_model.Repository, entryName string) ([]byte, error) {
_, parsed, err := LoadParsedScopedWorkflows(ctx, sourceRepo)
if err != nil {
return nil, err
}
for _, p := range parsed {
if p.EntryName == entryName {
return p.Content, nil
}
}
return nil, nil
}
+113 -59
View File
@@ -7,16 +7,60 @@ import (
"context"
"errors"
"fmt"
"sync"
"time"
actions_model "code.gitea.io/gitea/models/actions"
"code.gitea.io/gitea/models/db"
secret_model "code.gitea.io/gitea/models/secret"
notify_service "code.gitea.io/gitea/services/notify"
runnerv1 "gitea.dev/actions-proto-go/runner/v1"
actions_model "gitea.dev/models/actions"
"gitea.dev/models/db"
secret_model "gitea.dev/models/secret"
"gitea.dev/modules/graceful"
"gitea.dev/modules/log"
"gitea.dev/modules/setting"
runnerv1 "code.gitea.io/actions-proto-go/runner/v1"
"google.golang.org/protobuf/types/known/structpb"
)
var (
taskPickSem chan struct{}
taskPickSemOnce sync.Once
)
func taskPickLimiter() chan struct{} {
taskPickSemOnce.Do(func() {
taskPickSem = make(chan struct{}, setting.Actions.MaxConcurrentTaskPicks)
})
return taskPickSem
}
// TryPickTask attempts to assign a task to the runner, bounding the number of
// concurrent assignment transactions to avoid a thundering herd when many
// runners poll at once. When the concurrency limit is reached it returns
// throttled=true without touching the DB, so the caller can let the runner
// retry on its next poll instead of advancing its tasks version.
func TryPickTask(ctx context.Context, runner *actions_model.ActionRunner) (task *runnerv1.Task, ok, throttled bool, err error) {
sem := taskPickLimiter()
select {
case sem <- struct{}{}:
defer func() { <-sem }()
default:
return nil, false, true, nil
}
task, ok, err = PickTask(ctx, runner)
return task, ok, false, err
}
// releaseTaskForRunnerCleanup releases a claimed task using a fresh, bounded context. The request context
// is typically already canceled when we reach the release paths below, and a DB transaction on a canceled
// context fails immediately, which would strand the claimed job in running state.
func releaseTaskForRunnerCleanup(t *actions_model.ActionTask) {
ctx, cancel := context.WithTimeout(graceful.GetManager().ShutdownContext(), 10*time.Second)
defer cancel()
if relErr := actions_model.ReleaseTaskForRunner(ctx, t); relErr != nil {
log.Error("ReleaseTaskForRunner [task_id: %d]: %v", t.ID, relErr)
}
}
func PickTask(ctx context.Context, runner *actions_model.ActionRunner) (*runnerv1.Task, bool, error) {
var (
task *runnerv1.Task
@@ -36,7 +80,7 @@ func PickTask(ctx context.Context, runner *actions_model.ActionRunner) (*runnerv
return nil, false, err
}
if has {
if task.Status == actions_model.StatusWaiting || task.Status == actions_model.StatusRunning || task.Status == actions_model.StatusBlocked {
if task.Status.In(actions_model.StatusWaiting, actions_model.StatusRunning, actions_model.StatusBlocked, actions_model.StatusCancelling) {
return nil, false, nil
}
// task has been finished, remove it
@@ -48,77 +92,87 @@ func PickTask(ctx context.Context, runner *actions_model.ActionRunner) (*runnerv
}
}
if err := db.WithTx(ctx, func(ctx context.Context) error {
t, ok, err := actions_model.CreateTaskForRunner(ctx, runner)
if err != nil {
return fmt.Errorf("CreateTaskForRunner: %w", err)
}
if !ok {
return nil
}
if err := t.LoadAttributes(ctx); err != nil {
return fmt.Errorf("task LoadAttributes: %w", err)
}
job = t.Job
actionTask = t
secrets, err := secret_model.GetSecretsOfTask(ctx, t)
if err != nil {
return fmt.Errorf("GetSecretsOfTask: %w", err)
}
vars, err := actions_model.GetVariablesOfRun(ctx, t.Job.Run)
if err != nil {
return fmt.Errorf("GetVariablesOfRun: %w", err)
}
needs, err := findTaskNeeds(ctx, job)
if err != nil {
return fmt.Errorf("findTaskNeeds: %w", err)
}
taskContext, err := generateTaskContext(t)
if err != nil {
return fmt.Errorf("generateTaskContext: %w", err)
}
task = &runnerv1.Task{
Id: t.ID,
WorkflowPayload: t.Job.WorkflowPayload,
Context: taskContext,
Secrets: secrets,
Vars: vars,
Needs: needs,
}
return nil
}); err != nil {
return nil, false, err
t, ok, err := actions_model.CreateTaskForRunner(ctx, runner)
if err != nil {
return nil, false, fmt.Errorf("CreateTaskForRunner: %w", err)
}
if task == nil {
if !ok {
return nil, false, nil
}
task, job, err = buildRunnerTask(ctx, t)
if err != nil {
// The job was already claimed but assembling its payload failed; release the
// claim so the job returns to the waiting queue instead of being stranded in
// running state with no runner ever executing it.
releaseTaskForRunnerCleanup(t)
return nil, false, err
}
actionTask = t
CreateCommitStatusForRunJobs(ctx, job.Run, job)
notify_service.WorkflowJobStatusUpdate(ctx, job.Run.Repo, job.Run.TriggerUser, job, actionTask)
NotifyWorkflowJobStatusUpdateWithTask(ctx, job, actionTask)
// job.Run is loaded inside the transaction before UpdateRunJob sets run.Started,
// so Started is zero only on the very first pick-up of that run.
if job.Run.Started.IsZero() {
NotifyWorkflowRunStatusUpdateWithReload(ctx, job)
NotifyWorkflowRunStatusUpdateWithReload(ctx, job.RepoID, job.RunID)
}
// The job is claimed and its payload assembled, but if the request context was cancelled meanwhile, response can no longer reach the runner.
// Release the claim so another runner can pick the job up.
if err := ctx.Err(); err != nil {
releaseTaskForRunnerCleanup(t)
return nil, false, err
}
return task, true, nil
}
func generateTaskContext(t *actions_model.ActionTask) (*structpb.Struct, error) {
// buildRunnerTask assembles the runner-facing task payload for an already-claimed
// task. All operations are read-only; on error the caller releases the claim.
func buildRunnerTask(ctx context.Context, t *actions_model.ActionTask) (*runnerv1.Task, *actions_model.ActionRunJob, error) {
if err := t.LoadAttributes(ctx); err != nil {
return nil, nil, fmt.Errorf("task LoadAttributes: %w", err)
}
job := t.Job
secrets, err := secret_model.GetSecretsOfTask(ctx, t)
if err != nil {
return nil, nil, fmt.Errorf("GetSecretsOfTask: %w", err)
}
vars, err := actions_model.GetVariablesOfRun(ctx, t.Job.Run)
if err != nil {
return nil, nil, fmt.Errorf("GetVariablesOfRun: %w", err)
}
needs, err := findTaskNeeds(ctx, job)
if err != nil {
return nil, nil, fmt.Errorf("findTaskNeeds: %w", err)
}
taskContext, err := generateTaskContext(ctx, t)
if err != nil {
return nil, nil, fmt.Errorf("generateTaskContext: %w", err)
}
return &runnerv1.Task{
Id: t.ID,
WorkflowPayload: t.Job.WorkflowPayload,
Context: taskContext,
Secrets: secrets,
Vars: vars,
Needs: needs,
}, job, nil
}
func generateTaskContext(ctx context.Context, t *actions_model.ActionTask) (*structpb.Struct, error) {
giteaRuntimeToken, err := CreateAuthorizationToken(t.ID, t.Job.RunID, t.JobID)
if err != nil {
return nil, err
}
gitCtx := GenerateGiteaContext(t.Job.Run, t.Job)
gitCtx := GenerateGiteaContext(ctx, t.Job.Run, nil, t.Job)
gitCtx["token"] = t.Token
gitCtx["gitea_runtime_token"] = giteaRuntimeToken
@@ -0,0 +1,74 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package actions
import (
"testing"
actions_model "gitea.dev/models/actions"
"gitea.dev/models/db"
"gitea.dev/models/unittest"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestTryPickTaskThrottled(t *testing.T) {
sem := taskPickLimiter()
// Saturate every assignment slot so the next attempt must be throttled.
for range cap(sem) {
sem <- struct{}{}
}
defer func() {
for range cap(sem) {
<-sem
}
}()
// No DB access happens on the throttled path, so this is safe without fixtures.
task, ok, throttled, err := TryPickTask(t.Context(), &actions_model.ActionRunner{})
require.NoError(t, err)
assert.Nil(t, task)
assert.False(t, ok)
assert.True(t, throttled)
}
// TestReleaseTaskForRunnerCleanup verifies the cleanup used by PickTask releases a claimed task through a
// fresh context. PickTask reaches this path when the request context is already canceled, and on
// PostgreSQL/MySQL a DB transaction on a canceled context fails immediately; reusing it would strand the
// claimed job in running state, so the cleanup must not use the request context.
func TestReleaseTaskForRunnerCleanup(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
run := &actions_model.ActionRun{
Title: "cleanup-run", RepoID: 1, OwnerID: 2, WorkflowID: "test.yaml",
TriggerUserID: 2, Ref: "refs/heads/main",
CommitSHA: "c2d72f548424103f01ee1dc02889c1e2bff816b0", Event: "push", TriggerEvent: "push",
Status: actions_model.StatusWaiting,
}
require.NoError(t, db.Insert(t.Context(), run))
job := &actions_model.ActionRunJob{
RunID: run.ID, RepoID: run.RepoID, OwnerID: run.OwnerID, CommitSHA: run.CommitSHA,
Name: "cleanup-job", Attempt: 1, JobID: "cleanup-job", Status: actions_model.StatusWaiting,
RunsOn: []string{"ubuntu-latest"},
WorkflowPayload: []byte("on: push\njobs:\n cleanup-job:\n runs-on: ubuntu-latest\n steps:\n - run: echo hi\n"),
}
require.NoError(t, db.Insert(t.Context(), job))
runner := &actions_model.ActionRunner{Name: "cleanup-runner", AgentLabels: []string{"ubuntu-latest"}}
runner.GenerateAndFillToken()
require.NoError(t, db.Insert(t.Context(), runner))
task, ok, err := actions_model.CreateTaskForRunner(t.Context(), runner)
require.NoError(t, err)
require.True(t, ok)
require.Equal(t, actions_model.StatusRunning, unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: job.ID}).Status)
// the cleanup helper uses its own context, so the claimed job is returned to the waiting queue
releaseTaskForRunnerCleanup(task)
released := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: job.ID})
assert.Equal(t, actions_model.StatusWaiting, released.Status)
assert.Zero(t, released.TaskID)
unittest.AssertNotExistsBean(t, &actions_model.ActionTask{ID: task.ID})
}
@@ -6,9 +6,9 @@ package actions
import (
"context"
actions_model "code.gitea.io/gitea/models/actions"
"code.gitea.io/gitea/modules/util"
secret_service "code.gitea.io/gitea/services/secrets"
actions_model "gitea.dev/models/actions"
"gitea.dev/modules/util"
secret_service "gitea.dev/services/secrets"
)
func CreateVariable(ctx context.Context, ownerID, repoID int64, name, data, description string) (*actions_model.ActionVariable, error) {
+136 -54
View File
@@ -5,24 +5,24 @@ package actions
import (
"fmt"
"strconv"
actions_model "code.gitea.io/gitea/models/actions"
"code.gitea.io/gitea/models/perm"
access_model "code.gitea.io/gitea/models/perm/access"
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unit"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/actions"
"code.gitea.io/gitea/modules/actions/jobparser"
"code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/modules/reqctx"
api "code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/modules/util"
"code.gitea.io/gitea/services/context"
"code.gitea.io/gitea/services/convert"
actions_model "gitea.dev/models/actions"
"gitea.dev/models/perm"
access_model "gitea.dev/models/perm/access"
repo_model "gitea.dev/models/repo"
"gitea.dev/models/unit"
user_model "gitea.dev/models/user"
"gitea.dev/modules/actions"
"gitea.dev/modules/actions/jobparser"
"gitea.dev/modules/git"
"gitea.dev/modules/reqctx"
api "gitea.dev/modules/structs"
"gitea.dev/modules/util"
"gitea.dev/services/context"
"gitea.dev/services/convert"
"github.com/nektos/act/pkg/model"
"go.yaml.in/yaml/v4"
"gitea.com/gitea/runner/act/model"
)
func EnableOrDisableWorkflow(ctx *context.APIContext, workflowID string, isEnable bool) error {
@@ -43,7 +43,9 @@ func EnableOrDisableWorkflow(ctx *context.APIContext, workflowID string, isEnabl
return repo_model.UpdateRepoUnitConfig(ctx, cfgUnit)
}
func DispatchActionWorkflow(ctx reqctx.RequestContext, doer *user_model.User, repo *repo_model.Repository, gitRepo *git.Repository, workflowID, ref string, processInputs func(model *model.WorkflowDispatch, inputs map[string]any) error) (runID int64, _ error) {
// DispatchActionWorkflow manually triggers a workflow_dispatch run.
// scopedWorkflowSourceRepoID selects the workflow source: 0 means a repo-level workflow in this repo; a non-zero value is the source repo of a scoped workflow.
func DispatchActionWorkflow(ctx reqctx.RequestContext, doer *user_model.User, repo *repo_model.Repository, gitRepo *git.Repository, workflowID, ref string, scopedWorkflowSourceRepoID int64, processInputs func(model *model.WorkflowDispatch, inputs map[string]any) error) (runID int64, _ error) {
if workflowID == "" {
return 0, util.ErrorWrapTranslatable(
util.NewNotExistErrorf("workflowID is empty"),
@@ -58,10 +60,20 @@ func DispatchActionWorkflow(ctx reqctx.RequestContext, doer *user_model.User, re
)
}
// can not rerun job when workflow is disabled
isScoped := scopedWorkflowSourceRepoID > 0
cfgUnit := repo.MustGetUnit(ctx, unit.TypeActions)
cfg := cfgUnit.ActionsConfig()
if cfg.IsWorkflowDisabled(workflowID) {
var workflowDisabled bool
if isScoped {
var err error
if workflowDisabled, err = actions_model.IsScopedWorkflowOptedOut(ctx, cfg, repo.OwnerID, scopedWorkflowSourceRepoID, workflowID); err != nil {
return 0, err
}
} else {
workflowDisabled = cfg.IsWorkflowDisabled(workflowID)
}
if workflowDisabled {
return 0, util.ErrorWrapTranslatable(
util.NewPermissionDeniedErrorf("workflow is disabled"),
"actions.workflow.disabled",
@@ -87,17 +99,8 @@ func DispatchActionWorkflow(ctx reqctx.RequestContext, doer *user_model.User, re
)
}
// get workflow entry from runTargetCommit
_, entries, err := actions.ListWorkflows(runTargetCommit)
if err != nil {
return 0, err
}
// find workflow from commit
var entry *git.TreeEntry
run := &actions_model.ActionRun{
Title: runTargetCommit.Summary(),
Title: runTargetCommit.MessageTitle(),
RepoID: repo.ID,
Repo: repo,
OwnerID: repo.OwnerID,
@@ -110,42 +113,35 @@ func DispatchActionWorkflow(ctx reqctx.RequestContext, doer *user_model.User, re
Event: "workflow_dispatch",
TriggerEvent: "workflow_dispatch",
Status: actions_model.StatusWaiting,
// local dispatch: own repo at the target commit; the scoped path overrides these below
WorkflowRepoID: repo.ID,
WorkflowCommitSHA: runTargetCommit.ID.String(),
}
for _, e := range entries {
if e.Name() != workflowID {
continue
}
entry = e
break
}
if entry == nil {
return 0, util.ErrorWrapTranslatable(
util.NewNotExistErrorf("workflow %q doesn't exist", workflowID),
"actions.workflow.not_found", workflowID,
)
}
content, err := actions.GetContentFromEntry(entry)
// resolve the workflow content and record its source on the run (scoped runs read from the source repo)
content, err := resolveDispatchWorkflowContent(ctx, repo, runTargetCommit, workflowID, scopedWorkflowSourceRepoID, isScoped, run)
if err != nil {
return 0, err
}
singleWorkflow := &jobparser.SingleWorkflow{}
if err := yaml.Unmarshal(content, singleWorkflow); err != nil {
workflow, err := jobparser.ReadWorkflow(content)
if err != nil {
return 0, fmt.Errorf("failed to unmarshal workflow content: %w", err)
}
// get inputs from post
workflow := &model.Workflow{
RawOn: singleWorkflow.RawOn,
workflowDispatch := workflow.WorkflowDispatchConfig()
if workflowDispatch == nil {
return 0, util.ErrorWrapTranslatable(
util.NewInvalidArgumentErrorf("workflow %q has no workflow_dispatch event trigger", workflowID),
"actions.workflow.has_no_workflow_dispatch", workflowID,
)
}
inputsWithDefaults := make(map[string]any)
if workflowDispatch := workflow.WorkflowDispatchConfig(); workflowDispatch != nil {
if err = processInputs(workflowDispatch, inputsWithDefaults); err != nil {
return 0, err
}
if err = processInputs(workflowDispatch, inputsWithDefaults); err != nil {
return 0, err
}
coerceDispatchInputTypes(workflowDispatch, inputsWithDefaults)
// ctx.Req.PostForm -> WorkflowDispatchPayload.Inputs -> ActionRun.EventPayload -> runner: ghc.Event
// https://docs.github.com/en/actions/learn-github-actions/contexts#github-context
@@ -154,7 +150,7 @@ func DispatchActionWorkflow(ctx reqctx.RequestContext, doer *user_model.User, re
Workflow: workflowID,
Ref: ref,
Repository: convert.ToRepo(ctx, repo, access_model.Permission{AccessMode: perm.AccessModeNone}),
Inputs: inputsWithDefaults,
Inputs: dispatchEventInputs(inputsWithDefaults),
Sender: convert.ToUserWithAccessMode(ctx, doer, perm.AccessModeNone),
}
@@ -170,3 +166,89 @@ func DispatchActionWorkflow(ctx reqctx.RequestContext, doer *user_model.User, re
}
return run.ID, nil
}
// coerceDispatchInputTypes types `inputs`, where boolean is the only non-string dispatch input type.
func coerceDispatchInputTypes(dispatch *model.WorkflowDispatch, inputs map[string]any) {
for name, cfg := range dispatch.Inputs {
if cfg.Type != "boolean" {
continue
}
if s, ok := inputs[name].(string); ok {
inputs[name] = util.ParseYamlBool(s)
}
}
}
// dispatchEventInputs stringifies the typed inputs for `github.event.inputs`.
// workflow_dispatch input types are string, choice, boolean and environment, so after
// coerceDispatchInputTypes a value is either already a string or a bool.
func dispatchEventInputs(inputs map[string]any) map[string]any {
eventInputs := make(map[string]any, len(inputs))
for name, value := range inputs {
if b, ok := value.(bool); ok {
eventInputs[name] = strconv.FormatBool(b)
} else {
eventInputs[name] = value
}
}
return eventInputs
}
// resolveDispatchWorkflowContent returns the YAML for a dispatched workflow and records its source on the run.
// - Repo-level: from the consumer's runTargetCommit.
// - Scoped: from the source repo's default branch.
func resolveDispatchWorkflowContent(ctx reqctx.RequestContext, repo *repo_model.Repository, runTargetCommit *git.Commit, workflowID string, sourceRepoID int64, isScoped bool, run *actions_model.ActionRun) ([]byte, error) {
if isScoped {
return resolveScopedDispatchContent(ctx, repo, sourceRepoID, workflowID, run)
}
_, entries, err := actions.ListWorkflows(runTargetCommit)
if err != nil {
return nil, err
}
for _, e := range entries {
if e.Name() == workflowID {
return actions.GetContentFromEntry(e)
}
}
return nil, util.ErrorWrapTranslatable(
util.NewNotExistErrorf("workflow %q doesn't exist", workflowID),
"actions.workflow.not_found", workflowID,
)
}
func resolveScopedDispatchContent(ctx reqctx.RequestContext, repo *repo_model.Repository, sourceRepoID int64, workflowID string, run *actions_model.ActionRun) ([]byte, error) {
// the source must be an effective scoped source for this consumer repo
effective, err := actions_model.IsScopedWorkflowSourceEffective(ctx, repo.OwnerID, sourceRepoID)
if err != nil {
return nil, err
}
if !effective {
return nil, util.ErrorWrapTranslatable(
util.NewNotExistErrorf("scoped workflow source %d is not effective for this repository", sourceRepoID),
"actions.workflow.not_found", workflowID,
)
}
sourceRepo, err := repo_model.GetRepositoryByID(ctx, sourceRepoID)
if err != nil {
return nil, err
}
sha, parsed, err := LoadParsedScopedWorkflows(ctx, sourceRepo)
if err != nil {
return nil, err
}
for _, p := range parsed {
if p.EntryName == workflowID {
run.WorkflowRepoID = sourceRepo.ID
run.WorkflowCommitSHA = sha
run.IsScopedRun = true
return p.Content, nil
}
}
return nil, util.ErrorWrapTranslatable(
util.NewNotExistErrorf("scoped workflow %q doesn't exist", workflowID),
"actions.workflow.not_found", workflowID,
)
}
@@ -0,0 +1,58 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package actions
import (
"testing"
"gitea.com/gitea/runner/act/model"
"github.com/stretchr/testify/assert"
)
func TestCoerceDispatchInputTypes(t *testing.T) {
dispatch := &model.WorkflowDispatch{
Inputs: map[string]model.WorkflowDispatchInput{
"build_server": {Type: "boolean"},
"dry_run": {Type: "boolean"},
"already_bool": {Type: "boolean"},
"yaml_true": {Type: "boolean"},
"yaml_truthy": {Type: "boolean"},
"version": {Type: "string"},
},
}
inputs := map[string]any{
// dispatch callbacks fill booleans as strconv.FormatBool(...) strings
"build_server": "true",
"dry_run": "false",
// already-native booleans are passed through unchanged (coercion is idempotent)
"already_bool": true,
// source text of `default: True` and `default: yes`, only the former is a YAML 1.2 boolean
"yaml_true": "True",
"yaml_truthy": "yes",
// non-boolean inputs must be left untouched
"version": "1.2.3",
}
coerceDispatchInputTypes(dispatch, inputs)
// Regression: without coercion these stay strings, and a server-side needs-gated
// job `if: inputs.build_server == true` never matches, leaving the job blocked.
assert.Equal(t, true, inputs["build_server"])
assert.Equal(t, false, inputs["dry_run"])
assert.Equal(t, true, inputs["already_bool"])
assert.Equal(t, true, inputs["yaml_true"])
assert.Equal(t, false, inputs["yaml_truthy"])
assert.Equal(t, "1.2.3", inputs["version"])
// `github.event.inputs` mirrors them as normalized strings
assert.Equal(t, map[string]any{
"build_server": "true",
"dry_run": "false",
"already_bool": "true",
"yaml_true": "true",
"yaml_truthy": "false",
"version": "1.2.3",
}, dispatchEventInputs(inputs))
}