This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
Reference in New Issue
Block a user