forked from hinterland/hearth
feat: vendor gitea 1.16.2
This commit is contained in:
@@ -246,6 +246,8 @@ func DeleteRun(ctx context.Context, run *actions_model.ActionRun) error {
|
||||
return err
|
||||
}
|
||||
|
||||
actions_model.UpdateRepoRunsNumbers(ctx, repoID)
|
||||
|
||||
// Delete files on storage
|
||||
for _, tas := range tasks {
|
||||
removeTaskLog(ctx, tas)
|
||||
|
||||
@@ -12,9 +12,11 @@ import (
|
||||
"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"
|
||||
)
|
||||
@@ -36,29 +38,115 @@ func StopEndlessTasks(ctx context.Context) error {
|
||||
}
|
||||
|
||||
func notifyWorkflowJobStatusUpdate(ctx context.Context, jobs []*actions_model.ActionRunJob) {
|
||||
if len(jobs) > 0 {
|
||||
CreateCommitStatus(ctx, jobs...)
|
||||
for _, job := range jobs {
|
||||
_ = job.LoadAttributes(ctx)
|
||||
notify_service.WorkflowJobStatusUpdate(ctx, job.Run.Repo, job.Run.TriggerUser, job, nil)
|
||||
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
|
||||
}
|
||||
job := jobs[0]
|
||||
notify_service.WorkflowRunStatusUpdate(ctx, job.Run.Repo, job.Run.TriggerUser, job.Run)
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
for _, run := range runs {
|
||||
notify_service.WorkflowRunStatusUpdate(ctx, run.Repo, run.TriggerUser, run)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
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)
|
||||
}
|
||||
EmitJobsIfReadyByJobs(jobs)
|
||||
return err
|
||||
}
|
||||
|
||||
func shouldBlockJobByConcurrency(ctx context.Context, job *actions_model.ActionRunJob) (bool, error) {
|
||||
if job.RawConcurrency != "" && !job.IsConcurrencyEvaluated {
|
||||
// when the job depends on other jobs, we cannot evaluate its concurrency, so it should be blocked and will be evaluated again when its dependencies are done
|
||||
return true, nil
|
||||
}
|
||||
|
||||
if job.ConcurrencyGroup == "" || job.ConcurrencyCancel {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
runs, jobs, err := actions_model.GetConcurrentRunsAndJobs(ctx, job.RepoID, job.ConcurrencyGroup, []actions_model.Status{actions_model.StatusRunning})
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("GetConcurrentRunsAndJobs: %w", err)
|
||||
}
|
||||
|
||||
return len(runs) > 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) {
|
||||
shouldBlock, err := shouldBlockJobByConcurrency(ctx, job)
|
||||
if err != nil {
|
||||
return actions_model.StatusBlocked, 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)
|
||||
}
|
||||
notifyWorkflowJobStatusUpdate(ctx, jobs)
|
||||
|
||||
return util.Iif(shouldBlock, actions_model.StatusBlocked, actions_model.StatusWaiting), nil
|
||||
}
|
||||
|
||||
func shouldBlockRunByConcurrency(ctx context.Context, actionRun *actions_model.ActionRun) (bool, error) {
|
||||
if actionRun.ConcurrencyGroup == "" || actionRun.ConcurrencyCancel {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
runs, jobs, err := actions_model.GetConcurrentRunsAndJobs(ctx, actionRun.RepoID, actionRun.ConcurrencyGroup, []actions_model.Status{actions_model.StatusRunning})
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("find concurrent runs and jobs: %w", err)
|
||||
}
|
||||
|
||||
return len(runs) > 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)
|
||||
if err != nil {
|
||||
return actions_model.StatusBlocked, 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)
|
||||
if err != nil {
|
||||
return actions_model.StatusBlocked, fmt.Errorf("CancelPreviousJobsByRunConcurrency: %w", err)
|
||||
}
|
||||
notifyWorkflowJobStatusUpdate(ctx, jobs)
|
||||
|
||||
return util.Iif(shouldBlock, actions_model.StatusBlocked, actions_model.StatusWaiting), nil
|
||||
}
|
||||
|
||||
func stopTasks(ctx context.Context, opts actions_model.FindTaskOptions) error {
|
||||
tasks, err := db.Find[actions_model.ActionTask](ctx, opts)
|
||||
if err != nil {
|
||||
@@ -96,6 +184,17 @@ func stopTasks(ctx context.Context, opts actions_model.FindTaskOptions) error {
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
EmitJobsIfReadyByJobs(jobs)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -103,7 +202,7 @@ func stopTasks(ctx context.Context, opts actions_model.FindTaskOptions) error {
|
||||
func CancelAbandonedJobs(ctx context.Context) error {
|
||||
jobs, err := db.Find[actions_model.ActionRunJob](ctx, actions_model.FindRunJobOptions{
|
||||
Statuses: []actions_model.Status{actions_model.StatusWaiting, actions_model.StatusBlocked},
|
||||
UpdatedBefore: timeutil.TimeStamp(time.Now().Add(-setting.Actions.AbandonedJobTimeout).Unix()),
|
||||
UpdatedBefore: timeutil.TimeStampNow().AddDuration(-setting.Actions.AbandonedJobTimeout),
|
||||
})
|
||||
if err != nil {
|
||||
log.Warn("find abandoned tasks: %v", err)
|
||||
@@ -114,6 +213,8 @@ func CancelAbandonedJobs(ctx context.Context) error {
|
||||
|
||||
// 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
|
||||
@@ -130,14 +231,19 @@ func CancelAbandonedJobs(ctx context.Context) error {
|
||||
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
|
||||
}
|
||||
CreateCommitStatus(ctx, job)
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -145,6 +251,11 @@ func CancelAbandonedJobs(ctx context.Context) error {
|
||||
for _, job := range updatedRuns {
|
||||
notify_service.WorkflowRunStatusUpdate(ctx, job.Run.Repo, job.Run.TriggerUser, job.Run)
|
||||
}
|
||||
EmitJobsIfReadyByJobs(updatedJobs)
|
||||
|
||||
for repoID := range updatedRepoIDs {
|
||||
actions_model.UpdateRepoRunsNumbers(ctx, repoID)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -13,49 +13,84 @@ import (
|
||||
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"
|
||||
git "code.gitea.io/gitea/modules/git"
|
||||
"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"
|
||||
|
||||
"github.com/nektos/act/pkg/jobparser"
|
||||
)
|
||||
|
||||
// CreateCommitStatus creates a commit status for the given job.
|
||||
// CreateCommitStatusForRunJobs creates a commit status for the given job if it has a supported event and related commit.
|
||||
// It won't return an error failed, but will log it, because it's not critical.
|
||||
func CreateCommitStatus(ctx context.Context, jobs ...*actions_model.ActionRunJob) {
|
||||
func CreateCommitStatusForRunJobs(ctx context.Context, run *actions_model.ActionRun, jobs ...*actions_model.ActionRunJob) {
|
||||
// don't create commit status for cron job
|
||||
if run.ScheduleID != 0 {
|
||||
return
|
||||
}
|
||||
|
||||
event, commitID, err := getCommitStatusEventNameAndCommitID(run)
|
||||
if err != nil {
|
||||
log.Error("GetCommitStatusEventNameAndSHA: %v", err)
|
||||
}
|
||||
if event == "" || commitID == "" {
|
||||
return // unsupported event, or no commit id, or error occurs, do nothing
|
||||
}
|
||||
|
||||
if err = run.LoadAttributes(ctx); err != nil {
|
||||
log.Error("run.LoadAttributes: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
for _, job := range jobs {
|
||||
if err := createCommitStatus(ctx, job); err != nil {
|
||||
if err = createCommitStatus(ctx, run.Repo, event, commitID, run, job); err != nil {
|
||||
log.Error("Failed to create commit status for job %d: %v", job.ID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func createCommitStatus(ctx context.Context, job *actions_model.ActionRunJob) error {
|
||||
if err := job.LoadAttributes(ctx); err != nil {
|
||||
return fmt.Errorf("load run: %w", err)
|
||||
func GetRunsFromCommitStatuses(ctx context.Context, statuses []*git_model.CommitStatus) ([]*actions_model.ActionRun, error) {
|
||||
runMap := make(map[int64]*actions_model.ActionRun)
|
||||
for _, status := range statuses {
|
||||
runID, _, ok := status.ParseGiteaActionsTargetURL(ctx)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
_, ok = runMap[runID]
|
||||
if !ok {
|
||||
run, err := actions_model.GetRunByRepoAndID(ctx, status.RepoID, runID)
|
||||
if err != nil {
|
||||
if errors.Is(err, util.ErrNotExist) {
|
||||
// the run may be deleted manually, just skip it
|
||||
continue
|
||||
}
|
||||
return nil, fmt.Errorf("GetRunByRepoAndID: %w", err)
|
||||
}
|
||||
runMap[runID] = run
|
||||
}
|
||||
}
|
||||
runs := make([]*actions_model.ActionRun, 0, len(runMap))
|
||||
for _, run := range runMap {
|
||||
runs = append(runs, run)
|
||||
}
|
||||
return runs, nil
|
||||
}
|
||||
|
||||
run := job.Run
|
||||
|
||||
var (
|
||||
sha string
|
||||
event string
|
||||
)
|
||||
func getCommitStatusEventNameAndCommitID(run *actions_model.ActionRun) (event, commitID string, _ error) {
|
||||
switch run.Event {
|
||||
case webhook_module.HookEventPush:
|
||||
event = "push"
|
||||
payload, err := run.GetPushEventPayload()
|
||||
if err != nil {
|
||||
return fmt.Errorf("GetPushEventPayload: %w", err)
|
||||
return "", "", fmt.Errorf("GetPushEventPayload: %w", err)
|
||||
}
|
||||
if payload.HeadCommit == nil {
|
||||
return errors.New("head commit is missing in event payload")
|
||||
return "", "", errors.New("head commit is missing in event payload")
|
||||
}
|
||||
sha = payload.HeadCommit.ID
|
||||
commitID = payload.HeadCommit.ID
|
||||
case // pull_request
|
||||
webhook_module.HookEventPullRequest,
|
||||
webhook_module.HookEventPullRequestSync,
|
||||
@@ -70,83 +105,94 @@ func createCommitStatus(ctx context.Context, job *actions_model.ActionRunJob) er
|
||||
}
|
||||
payload, err := run.GetPullRequestEventPayload()
|
||||
if err != nil {
|
||||
return fmt.Errorf("GetPullRequestEventPayload: %w", err)
|
||||
return "", "", fmt.Errorf("GetPullRequestEventPayload: %w", err)
|
||||
}
|
||||
if payload.PullRequest == nil {
|
||||
return errors.New("pull request is missing in event payload")
|
||||
return "", "", errors.New("pull request is missing in event payload")
|
||||
} else if payload.PullRequest.Head == nil {
|
||||
return errors.New("head of pull request is missing in event payload")
|
||||
return "", "", errors.New("head of pull request is missing in event payload")
|
||||
}
|
||||
sha = payload.PullRequest.Head.Sha
|
||||
commitID = payload.PullRequest.Head.Sha
|
||||
case // pull_request_review events share the same PullRequestPayload as pull_request
|
||||
webhook_module.HookEventPullRequestReviewApproved,
|
||||
webhook_module.HookEventPullRequestReviewRejected,
|
||||
webhook_module.HookEventPullRequestReviewComment:
|
||||
event = run.TriggerEvent
|
||||
payload, err := run.GetPullRequestEventPayload()
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("GetPullRequestEventPayload: %w", err)
|
||||
}
|
||||
if payload.PullRequest == nil {
|
||||
return "", "", errors.New("pull request is missing in event payload")
|
||||
} else if payload.PullRequest.Head == nil {
|
||||
return "", "", errors.New("head of pull request is missing in event payload")
|
||||
}
|
||||
commitID = payload.PullRequest.Head.Sha
|
||||
case webhook_module.HookEventRelease:
|
||||
event = string(run.Event)
|
||||
sha = run.CommitSHA
|
||||
default:
|
||||
return nil
|
||||
commitID = run.CommitSHA
|
||||
default: // do nothing, return empty
|
||||
}
|
||||
return event, commitID, nil
|
||||
}
|
||||
|
||||
repo := run.Repo
|
||||
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
|
||||
}
|
||||
ctxname := fmt.Sprintf("%s / %s (%s)", runName, job.Name, event)
|
||||
ctxname = strings.TrimSpace(ctxname) // git_model.NewCommitStatus also trims spaces
|
||||
ctxName := strings.TrimSpace(fmt.Sprintf("%s / %s (%s)", runName, job.Name, event)) // git_model.NewCommitStatus also trims spaces
|
||||
state := toCommitStatus(job.Status)
|
||||
if statuses, err := git_model.GetLatestCommitStatus(ctx, repo.ID, sha, db.ListOptionsAll); err == nil {
|
||||
for _, v := range statuses {
|
||||
if v.Context == ctxname {
|
||||
if v.State == state {
|
||||
// no need to update
|
||||
return nil
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
} else {
|
||||
targetURL := fmt.Sprintf("%s/jobs/%d", run.Link(), job.ID)
|
||||
description := toCommitStatusDescription(job)
|
||||
|
||||
statuses, err := git_model.GetLatestCommitStatus(ctx, repo.ID, commitID, db.ListOptionsAll)
|
||||
if err != nil {
|
||||
return fmt.Errorf("GetLatestCommitStatus: %w", err)
|
||||
}
|
||||
|
||||
description := ""
|
||||
switch job.Status {
|
||||
// TODO: if we want support description in different languages, we need to support i18n placeholders in it
|
||||
case actions_model.StatusSuccess:
|
||||
description = fmt.Sprintf("Successful in %s", job.Duration())
|
||||
case actions_model.StatusFailure:
|
||||
description = fmt.Sprintf("Failing after %s", job.Duration())
|
||||
case actions_model.StatusCancelled:
|
||||
description = "Has been cancelled"
|
||||
case actions_model.StatusSkipped:
|
||||
description = "Has been skipped"
|
||||
case actions_model.StatusRunning:
|
||||
description = "Has started running"
|
||||
case actions_model.StatusWaiting:
|
||||
description = "Waiting to run"
|
||||
case actions_model.StatusBlocked:
|
||||
description = "Blocked by required conditions"
|
||||
}
|
||||
|
||||
index, err := getIndexOfJob(ctx, job)
|
||||
if err != nil {
|
||||
return fmt.Errorf("getIndexOfJob: %w", err)
|
||||
for _, v := range statuses {
|
||||
if v.Context == ctxName {
|
||||
if v.State == state && v.TargetURL == targetURL && v.Description == description {
|
||||
return nil
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
creator := user_model.NewActionsUser()
|
||||
commitID, err := git.NewIDFromString(sha)
|
||||
if err != nil {
|
||||
return fmt.Errorf("HashTypeInterfaceFromHashString: %w", err)
|
||||
}
|
||||
status := git_model.CommitStatus{
|
||||
SHA: sha,
|
||||
TargetURL: fmt.Sprintf("%s/jobs/%d", run.Link(), index),
|
||||
SHA: commitID,
|
||||
TargetURL: targetURL,
|
||||
Description: description,
|
||||
Context: ctxname,
|
||||
CreatorID: creator.ID,
|
||||
Context: ctxName,
|
||||
State: state,
|
||||
CreatorID: creator.ID,
|
||||
}
|
||||
|
||||
return commitstatus_service.CreateCommitStatus(ctx, repo, creator, commitID.String(), &status)
|
||||
return commitstatus_service.CreateCommitStatus(ctx, repo, creator, commitID, &status)
|
||||
}
|
||||
|
||||
func toCommitStatusDescription(job *actions_model.ActionRunJob) string {
|
||||
switch job.Status {
|
||||
// TODO: if we want support description in different languages, we need to support i18n placeholders in it
|
||||
case actions_model.StatusSuccess:
|
||||
return fmt.Sprintf("Successful in %s", job.Duration())
|
||||
case actions_model.StatusFailure:
|
||||
return fmt.Sprintf("Failing after %s", job.Duration())
|
||||
case actions_model.StatusCancelled:
|
||||
return "Has been cancelled"
|
||||
case actions_model.StatusSkipped:
|
||||
return "Has been skipped"
|
||||
case actions_model.StatusRunning:
|
||||
return "Has started running"
|
||||
case actions_model.StatusWaiting:
|
||||
return "Waiting to run"
|
||||
case actions_model.StatusBlocked:
|
||||
return "Blocked by required conditions"
|
||||
default:
|
||||
return fmt.Sprintf("Unknown status: %d", job.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func toCommitStatus(status actions_model.Status) commitstatus.CommitStatusState {
|
||||
@@ -163,17 +209,3 @@ func toCommitStatus(status actions_model.Status) commitstatus.CommitStatusState
|
||||
return commitstatus.CommitStatusError
|
||||
}
|
||||
}
|
||||
|
||||
func getIndexOfJob(ctx context.Context, job *actions_model.ActionRunJob) (int, error) {
|
||||
// TODO: store job index as a field in ActionRunJob to avoid this
|
||||
jobs, err := actions_model.GetRunJobsByRunID(ctx, job.RunID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
for i, v := range jobs {
|
||||
if v.ID == job.ID {
|
||||
return i, nil
|
||||
}
|
||||
}
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
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"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestCreateCommitStatus_Dedupe(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 4})
|
||||
gitRepo, err := gitrepo.OpenRepository(t.Context(), repo)
|
||||
require.NoError(t, err)
|
||||
defer gitRepo.Close()
|
||||
|
||||
commit, err := gitRepo.GetBranchCommit(repo.DefaultBranch)
|
||||
require.NoError(t, err)
|
||||
|
||||
run := &actions_model.ActionRun{
|
||||
ID: 99001,
|
||||
RepoID: repo.ID,
|
||||
Repo: repo,
|
||||
WorkflowID: "status-dedupe-test.yaml",
|
||||
}
|
||||
job := &actions_model.ActionRunJob{
|
||||
ID: 99002,
|
||||
RunID: run.ID,
|
||||
RepoID: repo.ID,
|
||||
Name: "status-dedupe-job",
|
||||
Status: actions_model.StatusWaiting,
|
||||
}
|
||||
|
||||
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))
|
||||
|
||||
statuses := findCommitStatusesForContext(t, repo.ID, commit.ID.String(), expectedContext)
|
||||
require.Len(t, statuses, 1)
|
||||
assert.Equal(t, commitstatus.CommitStatusPending, statuses[0].State)
|
||||
assert.Equal(t, "Waiting to run", statuses[0].Description)
|
||||
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))
|
||||
|
||||
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, expectedTargetURL, statuses[1].TargetURL)
|
||||
|
||||
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))
|
||||
statuses = findCommitStatusesForContext(t, repo.ID, commit.ID.String(), expectedContext)
|
||||
require.Len(t, statuses, 3)
|
||||
assert.Equal(t, commitstatus.CommitStatusSuccess, statuses[2].State)
|
||||
}
|
||||
|
||||
func findCommitStatusesForContext(t *testing.T, repoID int64, sha, context string) []*git_model.CommitStatus {
|
||||
t.Helper()
|
||||
|
||||
var statuses []*git_model.CommitStatus
|
||||
err := db.GetEngine(t.Context()).
|
||||
Where("repo_id = ? AND sha = ? AND context = ?", repoID, sha, context).
|
||||
Asc("`index`").
|
||||
Find(&statuses)
|
||||
require.NoError(t, err)
|
||||
return statuses
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
// Copyright 2025 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package actions
|
||||
|
||||
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"
|
||||
|
||||
act_model "github.com/nektos/act/pkg/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`.
|
||||
// Workflow-level concurrency doesn't depend on the job outputs, so it can always be evaluated if there is no syntax error.
|
||||
// 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 {
|
||||
if err := run.LoadAttributes(ctx); err != nil {
|
||||
return fmt.Errorf("run LoadAttributes: %w", err)
|
||||
}
|
||||
|
||||
actionsRunCtx := GenerateGiteaContext(run, 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)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
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 {
|
||||
if err := actionRunJob.LoadAttributes(ctx); err != nil {
|
||||
return fmt.Errorf("job LoadAttributes: %w", err)
|
||||
}
|
||||
|
||||
var rawConcurrency act_model.RawConcurrency
|
||||
if err := yaml.Unmarshal([]byte(actionRunJob.RawConcurrency), &rawConcurrency); err != nil {
|
||||
return fmt.Errorf("unmarshal raw concurrency: %w", err)
|
||||
}
|
||||
|
||||
actionsJobCtx := GenerateGiteaContext(run, actionRunJob)
|
||||
|
||||
jobResults, err := findJobNeedsAndFillJobResults(ctx, actionRunJob)
|
||||
if err != nil {
|
||||
return fmt.Errorf("find job needs and fill job results: %w", err)
|
||||
}
|
||||
|
||||
if inputs == nil {
|
||||
var err error
|
||||
inputs, err = getInputsFromRun(run)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get inputs: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
workflowJob, err := actionRunJob.ParseJob()
|
||||
if err != nil {
|
||||
return fmt.Errorf("load job %d: %w", actionRunJob.ID, err)
|
||||
}
|
||||
|
||||
actionRunJob.ConcurrencyGroup, actionRunJob.ConcurrencyCancel, err = jobparser.EvaluateConcurrency(&rawConcurrency, actionRunJob.JobID, workflowJob, actionsJobCtx, jobResults, vars, inputs)
|
||||
if err != nil {
|
||||
return fmt.Errorf("evaluate concurrency: %w", err)
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -73,7 +73,7 @@ func GenerateGiteaContext(run *actions_model.ActionRun, job *actions_model.Actio
|
||||
"repository_owner": run.Repo.OwnerName, // string, The repository owner's name. For example, Codertocat.
|
||||
"repositoryUrl": run.Repo.HTMLURL(), // string, The Git URL to the repository. For example, git://github.com/codertocat/hello-world.git.
|
||||
"retention_days": "", // string, The number of days that workflow run logs and artifacts are kept.
|
||||
"run_id": "", // string, A unique number for each workflow run within a repository. This number does not change if you re-run the workflow run.
|
||||
"run_id": strconv.FormatInt(run.ID, 10), // string, A unique number for each workflow run within a repository. This number does not change if you re-run the workflow run.
|
||||
"run_number": strconv.FormatInt(run.Index, 10), // string, A unique number for each run of a particular workflow in a repository. This number begins at 1 for the workflow's first run, and increments with each new run. This number does not change if you re-run the workflow run.
|
||||
"run_attempt": "", // string, A unique number for each attempt of a particular workflow run in a repository. This number begins at 1 for the workflow run's first attempt, and increments with each re-run.
|
||||
"secret_source": "Actions", // string, The source of a secret used in a workflow. Possible values are None, Actions, Dependabot, or Codespaces.
|
||||
@@ -104,7 +104,7 @@ type TaskNeed struct {
|
||||
// FindTaskNeeds finds the `needs` for the task by the task's job
|
||||
func FindTaskNeeds(ctx context.Context, job *actions_model.ActionRunJob) (map[string]*TaskNeed, error) {
|
||||
if len(job.Needs) == 0 {
|
||||
return nil, nil
|
||||
return nil, nil //nolint:nilnil // return nil when the job has no needs
|
||||
}
|
||||
needs := container.SetOf(job.Needs...)
|
||||
|
||||
|
||||
@@ -4,14 +4,86 @@
|
||||
package actions
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
actions_model "code.gitea.io/gitea/models/actions"
|
||||
"code.gitea.io/gitea/models/unittest"
|
||||
|
||||
act_model "github.com/nektos/act/pkg/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.
|
||||
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})
|
||||
|
||||
expr := &act_model.RawConcurrency{
|
||||
Group: "${{ github.workflow }}-${{ github.head_ref || github.run_id }}",
|
||||
CancelInProgress: "true",
|
||||
}
|
||||
|
||||
assert.NoError(t, EvaluateRunConcurrencyFillModel(ctx, runA, expr, nil, nil))
|
||||
assert.NoError(t, EvaluateRunConcurrencyFillModel(ctx, runB, expr, nil, nil))
|
||||
|
||||
assert.Contains(t, runA.ConcurrencyGroup, "791")
|
||||
assert.Contains(t, runB.ConcurrencyGroup, "792")
|
||||
assert.NotEqual(t, runA.ConcurrencyGroup, runB.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.
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
ctx := t.Context()
|
||||
|
||||
content := []byte(`name: cross-branch
|
||||
run-name: "Run ${{ github.run_id }}"
|
||||
on: push
|
||||
concurrency:
|
||||
group: group-${{ github.run_id }}
|
||||
cancel-in-progress: true
|
||||
jobs:
|
||||
hello:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo hi
|
||||
`)
|
||||
|
||||
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: "{}",
|
||||
}
|
||||
require.NoError(t, PrepareRunAndInsert(ctx, content, run, nil))
|
||||
require.Positive(t, run.ID)
|
||||
|
||||
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)
|
||||
// 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 TestFindTaskNeeds(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
|
||||
@@ -18,7 +18,6 @@ import (
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
unittest.MainTest(m)
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
|
||||
func TestInitToken(t *testing.T) {
|
||||
|
||||
@@ -23,8 +23,6 @@ type API interface {
|
||||
CreateVariable(*context.APIContext)
|
||||
// UpdateVariable update a variable
|
||||
UpdateVariable(*context.APIContext)
|
||||
// GetRegistrationToken get registration token
|
||||
GetRegistrationToken(*context.APIContext)
|
||||
// CreateRegistrationToken get registration token
|
||||
CreateRegistrationToken(*context.APIContext)
|
||||
// ListRunners list runners
|
||||
@@ -33,6 +31,8 @@ type API interface {
|
||||
GetRunner(*context.APIContext)
|
||||
// DeleteRunner delete runner
|
||||
DeleteRunner(*context.APIContext)
|
||||
// UpdateRunner update runner
|
||||
UpdateRunner(*context.APIContext)
|
||||
// ListWorkflowJobs list jobs
|
||||
ListWorkflowJobs(*context.APIContext)
|
||||
// ListWorkflowRuns list runs
|
||||
|
||||
@@ -10,12 +10,14 @@ import (
|
||||
|
||||
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"
|
||||
|
||||
"github.com/nektos/act/pkg/jobparser"
|
||||
"xorm.io/builder"
|
||||
)
|
||||
|
||||
@@ -25,7 +27,7 @@ type jobUpdate struct {
|
||||
RunID int64
|
||||
}
|
||||
|
||||
func EmitJobsIfReady(runID int64) error {
|
||||
func EmitJobsIfReadyByRun(runID int64) error {
|
||||
err := jobEmitterQueue.Push(&jobUpdate{
|
||||
RunID: runID,
|
||||
})
|
||||
@@ -35,30 +37,191 @@ func EmitJobsIfReady(runID int64) error {
|
||||
return err
|
||||
}
|
||||
|
||||
func EmitJobsIfReadyByJobs(jobs []*actions_model.ActionRunJob) {
|
||||
checkedRuns := make(container.Set[int64])
|
||||
for _, job := range jobs {
|
||||
if !job.Status.IsDone() || checkedRuns.Contains(job.RunID) {
|
||||
continue
|
||||
}
|
||||
if err := EmitJobsIfReadyByRun(job.RunID); err != nil {
|
||||
log.Error("Check jobs of run %d: %v", job.RunID, err)
|
||||
}
|
||||
checkedRuns.Add(job.RunID)
|
||||
}
|
||||
}
|
||||
|
||||
func jobEmitterQueueHandler(items ...*jobUpdate) []*jobUpdate {
|
||||
ctx := graceful.GetManager().ShutdownContext()
|
||||
var ret []*jobUpdate
|
||||
for _, update := range items {
|
||||
if err := checkJobsOfRun(ctx, update.RunID); err != nil {
|
||||
if err := checkJobsByRunID(ctx, update.RunID); err != nil {
|
||||
log.Error("check run %d: %v", update.RunID, err)
|
||||
ret = append(ret, update)
|
||||
}
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
func checkJobsOfRun(ctx context.Context, runID int64) error {
|
||||
jobs, err := db.Find[actions_model.ActionRunJob](ctx, actions_model.FindRunJobOptions{RunID: runID})
|
||||
func checkJobsByRunID(ctx context.Context, runID int64) error {
|
||||
run, exist, err := db.GetByID[actions_model.ActionRun](ctx, runID)
|
||||
if !exist {
|
||||
return fmt.Errorf("run %d does not exist", runID)
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("get action run: %w", err)
|
||||
}
|
||||
var jobs, updatedJobs []*actions_model.ActionRunJob
|
||||
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 {
|
||||
return err
|
||||
} else {
|
||||
jobs = append(jobs, js...)
|
||||
updatedJobs = append(updatedJobs, ujs...)
|
||||
}
|
||||
if js, ujs, err := checkRunConcurrency(ctx, run); err != nil {
|
||||
return err
|
||||
} else {
|
||||
jobs = append(jobs, js...)
|
||||
updatedJobs = append(updatedJobs, ujs...)
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
var updatedjobs []*actions_model.ActionRunJob
|
||||
if err := db.WithTx(ctx, func(ctx context.Context) error {
|
||||
idToJobs := make(map[string][]*actions_model.ActionRunJob, len(jobs))
|
||||
CreateCommitStatusForRunJobs(ctx, run, jobs...)
|
||||
for _, job := range updatedJobs {
|
||||
_ = job.LoadAttributes(ctx)
|
||||
notify_service.WorkflowJobStatusUpdate(ctx, job.Run.Repo, job.Run.TriggerUser, job, nil)
|
||||
}
|
||||
runJobs := make(map[int64][]*actions_model.ActionRunJob)
|
||||
for _, job := range jobs {
|
||||
runJobs[job.RunID] = append(runJobs[job.RunID], job)
|
||||
}
|
||||
runUpdatedJobs := make(map[int64][]*actions_model.ActionRunJob)
|
||||
for _, uj := range updatedJobs {
|
||||
runUpdatedJobs[uj.RunID] = append(runUpdatedJobs[uj.RunID], uj)
|
||||
}
|
||||
for runID, js := range runJobs {
|
||||
if len(runUpdatedJobs[runID]) == 0 {
|
||||
continue
|
||||
}
|
||||
runUpdated := true
|
||||
for _, job := range js {
|
||||
if !job.Status.IsDone() {
|
||||
runUpdated = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if runUpdated {
|
||||
NotifyWorkflowRunStatusUpdateWithReload(ctx, js[0])
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get run by job %d: %w", cJobs[0].ID, err)
|
||||
}
|
||||
concurrentRun = jobRun
|
||||
}
|
||||
|
||||
return concurrentRun, nil
|
||||
}
|
||||
|
||||
func checkRunConcurrency(ctx context.Context, run *actions_model.ActionRun) (jobs, updatedJobs []*actions_model.ActionRunJob, err error) {
|
||||
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)
|
||||
return nil
|
||||
}
|
||||
|
||||
// check run (workflow-level) concurrency
|
||||
if run.ConcurrencyGroup != "" {
|
||||
if err := collect(run.ConcurrencyGroup); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// check job concurrency
|
||||
runJobs, err := db.Find[actions_model.ActionRunJob](ctx, actions_model.FindRunJobOptions{RunID: run.ID})
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("find run %d jobs: %w", run.ID, err)
|
||||
}
|
||||
for _, job := range runJobs {
|
||||
if !job.Status.IsDone() {
|
||||
continue
|
||||
}
|
||||
if job.ConcurrencyGroup == "" || checkedConcurrencyGroup.Contains(job.ConcurrencyGroup) {
|
||||
continue
|
||||
}
|
||||
if err := collect(job.ConcurrencyGroup); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
}
|
||||
return jobs, updatedJobs, 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
|
||||
}
|
||||
// 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 err != nil {
|
||||
return nil, nil, fmt.Errorf("shouldBlockRunByConcurrency: %w", err)
|
||||
}
|
||||
if shouldBlock {
|
||||
return jobs, nil, nil
|
||||
}
|
||||
}
|
||||
vars, err := actions_model.GetVariablesOfRun(ctx, run)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
if err = db.WithTx(ctx, func(ctx context.Context) error {
|
||||
for _, job := range jobs {
|
||||
idToJobs[job.JobID] = append(idToJobs[job.JobID], job)
|
||||
job.Run = run
|
||||
}
|
||||
|
||||
updates := newJobStatusResolver(jobs).Resolve()
|
||||
updates := newJobStatusResolver(jobs, vars).Resolve(ctx)
|
||||
for _, job := range jobs {
|
||||
if status, ok := updates[job.ID]; ok {
|
||||
job.Status = status
|
||||
@@ -67,31 +230,15 @@ func checkJobsOfRun(ctx context.Context, runID int64) error {
|
||||
} else if n != 1 {
|
||||
return fmt.Errorf("no affected for updating blocked job %v", job.ID)
|
||||
}
|
||||
updatedjobs = append(updatedjobs, job)
|
||||
updatedJobs = append(updatedJobs, job)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
return err
|
||||
return nil, nil, err
|
||||
}
|
||||
CreateCommitStatus(ctx, jobs...)
|
||||
for _, job := range updatedjobs {
|
||||
_ = job.LoadAttributes(ctx)
|
||||
notify_service.WorkflowJobStatusUpdate(ctx, job.Run.Repo, job.Run.TriggerUser, job, nil)
|
||||
}
|
||||
if len(jobs) > 0 {
|
||||
runUpdated := true
|
||||
for _, job := range jobs {
|
||||
if !job.Status.IsDone() {
|
||||
runUpdated = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if runUpdated {
|
||||
NotifyWorkflowRunStatusUpdateWithReload(ctx, jobs[0])
|
||||
}
|
||||
}
|
||||
return nil
|
||||
|
||||
return jobs, updatedJobs, nil
|
||||
}
|
||||
|
||||
func NotifyWorkflowRunStatusUpdateWithReload(ctx context.Context, job *actions_model.ActionRunJob) {
|
||||
@@ -101,15 +248,19 @@ func NotifyWorkflowRunStatusUpdateWithReload(ctx context.Context, job *actions_m
|
||||
return
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
type jobStatusResolver struct {
|
||||
statuses map[int64]actions_model.Status
|
||||
needs map[int64][]int64
|
||||
jobMap map[int64]*actions_model.ActionRunJob
|
||||
vars map[string]string
|
||||
}
|
||||
|
||||
func newJobStatusResolver(jobs actions_model.ActionJobList) *jobStatusResolver {
|
||||
func newJobStatusResolver(jobs actions_model.ActionJobList, vars map[string]string) *jobStatusResolver {
|
||||
idToJobs := make(map[string][]*actions_model.ActionRunJob, len(jobs))
|
||||
jobMap := make(map[int64]*actions_model.ActionRunJob)
|
||||
for _, job := range jobs {
|
||||
@@ -131,13 +282,14 @@ func newJobStatusResolver(jobs actions_model.ActionJobList) *jobStatusResolver {
|
||||
statuses: statuses,
|
||||
needs: needs,
|
||||
jobMap: jobMap,
|
||||
vars: vars,
|
||||
}
|
||||
}
|
||||
|
||||
func (r *jobStatusResolver) Resolve() map[int64]actions_model.Status {
|
||||
func (r *jobStatusResolver) Resolve(ctx context.Context) map[int64]actions_model.Status {
|
||||
ret := map[int64]actions_model.Status{}
|
||||
for i := 0; i < len(r.statuses); i++ {
|
||||
updated := r.resolve()
|
||||
updated := r.resolve(ctx)
|
||||
if len(updated) == 0 {
|
||||
return ret
|
||||
}
|
||||
@@ -149,43 +301,86 @@ func (r *jobStatusResolver) Resolve() map[int64]actions_model.Status {
|
||||
return ret
|
||||
}
|
||||
|
||||
func (r *jobStatusResolver) resolve() map[int64]actions_model.Status {
|
||||
func (r *jobStatusResolver) resolveCheckNeeds(id int64) (allDone, allSucceed bool) {
|
||||
allDone, allSucceed = true, true
|
||||
for _, need := range r.needs[id] {
|
||||
needStatus := r.statuses[need]
|
||||
if !needStatus.IsDone() {
|
||||
allDone = false
|
||||
}
|
||||
if needStatus.In(actions_model.StatusFailure, actions_model.StatusCancelled, actions_model.StatusSkipped) {
|
||||
allSucceed = false
|
||||
}
|
||||
}
|
||||
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 {
|
||||
actionRunJob := r.jobMap[id]
|
||||
if status != actions_model.StatusBlocked {
|
||||
continue
|
||||
}
|
||||
allDone, allSucceed := true, true
|
||||
for _, need := range r.needs[id] {
|
||||
needStatus := r.statuses[need]
|
||||
if !needStatus.IsDone() {
|
||||
allDone = false
|
||||
}
|
||||
if needStatus.In(actions_model.StatusFailure, actions_model.StatusCancelled, actions_model.StatusSkipped) {
|
||||
allSucceed = false
|
||||
allDone, allSucceed := r.resolveCheckNeeds(id)
|
||||
if !allDone {
|
||||
continue
|
||||
}
|
||||
|
||||
// update concurrency and check whether the job can run now
|
||||
err := updateConcurrencyEvaluationForJobWithNeeds(ctx, actionRunJob, r.vars)
|
||||
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)
|
||||
}
|
||||
|
||||
newStatus := util.Iif(shouldStartJob, actions_model.StatusWaiting, actions_model.StatusSkipped)
|
||||
if newStatus == actions_model.StatusWaiting {
|
||||
newStatus, err = PrepareToStartJobWithConcurrency(ctx, actionRunJob)
|
||||
if err != nil {
|
||||
log.Error("ShouldBlockJobByConcurrency failed, this job will stay blocked: job: %d, err: %v", id, err)
|
||||
}
|
||||
}
|
||||
if allDone {
|
||||
if allSucceed {
|
||||
ret[id] = actions_model.StatusWaiting
|
||||
} else {
|
||||
// Check if the job has an "if" condition
|
||||
hasIf := false
|
||||
if wfJobs, _ := jobparser.Parse(r.jobMap[id].WorkflowPayload); len(wfJobs) == 1 {
|
||||
_, wfJob := wfJobs[0].Job()
|
||||
hasIf = len(wfJob.If.Value) > 0
|
||||
}
|
||||
|
||||
if hasIf {
|
||||
// act_runner will check the "if" condition
|
||||
ret[id] = actions_model.StatusWaiting
|
||||
} else {
|
||||
// If the "if" condition is empty and not all dependent jobs completed successfully,
|
||||
// the job should be skipped.
|
||||
ret[id] = actions_model.StatusSkipped
|
||||
}
|
||||
}
|
||||
if newStatus != actions_model.StatusBlocked {
|
||||
ret[id] = newStatus
|
||||
}
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
func updateConcurrencyEvaluationForJobWithNeeds(ctx context.Context, actionRunJob *actions_model.ActionRunJob, vars map[string]string) error {
|
||||
if setting.IsInTesting && actionRunJob.RepoID == 0 {
|
||||
return nil // for testing purpose only, no repo, no evaluation
|
||||
}
|
||||
|
||||
err := EvaluateJobConcurrencyFillModel(ctx, actionRunJob.Run, actionRunJob, vars, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("evaluate job concurrency: %w", err)
|
||||
}
|
||||
|
||||
if _, err := actions_model.UpdateRunJob(ctx, actionRunJob, nil, "concurrency_group", "concurrency_cancel", "is_concurrency_evaluated"); err != nil {
|
||||
return fmt.Errorf("update run job: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ import (
|
||||
"testing"
|
||||
|
||||
actions_model "code.gitea.io/gitea/models/actions"
|
||||
"code.gitea.io/gitea/models/db"
|
||||
"code.gitea.io/gitea/models/unittest"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
@@ -129,8 +131,125 @@ jobs:
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
r := newJobStatusResolver(tt.jobs)
|
||||
assert.Equal(t, tt.want, r.Resolve())
|
||||
r := newJobStatusResolver(tt.jobs, nil)
|
||||
assert.Equal(t, tt.want, r.Resolve(t.Context()))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Test_checkRunConcurrency_NoDuplicateConcurrencyGroupCheck verifies that when a run's
|
||||
// ConcurrencyGroup has already been checked at the run level, the same group is not
|
||||
// re-checked for individual jobs.
|
||||
func Test_checkRunConcurrency_NoDuplicateConcurrencyGroupCheck(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
ctx := t.Context()
|
||||
|
||||
// Run A: the triggering run with a concurrency group.
|
||||
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",
|
||||
}
|
||||
assert.NoError(t, db.Insert(ctx, runA))
|
||||
|
||||
// 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,
|
||||
RepoID: 4,
|
||||
OwnerID: 1,
|
||||
JobID: "job1",
|
||||
Name: "job1",
|
||||
Status: actions_model.StatusSuccess,
|
||||
ConcurrencyGroup: "test-cg",
|
||||
}
|
||||
assert.NoError(t, db.Insert(ctx, jobADone))
|
||||
|
||||
// Blocked run B competing for the same concurrency group.
|
||||
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",
|
||||
}
|
||||
assert.NoError(t, db.Insert(ctx, runB))
|
||||
|
||||
// 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,
|
||||
}
|
||||
assert.NoError(t, db.Insert(ctx, jobBBlocked))
|
||||
|
||||
jobs, _, err := checkRunConcurrency(ctx, runA)
|
||||
assert.NoError(t, err)
|
||||
|
||||
if assert.Len(t, jobs, 1) {
|
||||
assert.Equal(t, jobBBlocked.ID, jobs[0].ID)
|
||||
}
|
||||
}
|
||||
|
||||
// Test_checkJobsOfRun_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) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
ctx := t.Context()
|
||||
|
||||
const group = "test-run-level-concurrency-keeps-blocked"
|
||||
|
||||
// Holder run: Running run 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,
|
||||
}
|
||||
assert.NoError(t, db.Insert(ctx, holderRun))
|
||||
|
||||
// Blocked run: Blocked run 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.
|
||||
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,
|
||||
}
|
||||
assert.NoError(t, db.Insert(ctx, blockedRun))
|
||||
blockedJob := &actions_model.ActionRunJob{
|
||||
RunID: blockedRun.ID,
|
||||
RepoID: 4, OwnerID: 1, JobID: "job1", Name: "job1",
|
||||
Status: actions_model.StatusBlocked,
|
||||
WorkflowPayload: []byte(`
|
||||
name: test
|
||||
on: push
|
||||
jobs:
|
||||
job1:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo
|
||||
`),
|
||||
}
|
||||
assert.NoError(t, db.Insert(ctx, blockedJob))
|
||||
|
||||
_, updated, err := checkJobsOfRun(ctx, blockedRun)
|
||||
assert.NoError(t, err)
|
||||
assert.Empty(t, updated)
|
||||
|
||||
refreshed := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: blockedJob.ID})
|
||||
assert.Equal(t, actions_model.StatusBlocked, refreshed.Status)
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ package actions
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
actions_model "code.gitea.io/gitea/models/actions"
|
||||
issues_model "code.gitea.io/gitea/models/issues"
|
||||
@@ -20,6 +21,7 @@ import (
|
||||
"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"
|
||||
@@ -47,7 +49,7 @@ func (n *actionsNotifier) NewIssue(ctx context.Context, issue *issues_model.Issu
|
||||
log.Error("issue.LoadPoster: %v", err)
|
||||
return
|
||||
}
|
||||
permission, _ := access_model.GetUserRepoPermission(ctx, issue.Repo, issue.Poster)
|
||||
permission, _ := access_model.GetIndividualUserRepoPermission(ctx, issue.Repo, issue.Poster)
|
||||
|
||||
newNotifyInputFromIssue(issue, webhook_module.HookEventIssues).WithPayload(&api.IssuePayload{
|
||||
Action: api.HookIssueOpened,
|
||||
@@ -76,7 +78,7 @@ func (n *actionsNotifier) notifyIssueChangeWithTitleOrContent(ctx context.Contex
|
||||
return
|
||||
}
|
||||
|
||||
permission, _ := access_model.GetUserRepoPermission(ctx, issue.Repo, issue.Poster)
|
||||
permission, _ := access_model.GetIndividualUserRepoPermission(ctx, issue.Repo, issue.Poster)
|
||||
if issue.IsPull {
|
||||
if err = issue.LoadPullRequest(ctx); err != nil {
|
||||
log.Error("loadPullRequest: %v", err)
|
||||
@@ -110,7 +112,7 @@ func (n *actionsNotifier) notifyIssueChangeWithTitleOrContent(ctx context.Contex
|
||||
// IssueChangeStatus notifies close or reopen issue to notifiers
|
||||
func (n *actionsNotifier) IssueChangeStatus(ctx context.Context, doer *user_model.User, commitID string, issue *issues_model.Issue, _ *issues_model.Comment, isClosed bool) {
|
||||
ctx = withMethod(ctx, "IssueChangeStatus")
|
||||
permission, _ := access_model.GetUserRepoPermission(ctx, issue.Repo, issue.Poster)
|
||||
permission, _ := access_model.GetIndividualUserRepoPermission(ctx, issue.Repo, issue.Poster)
|
||||
if issue.IsPull {
|
||||
if err := issue.LoadPullRequest(ctx); err != nil {
|
||||
log.Error("LoadPullRequest: %v", err)
|
||||
@@ -259,7 +261,7 @@ func notifyIssueChange(ctx context.Context, doer *user_model.User, issue *issues
|
||||
return
|
||||
}
|
||||
|
||||
permission, _ := access_model.GetUserRepoPermission(ctx, issue.Repo, issue.Poster)
|
||||
permission, _ := access_model.GetIndividualUserRepoPermission(ctx, issue.Repo, issue.Poster)
|
||||
payload := &api.IssuePayload{
|
||||
Action: action,
|
||||
Index: issue.Index,
|
||||
@@ -322,7 +324,7 @@ func notifyIssueCommentChange(ctx context.Context, doer *user_model.User, commen
|
||||
return
|
||||
}
|
||||
|
||||
permission, _ := access_model.GetUserRepoPermission(ctx, comment.Issue.Repo, doer)
|
||||
permission, _ := access_model.GetDoerRepoPermission(ctx, comment.Issue.Repo, doer)
|
||||
|
||||
payload := &api.IssueCommentPayload{
|
||||
Action: action,
|
||||
@@ -376,7 +378,7 @@ func (n *actionsNotifier) NewPullRequest(ctx context.Context, pull *issues_model
|
||||
return
|
||||
}
|
||||
|
||||
permission, _ := access_model.GetUserRepoPermission(ctx, pull.Issue.Repo, pull.Issue.Poster)
|
||||
permission, _ := access_model.GetIndividualUserRepoPermission(ctx, pull.Issue.Repo, pull.Issue.Poster)
|
||||
|
||||
newNotifyInputFromIssue(pull.Issue, webhook_module.HookEventPullRequest).
|
||||
WithPayload(&api.PullRequestPayload{
|
||||
@@ -404,8 +406,8 @@ func (n *actionsNotifier) CreateRepository(ctx context.Context, doer, u *user_mo
|
||||
func (n *actionsNotifier) ForkRepository(ctx context.Context, doer *user_model.User, oldRepo, repo *repo_model.Repository) {
|
||||
ctx = withMethod(ctx, "ForkRepository")
|
||||
|
||||
oldPermission, _ := access_model.GetUserRepoPermission(ctx, oldRepo, doer)
|
||||
permission, _ := access_model.GetUserRepoPermission(ctx, repo, doer)
|
||||
oldPermission, _ := access_model.GetDoerRepoPermission(ctx, oldRepo, doer)
|
||||
permission, _ := access_model.GetDoerRepoPermission(ctx, repo, doer)
|
||||
|
||||
// forked webhook
|
||||
newNotifyInput(oldRepo, doer, webhook_module.HookEventFork).WithPayload(&api.ForkPayload{
|
||||
@@ -452,9 +454,9 @@ func (n *actionsNotifier) PullRequestReview(ctx context.Context, pr *issues_mode
|
||||
return
|
||||
}
|
||||
|
||||
permission, err := access_model.GetUserRepoPermission(ctx, review.Issue.Repo, review.Issue.Poster)
|
||||
permission, err := access_model.GetIndividualUserRepoPermission(ctx, review.Issue.Repo, review.Issue.Poster)
|
||||
if err != nil {
|
||||
log.Error("models.GetUserRepoPermission: %v", err)
|
||||
log.Error("models.GetIndividualUserRepoPermission: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -481,7 +483,7 @@ func (n *actionsNotifier) PullRequestReviewRequest(ctx context.Context, doer *us
|
||||
|
||||
ctx = withMethod(ctx, "PullRequestReviewRequest")
|
||||
|
||||
permission, _ := access_model.GetUserRepoPermission(ctx, issue.Repo, doer)
|
||||
permission, _ := access_model.GetDoerRepoPermission(ctx, issue.Repo, doer)
|
||||
if err := issue.LoadPullRequest(ctx); err != nil {
|
||||
log.Error("LoadPullRequest failed: %v", err)
|
||||
return
|
||||
@@ -525,9 +527,9 @@ func (*actionsNotifier) MergePullRequest(ctx context.Context, doer *user_model.U
|
||||
return
|
||||
}
|
||||
|
||||
permission, err := access_model.GetUserRepoPermission(ctx, pr.Issue.Repo, doer)
|
||||
permission, err := access_model.GetDoerRepoPermission(ctx, pr.Issue.Repo, doer)
|
||||
if err != nil {
|
||||
log.Error("models.GetUserRepoPermission: %v", err)
|
||||
log.Error("models.GetDoerRepoPermission: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -723,7 +725,7 @@ func (n *actionsNotifier) PullRequestChangeTargetBranch(ctx context.Context, doe
|
||||
return
|
||||
}
|
||||
|
||||
permission, _ := access_model.GetUserRepoPermission(ctx, pr.Issue.Repo, pr.Issue.Poster)
|
||||
permission, _ := access_model.GetIndividualUserRepoPermission(ctx, pr.Issue.Repo, pr.Issue.Poster)
|
||||
newNotifyInput(pr.Issue.Repo, doer, webhook_module.HookEventPullRequest).
|
||||
WithPayload(&api.PullRequestPayload{
|
||||
Action: api.HookIssueEdited,
|
||||
@@ -805,7 +807,10 @@ func (n *actionsNotifier) WorkflowRunStatusUpdate(ctx context.Context, repo *rep
|
||||
}
|
||||
defer gitRepo.Close()
|
||||
|
||||
convertedWorkflow, err := convert.GetActionWorkflow(ctx, gitRepo, repo, run.WorkflowID)
|
||||
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
|
||||
@@ -816,12 +821,14 @@ func (n *actionsNotifier) WorkflowRunStatusUpdate(ctx context.Context, repo *rep
|
||||
return
|
||||
}
|
||||
|
||||
newNotifyInput(repo, sender, webhook_module.HookEventWorkflowRun).WithPayload(&api.WorkflowRunPayload{
|
||||
Action: status,
|
||||
Workflow: convertedWorkflow,
|
||||
WorkflowRun: convertedRun,
|
||||
Organization: org,
|
||||
Repo: convert.ToRepo(ctx, repo, access_model.Permission{AccessMode: perm_model.AccessModeOwner}),
|
||||
Sender: convert.ToUser(ctx, sender, nil),
|
||||
}).Notify(ctx)
|
||||
newNotifyInput(repo, sender, webhook_module.HookEventWorkflowRun).
|
||||
WithRef(git.RefNameFromBranch(repo.DefaultBranch).String()).
|
||||
WithPayload(&api.WorkflowRunPayload{
|
||||
Action: status,
|
||||
Workflow: convertedWorkflow,
|
||||
WorkflowRun: convertedRun,
|
||||
Organization: org,
|
||||
Repo: convert.ToRepo(ctx, repo, access_model.Permission{AccessMode: perm_model.AccessModeOwner}),
|
||||
Sender: convert.ToUser(ctx, sender, nil),
|
||||
}).Notify(ctx)
|
||||
}
|
||||
|
||||
@@ -27,9 +27,7 @@ import (
|
||||
api "code.gitea.io/gitea/modules/structs"
|
||||
webhook_module "code.gitea.io/gitea/modules/webhook"
|
||||
"code.gitea.io/gitea/services/convert"
|
||||
notify_service "code.gitea.io/gitea/services/notify"
|
||||
|
||||
"github.com/nektos/act/pkg/jobparser"
|
||||
"github.com/nektos/act/pkg/model"
|
||||
)
|
||||
|
||||
@@ -195,7 +193,7 @@ func notify(ctx context.Context, input *notifyInput) error {
|
||||
}
|
||||
|
||||
log.Trace("repo %s with commit %s event %s find %d workflows and %d schedules",
|
||||
input.Repo.RepoPath(),
|
||||
input.Repo.RelativePath(),
|
||||
commit.ID,
|
||||
input.Event,
|
||||
len(workflows),
|
||||
@@ -204,7 +202,7 @@ func notify(ctx context.Context, input *notifyInput) error {
|
||||
|
||||
for _, wf := range workflows {
|
||||
if actionsConfig.IsWorkflowDisabled(wf.EntryName) {
|
||||
log.Trace("repo %s has disable workflows %s", input.Repo.RepoPath(), wf.EntryName)
|
||||
log.Trace("repo %s has disable workflows %s", input.Repo.RelativePath(), wf.EntryName)
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -225,7 +223,7 @@ func notify(ctx context.Context, input *notifyInput) error {
|
||||
return fmt.Errorf("DetectWorkflows: %w", err)
|
||||
}
|
||||
if len(baseWorkflows) == 0 {
|
||||
log.Trace("repo %s with commit %s couldn't find pull_request_target workflows", input.Repo.RepoPath(), baseCommit.ID)
|
||||
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 wf.TriggerEvent.Name == actions_module.GithubEventPullRequestTarget {
|
||||
@@ -255,11 +253,11 @@ func skipWorkflows(ctx context.Context, input *notifyInput, commit *git.Commit)
|
||||
if slices.Contains(skipWorkflowEvents, input.Event) {
|
||||
for _, s := range setting.Actions.SkipWorkflowStrings {
|
||||
if input.PullRequest != nil && strings.Contains(input.PullRequest.Issue.Title, s) {
|
||||
log.Debug("repo %s: skipped run for pr %v because of %s string", input.Repo.RepoPath(), input.PullRequest.Issue.ID, s)
|
||||
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) {
|
||||
log.Debug("repo %s with commit %s: skipped run because of %s string", input.Repo.RepoPath(), commit.ID, s)
|
||||
log.Debug("repo %s with commit %s: skipped run because of %s string", input.Repo.RelativePath(), commit.ID, s)
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -282,7 +280,7 @@ func skipWorkflows(ctx context.Context, input *notifyInput, commit *git.Commit)
|
||||
}
|
||||
}
|
||||
// skip workflow runs events exceeding the maximum of 5 recursive events
|
||||
log.Debug("repo %s: skipped workflow_run because of recursive event of 5", input.Repo.RepoPath())
|
||||
log.Debug("repo %s: skipped workflow_run because of recursive event of 5", input.Repo.RelativePath())
|
||||
return true
|
||||
}
|
||||
return false
|
||||
@@ -296,7 +294,7 @@ func handleWorkflows(
|
||||
ref git.RefName,
|
||||
) error {
|
||||
if len(detectedWorkflows) == 0 {
|
||||
log.Trace("repo %s with commit %s couldn't find workflows", input.Repo.RepoPath(), commit.ID)
|
||||
log.Trace("repo %s with commit %s couldn't find workflows", input.Repo.RelativePath(), commit.ID)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -322,7 +320,7 @@ func handleWorkflows(
|
||||
|
||||
for _, dwf := range detectedWorkflows {
|
||||
run := &actions_model.ActionRun{
|
||||
Title: strings.SplitN(commit.CommitMessage, "\n", 2)[0],
|
||||
Title: commit.Summary(),
|
||||
RepoID: input.Repo.ID,
|
||||
Repo: input.Repo,
|
||||
OwnerID: input.Repo.OwnerID,
|
||||
@@ -346,66 +344,10 @@ func handleWorkflows(
|
||||
|
||||
run.NeedApproval = need
|
||||
|
||||
if err := run.LoadAttributes(ctx); err != nil {
|
||||
log.Error("LoadAttributes: %v", err)
|
||||
if err := PrepareRunAndInsert(ctx, dwf.Content, run, nil); err != nil {
|
||||
log.Error("PrepareRunAndInsert: %v", err)
|
||||
continue
|
||||
}
|
||||
|
||||
vars, err := actions_model.GetVariablesOfRun(ctx, run)
|
||||
if err != nil {
|
||||
log.Error("GetVariablesOfRun: %v", err)
|
||||
continue
|
||||
}
|
||||
|
||||
giteaCtx := GenerateGiteaContext(run, nil)
|
||||
|
||||
jobs, err := jobparser.Parse(dwf.Content, jobparser.WithVars(vars), jobparser.WithGitContext(giteaCtx.ToGitHubContext()))
|
||||
if err != nil {
|
||||
log.Error("jobparser.Parse: %v", err)
|
||||
continue
|
||||
}
|
||||
|
||||
if len(jobs) > 0 && jobs[0].RunName != "" {
|
||||
run.Title = jobs[0].RunName
|
||||
}
|
||||
|
||||
// cancel running jobs if the event is push or pull_request_sync
|
||||
if run.Event == webhook_module.HookEventPush ||
|
||||
run.Event == webhook_module.HookEventPullRequestSync {
|
||||
if err := CancelPreviousJobs(
|
||||
ctx,
|
||||
run.RepoID,
|
||||
run.Ref,
|
||||
run.WorkflowID,
|
||||
run.Event,
|
||||
); err != nil {
|
||||
log.Error("CancelPreviousJobs: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := actions_model.InsertRun(ctx, run, jobs); err != nil {
|
||||
log.Error("InsertRun: %v", err)
|
||||
continue
|
||||
}
|
||||
|
||||
alljobs, err := db.Find[actions_model.ActionRunJob](ctx, actions_model.FindRunJobOptions{RunID: run.ID})
|
||||
if err != nil {
|
||||
log.Error("FindRunJobs: %v", err)
|
||||
continue
|
||||
}
|
||||
CreateCommitStatus(ctx, alljobs...)
|
||||
if len(alljobs) > 0 {
|
||||
job := alljobs[0]
|
||||
err := job.LoadRun(ctx)
|
||||
if err != nil {
|
||||
log.Error("LoadRun: %v", err)
|
||||
continue
|
||||
}
|
||||
notify_service.WorkflowRunStatusUpdate(ctx, job.Run.Repo, job.Run.TriggerUser, job.Run)
|
||||
}
|
||||
for _, job := range alljobs {
|
||||
notify_service.WorkflowJobStatusUpdate(ctx, input.Repo, input.Doer, job, nil)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -420,7 +362,7 @@ func notifyRelease(ctx context.Context, doer *user_model.User, rel *repo_model.R
|
||||
return
|
||||
}
|
||||
|
||||
permission, _ := access_model.GetUserRepoPermission(ctx, rel.Repo, doer)
|
||||
permission, _ := access_model.GetDoerRepoPermission(ctx, rel.Repo, doer)
|
||||
|
||||
newNotifyInput(rel.Repo, doer, webhook_module.HookEventRelease).
|
||||
WithRef(git.RefNameFromTag(rel.TagName).String()).
|
||||
@@ -471,8 +413,8 @@ 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.GetUserRepoPermission(ctx, repo, user); err != nil {
|
||||
return false, fmt.Errorf("GetUserRepoPermission: %w", err)
|
||||
if perm, err := access_model.GetDoerRepoPermission(ctx, repo, user); err != nil {
|
||||
return false, fmt.Errorf("GetDoerRepoPermission: %w", err)
|
||||
} else if perm.CanWrite(unit_model.TypeActions) {
|
||||
log.Trace("do not need approval because user %d can write", user.ID)
|
||||
return false, nil
|
||||
@@ -517,7 +459,7 @@ func handleSchedules(
|
||||
}
|
||||
|
||||
if len(detectedWorkflows) == 0 {
|
||||
log.Trace("repo %s with commit %s couldn't find schedules", input.Repo.RepoPath(), commit.ID)
|
||||
log.Trace("repo %s with commit %s couldn't find schedules", input.Repo.RelativePath(), commit.ID)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -541,7 +483,7 @@ func handleSchedules(
|
||||
}
|
||||
|
||||
run := &actions_model.ActionSchedule{
|
||||
Title: strings.SplitN(commit.CommitMessage, "\n", 2)[0],
|
||||
Title: commit.Summary(),
|
||||
RepoID: input.Repo.ID,
|
||||
Repo: input.Repo,
|
||||
OwnerID: input.Repo.OwnerID,
|
||||
@@ -556,24 +498,6 @@ func handleSchedules(
|
||||
Content: dwf.Content,
|
||||
}
|
||||
|
||||
vars, err := actions_model.GetVariablesOfRun(ctx, run.ToActionRun())
|
||||
if err != nil {
|
||||
log.Error("GetVariablesOfRun: %v", err)
|
||||
continue
|
||||
}
|
||||
|
||||
giteaCtx := GenerateGiteaContext(run.ToActionRun(), nil)
|
||||
|
||||
jobs, err := jobparser.Parse(dwf.Content, jobparser.WithVars(vars), jobparser.WithGitContext(giteaCtx.ToGitHubContext()))
|
||||
if err != nil {
|
||||
log.Error("jobparser.Parse: %v", err)
|
||||
continue
|
||||
}
|
||||
|
||||
if len(jobs) > 0 && jobs[0].RunName != "" {
|
||||
run.Title = jobs[0].RunName
|
||||
}
|
||||
|
||||
crons = append(crons, run)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
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"
|
||||
|
||||
"go.yaml.in/yaml/v4"
|
||||
)
|
||||
|
||||
// ExtractJobPermissionsFromWorkflow extracts permissions from an already parsed workflow/job.
|
||||
// It returns nil if neither workflow nor job explicitly specifies permissions.
|
||||
func ExtractJobPermissionsFromWorkflow(flow *jobparser.SingleWorkflow, job *jobparser.Job) *repo_model.ActionsTokenPermissions {
|
||||
if flow == nil || job == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
jobPerms := parseRawPermissionsExplicit(&job.RawPermissions)
|
||||
if jobPerms != nil {
|
||||
return jobPerms
|
||||
}
|
||||
|
||||
workflowPerms := parseRawPermissionsExplicit(&flow.RawPermissions)
|
||||
if workflowPerms != nil {
|
||||
return workflowPerms
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseRawPermissionsExplicit parses a YAML permissions node and returns only explicit scopes.
|
||||
// It returns nil if the node does not explicitly specify permissions.
|
||||
func parseRawPermissionsExplicit(rawPerms *yaml.Node) *repo_model.ActionsTokenPermissions {
|
||||
if rawPerms == nil || (rawPerms.Kind == yaml.ScalarNode && rawPerms.Value == "") {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Unwrap DocumentNode and resolve AliasNode
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
if node.Kind == yaml.ScalarNode && node.Value == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Handle scalar values: "read-all" or "write-all"
|
||||
if node.Kind == yaml.ScalarNode {
|
||||
switch node.Value {
|
||||
case "read-all":
|
||||
return new(repo_model.MakeActionsTokenPermissions(perm.AccessModeRead))
|
||||
case "write-all":
|
||||
return new(repo_model.MakeActionsTokenPermissions(perm.AccessModeWrite))
|
||||
default:
|
||||
// Explicit but unrecognized scalar: return all-none permissions.
|
||||
return new(repo_model.MakeActionsTokenPermissions(perm.AccessModeNone))
|
||||
}
|
||||
}
|
||||
|
||||
// Handle mapping: individual permission scopes
|
||||
if node.Kind == yaml.MappingNode {
|
||||
result := repo_model.MakeActionsTokenPermissions(perm.AccessModeNone)
|
||||
|
||||
// Collect all scopes into a map first to handle priority
|
||||
scopes := make(map[string]perm.AccessMode)
|
||||
for i := 0; i < len(node.Content); i += 2 {
|
||||
if i+1 >= len(node.Content) {
|
||||
break
|
||||
}
|
||||
keyNode := node.Content[i]
|
||||
valueNode := node.Content[i+1]
|
||||
|
||||
if keyNode.Kind != yaml.ScalarNode || valueNode.Kind != yaml.ScalarNode {
|
||||
continue
|
||||
}
|
||||
|
||||
scopes[keyNode.Value] = parseAccessMode(valueNode.Value)
|
||||
}
|
||||
|
||||
// 1. Apply 'contents' first (lower priority)
|
||||
if mode, ok := scopes["contents"]; ok {
|
||||
result.UnitAccessModes[unit.TypeCode] = mode
|
||||
result.UnitAccessModes[unit.TypeReleases] = mode
|
||||
}
|
||||
|
||||
// 2. Apply all other scopes (overwrites contents if specified)
|
||||
for scope, mode := range scopes {
|
||||
switch scope {
|
||||
case "contents":
|
||||
// already handled
|
||||
case "code":
|
||||
result.UnitAccessModes[unit.TypeCode] = mode
|
||||
case "issues":
|
||||
result.UnitAccessModes[unit.TypeIssues] = mode
|
||||
case "pull-requests":
|
||||
result.UnitAccessModes[unit.TypePullRequests] = mode
|
||||
case "packages":
|
||||
result.UnitAccessModes[unit.TypePackages] = mode
|
||||
case "actions":
|
||||
result.UnitAccessModes[unit.TypeActions] = mode
|
||||
case "wiki":
|
||||
result.UnitAccessModes[unit.TypeWiki] = mode
|
||||
case "releases":
|
||||
result.UnitAccessModes[unit.TypeReleases] = mode
|
||||
case "projects":
|
||||
result.UnitAccessModes[unit.TypeProjects] = mode
|
||||
// Scopes github supports but gitea does not, see url for details
|
||||
// https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax
|
||||
case "artifact-metadata", "attestations", "checks", "deployments",
|
||||
"id-token", "models", "discussions", "pages", "security-events", "statuses":
|
||||
// not supported
|
||||
default:
|
||||
setting.PanicInDevOrTesting("Unrecognized permission scope: %s", scope)
|
||||
}
|
||||
}
|
||||
|
||||
return &result
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseAccessMode converts a string access level to perm.AccessMode
|
||||
func parseAccessMode(s string) perm.AccessMode {
|
||||
switch s {
|
||||
case "write":
|
||||
return perm.AccessModeWrite
|
||||
case "read":
|
||||
return perm.AccessModeRead
|
||||
default:
|
||||
return perm.AccessModeNone
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
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"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.yaml.in/yaml/v4"
|
||||
)
|
||||
|
||||
func TestParseRawPermissions_ReadAll(t *testing.T) {
|
||||
var rawPerms yaml.Node
|
||||
err := yaml.Unmarshal([]byte(`read-all`), &rawPerms)
|
||||
assert.NoError(t, err)
|
||||
|
||||
result := parseRawPermissionsExplicit(&rawPerms)
|
||||
require.NotNil(t, result)
|
||||
|
||||
assert.Equal(t, perm.AccessModeRead, result.UnitAccessModes[unit.TypeCode])
|
||||
assert.Equal(t, perm.AccessModeRead, result.UnitAccessModes[unit.TypeIssues])
|
||||
assert.Equal(t, perm.AccessModeRead, result.UnitAccessModes[unit.TypePullRequests])
|
||||
assert.Equal(t, perm.AccessModeRead, result.UnitAccessModes[unit.TypePackages])
|
||||
assert.Equal(t, perm.AccessModeRead, result.UnitAccessModes[unit.TypeActions])
|
||||
assert.Equal(t, perm.AccessModeRead, result.UnitAccessModes[unit.TypeWiki])
|
||||
assert.Equal(t, perm.AccessModeRead, result.UnitAccessModes[unit.TypeProjects])
|
||||
}
|
||||
|
||||
// TestParseRawPermissions_GithubScopes verifies that all scopes that github supports are accounted for
|
||||
func TestParseRawPermissions_GithubScopes(t *testing.T) {
|
||||
var rawPerms yaml.Node
|
||||
// Taken and stripped down from:
|
||||
// https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#defining-access-for-the-github_token-scopes
|
||||
yamlContent := `
|
||||
actions: read
|
||||
artifact-metadata: read
|
||||
attestations: read
|
||||
checks: read
|
||||
contents: read
|
||||
deployments: read
|
||||
id-token: write
|
||||
issues: read
|
||||
models: read
|
||||
discussions: read
|
||||
packages: read
|
||||
pages: read
|
||||
pull-requests: read
|
||||
security-events: read
|
||||
statuses: read`
|
||||
err := yaml.Unmarshal([]byte(yamlContent), &rawPerms)
|
||||
require.NoError(t, err)
|
||||
|
||||
result := parseRawPermissionsExplicit(&rawPerms)
|
||||
require.NotNil(t, result)
|
||||
|
||||
// No asserts for permissions set on purpose
|
||||
}
|
||||
|
||||
func TestParseRawPermissions_WriteAll(t *testing.T) {
|
||||
var rawPerms yaml.Node
|
||||
err := yaml.Unmarshal([]byte(`write-all`), &rawPerms)
|
||||
assert.NoError(t, err)
|
||||
|
||||
result := parseRawPermissionsExplicit(&rawPerms)
|
||||
require.NotNil(t, result)
|
||||
|
||||
assert.Equal(t, perm.AccessModeWrite, result.UnitAccessModes[unit.TypeCode])
|
||||
assert.Equal(t, perm.AccessModeWrite, result.UnitAccessModes[unit.TypeIssues])
|
||||
assert.Equal(t, perm.AccessModeWrite, result.UnitAccessModes[unit.TypePullRequests])
|
||||
assert.Equal(t, perm.AccessModeWrite, result.UnitAccessModes[unit.TypePackages])
|
||||
assert.Equal(t, perm.AccessModeWrite, result.UnitAccessModes[unit.TypeActions])
|
||||
assert.Equal(t, perm.AccessModeWrite, result.UnitAccessModes[unit.TypeWiki])
|
||||
assert.Equal(t, perm.AccessModeWrite, result.UnitAccessModes[unit.TypeProjects])
|
||||
}
|
||||
|
||||
func TestParseRawPermissions_IndividualScopes(t *testing.T) {
|
||||
yamlContent := `
|
||||
contents: write
|
||||
issues: read
|
||||
pull-requests: none
|
||||
packages: write
|
||||
actions: read
|
||||
wiki: write
|
||||
projects: none
|
||||
`
|
||||
var rawPerms yaml.Node
|
||||
err := yaml.Unmarshal([]byte(yamlContent), &rawPerms)
|
||||
assert.NoError(t, err)
|
||||
|
||||
result := parseRawPermissionsExplicit(&rawPerms)
|
||||
require.NotNil(t, result)
|
||||
|
||||
assert.Equal(t, perm.AccessModeWrite, result.UnitAccessModes[unit.TypeCode])
|
||||
assert.Equal(t, perm.AccessModeRead, result.UnitAccessModes[unit.TypeIssues])
|
||||
assert.Equal(t, perm.AccessModeNone, result.UnitAccessModes[unit.TypePullRequests])
|
||||
assert.Equal(t, perm.AccessModeWrite, result.UnitAccessModes[unit.TypePackages])
|
||||
assert.Equal(t, perm.AccessModeRead, result.UnitAccessModes[unit.TypeActions])
|
||||
assert.Equal(t, perm.AccessModeWrite, result.UnitAccessModes[unit.TypeWiki])
|
||||
assert.Equal(t, perm.AccessModeNone, result.UnitAccessModes[unit.TypeProjects])
|
||||
}
|
||||
|
||||
func TestParseRawPermissions_Priority(t *testing.T) {
|
||||
t.Run("granular-wins-over-contents", func(t *testing.T) {
|
||||
yamlContent := `
|
||||
contents: read
|
||||
code: write
|
||||
releases: none
|
||||
`
|
||||
var rawPerms yaml.Node
|
||||
err := yaml.Unmarshal([]byte(yamlContent), &rawPerms)
|
||||
assert.NoError(t, err)
|
||||
|
||||
result := parseRawPermissionsExplicit(&rawPerms)
|
||||
require.NotNil(t, result)
|
||||
|
||||
assert.Equal(t, perm.AccessModeWrite, result.UnitAccessModes[unit.TypeCode])
|
||||
assert.Equal(t, perm.AccessModeNone, result.UnitAccessModes[unit.TypeReleases])
|
||||
})
|
||||
|
||||
t.Run("contents-applied-first", func(t *testing.T) {
|
||||
yamlContent := `
|
||||
code: none
|
||||
releases: write
|
||||
contents: read
|
||||
`
|
||||
var rawPerms yaml.Node
|
||||
err := yaml.Unmarshal([]byte(yamlContent), &rawPerms)
|
||||
assert.NoError(t, err)
|
||||
|
||||
result := parseRawPermissionsExplicit(&rawPerms)
|
||||
require.NotNil(t, result)
|
||||
|
||||
// code: none should win over contents: read
|
||||
assert.Equal(t, perm.AccessModeNone, result.UnitAccessModes[unit.TypeCode])
|
||||
// releases: write should win over contents: read
|
||||
assert.Equal(t, perm.AccessModeWrite, result.UnitAccessModes[unit.TypeReleases])
|
||||
})
|
||||
}
|
||||
|
||||
func TestParseRawPermissions_EmptyNode(t *testing.T) {
|
||||
var rawPerms yaml.Node
|
||||
// Empty node
|
||||
|
||||
result := parseRawPermissionsExplicit(&rawPerms)
|
||||
|
||||
// Should return nil for non-explicit
|
||||
assert.Nil(t, result)
|
||||
}
|
||||
|
||||
func TestParseRawPermissions_NilNode(t *testing.T) {
|
||||
result := parseRawPermissionsExplicit(nil)
|
||||
|
||||
// Should return nil
|
||||
assert.Nil(t, result)
|
||||
}
|
||||
|
||||
func TestParseAccessMode(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
expected perm.AccessMode
|
||||
}{
|
||||
{"write", perm.AccessModeWrite},
|
||||
{"read", perm.AccessModeRead},
|
||||
{"none", perm.AccessModeNone},
|
||||
{"", perm.AccessModeNone},
|
||||
{"invalid", perm.AccessModeNone},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.input, func(t *testing.T) {
|
||||
result := parseAccessMode(tt.input)
|
||||
assert.Equal(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractJobPermissionsFromWorkflow(t *testing.T) {
|
||||
workflowYAML := `
|
||||
name: Test Permissions
|
||||
on: workflow_dispatch
|
||||
permissions: read-all
|
||||
|
||||
jobs:
|
||||
job-read-only:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo "Full read-only"
|
||||
|
||||
job-none-perms:
|
||||
permissions: none
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo "Full read-only"
|
||||
|
||||
job-override:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- run: echo "Override to write"
|
||||
`
|
||||
|
||||
expectedPerms := map[string]*repo_model.ActionsTokenPermissions{}
|
||||
expectedPerms["job-read-only"] = new(repo_model.MakeActionsTokenPermissions(perm.AccessModeRead))
|
||||
expectedPerms["job-none-perms"] = new(repo_model.MakeActionsTokenPermissions(perm.AccessModeNone))
|
||||
expectedPerms["job-override"] = new(repo_model.MakeActionsTokenPermissions(perm.AccessModeNone))
|
||||
expectedPerms["job-override"].UnitAccessModes[unit.TypeCode] = perm.AccessModeWrite
|
||||
expectedPerms["job-override"].UnitAccessModes[unit.TypeReleases] = perm.AccessModeWrite
|
||||
|
||||
singleWorkflows, err := jobparser.Parse([]byte(workflowYAML))
|
||||
require.NoError(t, err)
|
||||
for _, flow := range singleWorkflows {
|
||||
jobID, jobDef := flow.Job()
|
||||
require.NotNil(t, jobDef)
|
||||
t.Run(jobID, func(t *testing.T) {
|
||||
assert.Equal(t, expectedPerms[jobID], ExtractJobPermissionsFromWorkflow(flow, jobDef))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -4,11 +4,43 @@
|
||||
package actions
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
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"
|
||||
|
||||
"github.com/nektos/act/pkg/model"
|
||||
"go.yaml.in/yaml/v4"
|
||||
"xorm.io/builder"
|
||||
)
|
||||
|
||||
// GetAllRerunJobs get all jobs that need to be rerun when job should be rerun
|
||||
// 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])
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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])
|
||||
@@ -36,3 +68,153 @@ func GetAllRerunJobs(job *actions_model.ActionRunJob, allJobs []*actions_model.A
|
||||
|
||||
return rerunJobs
|
||||
}
|
||||
|
||||
// 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)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("get run %d variables: %w", run.ID, 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 := EvaluateRunConcurrencyFillModel(ctx, run, &rawConcurrency, vars, nil); err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
run.Status, err = PrepareToStartRunWithConcurrency(ctx, run)
|
||||
if err != nil {
|
||||
return false, 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
|
||||
}
|
||||
|
||||
// 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 {
|
||||
if len(jobsToRerun) == 0 {
|
||||
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)
|
||||
}
|
||||
|
||||
for _, job := range jobsToRerun {
|
||||
shouldBlockJob := isRunBlocked
|
||||
if !shouldBlockJob {
|
||||
for _, need := range job.Needs {
|
||||
if rerunJobIDs.Contains(need) {
|
||||
shouldBlockJob = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -4,11 +4,14 @@
|
||||
package actions
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
actions_model "code.gitea.io/gitea/models/actions"
|
||||
"code.gitea.io/gitea/modules/util"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestGetAllRerunJobs(t *testing.T) {
|
||||
@@ -46,3 +49,97 @@ func TestGetAllRerunJobs(t *testing.T) {
|
||||
assert.ElementsMatch(t, tc.rerunJobs, rerunJobs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetFailedRerunJobs(t *testing.T) {
|
||||
// IDs must be non-zero to distinguish jobs in the dedup set.
|
||||
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}
|
||||
}
|
||||
|
||||
t.Run("no failed jobs returns empty", func(t *testing.T) {
|
||||
jobs := []*actions_model.ActionRunJob{
|
||||
makeJob(1, "job1", actions_model.StatusSuccess),
|
||||
makeJob(2, "job2", actions_model.StatusSkipped, "job1"),
|
||||
}
|
||||
assert.Empty(t, GetFailedRerunJobs(jobs))
|
||||
})
|
||||
|
||||
t.Run("single failed job with no dependents", func(t *testing.T) {
|
||||
job1 := makeJob(1, "job1", actions_model.StatusFailure)
|
||||
job2 := makeJob(2, "job2", actions_model.StatusSuccess)
|
||||
jobs := []*actions_model.ActionRunJob{job1, job2}
|
||||
|
||||
result := GetFailedRerunJobs(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)
|
||||
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)
|
||||
})
|
||||
|
||||
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
|
||||
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)
|
||||
})
|
||||
|
||||
t.Run("shared downstream dependent is not duplicated", func(t *testing.T) {
|
||||
// job1 and job2 both failed; job3 depends on both
|
||||
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
|
||||
})
|
||||
|
||||
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)
|
||||
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)
|
||||
})
|
||||
}
|
||||
|
||||
func TestRerunValidation(t *testing.T) {
|
||||
runningRun := &actions_model.ActionRun{Status: actions_model.StatusRunning}
|
||||
|
||||
t.Run("RerunWorkflowRunJobs rejects a non-done run", func(t *testing.T) {
|
||||
jobs := []*actions_model.ActionRunJob{
|
||||
{ID: 1, JobID: "job1"},
|
||||
}
|
||||
err := RerunWorkflowRunJobs(context.Background(), nil, runningRun, jobs)
|
||||
require.Error(t, err)
|
||||
assert.ErrorIs(t, err, util.ErrInvalidArgument)
|
||||
})
|
||||
|
||||
t.Run("RerunWorkflowRunJobs rejects a non-done run when failed jobs exist", func(t *testing.T) {
|
||||
jobs := []*actions_model.ActionRunJob{
|
||||
{ID: 1, JobID: "job1", Status: actions_model.StatusFailure},
|
||||
}
|
||||
err := RerunWorkflowRunJobs(context.Background(), nil, runningRun, GetFailedRerunJobs(jobs))
|
||||
require.Error(t, err)
|
||||
assert.ErrorIs(t, err, util.ErrInvalidArgument)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
// Copyright 2025 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package actions
|
||||
|
||||
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"
|
||||
|
||||
act_model "github.com/nektos/act/pkg/model"
|
||||
"go.yaml.in/yaml/v4"
|
||||
)
|
||||
|
||||
// PrepareRunAndInsert prepares a run and inserts it into the database
|
||||
// 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 err := run.LoadAttributes(ctx); err != nil {
|
||||
return fmt.Errorf("LoadAttributes: %w", err)
|
||||
}
|
||||
|
||||
vars, err := actions_model.GetVariablesOfRun(ctx, run)
|
||||
if err != nil {
|
||||
return fmt.Errorf("GetVariablesOfRun: %w", err)
|
||||
}
|
||||
|
||||
wfRawConcurrency, err := jobparser.ReadWorkflowRawConcurrency(content)
|
||||
if err != nil {
|
||||
return fmt.Errorf("ReadWorkflowRawConcurrency: %w", err)
|
||||
}
|
||||
|
||||
if err = InsertRun(ctx, run, content, vars, inputsWithDefaults, wfRawConcurrency); err != nil {
|
||||
return fmt.Errorf("InsertRun: %w", err)
|
||||
}
|
||||
|
||||
// Load the newly inserted jobs with all fields from database (the job models in InsertRun are partial, so load again)
|
||||
allJobs, err := db.Find[actions_model.ActionRunJob](ctx, actions_model.FindRunJobOptions{RunID: run.ID})
|
||||
if err != nil {
|
||||
return fmt.Errorf("FindRunJob: %w", err)
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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 {
|
||||
index, err := db.GetNextResourceIndex(ctx, "action_run_index", run.RepoID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
run.Index = index
|
||||
run.Title = util.EllipsisDisplayString(run.Title, 255)
|
||||
run.Status = actions_model.StatusWaiting
|
||||
|
||||
// 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
|
||||
// group like `${{ github.head_ref || github.run_id }}` — would otherwise
|
||||
// interpolate to an empty string.
|
||||
if err := db.Insert(ctx, run); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
giteaCtx := GenerateGiteaContext(run, 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
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
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
|
||||
}
|
||||
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
|
||||
// if there is a job in the waiting status, increase tasks version.
|
||||
if hasWaitingJobs {
|
||||
if err := actions_model.IncreaseTaskVersion(ctx, run.OwnerID, run.RepoID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
@@ -12,12 +12,10 @@ import (
|
||||
"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"
|
||||
notify_service "code.gitea.io/gitea/services/notify"
|
||||
|
||||
"github.com/nektos/act/pkg/jobparser"
|
||||
)
|
||||
|
||||
// StartScheduleTasks start the task
|
||||
@@ -53,20 +51,6 @@ func startTasks(ctx context.Context) error {
|
||||
|
||||
// Loop through each spec and create a schedule task for it
|
||||
for _, row := range specs {
|
||||
// cancel running jobs if the event is push
|
||||
if row.Schedule.Event == webhook_module.HookEventPush {
|
||||
// cancel running jobs of the same workflow
|
||||
if err := CancelPreviousJobs(
|
||||
ctx,
|
||||
row.RepoID,
|
||||
row.Schedule.Ref,
|
||||
row.Schedule.WorkflowID,
|
||||
webhook_module.HookEventSchedule,
|
||||
); err != nil {
|
||||
log.Error("CancelPreviousJobs: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
if row.Repo.IsArchived {
|
||||
// Skip if the repo is archived
|
||||
continue
|
||||
@@ -84,7 +68,7 @@ func startTasks(ctx context.Context) error {
|
||||
continue
|
||||
}
|
||||
|
||||
if err := CreateScheduleTask(ctx, row.Schedule); err != nil {
|
||||
if err := CreateScheduleTask(ctx, row); err != nil {
|
||||
log.Error("CreateScheduleTask: %v", err)
|
||||
return err
|
||||
}
|
||||
@@ -114,9 +98,12 @@ func startTasks(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// CreateScheduleTask creates a scheduled task from a cron action schedule.
|
||||
// CreateScheduleTask creates a scheduled task from a cron action schedule spec.
|
||||
// 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, cron *actions_model.ActionSchedule) error {
|
||||
func CreateScheduleTask(ctx context.Context, spec *actions_model.ActionScheduleSpec) error {
|
||||
cron := spec.Schedule
|
||||
eventPayload := withScheduleInEventPayload(cron.EventPayload, spec.Spec)
|
||||
|
||||
// Create a new action run based on the schedule
|
||||
run := &actions_model.ActionRun{
|
||||
Title: cron.Title,
|
||||
@@ -127,41 +114,48 @@ func CreateScheduleTask(ctx context.Context, cron *actions_model.ActionSchedule)
|
||||
Ref: cron.Ref,
|
||||
CommitSHA: cron.CommitSHA,
|
||||
Event: cron.Event,
|
||||
EventPayload: cron.EventPayload,
|
||||
EventPayload: eventPayload,
|
||||
TriggerEvent: string(webhook_module.HookEventSchedule),
|
||||
ScheduleID: cron.ID,
|
||||
Status: actions_model.StatusWaiting,
|
||||
}
|
||||
|
||||
vars, err := actions_model.GetVariablesOfRun(ctx, run)
|
||||
if err != nil {
|
||||
log.Error("GetVariablesOfRun: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
// Parse the workflow specification from the cron schedule
|
||||
workflows, err := jobparser.Parse(cron.Content, jobparser.WithVars(vars))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// FIXME cron.Content might be outdated if the workflow file has been changed.
|
||||
// Load the latest sha from default branch
|
||||
// Insert the action run and its associated jobs into the database
|
||||
if err := actions_model.InsertRun(ctx, run, workflows); err != nil {
|
||||
if err := PrepareRunAndInsert(ctx, cron.Content, run, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
allJobs, err := db.Find[actions_model.ActionRunJob](ctx, actions_model.FindRunJobOptions{RunID: run.ID})
|
||||
if err != nil {
|
||||
log.Error("FindRunJobs: %v", err)
|
||||
}
|
||||
err = run.LoadAttributes(ctx)
|
||||
if err != nil {
|
||||
log.Error("LoadAttributes: %v", err)
|
||||
}
|
||||
notify_service.WorkflowRunStatusUpdate(ctx, run.Repo, run.TriggerUser, run)
|
||||
for _, job := range allJobs {
|
||||
notify_service.WorkflowJobStatusUpdate(ctx, run.Repo, run.TriggerUser, job, nil)
|
||||
}
|
||||
|
||||
// Return nil if no errors occurred
|
||||
return nil
|
||||
}
|
||||
|
||||
func withScheduleInEventPayload(eventPayload, schedule string) string {
|
||||
if schedule == "" {
|
||||
return eventPayload
|
||||
}
|
||||
|
||||
// eventPayload originates from json.Marshal(input.Payload) in handleSchedules,
|
||||
// so a nil payload is stored as the literal "null" and pre-existing rows may be
|
||||
// empty. Both cases start from a fresh map so the schedule field can still be set.
|
||||
var event map[string]any
|
||||
if eventPayload != "" {
|
||||
if err := json.Unmarshal([]byte(eventPayload), &event); err != nil {
|
||||
log.Error("withScheduleInEventPayload: unmarshal: %v", err)
|
||||
return eventPayload
|
||||
}
|
||||
}
|
||||
if event == nil {
|
||||
event = map[string]any{}
|
||||
}
|
||||
|
||||
event["schedule"] = schedule
|
||||
updatedPayload, err := json.Marshal(event)
|
||||
if err != nil {
|
||||
log.Error("withScheduleInEventPayload: marshal: %v", err)
|
||||
return eventPayload
|
||||
}
|
||||
|
||||
return string(updatedPayload)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package actions
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"code.gitea.io/gitea/modules/json"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
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 * * * *")
|
||||
|
||||
event := map[string]any{}
|
||||
assert.NoError(t, json.Unmarshal([]byte(updated), &event))
|
||||
assert.Equal(t, "*/5 * * * *", event["schedule"])
|
||||
assert.Equal(t, "refs/heads/main", event["ref"])
|
||||
})
|
||||
|
||||
t.Run("adds schedule to null payload", func(t *testing.T) {
|
||||
updated := withScheduleInEventPayload("null", "37 12 5 1 2")
|
||||
|
||||
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 to empty payload", func(t *testing.T) {
|
||||
updated := withScheduleInEventPayload("", "37 12 5 1 2")
|
||||
|
||||
event := map[string]any{}
|
||||
assert.NoError(t, json.Unmarshal([]byte(updated), &event))
|
||||
assert.Equal(t, "37 12 5 1 2", event["schedule"])
|
||||
})
|
||||
|
||||
t.Run("keeps payload when schedule empty", func(t *testing.T) {
|
||||
payload := `{"ref":"refs/heads/main"}`
|
||||
updated := withScheduleInEventPayload(payload, "")
|
||||
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 * * * *")
|
||||
assert.Equal(t, payload, updated)
|
||||
})
|
||||
}
|
||||
@@ -24,6 +24,10 @@ func PickTask(ctx context.Context, runner *actions_model.ActionRunner) (*runnerv
|
||||
actionTask *actions_model.ActionTask
|
||||
)
|
||||
|
||||
if runner.IsDisabled {
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
if runner.Ephemeral {
|
||||
var task actions_model.ActionTask
|
||||
has, err := db.GetEngine(ctx).Where("runner_id = ?", runner.ID).Get(&task)
|
||||
@@ -97,8 +101,13 @@ func PickTask(ctx context.Context, runner *actions_model.ActionRunner) (*runnerv
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
CreateCommitStatus(ctx, job)
|
||||
CreateCommitStatusForRunJobs(ctx, job.Run, job)
|
||||
notify_service.WorkflowJobStatusUpdate(ctx, job.Run.Repo, job.Run.TriggerUser, 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)
|
||||
}
|
||||
|
||||
return task, true, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
# Actions Token Permission System Design
|
||||
|
||||
This document details the design of the Actions Token Permission system within Gitea, originally proposed in [#24635](https://github.com/go-gitea/gitea/issues/24635).
|
||||
|
||||
## Design Philosophy & GitHub Differences
|
||||
|
||||
Gitea Actions uses a **strict clamping mechanism** for token permissions.
|
||||
While workflows can request explicit permissions that exceed the repository's default baseline
|
||||
(e.g., requesting `write` when the default mode is `Restricted`),
|
||||
these requests are always bounded by a hard ceiling.
|
||||
|
||||
The maximum allowable permissions (`MaxTokenPermissions`) are set at the Repository or Organization level.
|
||||
**Any permissions requested by a workflow are strictly clamped by this ceiling policy.**
|
||||
This ensures that workflows cannot bypass organizational or repository-level security restrictions.
|
||||
|
||||
## Terminology
|
||||
|
||||
### 1. `GITEA_TOKEN`
|
||||
- The automatic token generated for each Actions job.
|
||||
- Its permissions (read/write/none) are scoped to the repository and specific features (Code, Issues, etc.).
|
||||
|
||||
### 2. Token Permission Mode
|
||||
- The default access level granted to a token when no explicit `permissions:` block is present in a workflow.
|
||||
- **Permissive**: Grants `write` access to most repository scopes by default.
|
||||
- **Restricted**: Grants `read` access (or none) to repository scopes by default.
|
||||
|
||||
### 3. Actions Token Permissions
|
||||
- A structure representing the granular permission scopes available to a token.
|
||||
- Includes scopes like: Code, Releases (both grouped under `contents` in workflow syntax),
|
||||
Issues, PullRequests, Actions, Wiki, and Projects.
|
||||
- **Note**: The `Packages` scope is supported in workflow/job `permissions:` blocks
|
||||
but is currently hidden from the settings UI.
|
||||
|
||||
### 4. Cross-Repository Access
|
||||
- By default, a token can access the repository where the workflow is running,
|
||||
as well as any **public repositories (read-only)** on the instance.
|
||||
- Users and organizations can configure an `AllowedCrossRepoIDs` list in their owner-level settings
|
||||
to grant the token **read-only** access to other private/internal repositories they own.
|
||||
- If the `AllowedCrossRepoIDs` list is empty, there is no cross-repository access
|
||||
to other private repositories (default for enhanced security).
|
||||
- In any configuration, individual jobs can disable or limit cross-repo access
|
||||
by explicitly restricting their permissions (e.g., `permissions: none`).
|
||||
- **Note on Forks**: Cross-repository access to private repositories is fundamentally denied
|
||||
for workflows triggered by fork pull requests (see [Special Cases](#2-fork-pull-requests)).
|
||||
|
||||
## Token Lifecycle & Permission Evaluation
|
||||
|
||||
When a job starts, Gitea evaluates the requested permissions for the `GITEA_TOKEN` through a multistep clamping process:
|
||||
|
||||
### Step 1: Determine Base Permissions From Workflow
|
||||
- If the job explicitly specifies a valid `permissions:` block, Gitea parses it.
|
||||
- If the job inherits a top-level `permissions:` block, Gitea parses that.
|
||||
- If an invalid or unparseable `permissions:` block is specified, or no explicit permissions are defined at all,
|
||||
Gitea falls back to using the repository's default `TokenPermissionMode` (Permissive or Restricted)
|
||||
to generate base permissions.
|
||||
|
||||
### Step 2: Apply Repository Clamping
|
||||
- Repositories can define `MaxTokenPermissions` in their Actions settings.
|
||||
- The base permissions from Step 1 are clamped against these maximum allowed permissions.
|
||||
- If the repository says `Issues: read` and the workflow requests `Issues: write`, the final token gets `Issues: read`.
|
||||
|
||||
### Step 3: Apply Organization/User Clamping (Hierarchical Override)
|
||||
- The organization (or user) has an owner-level configuration (`UserActionsConfig`) containing `MaxTokenPermissions`,
|
||||
and these restrictions cascade down.
|
||||
- The repository's clamping limits cannot exceed the owner's limits
|
||||
UNLESS the repository explicitly enables `OverrideOwnerConfig`.
|
||||
- If `OverrideOwnerConfig` is false, and the owner sets `MaxTokenPermissions` to `read` for all scopes,
|
||||
no repository under that owner can grant `write` access, regardless of their own settings or the workflow's request.
|
||||
|
||||
## Parsing Priority for "contents" Scope
|
||||
|
||||
In GitHub Actions compatibility, the `contents` scope maps to multiple granular scopes in Gitea.
|
||||
- `contents: write` maps to `Code: write` and `Releases: write`.
|
||||
- When a workflow specifies both `contents` and a more granular scope (e.g., `code`),
|
||||
the granular scope takes absolute priority.
|
||||
|
||||
**Example YAML**:
|
||||
```yaml
|
||||
permissions:
|
||||
contents: write
|
||||
code: read
|
||||
```
|
||||
**Result**: The token gets `Code: read` (from granular) and `Releases: write` (from contents).
|
||||
|
||||
## Special Cases & Edge Scenarios
|
||||
|
||||
### 1. Empty Permissions Mapping (`permissions: {}`)
|
||||
- Explicitly setting an empty mapping means "revoke all permissions".
|
||||
- The token gets `none` for all scopes.
|
||||
|
||||
### 2. Fork Pull Requests
|
||||
- Workflows triggered by Pull Requests from forks inherently operate in `Restricted` mode for security reasons.
|
||||
- The base permissions for the current repository are automatically downgraded to `read` (or `none`),
|
||||
preventing untrusted code from modifying the repository.
|
||||
- **Cross-Repo Access in Forks**: For workflows triggered by fork pull requests, cross-repository access
|
||||
to other private repositories is strictly denied, regardless of the `AllowedCrossRepoIDs` configuration.
|
||||
Fork PRs can only read the target repository and truly public repositories.
|
||||
|
||||
### 3. Public Repositories in Cross-Repo Access
|
||||
- As mentioned in Cross-Repository Access, truly public repositories can always be read by the token,
|
||||
regardless of the `AllowedCrossRepoIDs` setting. The allowed list only governs access
|
||||
to private/internal repositories owned by the same user or organization.
|
||||
|
||||
## Packages Registry
|
||||
|
||||
"Packages" belong to "owner" but not "repository". Although there is a function "linking a package to a repository",
|
||||
in most cases it doesn't really work. When accessing a package, usually there is no information about a repository.
|
||||
So the "packages" permission should be designed separately from other permissions.
|
||||
|
||||
A possible approach is like this: let owner set packages permissions, and make the repositories follow.
|
||||
|
||||
- On owner-level:
|
||||
- Add a "Packages" permission section
|
||||
- "Default permissions for all repositories" can be set to none/read/write
|
||||
- Set different permissions for selected repositories (if needed), like the "Collaborators" permission setting
|
||||
|
||||
- On repository-level:
|
||||
- Now a repository can have "Packages" permission
|
||||
- The repository-level "Packages" permission is clamped by the owner-level "Packages" permission
|
||||
- If the owner-level "Packages" permission for this repository is read,
|
||||
then the repository cannot set its "Packages" permission to write
|
||||
|
||||
Maybe reusing the "org teams" permission system is a good choice: bind a repository's Actions token to a team.
|
||||
@@ -16,7 +16,7 @@ func CreateVariable(ctx context.Context, ownerID, repoID int64, name, data, desc
|
||||
return nil, err
|
||||
}
|
||||
|
||||
v, err := actions_model.InsertVariable(ctx, ownerID, repoID, name, util.ReserveLineBreakForTextarea(data), description)
|
||||
v, err := actions_model.InsertVariable(ctx, ownerID, repoID, name, util.NormalizeStringEOL(data), description)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -29,7 +29,7 @@ func UpdateVariableNameData(ctx context.Context, variable *actions_model.ActionV
|
||||
return false, err
|
||||
}
|
||||
|
||||
variable.Data = util.ReserveLineBreakForTextarea(variable.Data)
|
||||
variable.Data = util.NormalizeStringEOL(variable.Data)
|
||||
|
||||
return actions_model.UpdateVariableCols(ctx, variable, "name", "data", "description")
|
||||
}
|
||||
|
||||
@@ -5,28 +5,24 @@ package actions
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
actions_model "code.gitea.io/gitea/models/actions"
|
||||
"code.gitea.io/gitea/models/db"
|
||||
"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/log"
|
||||
"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"
|
||||
notify_service "code.gitea.io/gitea/services/notify"
|
||||
|
||||
"github.com/nektos/act/pkg/jobparser"
|
||||
"github.com/nektos/act/pkg/model"
|
||||
"gopkg.in/yaml.v3"
|
||||
"go.yaml.in/yaml/v4"
|
||||
)
|
||||
|
||||
func EnableOrDisableWorkflow(ctx *context.APIContext, workflowID string, isEnable bool) error {
|
||||
@@ -44,19 +40,19 @@ func EnableOrDisableWorkflow(ctx *context.APIContext, workflowID string, isEnabl
|
||||
cfg.DisableWorkflow(workflow.ID)
|
||||
}
|
||||
|
||||
return repo_model.UpdateRepoUnit(ctx, cfgUnit)
|
||||
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) error {
|
||||
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) {
|
||||
if workflowID == "" {
|
||||
return util.ErrorWrapTranslatable(
|
||||
return 0, util.ErrorWrapTranslatable(
|
||||
util.NewNotExistErrorf("workflowID is empty"),
|
||||
"actions.workflow.not_found", workflowID,
|
||||
)
|
||||
}
|
||||
|
||||
if ref == "" {
|
||||
return util.ErrorWrapTranslatable(
|
||||
return 0, util.ErrorWrapTranslatable(
|
||||
util.NewNotExistErrorf("ref is empty"),
|
||||
"form.target_ref_not_exist", ref,
|
||||
)
|
||||
@@ -66,7 +62,7 @@ func DispatchActionWorkflow(ctx reqctx.RequestContext, doer *user_model.User, re
|
||||
cfgUnit := repo.MustGetUnit(ctx, unit.TypeActions)
|
||||
cfg := cfgUnit.ActionsConfig()
|
||||
if cfg.IsWorkflowDisabled(workflowID) {
|
||||
return util.ErrorWrapTranslatable(
|
||||
return 0, util.ErrorWrapTranslatable(
|
||||
util.NewPermissionDeniedErrorf("workflow is disabled"),
|
||||
"actions.workflow.disabled",
|
||||
)
|
||||
@@ -85,7 +81,7 @@ func DispatchActionWorkflow(ctx reqctx.RequestContext, doer *user_model.User, re
|
||||
runTargetCommit, err = gitRepo.GetBranchCommit(ref)
|
||||
}
|
||||
if err != nil {
|
||||
return util.ErrorWrapTranslatable(
|
||||
return 0, util.ErrorWrapTranslatable(
|
||||
util.NewNotExistErrorf("ref %q doesn't exist", ref),
|
||||
"form.target_ref_not_exist", ref,
|
||||
)
|
||||
@@ -94,15 +90,14 @@ func DispatchActionWorkflow(ctx reqctx.RequestContext, doer *user_model.User, re
|
||||
// get workflow entry from runTargetCommit
|
||||
_, entries, err := actions.ListWorkflows(runTargetCommit)
|
||||
if err != nil {
|
||||
return err
|
||||
return 0, err
|
||||
}
|
||||
|
||||
// find workflow from commit
|
||||
var workflows []*jobparser.SingleWorkflow
|
||||
var entry *git.TreeEntry
|
||||
|
||||
run := &actions_model.ActionRun{
|
||||
Title: strings.SplitN(runTargetCommit.CommitMessage, "\n", 2)[0],
|
||||
Title: runTargetCommit.Summary(),
|
||||
RepoID: repo.ID,
|
||||
Repo: repo,
|
||||
OwnerID: repo.OwnerID,
|
||||
@@ -126,7 +121,7 @@ func DispatchActionWorkflow(ctx reqctx.RequestContext, doer *user_model.User, re
|
||||
}
|
||||
|
||||
if entry == nil {
|
||||
return util.ErrorWrapTranslatable(
|
||||
return 0, util.ErrorWrapTranslatable(
|
||||
util.NewNotExistErrorf("workflow %q doesn't exist", workflowID),
|
||||
"actions.workflow.not_found", workflowID,
|
||||
)
|
||||
@@ -134,12 +129,12 @@ func DispatchActionWorkflow(ctx reqctx.RequestContext, doer *user_model.User, re
|
||||
|
||||
content, err := actions.GetContentFromEntry(entry)
|
||||
if err != nil {
|
||||
return err
|
||||
return 0, err
|
||||
}
|
||||
|
||||
singleWorkflow := &jobparser.SingleWorkflow{}
|
||||
if err := yaml.Unmarshal(content, singleWorkflow); err != nil {
|
||||
return fmt.Errorf("failed to unmarshal workflow content: %w", err)
|
||||
return 0, fmt.Errorf("failed to unmarshal workflow content: %w", err)
|
||||
}
|
||||
// get inputs from post
|
||||
workflow := &model.Workflow{
|
||||
@@ -148,28 +143,10 @@ func DispatchActionWorkflow(ctx reqctx.RequestContext, doer *user_model.User, re
|
||||
inputsWithDefaults := make(map[string]any)
|
||||
if workflowDispatch := workflow.WorkflowDispatchConfig(); workflowDispatch != nil {
|
||||
if err = processInputs(workflowDispatch, inputsWithDefaults); err != nil {
|
||||
return err
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
|
||||
giteaCtx := GenerateGiteaContext(run, nil)
|
||||
|
||||
workflows, err = jobparser.Parse(content, jobparser.WithGitContext(giteaCtx.ToGitHubContext()), jobparser.WithInputs(inputsWithDefaults))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(workflows) > 0 && workflows[0].RunName != "" {
|
||||
run.Title = workflows[0].RunName
|
||||
}
|
||||
|
||||
if len(workflows) == 0 {
|
||||
return util.ErrorWrapTranslatable(
|
||||
util.NewNotExistErrorf("workflow %q doesn't exist", workflowID),
|
||||
"actions.workflow.not_found", workflowID,
|
||||
)
|
||||
}
|
||||
|
||||
// ctx.Req.PostForm -> WorkflowDispatchPayload.Inputs -> ActionRun.EventPayload -> runner: ghc.Event
|
||||
// https://docs.github.com/en/actions/learn-github-actions/contexts#github-context
|
||||
// https://docs.github.com/en/webhooks/webhook-events-and-payloads#workflow_dispatch
|
||||
@@ -183,42 +160,13 @@ func DispatchActionWorkflow(ctx reqctx.RequestContext, doer *user_model.User, re
|
||||
|
||||
var eventPayload []byte
|
||||
if eventPayload, err = workflowDispatchPayload.JSONPayload(); err != nil {
|
||||
return fmt.Errorf("JSONPayload: %w", err)
|
||||
return 0, fmt.Errorf("JSONPayload: %w", err)
|
||||
}
|
||||
run.EventPayload = string(eventPayload)
|
||||
|
||||
// cancel running jobs of the same workflow
|
||||
if err := CancelPreviousJobs(
|
||||
ctx,
|
||||
run.RepoID,
|
||||
run.Ref,
|
||||
run.WorkflowID,
|
||||
run.Event,
|
||||
); err != nil {
|
||||
log.Error("CancelRunningJobs: %v", err)
|
||||
}
|
||||
|
||||
// Insert the action run and its associated jobs into the database
|
||||
if err := actions_model.InsertRun(ctx, run, workflows); err != nil {
|
||||
return fmt.Errorf("InsertRun: %w", err)
|
||||
if err := PrepareRunAndInsert(ctx, content, run, inputsWithDefaults); err != nil {
|
||||
return 0, fmt.Errorf("PrepareRun: %w", err)
|
||||
}
|
||||
|
||||
allJobs, err := db.Find[actions_model.ActionRunJob](ctx, actions_model.FindRunJobOptions{RunID: run.ID})
|
||||
if err != nil {
|
||||
log.Error("FindRunJobs: %v", err)
|
||||
}
|
||||
CreateCommitStatus(ctx, allJobs...)
|
||||
if len(allJobs) > 0 {
|
||||
job := allJobs[0]
|
||||
err := job.LoadRun(ctx)
|
||||
if err != nil {
|
||||
log.Error("LoadRun: %v", err)
|
||||
} else {
|
||||
notify_service.WorkflowRunStatusUpdate(ctx, job.Run.Repo, job.Run.TriggerUser, job.Run)
|
||||
}
|
||||
}
|
||||
for _, job := range allJobs {
|
||||
notify_service.WorkflowJobStatusUpdate(ctx, repo, doer, job, nil)
|
||||
}
|
||||
return nil
|
||||
return run.ID, nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user