初始提交: Gitea 项目代码
This commit is contained in:
@@ -0,0 +1,107 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package actions
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
actions_model "gitea.dev/models/actions"
|
||||
"gitea.dev/models/db"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/container"
|
||||
"gitea.dev/modules/log"
|
||||
)
|
||||
|
||||
func ApproveRuns(ctx context.Context, repo *repo_model.Repository, doer *user_model.User, runIDs []int64) error {
|
||||
updatedJobs := make([]*actions_model.ActionRunJob, 0)
|
||||
cancelledConcurrencyJobs := make([]*actions_model.ActionRunJob, 0)
|
||||
// Track runs whose reusable callers were just expanded so we can re-emit after the tx commits.
|
||||
expandedCallerRunIDs := make(container.Set[int64])
|
||||
|
||||
err := db.WithTx(ctx, func(ctx context.Context) (err error) {
|
||||
for _, runID := range runIDs {
|
||||
run, err := actions_model.GetRunByRepoAndID(ctx, repo.ID, runID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
run.NeedApproval = false
|
||||
run.ApprovedBy = doer.ID
|
||||
if err := actions_model.UpdateRun(ctx, run, "need_approval", "approved_by"); err != nil {
|
||||
return err
|
||||
}
|
||||
jobs, err := actions_model.GetLatestAttemptJobsByRepoAndRunID(ctx, repo.ID, run.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, job := range jobs {
|
||||
// Skip jobs with `needs`: they stay blocked until their dependencies finish,
|
||||
// at which point job_emitter will evaluate and start them.
|
||||
if len(job.Needs) > 0 {
|
||||
continue
|
||||
}
|
||||
var jobsToCancel []*actions_model.ActionRunJob
|
||||
job.Status, jobsToCancel, err = PrepareToStartJobWithConcurrency(ctx, job)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cancelledConcurrencyJobs = append(cancelledConcurrencyJobs, jobsToCancel...)
|
||||
if job.Status != actions_model.StatusWaiting {
|
||||
continue
|
||||
}
|
||||
n, err := actions_model.UpdateRunJob(ctx, job, nil, "status")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n == 0 {
|
||||
continue
|
||||
}
|
||||
updatedJobs = append(updatedJobs, job)
|
||||
|
||||
// A top-level reusable caller was just unblocked by approval, expand it
|
||||
if job.IsReusableCaller && !job.IsExpanded {
|
||||
attempt, has, err := run.GetLatestAttempt(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get latest attempt of run %d: %w", run.ID, err)
|
||||
}
|
||||
if !has {
|
||||
return errors.New("run has no attempt")
|
||||
}
|
||||
vars, err := actions_model.GetVariablesOfRun(ctx, run)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := expandReusableWorkflowCaller(ctx, run, attempt, job, vars); err != nil {
|
||||
return fmt.Errorf("expand caller %d on approval: %w", job.ID, err)
|
||||
}
|
||||
if err := actions_model.RefreshReusableCallerStatus(ctx, job); err != nil {
|
||||
return fmt.Errorf("refresh caller %d status after approval-time expansion: %w", job.ID, err)
|
||||
}
|
||||
expandedCallerRunIDs.Add(run.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Re-emit AFTER the tx commits so the newly inserted callee rows transition Blocked -> Waiting.
|
||||
for runID := range expandedCallerRunIDs {
|
||||
if err := EmitJobsIfReadyByRun(runID); err != nil {
|
||||
log.Error("emit run %d after approval-time caller expansion: %v", runID, err)
|
||||
}
|
||||
}
|
||||
|
||||
NotifyWorkflowJobsAndRunsStatusUpdate(ctx, updatedJobs)
|
||||
NotifyWorkflowJobsAndRunsStatusUpdate(ctx, cancelledConcurrencyJobs)
|
||||
|
||||
EmitJobsIfReadyByJobs(cancelledConcurrencyJobs)
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
// Copyright 2025 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package actions
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
actions_model "gitea.dev/models/actions"
|
||||
"gitea.dev/modules/httplib"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/storage"
|
||||
"gitea.dev/services/context"
|
||||
)
|
||||
|
||||
// IsArtifactV4 detects whether the artifact is likely from v4.
|
||||
// V4 backend stores the files as a single combined zip file per artifact, and ensures ContentEncoding contains a slash
|
||||
// (otherwise this uses application/zip instead of the custom mime type), which is not the case for the old backend.
|
||||
func IsArtifactV4(art *actions_model.ActionArtifact) bool {
|
||||
return strings.Contains(art.ContentEncodingOrType, "/")
|
||||
}
|
||||
|
||||
func GetArtifactV4ServeDirectURL(art *actions_model.ActionArtifact, method string) (string, error) {
|
||||
contentType := art.ContentEncodingOrType
|
||||
u, err := storage.ActionsArtifacts.ServeDirectURL(art.StoragePath, art.ArtifactPath, method, &storage.ServeDirectOptions{ContentType: contentType})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return u.String(), nil
|
||||
}
|
||||
|
||||
func DownloadArtifactV4ServeDirect(ctx *context.Base, art *actions_model.ActionArtifact) bool {
|
||||
if !setting.Actions.ArtifactStorage.ServeDirect() {
|
||||
return false
|
||||
}
|
||||
u, err := GetArtifactV4ServeDirectURL(art, ctx.Req.Method)
|
||||
if err != nil {
|
||||
log.Error("GetArtifactV4ServeDirectURL: %v", err)
|
||||
return false
|
||||
}
|
||||
ctx.Redirect(u, http.StatusFound)
|
||||
return true
|
||||
}
|
||||
|
||||
func DownloadArtifactV4ReadStorage(ctx *context.Base, art *actions_model.ActionArtifact) error {
|
||||
f, err := storage.ActionsArtifacts.Open(art.StoragePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
httplib.ServeUserContentByFile(ctx.Req, ctx.Resp, f, httplib.ServeHeaderOptions{
|
||||
Filename: art.ArtifactPath,
|
||||
ContentType: art.ContentEncodingOrType, // v4 guarantees that the field is Content-Type
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
func DownloadArtifactV4(ctx *context.Base, art *actions_model.ActionArtifact) error {
|
||||
if DownloadArtifactV4ServeDirect(ctx, art) {
|
||||
return nil
|
||||
}
|
||||
return DownloadArtifactV4ReadStorage(ctx, art)
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package actions
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.dev/modules/json"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/setting"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
type actionsClaims struct {
|
||||
jwt.RegisteredClaims
|
||||
Scp string `json:"scp"`
|
||||
TaskID int64
|
||||
RunID int64
|
||||
JobID int64
|
||||
Ac string `json:"ac"`
|
||||
}
|
||||
|
||||
type actionsCacheScope struct {
|
||||
Scope string
|
||||
Permission actionsCachePermission
|
||||
}
|
||||
|
||||
type actionsCachePermission int
|
||||
|
||||
const (
|
||||
actionsCachePermissionRead = 1 << iota
|
||||
actionsCachePermissionWrite
|
||||
)
|
||||
|
||||
func CreateAuthorizationToken(taskID, runID, jobID int64) (string, error) {
|
||||
now := time.Now()
|
||||
|
||||
ac, err := json.Marshal(&[]actionsCacheScope{
|
||||
{
|
||||
Scope: "",
|
||||
Permission: actionsCachePermissionWrite,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
claims := actionsClaims{
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(now.Add(1*time.Hour + setting.Actions.EndlessTaskTimeout)),
|
||||
NotBefore: jwt.NewNumericDate(now),
|
||||
},
|
||||
Scp: fmt.Sprintf("Actions.Results:%d:%d", runID, jobID),
|
||||
Ac: string(ac),
|
||||
TaskID: taskID,
|
||||
RunID: runID,
|
||||
JobID: jobID,
|
||||
}
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
|
||||
tokenString, err := token.SignedString(setting.GetGeneralTokenSigningSecret())
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return tokenString, nil
|
||||
}
|
||||
|
||||
func ParseAuthorizationToken(req *http.Request) (int64, error) {
|
||||
h := req.Header.Get("Authorization")
|
||||
if h == "" {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
parts := strings.SplitN(h, " ", 2)
|
||||
if len(parts) != 2 {
|
||||
log.Error("split token failed: %s", h)
|
||||
return 0, errors.New("split token failed")
|
||||
}
|
||||
|
||||
return TokenToTaskID(parts[1])
|
||||
}
|
||||
|
||||
// TokenToTaskID returns the TaskID associated with the provided JWT token
|
||||
func TokenToTaskID(token string) (int64, error) {
|
||||
parsedToken, err := jwt.ParseWithClaims(token, &actionsClaims{}, func(t *jwt.Token) (any, error) {
|
||||
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
|
||||
}
|
||||
return setting.GetGeneralTokenSigningSecret(), nil
|
||||
})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
c, ok := parsedToken.Claims.(*actionsClaims)
|
||||
if !parsedToken.Valid || !ok {
|
||||
return 0, errors.New("invalid token claim")
|
||||
}
|
||||
|
||||
return c.TaskID, nil
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package actions
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"gitea.dev/modules/json"
|
||||
"gitea.dev/modules/setting"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestCreateAuthorizationToken(t *testing.T) {
|
||||
var taskID int64 = 23
|
||||
token, err := CreateAuthorizationToken(taskID, 1, 2)
|
||||
assert.NoError(t, err)
|
||||
assert.NotEmpty(t, token)
|
||||
claims := jwt.MapClaims{}
|
||||
_, err = jwt.ParseWithClaims(token, claims, func(t *jwt.Token) (any, error) {
|
||||
return setting.GetGeneralTokenSigningSecret(), nil
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
scp, ok := claims["scp"]
|
||||
assert.True(t, ok, "Has scp claim in jwt token")
|
||||
assert.Contains(t, scp, "Actions.Results:1:2")
|
||||
taskIDClaim, ok := claims["TaskID"]
|
||||
assert.True(t, ok, "Has TaskID claim in jwt token")
|
||||
assert.InDelta(t, float64(taskID), taskIDClaim, 0, "Supplied taskid must match stored one")
|
||||
acClaim, ok := claims["ac"]
|
||||
assert.True(t, ok, "Has ac claim in jwt token")
|
||||
ac, ok := acClaim.(string)
|
||||
assert.True(t, ok, "ac claim is a string for buildx gha cache")
|
||||
scopes := []actionsCacheScope{}
|
||||
err = json.Unmarshal([]byte(ac), &scopes)
|
||||
assert.NoError(t, err, "ac claim is a json list for buildx gha cache")
|
||||
assert.GreaterOrEqual(t, len(scopes), 1, "Expected at least one action cache scope for buildx gha cache")
|
||||
}
|
||||
|
||||
func TestParseAuthorizationToken(t *testing.T) {
|
||||
var taskID int64 = 23
|
||||
token, err := CreateAuthorizationToken(taskID, 1, 2)
|
||||
assert.NoError(t, err)
|
||||
assert.NotEmpty(t, token)
|
||||
headers := http.Header{}
|
||||
headers.Set("Authorization", "Bearer "+token)
|
||||
rTaskID, err := ParseAuthorizationToken(&http.Request{
|
||||
Header: headers,
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, taskID, rTaskID)
|
||||
}
|
||||
|
||||
func TestParseAuthorizationTokenNoAuthHeader(t *testing.T) {
|
||||
headers := http.Header{}
|
||||
rTaskID, err := ParseAuthorizationToken(&http.Request{
|
||||
Header: headers,
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, int64(0), rTaskID)
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package actions
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
actions_model "gitea.dev/models/actions"
|
||||
"gitea.dev/models/db"
|
||||
actions_module "gitea.dev/modules/actions"
|
||||
"gitea.dev/modules/container"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/storage"
|
||||
"gitea.dev/modules/timeutil"
|
||||
|
||||
"xorm.io/builder"
|
||||
)
|
||||
|
||||
// Cleanup removes expired actions logs, data, artifacts and used ephemeral runners
|
||||
func Cleanup(ctx context.Context) error {
|
||||
// clean up expired artifacts
|
||||
if err := CleanupArtifacts(ctx); err != nil {
|
||||
return fmt.Errorf("cleanup artifacts: %w", err)
|
||||
}
|
||||
|
||||
// clean up old logs
|
||||
if err := CleanupExpiredLogs(ctx); err != nil {
|
||||
return fmt.Errorf("cleanup logs: %w", err)
|
||||
}
|
||||
|
||||
// clean up old ephemeral runners
|
||||
if err := CleanupEphemeralRunners(ctx); err != nil {
|
||||
return fmt.Errorf("cleanup old ephemeral runners: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// CleanupArtifacts removes expired add need-deleted artifacts and set records expired status
|
||||
func CleanupArtifacts(taskCtx context.Context) error {
|
||||
if err := cleanExpiredArtifacts(taskCtx); err != nil {
|
||||
return err
|
||||
}
|
||||
return cleanNeedDeleteArtifacts(taskCtx)
|
||||
}
|
||||
|
||||
func cleanExpiredArtifacts(taskCtx context.Context) error {
|
||||
artifacts, err := actions_model.ListNeedExpiredArtifacts(taskCtx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
log.Info("Found %d expired artifacts", len(artifacts))
|
||||
for _, artifact := range artifacts {
|
||||
if err := actions_model.SetArtifactExpired(taskCtx, artifact.ID); err != nil {
|
||||
log.Error("Cannot set artifact %d expired: %v", artifact.ID, err)
|
||||
continue
|
||||
}
|
||||
if err := storage.ActionsArtifacts.Delete(artifact.StoragePath); err != nil {
|
||||
log.Error("Cannot delete artifact %d: %v", artifact.ID, err)
|
||||
// go on
|
||||
}
|
||||
log.Info("Artifact %d is deleted (due to expiration)", artifact.ID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// deleteArtifactBatchSize is the batch size of deleting artifacts
|
||||
const deleteArtifactBatchSize = 100
|
||||
|
||||
func cleanNeedDeleteArtifacts(taskCtx context.Context) error {
|
||||
for {
|
||||
artifacts, err := actions_model.ListPendingDeleteArtifacts(taskCtx, deleteArtifactBatchSize)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
log.Info("Found %d artifacts pending deletion", len(artifacts))
|
||||
for _, artifact := range artifacts {
|
||||
if err := actions_model.SetArtifactDeleted(taskCtx, artifact.ID); err != nil {
|
||||
log.Error("Cannot set artifact %d deleted: %v", artifact.ID, err)
|
||||
continue
|
||||
}
|
||||
if err := storage.ActionsArtifacts.Delete(artifact.StoragePath); err != nil {
|
||||
log.Error("Cannot delete artifact %d: %v", artifact.ID, err)
|
||||
// go on
|
||||
}
|
||||
log.Info("Artifact %d is deleted (due to pending deletion)", artifact.ID)
|
||||
}
|
||||
if len(artifacts) < deleteArtifactBatchSize {
|
||||
log.Debug("No more artifacts pending deletion")
|
||||
break
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
const deleteLogBatchSize = 100
|
||||
|
||||
func removeTaskLog(ctx context.Context, task *actions_model.ActionTask) {
|
||||
if err := actions_module.RemoveLogs(ctx, task.LogInStorage, task.LogFilename); err != nil {
|
||||
log.Error("Failed to remove log %s (in storage %v) of task %v: %v", task.LogFilename, task.LogInStorage, task.ID, err)
|
||||
// do not return error here, go on
|
||||
}
|
||||
}
|
||||
|
||||
// CleanupExpiredLogs removes logs which are older than the configured retention time
|
||||
func CleanupExpiredLogs(ctx context.Context) error {
|
||||
olderThan := timeutil.TimeStampNow().AddDuration(-time.Duration(setting.Actions.LogRetentionDays) * 24 * time.Hour)
|
||||
|
||||
count := 0
|
||||
for {
|
||||
tasks, err := actions_model.FindOldTasksToExpire(ctx, olderThan, deleteLogBatchSize)
|
||||
if err != nil {
|
||||
return fmt.Errorf("find old tasks: %w", err)
|
||||
}
|
||||
for _, task := range tasks {
|
||||
removeTaskLog(ctx, task)
|
||||
task.LogIndexes = nil // clear log indexes since it's a heavy field
|
||||
task.LogExpired = true
|
||||
if err := actions_model.UpdateTask(ctx, task, "log_indexes", "log_expired"); err != nil {
|
||||
log.Error("Failed to update task %v: %v", task.ID, err)
|
||||
// do not return error here, continue to next task
|
||||
continue
|
||||
}
|
||||
count++
|
||||
log.Trace("Removed log %s of task %v", task.LogFilename, task.ID)
|
||||
}
|
||||
if len(tasks) < deleteLogBatchSize {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
log.Info("Removed %d logs", count)
|
||||
return nil
|
||||
}
|
||||
|
||||
// CleanupEphemeralRunners removes used ephemeral runners which are no longer able to process jobs
|
||||
func CleanupEphemeralRunners(ctx context.Context) error {
|
||||
subQuery := builder.Select("`action_runner`.id").
|
||||
From(builder.Select("*").From("`action_runner`"), "`action_runner`"). // mysql needs this redundant subquery
|
||||
Join("INNER", "`action_task`", "`action_task`.`runner_id` = `action_runner`.`id`").
|
||||
Where(builder.Eq{"`action_runner`.`ephemeral`": true}).
|
||||
And(builder.NotIn("`action_task`.`status`", actions_model.StatusWaiting, actions_model.StatusRunning, actions_model.StatusBlocked, actions_model.StatusCancelling))
|
||||
b := builder.Delete(builder.In("id", subQuery)).From("`action_runner`")
|
||||
res, err := db.GetEngine(ctx).Exec(b)
|
||||
if err != nil {
|
||||
return fmt.Errorf("find runners: %w", err)
|
||||
}
|
||||
affected, _ := res.RowsAffected()
|
||||
log.Info("Removed %d runners", affected)
|
||||
return nil
|
||||
}
|
||||
|
||||
// CleanupEphemeralRunnersByPickedTaskOfRepo removes all ephemeral runners that have active/finished tasks on the given repository
|
||||
func CleanupEphemeralRunnersByPickedTaskOfRepo(ctx context.Context, repoID int64) error {
|
||||
subQuery := builder.Select("`action_runner`.id").
|
||||
From(builder.Select("*").From("`action_runner`"), "`action_runner`"). // mysql needs this redundant subquery
|
||||
Join("INNER", "`action_task`", "`action_task`.`runner_id` = `action_runner`.`id`").
|
||||
Where(builder.And(builder.Eq{"`action_runner`.`ephemeral`": true}, builder.Eq{"`action_task`.`repo_id`": repoID}))
|
||||
b := builder.Delete(builder.In("id", subQuery)).From("`action_runner`")
|
||||
res, err := db.GetEngine(ctx).Exec(b)
|
||||
if err != nil {
|
||||
return fmt.Errorf("find runners: %w", err)
|
||||
}
|
||||
affected, _ := res.RowsAffected()
|
||||
log.Info("Removed %d runners", affected)
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteRun deletes workflow run, including all logs and artifacts.
|
||||
func DeleteRun(ctx context.Context, run *actions_model.ActionRun) error {
|
||||
if !run.Status.IsDone() {
|
||||
return errors.New("run is not done")
|
||||
}
|
||||
|
||||
repoID := run.RepoID
|
||||
|
||||
jobs, err := actions_model.GetAllRunJobsByRepoAndRunID(ctx, run.RepoID, run.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
jobIDs := container.FilterSlice(jobs, func(j *actions_model.ActionRunJob) (int64, bool) {
|
||||
return j.ID, true
|
||||
})
|
||||
tasks := make(actions_model.TaskList, 0)
|
||||
if len(jobIDs) > 0 {
|
||||
if err := db.GetEngine(ctx).Where("repo_id = ?", repoID).In("job_id", jobIDs).Find(&tasks); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
artifacts, err := db.Find[actions_model.ActionArtifact](ctx, actions_model.FindArtifactsOptions{
|
||||
RepoID: repoID,
|
||||
RunID: run.ID,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var recordsToDelete []any
|
||||
|
||||
recordsToDelete = append(recordsToDelete, &actions_model.ActionRun{
|
||||
RepoID: repoID,
|
||||
ID: run.ID,
|
||||
})
|
||||
recordsToDelete = append(recordsToDelete, &actions_model.ActionRunAttempt{
|
||||
RepoID: repoID,
|
||||
RunID: run.ID,
|
||||
})
|
||||
recordsToDelete = append(recordsToDelete, &actions_model.ActionRunJob{
|
||||
RepoID: repoID,
|
||||
RunID: run.ID,
|
||||
})
|
||||
for _, tas := range tasks {
|
||||
recordsToDelete = append(recordsToDelete, &actions_model.ActionTask{
|
||||
RepoID: repoID,
|
||||
ID: tas.ID,
|
||||
})
|
||||
recordsToDelete = append(recordsToDelete, &actions_model.ActionTaskStep{
|
||||
RepoID: repoID,
|
||||
TaskID: tas.ID,
|
||||
})
|
||||
recordsToDelete = append(recordsToDelete, &actions_model.ActionTaskOutput{
|
||||
TaskID: tas.ID,
|
||||
})
|
||||
}
|
||||
recordsToDelete = append(recordsToDelete, &actions_model.ActionArtifact{
|
||||
RepoID: repoID,
|
||||
RunID: run.ID,
|
||||
})
|
||||
|
||||
if err := db.WithTx(ctx, func(ctx context.Context) error {
|
||||
// TODO: Deleting task records could break current ephemeral runner implementation. This is a temporary workaround suggested by ChristopherHX.
|
||||
// Since you delete potentially the only task an ephemeral act_runner has ever run, please delete the affected runners first.
|
||||
// one of
|
||||
// call cleanup ephemeral runners first
|
||||
// delete affected ephemeral act_runners
|
||||
// I would make ephemeral runners fully delete directly before formally finishing the task
|
||||
//
|
||||
// See also: https://github.com/go-gitea/gitea/pull/34337#issuecomment-2862222788
|
||||
if err := CleanupEphemeralRunners(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
return db.DeleteBeans(ctx, recordsToDelete...)
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
actions_model.UpdateRepoRunsNumbers(ctx, repoID)
|
||||
|
||||
// Delete files on storage
|
||||
for _, tas := range tasks {
|
||||
removeTaskLog(ctx, tas)
|
||||
}
|
||||
for _, art := range artifacts {
|
||||
if err := storage.ActionsArtifacts.Delete(art.StoragePath); err != nil {
|
||||
log.Error("remove artifact file %q: %v", art.StoragePath, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package actions
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
actions_model "gitea.dev/models/actions"
|
||||
"gitea.dev/models/db"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/modules/actions"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/timeutil"
|
||||
"gitea.dev/modules/util"
|
||||
webhook_module "gitea.dev/modules/webhook"
|
||||
)
|
||||
|
||||
// StopZombieTasks stops tasks in running/cancelling status that haven't been updated for a long time
|
||||
func StopZombieTasks(ctx context.Context) error {
|
||||
return stopTasksByStatuses(ctx, actions_model.FindTaskOptions{
|
||||
UpdatedBefore: timeutil.TimeStamp(time.Now().Add(-setting.Actions.ZombieTaskTimeout).Unix()),
|
||||
}, actions_model.StatusRunning, actions_model.StatusCancelling)
|
||||
}
|
||||
|
||||
// StopEndlessTasks stops tasks in running/cancelling status with continuous updates that don't end for a long time
|
||||
func StopEndlessTasks(ctx context.Context) error {
|
||||
return stopTasksByStatuses(ctx, actions_model.FindTaskOptions{
|
||||
StartedBefore: timeutil.TimeStamp(time.Now().Add(-setting.Actions.EndlessTaskTimeout).Unix()),
|
||||
}, actions_model.StatusRunning, actions_model.StatusCancelling)
|
||||
}
|
||||
|
||||
func stopTasksByStatuses(ctx context.Context, opts actions_model.FindTaskOptions, statuses ...actions_model.Status) error {
|
||||
for _, status := range statuses {
|
||||
optsByStatus := opts
|
||||
optsByStatus.Status = status
|
||||
if err := stopTasks(ctx, optsByStatus); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func CancelPreviousJobs(ctx context.Context, repoID int64, ref, workflowID string, event webhook_module.HookEventType) error {
|
||||
jobs, err := actions_model.CancelPreviousJobs(ctx, repoID, ref, workflowID, event)
|
||||
NotifyWorkflowJobsAndRunsStatusUpdate(ctx, jobs)
|
||||
EmitJobsIfReadyByJobs(jobs)
|
||||
return err
|
||||
}
|
||||
|
||||
func CleanRepoScheduleTasks(ctx context.Context, repo *repo_model.Repository) error {
|
||||
jobs, err := actions_model.CleanRepoScheduleTasks(ctx, repo)
|
||||
NotifyWorkflowJobsAndRunsStatusUpdate(ctx, jobs)
|
||||
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
|
||||
}
|
||||
|
||||
attempts, jobs, err := actions_model.GetConcurrentRunAttemptsAndJobs(ctx, job.RepoID, job.ConcurrencyGroup, []actions_model.Status{actions_model.StatusRunning, actions_model.StatusCancelling})
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("GetConcurrentRunAttemptsAndJobs: %w", err)
|
||||
}
|
||||
|
||||
return len(attempts) > 0 || len(jobs) > 0, nil
|
||||
}
|
||||
|
||||
// PrepareToStartJobWithConcurrency prepares a job to start by its evaluated concurrency group and cancelling previous jobs if necessary.
|
||||
// It returns the new status of the job (either StatusBlocked or StatusWaiting), any cancelled jobs, and any error encountered during the process.
|
||||
func PrepareToStartJobWithConcurrency(ctx context.Context, job *actions_model.ActionRunJob) (actions_model.Status, []*actions_model.ActionRunJob, error) {
|
||||
shouldBlock, err := shouldBlockJobByConcurrency(ctx, job)
|
||||
if err != nil {
|
||||
return actions_model.StatusBlocked, nil, err
|
||||
}
|
||||
|
||||
// even if the current job is blocked, we still need to cancel previous "waiting/blocked" jobs in the same concurrency group
|
||||
jobs, err := actions_model.CancelPreviousJobsByJobConcurrency(ctx, job)
|
||||
if err != nil {
|
||||
return actions_model.StatusBlocked, nil, fmt.Errorf("CancelPreviousJobsByJobConcurrency: %w", err)
|
||||
}
|
||||
|
||||
return util.Iif(shouldBlock, actions_model.StatusBlocked, actions_model.StatusWaiting), jobs, nil
|
||||
}
|
||||
|
||||
func shouldBlockRunByConcurrency(ctx context.Context, attempt *actions_model.ActionRunAttempt) (bool, error) {
|
||||
if attempt.ConcurrencyGroup == "" || attempt.ConcurrencyCancel {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
attempts, jobs, err := actions_model.GetConcurrentRunAttemptsAndJobs(ctx, attempt.RepoID, attempt.ConcurrencyGroup, []actions_model.Status{actions_model.StatusRunning, actions_model.StatusCancelling})
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("find concurrent runs and jobs: %w", err)
|
||||
}
|
||||
|
||||
return len(attempts) > 0 || len(jobs) > 0, nil
|
||||
}
|
||||
|
||||
// PrepareToStartRunWithConcurrency prepares a run attempt to start by its evaluated concurrency group and cancelling previous jobs if necessary.
|
||||
// It returns the new status of the run attempt (either StatusBlocked or StatusWaiting), any cancelled jobs, and any error encountered during the process.
|
||||
func PrepareToStartRunWithConcurrency(ctx context.Context, attempt *actions_model.ActionRunAttempt) (actions_model.Status, []*actions_model.ActionRunJob, error) {
|
||||
shouldBlock, err := shouldBlockRunByConcurrency(ctx, attempt)
|
||||
if err != nil {
|
||||
return actions_model.StatusBlocked, nil, err
|
||||
}
|
||||
|
||||
// even if the current run is blocked, we still need to cancel previous "waiting/blocked" jobs in the same concurrency group
|
||||
jobs, err := actions_model.CancelPreviousJobsByRunConcurrency(ctx, attempt)
|
||||
if err != nil {
|
||||
return actions_model.StatusBlocked, nil, fmt.Errorf("CancelPreviousJobsByRunConcurrency: %w", err)
|
||||
}
|
||||
|
||||
return util.Iif(shouldBlock, actions_model.StatusBlocked, actions_model.StatusWaiting), jobs, nil
|
||||
}
|
||||
|
||||
func stopTasks(ctx context.Context, opts actions_model.FindTaskOptions) error {
|
||||
tasks, err := db.Find[actions_model.ActionTask](ctx, opts)
|
||||
if err != nil {
|
||||
return fmt.Errorf("find tasks: %w", err)
|
||||
}
|
||||
|
||||
jobs := make([]*actions_model.ActionRunJob, 0, len(tasks))
|
||||
for _, task := range tasks {
|
||||
if err := db.WithTx(ctx, func(ctx context.Context) error {
|
||||
stopStatus := actions_model.StatusFailure
|
||||
if task.Status == actions_model.StatusCancelling {
|
||||
stopStatus = actions_model.StatusCancelled
|
||||
}
|
||||
if err := actions_model.StopTask(ctx, task.ID, stopStatus); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := task.LoadJob(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
jobs = append(jobs, task.Job)
|
||||
return nil
|
||||
}); err != nil {
|
||||
log.Warn("Cannot stop task %v: %v", task.ID, err)
|
||||
continue
|
||||
}
|
||||
|
||||
remove, err := actions.TransferLogs(ctx, task.LogFilename)
|
||||
if err != nil {
|
||||
log.Warn("Cannot transfer logs of task %v: %v", task.ID, err)
|
||||
continue
|
||||
}
|
||||
task.LogInStorage = true
|
||||
if err := actions_model.UpdateTask(ctx, task, "log_in_storage"); err != nil {
|
||||
log.Warn("Cannot update task %v: %v", task.ID, err)
|
||||
continue
|
||||
}
|
||||
remove()
|
||||
}
|
||||
|
||||
NotifyWorkflowJobsAndRunsStatusUpdate(ctx, jobs)
|
||||
EmitJobsIfReadyByJobs(jobs)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// CancelAbandonedJobs cancels jobs that have not been picked by any runner for a long time
|
||||
func CancelAbandonedJobs(ctx context.Context) error {
|
||||
abandonedJobs, err := db.Find[actions_model.ActionRunJob](ctx, actions_model.FindRunJobOptions{
|
||||
Statuses: []actions_model.Status{actions_model.StatusWaiting, actions_model.StatusBlocked},
|
||||
UpdatedBefore: timeutil.TimeStampNow().AddDuration(-setting.Actions.AbandonedJobTimeout),
|
||||
})
|
||||
if err != nil {
|
||||
log.Warn("find abandoned jobs: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
updatedJobs, err := actions_model.CancelJobs(ctx, abandonedJobs)
|
||||
if err != nil {
|
||||
log.Warn("cancel abandoned jobs: %v", err)
|
||||
}
|
||||
|
||||
NotifyWorkflowJobsAndRunsStatusUpdate(ctx, updatedJobs)
|
||||
EmitJobsIfReadyByJobs(updatedJobs)
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package actions
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
actions_model "gitea.dev/models/actions"
|
||||
"gitea.dev/models/db"
|
||||
"gitea.dev/models/unittest"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func createConflictingCancellingJob(t *testing.T, concurrencyGroup string, runIndex int64) *actions_model.ActionRunJob {
|
||||
t.Helper()
|
||||
|
||||
run := &actions_model.ActionRun{
|
||||
RepoID: 1,
|
||||
OwnerID: 2,
|
||||
TriggerUserID: 2,
|
||||
WorkflowID: "test.yml",
|
||||
Index: runIndex,
|
||||
Ref: "refs/heads/main",
|
||||
Status: actions_model.StatusBlocked,
|
||||
}
|
||||
require.NoError(t, db.Insert(t.Context(), run))
|
||||
|
||||
attempt := &actions_model.ActionRunAttempt{
|
||||
RepoID: run.RepoID,
|
||||
RunID: run.ID,
|
||||
Attempt: 1,
|
||||
TriggerUserID: run.TriggerUserID,
|
||||
Status: actions_model.StatusBlocked,
|
||||
ConcurrencyGroup: concurrencyGroup,
|
||||
}
|
||||
require.NoError(t, db.Insert(t.Context(), attempt))
|
||||
|
||||
job := &actions_model.ActionRunJob{
|
||||
RunID: run.ID,
|
||||
RunAttemptID: attempt.ID,
|
||||
AttemptJobID: 1,
|
||||
RepoID: run.RepoID,
|
||||
OwnerID: run.OwnerID,
|
||||
CommitSHA: "c2d72f548424103f01ee1dc02889c1e2bff816b0",
|
||||
Name: "conflicting-cancelling-job",
|
||||
JobID: "conflicting-cancelling-job",
|
||||
Status: actions_model.StatusCancelling,
|
||||
ConcurrencyGroup: concurrencyGroup,
|
||||
}
|
||||
require.NoError(t, db.Insert(t.Context(), job))
|
||||
|
||||
return job
|
||||
}
|
||||
|
||||
func TestShouldBlockJobByConcurrency_CancellingJobBlocks(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
const concurrencyGroup = "test-cancelling-job-blocks"
|
||||
createConflictingCancellingJob(t, concurrencyGroup, 9903)
|
||||
|
||||
job := &actions_model.ActionRunJob{
|
||||
RepoID: 1,
|
||||
RawConcurrency: concurrencyGroup,
|
||||
IsConcurrencyEvaluated: true,
|
||||
ConcurrencyGroup: concurrencyGroup,
|
||||
}
|
||||
|
||||
shouldBlock, err := shouldBlockJobByConcurrency(t.Context(), job)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, shouldBlock)
|
||||
}
|
||||
|
||||
func TestShouldBlockRunByConcurrency_CancellingJobBlocks(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
const concurrencyGroup = "test-cancelling-run-blocks"
|
||||
createConflictingCancellingJob(t, concurrencyGroup, 9904)
|
||||
|
||||
attempt := &actions_model.ActionRunAttempt{
|
||||
RepoID: 1,
|
||||
ConcurrencyGroup: concurrencyGroup,
|
||||
}
|
||||
|
||||
shouldBlock, err := shouldBlockRunByConcurrency(t.Context(), attempt)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, shouldBlock)
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package actions
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
actions_model "gitea.dev/models/actions"
|
||||
"gitea.dev/models/db"
|
||||
git_model "gitea.dev/models/git"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
user_model "gitea.dev/models/user"
|
||||
actions_module "gitea.dev/modules/actions"
|
||||
"gitea.dev/modules/actions/jobparser"
|
||||
"gitea.dev/modules/commitstatus"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/util"
|
||||
webhook_module "gitea.dev/modules/webhook"
|
||||
commitstatus_service "gitea.dev/services/repository/commitstatus"
|
||||
)
|
||||
|
||||
// CreateCommitStatusForRunJobs creates a commit status for the given job if it has a supported event and related commit.
|
||||
// It won't return an error failed, but will log it, because it's not critical.
|
||||
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, run.Repo, event, commitID, run, job); err != nil {
|
||||
log.Error("Failed to create commit status for job %d: %v", job.ID, 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
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
if payload.HeadCommit == nil {
|
||||
return "", "", errors.New("head commit is missing in event payload")
|
||||
}
|
||||
commitID = payload.HeadCommit.ID
|
||||
case // pull_request
|
||||
webhook_module.HookEventPullRequest,
|
||||
webhook_module.HookEventPullRequestSync,
|
||||
webhook_module.HookEventPullRequestAssign,
|
||||
webhook_module.HookEventPullRequestLabel,
|
||||
webhook_module.HookEventPullRequestReviewRequest,
|
||||
webhook_module.HookEventPullRequestMilestone:
|
||||
if run.TriggerEvent == actions_module.GithubEventPullRequestTarget {
|
||||
event = "pull_request_target"
|
||||
} else {
|
||||
event = "pull_request"
|
||||
}
|
||||
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 // 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)
|
||||
commitID = run.CommitSHA
|
||||
default: // do nothing, return empty
|
||||
}
|
||||
return event, commitID, nil
|
||||
}
|
||||
|
||||
func createCommitStatus(ctx context.Context, repo *repo_model.Repository, event, commitID string, run *actions_model.ActionRun, job *actions_model.ActionRunJob) error {
|
||||
// TODO: store workflow name as a field in ActionRun to avoid parsing
|
||||
runName := path.Base(run.WorkflowID)
|
||||
if wfs, err := jobparser.Parse(job.WorkflowPayload); err == nil && len(wfs) > 0 {
|
||||
runName = wfs[0].Name
|
||||
}
|
||||
ctxName := strings.TrimSpace(fmt.Sprintf("%s / %s (%s)", runName, job.Name, event)) // git_model.NewCommitStatus also trims spaces
|
||||
state := toCommitStatus(job.Status)
|
||||
targetURL := fmt.Sprintf("%s/jobs/%d", run.Link(), job.ID)
|
||||
description := toCommitStatusDescription(job)
|
||||
|
||||
statuses, err := git_model.GetLatestCommitStatus(ctx, repo.ID, commitID, db.ListOptionsAll)
|
||||
if err != nil {
|
||||
return fmt.Errorf("GetLatestCommitStatus: %w", err)
|
||||
}
|
||||
for _, v := range statuses {
|
||||
if v.Context == ctxName {
|
||||
if v.State == state && v.TargetURL == targetURL && v.Description == description {
|
||||
return nil
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
creator := user_model.NewActionsUser()
|
||||
status := git_model.CommitStatus{
|
||||
SHA: commitID,
|
||||
TargetURL: targetURL,
|
||||
Description: description,
|
||||
Context: ctxName,
|
||||
State: state,
|
||||
CreatorID: creator.ID,
|
||||
}
|
||||
|
||||
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 fmt.Sprintf("Canceled after %s", job.Duration())
|
||||
case actions_model.StatusSkipped:
|
||||
return "Skipped"
|
||||
case actions_model.StatusRunning:
|
||||
return "In progress"
|
||||
case actions_model.StatusCancelling:
|
||||
return "Canceling"
|
||||
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 {
|
||||
switch status {
|
||||
case actions_model.StatusSuccess:
|
||||
return commitstatus.CommitStatusSuccess
|
||||
case actions_model.StatusFailure, actions_model.StatusCancelled:
|
||||
return commitstatus.CommitStatusFailure
|
||||
case actions_model.StatusWaiting, actions_model.StatusBlocked, actions_model.StatusRunning, actions_model.StatusCancelling:
|
||||
return commitstatus.CommitStatusPending
|
||||
case actions_model.StatusSkipped:
|
||||
return commitstatus.CommitStatusSkipped
|
||||
default:
|
||||
return commitstatus.CommitStatusError
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package actions
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
actions_model "gitea.dev/models/actions"
|
||||
"gitea.dev/models/db"
|
||||
git_model "gitea.dev/models/git"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/unittest"
|
||||
actions_module "gitea.dev/modules/actions"
|
||||
"gitea.dev/modules/commitstatus"
|
||||
"gitea.dev/modules/gitrepo"
|
||||
"gitea.dev/modules/timeutil"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestCommitStatusDescription(t *testing.T) {
|
||||
cases := []struct {
|
||||
status actions_model.Status
|
||||
started, stopped timeutil.TimeStamp
|
||||
want string
|
||||
}{
|
||||
{actions_model.StatusSuccess, 100, 102, "Successful in 2s"},
|
||||
{actions_model.StatusFailure, 100, 130, "Failing after 30s"},
|
||||
{actions_model.StatusCancelled, 100, 145, "Canceled after 45s"},
|
||||
{actions_model.StatusCancelling, 0, 0, "Canceling"},
|
||||
{actions_model.StatusSkipped, 0, 0, "Skipped"},
|
||||
{actions_model.StatusRunning, 0, 0, "In progress"},
|
||||
{actions_model.StatusWaiting, 0, 0, "Waiting to run"},
|
||||
{actions_model.StatusBlocked, 0, 0, "Blocked by required conditions"},
|
||||
{actions_model.StatusUnknown, 0, 0, "Unknown status: 0"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
job := &actions_model.ActionRunJob{Status: tc.status, Started: tc.started, Stopped: tc.stopped}
|
||||
assert.Equal(t, tc.want, toCommitStatusDescription(job), tc.status.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateCommitStatus_Dedupe(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
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, "In progress", statuses[1].Description)
|
||||
assert.Equal(t, expectedTargetURL, statuses[1].TargetURL)
|
||||
|
||||
require.NoError(t, createCommitStatus(t.Context(), repo, "push", commit.ID.String(), run, job))
|
||||
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 TestGetCommitActionsStatusMap(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 4})
|
||||
branch := unittest.AssertExistsAndLoadBean(t, &git_model.Branch{RepoID: repo.ID, Name: repo.DefaultBranch})
|
||||
|
||||
run := &actions_model.ActionRun{
|
||||
RepoID: repo.ID, Repo: repo, OwnerID: repo.OwnerID, TriggerUserID: repo.OwnerID,
|
||||
WorkflowID: "test.yaml", CommitSHA: branch.CommitID,
|
||||
}
|
||||
require.NoError(t, db.Insert(t.Context(), run))
|
||||
|
||||
cases := []struct {
|
||||
jobName string
|
||||
status actions_model.Status
|
||||
}{
|
||||
{"running-job", actions_model.StatusRunning},
|
||||
{"waiting-job", actions_model.StatusWaiting},
|
||||
{"unknown-job", actions_model.StatusUnknown},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
job := &actions_model.ActionRunJob{
|
||||
RunID: run.ID, RepoID: repo.ID, OwnerID: repo.OwnerID, Name: tc.jobName, Status: tc.status,
|
||||
}
|
||||
require.NoError(t, db.Insert(t.Context(), job))
|
||||
require.NoError(t, createCommitStatus(t.Context(), repo, "push", branch.CommitID, run, job))
|
||||
}
|
||||
|
||||
statuses, err := git_model.GetLatestCommitStatus(t.Context(), repo.ID, branch.CommitID, db.ListOptionsAll)
|
||||
require.NoError(t, err)
|
||||
|
||||
info := actions_module.GetCommitActionsStatusMap(t.Context(), statuses)
|
||||
got := map[string]string{}
|
||||
for _, s := range statuses {
|
||||
got[s.Context] = info.IconStatus(s)
|
||||
}
|
||||
for _, tc := range cases {
|
||||
key := "test.yaml / " + tc.jobName + " (push)"
|
||||
want := tc.status.String()
|
||||
assert.Equal(t, want, got[key], "icon status for %s", tc.jobName)
|
||||
}
|
||||
|
||||
// Nil receiver returns "" without panicking — used by callers that skip enrichment.
|
||||
var nilInfo actions_module.CommitActionsStatusMap
|
||||
assert.Empty(t, nilInfo.IconStatus(statuses[0]))
|
||||
}
|
||||
|
||||
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,85 @@
|
||||
// Copyright 2025 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package actions
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
actions_model "gitea.dev/models/actions"
|
||||
"gitea.dev/modules/actions/jobparser"
|
||||
|
||||
act_model "gitea.com/gitea/runner/act/model"
|
||||
"go.yaml.in/yaml/v4"
|
||||
)
|
||||
|
||||
// EvaluateRunConcurrencyFillModel evaluates the expressions in a run-level (workflow) concurrency,
|
||||
// and fills the run attempt model with the evaluated `concurrency.group` and `concurrency.cancel-in-progress` values.
|
||||
// Workflow-level concurrency doesn't depend on the job outputs, so it can always be evaluated if there is no syntax error.
|
||||
// See https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#concurrency
|
||||
func EvaluateRunConcurrencyFillModel(ctx context.Context, run *actions_model.ActionRun, attempt *actions_model.ActionRunAttempt, wfRawConcurrency *act_model.RawConcurrency, vars map[string]string, inputs map[string]any) error {
|
||||
if err := run.LoadAttributes(ctx); err != nil {
|
||||
return fmt.Errorf("run LoadAttributes: %w", err)
|
||||
}
|
||||
|
||||
actionsRunCtx := GenerateGiteaContext(ctx, run, attempt, nil)
|
||||
jobResults := map[string]*jobparser.JobResult{"": {}}
|
||||
if inputs == nil {
|
||||
var err error
|
||||
inputs, err = getWorkflowDispatchInputsFromRun(run)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get inputs: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
var err error
|
||||
attempt.ConcurrencyGroup, attempt.ConcurrencyCancel, err = jobparser.EvaluateConcurrency(wfRawConcurrency, "", nil, actionsRunCtx, jobResults, vars, inputs)
|
||||
if err != nil {
|
||||
return fmt.Errorf("evaluate concurrency: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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, attempt *actions_model.ActionRunAttempt, actionRunJob *actions_model.ActionRunJob, vars map[string]string, inputs map[string]any) error {
|
||||
if err := actionRunJob.LoadAttributes(ctx); err != nil {
|
||||
return fmt.Errorf("job LoadAttributes: %w", err)
|
||||
}
|
||||
|
||||
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(ctx, run, attempt, 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 = getInputsForJob(ctx, run, actionRunJob)
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
// Copyright 2025 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package actions
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
actions_model "gitea.dev/models/actions"
|
||||
"gitea.dev/models/db"
|
||||
actions_module "gitea.dev/modules/actions"
|
||||
"gitea.dev/modules/actions/jobparser"
|
||||
"gitea.dev/modules/container"
|
||||
"gitea.dev/modules/git"
|
||||
"gitea.dev/modules/json"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/optional"
|
||||
"gitea.dev/modules/setting"
|
||||
api "gitea.dev/modules/structs"
|
||||
"gitea.dev/modules/util"
|
||||
|
||||
"gitea.com/gitea/runner/act/model"
|
||||
)
|
||||
|
||||
type GiteaContext map[string]any
|
||||
|
||||
// GenerateGiteaContext generate the gitea context without token and gitea_runtime_token.
|
||||
// attempt and job can be nil when generating a context for parsing workflow-level expressions.
|
||||
//
|
||||
// The run_attempt value is resolved with the following precedence:
|
||||
// 1. attempt.Attempt - the explicit attempt argument, or run.GetLatestAttempt() as a fallback
|
||||
// 2. job.Attempt - only used when neither an explicit nor latest attempt is available
|
||||
// 3. "1" - when none of the above apply (first-run parse time, before the first attempt exists)
|
||||
func GenerateGiteaContext(ctx context.Context, run *actions_model.ActionRun, attempt *actions_model.ActionRunAttempt, job *actions_model.ActionRunJob) GiteaContext {
|
||||
event := map[string]any{}
|
||||
_ = json.Unmarshal([]byte(run.EventPayload), &event)
|
||||
|
||||
baseRef := ""
|
||||
headRef := ""
|
||||
ref := run.Ref
|
||||
sha := run.CommitSHA
|
||||
if pullPayload, err := run.GetPullRequestEventPayload(); err == nil && pullPayload.PullRequest != nil && pullPayload.PullRequest.Base != nil && pullPayload.PullRequest.Head != nil {
|
||||
baseRef = pullPayload.PullRequest.Base.Ref
|
||||
headRef = pullPayload.PullRequest.Head.Ref
|
||||
|
||||
// if the TriggerEvent is pull_request_target, ref and sha need to be set according to the base of pull request
|
||||
// In GitHub's documentation, ref should be the branch or tag that triggered workflow. But when the TriggerEvent is pull_request_target,
|
||||
// the ref will be the base branch.
|
||||
if run.TriggerEvent == actions_module.GithubEventPullRequestTarget {
|
||||
ref = git.BranchPrefix + pullPayload.PullRequest.Base.Name
|
||||
sha = pullPayload.PullRequest.Base.Sha
|
||||
}
|
||||
}
|
||||
|
||||
refName := git.RefName(ref)
|
||||
|
||||
gitContext := GiteaContext{
|
||||
// standard contexts, see https://docs.github.com/en/actions/learn-github-actions/contexts#github-context
|
||||
"action": "", // string, The name of the action currently running, or the id of a step. GitHub removes special characters, and uses the name __run when the current step runs a script without an id. If you use the same action more than once in the same job, the name will include a suffix with the sequence number with underscore before it. For example, the first script you run will have the name __run, and the second script will be named __run_2. Similarly, the second invocation of actions/checkout will be actionscheckout2.
|
||||
"action_path": "", // string, The path where an action is located. This property is only supported in composite actions. You can use this path to access files located in the same repository as the action.
|
||||
"action_ref": "", // string, For a step executing an action, this is the ref of the action being executed. For example, v2.
|
||||
"action_repository": "", // string, For a step executing an action, this is the owner and repository name of the action. For example, actions/checkout.
|
||||
"action_status": "", // string, For a composite action, the current result of the composite action.
|
||||
"actor": run.TriggerUser.Name, // string, The username of the user that triggered the initial workflow run. If the workflow run is a re-run, this value may differ from github.triggering_actor. Any workflow re-runs will use the privileges of github.actor, even if the actor initiating the re-run (github.triggering_actor) has different privileges.
|
||||
"api_url": setting.AppURL + "api/v1", // string, The URL of the GitHub REST API.
|
||||
"base_ref": baseRef, // string, The base_ref or target branch of the pull request in a workflow run. This property is only available when the event that triggers a workflow run is either pull_request or pull_request_target.
|
||||
"env": "", // string, Path on the runner to the file that sets environment variables from workflow commands. This file is unique to the current step and is a different file for each step in a job. For more information, see "Workflow commands for GitHub Actions."
|
||||
"event": event, // object, The full event webhook payload. You can access individual properties of the event using this context. This object is identical to the webhook payload of the event that triggered the workflow run, and is different for each event. The webhooks for each GitHub Actions event is linked in "Events that trigger workflows." For example, for a workflow run triggered by the push event, this object contains the contents of the push webhook payload.
|
||||
"event_name": run.TriggerEvent, // string, The name of the event that triggered the workflow run.
|
||||
"event_path": "", // string, The path to the file on the runner that contains the full event webhook payload.
|
||||
"graphql_url": "", // string, The URL of the GitHub GraphQL API.
|
||||
"head_ref": headRef, // string, The head_ref or source branch of the pull request in a workflow run. This property is only available when the event that triggers a workflow run is either pull_request or pull_request_target.
|
||||
"job": "", // string, The job_id of the current job.
|
||||
"ref": ref, // string, The fully-formed ref of the branch or tag that triggered the workflow run. For workflows triggered by push, this is the branch or tag ref that was pushed. For workflows triggered by pull_request, this is the pull request merge branch. For workflows triggered by release, this is the release tag created. For other triggers, this is the branch or tag ref that triggered the workflow run. This is only set if a branch or tag is available for the event type. The ref given is fully-formed, meaning that for branches the format is refs/heads/<branch_name>, for pull requests it is refs/pull/<pr_number>/merge, and for tags it is refs/tags/<tag_name>. For example, refs/heads/feature-branch-1.
|
||||
"ref_name": refName.ShortName(), // string, The short ref name of the branch or tag that triggered the workflow run. This value matches the branch or tag name shown on GitHub. For example, feature-branch-1.
|
||||
"ref_protected": false, // boolean, true if branch protections are configured for the ref that triggered the workflow run.
|
||||
"ref_type": string(refName.RefType()), // string, The type of ref that triggered the workflow run. Valid values are branch or tag.
|
||||
"path": "", // string, Path on the runner to the file that sets system PATH variables from workflow commands. This file is unique to the current step and is a different file for each step in a job. For more information, see "Workflow commands for GitHub Actions."
|
||||
"repository": run.Repo.OwnerName + "/" + run.Repo.Name, // string, The owner and repository name. For example, Codertocat/Hello-World.
|
||||
"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": 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.
|
||||
"server_url": setting.AppURL, // string, The URL of the GitHub server. For example: https://github.com.
|
||||
"sha": sha, // string, The commit SHA that triggered the workflow. The value of this commit SHA depends on the event that triggered the workflow. For more information, see "Events that trigger workflows." For example, ffac537e6cbbf934b08745a378932722df287a53.
|
||||
"triggering_actor": "", // string, The username of the user that initiated the workflow run. If the workflow run is a re-run, this value may differ from github.actor. Any workflow re-runs will use the privileges of github.actor, even if the actor initiating the re-run (github.triggering_actor) has different privileges.
|
||||
"workflow": run.WorkflowID, // string, The name of the workflow. If the workflow file doesn't specify a name, the value of this property is the full path of the workflow file in the repository.
|
||||
"workspace": "", // string, The default working directory on the runner for steps, and the default location of your repository when using the checkout action.
|
||||
|
||||
// additional contexts
|
||||
"gitea_default_actions_url": setting.Actions.DefaultActionsURL.URL(),
|
||||
}
|
||||
|
||||
if job != nil {
|
||||
gitContext["job"] = job.JobID
|
||||
gitContext["run_attempt"] = strconv.FormatInt(job.Attempt, 10)
|
||||
|
||||
if job.ParentJobID > 0 {
|
||||
// Inject the caller's resolved workflow_call inputs into gitea.event.inputs.
|
||||
// The rest of gitea.event stays as the caller's actual trigger event (push/pull_request/etc.)
|
||||
// to match GitHub's semantics (see https://docs.github.com/en/actions/reference/workflows-and-actions/reusing-workflow-configurations#github-context).
|
||||
// FIXME: If the run is triggered by "workflow_dispatch", the original inputs of "workflow_dispatch" will be overridden.
|
||||
// If necessary, the caller can send these values to the called workflow via `with:`.
|
||||
caller, err := actions_model.GetRunJobByRunAndID(ctx, job.RunID, job.ParentJobID)
|
||||
if err != nil {
|
||||
log.Error("GenerateGiteaContext: load caller job %d of job %d: %v", job.ParentJobID, job.ID, err)
|
||||
} else if caller.CallPayload != "" {
|
||||
var cp api.WorkflowCallPayload
|
||||
if err := json.Unmarshal([]byte(caller.CallPayload), &cp); err != nil {
|
||||
log.Error("GenerateGiteaContext: decode CallPayload of caller %d: %v", caller.ID, err)
|
||||
} else if cp.Inputs != nil {
|
||||
event["inputs"] = cp.Inputs
|
||||
}
|
||||
}
|
||||
|
||||
// Override gitea.event_name to "workflow_call", so that the runner-side `getEvaluatorInputs` can get inputs from event["inputs"].
|
||||
// https://gitea.com/gitea/runner/src/commit/0b9f251b6abb30d5f292a49cfe0c611f7c26d857/act/runner/expression.go#L509
|
||||
// FIXME: The trade-off is that `${{ gitea.event_name }}` inside a reusable workflow's child job reads "workflow_call"
|
||||
// instead of the caller's real trigger event name (push/pull_request/etc.) This is a small deviation from GitHub spec.
|
||||
gitContext["event_name"] = "workflow_call"
|
||||
}
|
||||
}
|
||||
|
||||
if attempt == nil {
|
||||
if latestAttempt, has, err := run.GetLatestAttempt(ctx); err == nil && has {
|
||||
attempt = latestAttempt
|
||||
}
|
||||
}
|
||||
|
||||
if attempt != nil {
|
||||
gitContext["run_attempt"] = strconv.FormatInt(attempt.Attempt, 10)
|
||||
if err := attempt.LoadAttributes(ctx); err == nil {
|
||||
gitContext["triggering_actor"] = attempt.TriggerUser.Name
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback for first-run parse time: no job, no attempt (LatestAttemptID==0). github.run_attempt
|
||||
// is 1-based per the documented contract, so emit "1" rather than leaving it empty.
|
||||
if gitContext["run_attempt"] == "" {
|
||||
gitContext["run_attempt"] = "1"
|
||||
}
|
||||
|
||||
return gitContext
|
||||
}
|
||||
|
||||
type TaskNeed struct {
|
||||
Result actions_model.Status
|
||||
Outputs map[string]string
|
||||
}
|
||||
|
||||
// FindTaskNeeds finds the `needs` for the task by the task's job.
|
||||
// Lookup is scoped to the same ParentJobID.
|
||||
func FindTaskNeeds(ctx context.Context, job *actions_model.ActionRunJob) (map[string]*TaskNeed, error) {
|
||||
if len(job.Needs) == 0 {
|
||||
return nil, nil //nolint:nilnil // return nil when the job has no needs
|
||||
}
|
||||
needs := container.SetOf(job.Needs...)
|
||||
|
||||
// Scope to the same attempt. For legacy jobs RunAttemptID==0, which matches all other legacy jobs in the same run.
|
||||
findOpts := actions_model.FindRunJobOptions{
|
||||
RunID: job.RunID,
|
||||
RunAttemptID: optional.Some(job.RunAttemptID),
|
||||
}
|
||||
|
||||
jobs, err := db.Find[actions_model.ActionRunJob](ctx, findOpts)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("FindRunJobs: %w", err)
|
||||
}
|
||||
|
||||
jobIDJobs := make(map[string][]*actions_model.ActionRunJob)
|
||||
// childrenByParent indexes every job by its ParentJobID
|
||||
childrenByParent := make(map[int64][]*actions_model.ActionRunJob)
|
||||
for _, candidate := range jobs {
|
||||
if candidate.ParentJobID != 0 {
|
||||
childrenByParent[candidate.ParentJobID] = append(childrenByParent[candidate.ParentJobID], candidate)
|
||||
}
|
||||
// `needs` references are scope-bound: only candidates in the same caller scope match.
|
||||
if candidate.ParentJobID == job.ParentJobID {
|
||||
jobIDJobs[candidate.JobID] = append(jobIDJobs[candidate.JobID], candidate)
|
||||
}
|
||||
}
|
||||
|
||||
ret := make(map[string]*TaskNeed, len(needs))
|
||||
for jobID, jobsWithSameID := range jobIDJobs {
|
||||
if !needs.Contains(jobID) {
|
||||
continue
|
||||
}
|
||||
var jobOutputs map[string]string
|
||||
for _, candidate := range jobsWithSameID {
|
||||
if !candidate.Status.IsDone() {
|
||||
continue
|
||||
}
|
||||
var outputs map[string]string
|
||||
var err error
|
||||
if candidate.IsReusableCaller {
|
||||
outputs, err = computeReusableCallerOutputs(ctx, candidate, childrenByParent)
|
||||
} else {
|
||||
outputs, err = loadJobTaskOutputs(ctx, candidate)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(jobOutputs) == 0 {
|
||||
jobOutputs = outputs
|
||||
} else {
|
||||
jobOutputs = mergeTwoOutputs(outputs, jobOutputs)
|
||||
}
|
||||
}
|
||||
ret[jobID] = &TaskNeed{
|
||||
Outputs: jobOutputs,
|
||||
Result: actions_model.AggregateJobStatus(jobsWithSameID),
|
||||
}
|
||||
}
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
// computeReusableCallerOutputs returns the workflow_call outputs of a reusable caller by recursing into its child subtree.
|
||||
func computeReusableCallerOutputs(ctx context.Context, caller *actions_model.ActionRunJob, childrenByParent map[int64][]*actions_model.ActionRunJob) (map[string]string, error) {
|
||||
if !caller.IsExpanded {
|
||||
// A caller that was never expanded (e.g. Skipped because its `if:` was false) has no workflow_call outputs, return early.
|
||||
return map[string]string{}, nil
|
||||
}
|
||||
|
||||
directChildren := childrenByParent[caller.ID]
|
||||
|
||||
if err := caller.LoadRun(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
wcSpec, err := jobparser.ParseWorkflowCallSpec(caller.ReusableWorkflowContent)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(wcSpec.Outputs) == 0 {
|
||||
return map[string]string{}, nil
|
||||
}
|
||||
|
||||
// Per-job outputs over the children of this caller.
|
||||
jobOutputs := make(jobparser.JobOutputs, len(directChildren))
|
||||
for _, child := range directChildren {
|
||||
var outs map[string]string
|
||||
switch {
|
||||
case child.IsReusableCaller:
|
||||
outs, err = computeReusableCallerOutputs(ctx, child, childrenByParent)
|
||||
default:
|
||||
outs, err = loadJobTaskOutputs(ctx, child)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if existing, ok := jobOutputs[child.JobID]; ok {
|
||||
jobOutputs[child.JobID] = mergeTwoOutputs(outs, existing)
|
||||
} else {
|
||||
jobOutputs[child.JobID] = outs
|
||||
}
|
||||
}
|
||||
|
||||
// build contexts for evaluating outputs
|
||||
if err := caller.Run.LoadAttributes(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
gitCtx := GenerateGiteaContext(ctx, caller.Run, nil, caller)
|
||||
vars, err := actions_model.GetVariablesOfRun(ctx, caller.Run)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
inputs := map[string]any{}
|
||||
if caller.CallPayload != "" {
|
||||
var p api.WorkflowCallPayload
|
||||
if err := json.Unmarshal([]byte(caller.CallPayload), &p); err != nil {
|
||||
return nil, fmt.Errorf("decode caller payload: %w", err)
|
||||
}
|
||||
if p.Inputs != nil {
|
||||
inputs = p.Inputs
|
||||
}
|
||||
}
|
||||
|
||||
return jobparser.EvaluateWorkflowCallOutputs(wcSpec, gitCtx.ToGitHubContext(), vars, inputs, jobOutputs)
|
||||
}
|
||||
|
||||
// loadJobTaskOutputs returns the task-output map of `job`.
|
||||
func loadJobTaskOutputs(ctx context.Context, job *actions_model.ActionRunJob) (map[string]string, error) {
|
||||
tid := job.EffectiveTaskID()
|
||||
if tid == 0 {
|
||||
return map[string]string{}, nil
|
||||
}
|
||||
rows, err := actions_model.FindTaskOutputByTaskID(ctx, tid)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("FindTaskOutputByTaskID: %w", err)
|
||||
}
|
||||
out := make(map[string]string, len(rows))
|
||||
for _, r := range rows {
|
||||
out[r.OutputKey] = r.OutputValue
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// mergeTwoOutputs merges two outputs from two different ActionRunJobs
|
||||
// Values with the same output name may be overridden. The user should ensure the output names are unique.
|
||||
// See https://docs.github.com/en/actions/writing-workflows/workflow-syntax-for-github-actions#using-job-outputs-in-a-matrix-job
|
||||
func mergeTwoOutputs(o1, o2 map[string]string) map[string]string {
|
||||
ret := make(map[string]string, len(o1))
|
||||
for k1, v1 := range o1 {
|
||||
if len(v1) > 0 {
|
||||
ret[k1] = v1
|
||||
} else {
|
||||
ret[k1] = o2[k1]
|
||||
}
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
func (g *GiteaContext) ToGitHubContext() *model.GithubContext {
|
||||
return &model.GithubContext{
|
||||
Event: util.GetMapValueOrDefault(*g, "event", map[string]any(nil)),
|
||||
EventPath: util.GetMapValueOrDefault(*g, "event_path", ""),
|
||||
Workflow: util.GetMapValueOrDefault(*g, "workflow", ""),
|
||||
RunID: util.GetMapValueOrDefault(*g, "run_id", ""),
|
||||
RunNumber: util.GetMapValueOrDefault(*g, "run_number", ""),
|
||||
Actor: util.GetMapValueOrDefault(*g, "actor", ""),
|
||||
Repository: util.GetMapValueOrDefault(*g, "repository", ""),
|
||||
EventName: util.GetMapValueOrDefault(*g, "event_name", ""),
|
||||
Sha: util.GetMapValueOrDefault(*g, "sha", ""),
|
||||
Ref: util.GetMapValueOrDefault(*g, "ref", ""),
|
||||
RefName: util.GetMapValueOrDefault(*g, "ref_name", ""),
|
||||
RefType: util.GetMapValueOrDefault(*g, "ref_type", ""),
|
||||
HeadRef: util.GetMapValueOrDefault(*g, "head_ref", ""),
|
||||
BaseRef: util.GetMapValueOrDefault(*g, "base_ref", ""),
|
||||
Token: "", // deliberately omitted for security
|
||||
Workspace: util.GetMapValueOrDefault(*g, "workspace", ""),
|
||||
Action: util.GetMapValueOrDefault(*g, "action", ""),
|
||||
ActionPath: util.GetMapValueOrDefault(*g, "action_path", ""),
|
||||
ActionRef: util.GetMapValueOrDefault(*g, "action_ref", ""),
|
||||
ActionRepository: util.GetMapValueOrDefault(*g, "action_repository", ""),
|
||||
Job: util.GetMapValueOrDefault(*g, "job", ""),
|
||||
JobName: "", // not present in GiteaContext
|
||||
RepositoryOwner: util.GetMapValueOrDefault(*g, "repository_owner", ""),
|
||||
RetentionDays: util.GetMapValueOrDefault(*g, "retention_days", ""),
|
||||
RunnerPerflog: "", // not present in GiteaContext
|
||||
RunnerTrackingID: "", // not present in GiteaContext
|
||||
ServerURL: util.GetMapValueOrDefault(*g, "server_url", ""),
|
||||
APIURL: util.GetMapValueOrDefault(*g, "api_url", ""),
|
||||
GraphQLURL: util.GetMapValueOrDefault(*g, "graphql_url", ""),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package actions
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
actions_model "gitea.dev/models/actions"
|
||||
"gitea.dev/models/db"
|
||||
"gitea.dev/models/unittest"
|
||||
"gitea.dev/modules/json"
|
||||
api "gitea.dev/modules/structs"
|
||||
|
||||
act_model "gitea.com/gitea/runner/act/model"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestEvaluateRunConcurrency_RunIDFallback(t *testing.T) {
|
||||
// Unit-level check that EvaluateRunConcurrencyFillModel resolves github.run_id from run.ID.
|
||||
// The full-flow regression (run.ID non-zero by evaluation time) is TestPrepareRunAndInsert_ExpressionsSeeRunID.
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
ctx := t.Context()
|
||||
|
||||
runA := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: 791})
|
||||
runB := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: 792})
|
||||
|
||||
attemptA := &actions_model.ActionRunAttempt{RepoID: runA.RepoID, RunID: runA.ID, Attempt: 1}
|
||||
attemptB := &actions_model.ActionRunAttempt{RepoID: runB.RepoID, RunID: runB.ID, Attempt: 1}
|
||||
|
||||
expr := &act_model.RawConcurrency{
|
||||
Group: "${{ github.workflow }}-${{ github.head_ref || github.run_id }}",
|
||||
CancelInProgress: "true",
|
||||
}
|
||||
|
||||
assert.NoError(t, EvaluateRunConcurrencyFillModel(ctx, runA, attemptA, expr, nil, nil))
|
||||
assert.NoError(t, EvaluateRunConcurrencyFillModel(ctx, runB, attemptB, expr, nil, nil))
|
||||
|
||||
assert.Contains(t, attemptA.ConcurrencyGroup, "791")
|
||||
assert.Contains(t, attemptB.ConcurrencyGroup, "792")
|
||||
assert.NotEqual(t, attemptA.ConcurrencyGroup, attemptB.ConcurrencyGroup)
|
||||
}
|
||||
|
||||
func TestPrepareRunAndInsert_ExpressionsSeeRunID(t *testing.T) {
|
||||
// Regression for the cross-branch concurrency leak: github.run_id must be available during both
|
||||
// jobparser.Parse (run-name) and concurrency evaluation; inserting run after either leaves run.ID at 0.
|
||||
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)
|
||||
// ConcurrencyGroup lives on the latest attempt after migration v331.
|
||||
require.Positive(t, persisted.LatestAttemptID)
|
||||
attempt := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunAttempt{ID: persisted.LatestAttemptID})
|
||||
assert.Equal(t, "group-"+runIDStr, attempt.ConcurrencyGroup)
|
||||
// Rerun reads raw_concurrency from the DB to re-evaluate the group;
|
||||
// see services/actions/rerun.go. Must survive the insert.
|
||||
assert.NotEmpty(t, persisted.RawConcurrency)
|
||||
}
|
||||
|
||||
func TestComputeReusableCallerOutputs(t *testing.T) {
|
||||
require.NoError(t, unittest.PrepareTestDatabase())
|
||||
ctx := t.Context()
|
||||
|
||||
var nextRunIndex int64 = 9001
|
||||
insertRun := func(t *testing.T, workflowID string) *actions_model.ActionRun {
|
||||
t.Helper()
|
||||
run := &actions_model.ActionRun{
|
||||
Title: "reusable-out",
|
||||
RepoID: 4,
|
||||
Index: nextRunIndex,
|
||||
OwnerID: 1,
|
||||
WorkflowID: workflowID,
|
||||
TriggerUserID: 1,
|
||||
Ref: "refs/heads/master",
|
||||
CommitSHA: "c2d72f548424103f01ee1dc02889c1e2bff816b0",
|
||||
Event: "push",
|
||||
TriggerEvent: "push",
|
||||
EventPayload: "{}",
|
||||
Status: actions_model.StatusSuccess,
|
||||
}
|
||||
nextRunIndex++
|
||||
require.NoError(t, db.Insert(ctx, run))
|
||||
return run
|
||||
}
|
||||
|
||||
insertCaller := func(t *testing.T, run *actions_model.ActionRun, jobID string, parentID int64, content, callPayload string) *actions_model.ActionRunJob {
|
||||
t.Helper()
|
||||
job := &actions_model.ActionRunJob{
|
||||
RunID: run.ID,
|
||||
RepoID: run.RepoID,
|
||||
OwnerID: run.OwnerID,
|
||||
CommitSHA: run.CommitSHA,
|
||||
Name: jobID,
|
||||
JobID: jobID,
|
||||
Attempt: 1,
|
||||
Status: actions_model.StatusSuccess,
|
||||
ParentJobID: parentID,
|
||||
IsReusableCaller: true,
|
||||
IsExpanded: true,
|
||||
ReusableWorkflowContent: []byte(content),
|
||||
CallPayload: callPayload,
|
||||
}
|
||||
require.NoError(t, db.Insert(ctx, job))
|
||||
return job
|
||||
}
|
||||
|
||||
// Each call to insertChildJobAndTask with non-empty outputs allocates a fresh TaskID
|
||||
// so its action_task_output rows stay isolated per subtest.
|
||||
var nextTaskID int64 = 90001
|
||||
insertChildJobAndTask := func(t *testing.T, run *actions_model.ActionRun, jobID string, parentID int64, outputs map[string]string) *actions_model.ActionRunJob {
|
||||
t.Helper()
|
||||
var taskID int64
|
||||
if len(outputs) > 0 {
|
||||
taskID = nextTaskID
|
||||
nextTaskID++
|
||||
}
|
||||
job := &actions_model.ActionRunJob{
|
||||
RunID: run.ID,
|
||||
RepoID: run.RepoID,
|
||||
OwnerID: run.OwnerID,
|
||||
CommitSHA: run.CommitSHA,
|
||||
Name: jobID,
|
||||
JobID: jobID,
|
||||
Attempt: 1,
|
||||
Status: actions_model.StatusSuccess,
|
||||
ParentJobID: parentID,
|
||||
TaskID: taskID,
|
||||
}
|
||||
require.NoError(t, db.Insert(ctx, job))
|
||||
for k, v := range outputs {
|
||||
require.NoError(t, db.Insert(ctx, &actions_model.ActionTaskOutput{
|
||||
TaskID: taskID,
|
||||
OutputKey: k,
|
||||
OutputValue: v,
|
||||
}))
|
||||
}
|
||||
return job
|
||||
}
|
||||
|
||||
// childrenByParentOfRun returns the run's jobs indexed by ParentJobID, the shape computeReusableCallerOutputs expects.
|
||||
childrenByParentOfRun := func(t *testing.T, runID int64) map[int64][]*actions_model.ActionRunJob {
|
||||
t.Helper()
|
||||
all, err := db.Find[actions_model.ActionRunJob](ctx, actions_model.FindRunJobOptions{RunID: runID})
|
||||
require.NoError(t, err)
|
||||
index := make(map[int64][]*actions_model.ActionRunJob)
|
||||
for _, j := range all {
|
||||
if j.ParentJobID != 0 {
|
||||
index[j.ParentJobID] = append(index[j.ParentJobID], j)
|
||||
}
|
||||
}
|
||||
return index
|
||||
}
|
||||
|
||||
t.Run("returns empty when callee declares no outputs", func(t *testing.T) {
|
||||
run := insertRun(t, "no-outputs.yaml")
|
||||
caller := insertCaller(t, run, "caller", 0, `on:
|
||||
workflow_call:
|
||||
outputs: {}
|
||||
`, "")
|
||||
out, err := computeReusableCallerOutputs(ctx, caller, childrenByParentOfRun(t, run.ID))
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, out)
|
||||
})
|
||||
|
||||
t.Run("unexpanded (skipped) caller yields empty outputs without error", func(t *testing.T) {
|
||||
run := insertRun(t, "skipped-caller.yaml")
|
||||
// A reusable caller skipped before expansion: IsExpanded=false, empty ReusableWorkflowContent, no children.
|
||||
caller := &actions_model.ActionRunJob{
|
||||
RunID: run.ID,
|
||||
RepoID: run.RepoID,
|
||||
OwnerID: run.OwnerID,
|
||||
CommitSHA: run.CommitSHA,
|
||||
Name: "caller",
|
||||
JobID: "caller",
|
||||
Attempt: 1,
|
||||
Status: actions_model.StatusSkipped,
|
||||
IsReusableCaller: true,
|
||||
IsExpanded: false,
|
||||
}
|
||||
require.NoError(t, db.Insert(ctx, caller))
|
||||
out, err := computeReusableCallerOutputs(ctx, caller, childrenByParentOfRun(t, run.ID))
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, out)
|
||||
})
|
||||
|
||||
t.Run("literal output value passes through", func(t *testing.T) {
|
||||
run := insertRun(t, "literal-out.yaml")
|
||||
caller := insertCaller(t, run, "caller", 0, `on:
|
||||
workflow_call:
|
||||
outputs:
|
||||
hello:
|
||||
value: world
|
||||
`, "")
|
||||
out, err := computeReusableCallerOutputs(ctx, caller, childrenByParentOfRun(t, run.ID))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, map[string]string{"hello": "world"}, out)
|
||||
})
|
||||
|
||||
t.Run("output expression reads child task outputs", func(t *testing.T) {
|
||||
run := insertRun(t, "child-out.yaml")
|
||||
caller := insertCaller(t, run, "caller", 0, `on:
|
||||
workflow_call:
|
||||
outputs:
|
||||
result:
|
||||
value: ${{ jobs.child.outputs.foo }}
|
||||
`, "")
|
||||
insertChildJobAndTask(t, run, "child", caller.ID, map[string]string{"foo": "bar"})
|
||||
|
||||
out, err := computeReusableCallerOutputs(ctx, caller, childrenByParentOfRun(t, run.ID))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, map[string]string{"result": "bar"}, out)
|
||||
})
|
||||
|
||||
t.Run("CallPayload inputs reachable in output expression", func(t *testing.T) {
|
||||
run := insertRun(t, "payload-out.yaml")
|
||||
payload, err := json.Marshal(api.WorkflowCallPayload{
|
||||
Inputs: map[string]any{"env": "staging"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
caller := insertCaller(t, run, "caller", 0, `on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
env:
|
||||
type: string
|
||||
outputs:
|
||||
env:
|
||||
value: ${{ inputs.env }}
|
||||
`, string(payload))
|
||||
|
||||
out, err := computeReusableCallerOutputs(ctx, caller, childrenByParentOfRun(t, run.ID))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, map[string]string{"env": "staging"}, out)
|
||||
})
|
||||
|
||||
t.Run("nested caller outputs propagate to outer", func(t *testing.T) {
|
||||
run := insertRun(t, "nested-out.yaml")
|
||||
outer := insertCaller(t, run, "outer", 0, `on:
|
||||
workflow_call:
|
||||
outputs:
|
||||
bubbled:
|
||||
value: ${{ jobs.inner.outputs.up }}
|
||||
`, "")
|
||||
inner := insertCaller(t, run, "inner", outer.ID, `on:
|
||||
workflow_call:
|
||||
outputs:
|
||||
up:
|
||||
value: ${{ jobs.leaf.outputs.foo }}
|
||||
`, "")
|
||||
insertChildJobAndTask(t, run, "leaf", inner.ID, map[string]string{"foo": "bubble-value"})
|
||||
|
||||
out, err := computeReusableCallerOutputs(ctx, outer, childrenByParentOfRun(t, run.ID))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, map[string]string{"bubbled": "bubble-value"}, out)
|
||||
})
|
||||
|
||||
t.Run("matrix children with same JobID prefer non-empty values", func(t *testing.T) {
|
||||
run := insertRun(t, "matrix-out.yaml")
|
||||
caller := insertCaller(t, run, "caller", 0, `on:
|
||||
workflow_call:
|
||||
outputs:
|
||||
foo:
|
||||
value: ${{ jobs.matrix.outputs.foo }}
|
||||
`, "")
|
||||
insertChildJobAndTask(t, run, "matrix", caller.ID, map[string]string{"foo": ""})
|
||||
insertChildJobAndTask(t, run, "matrix", caller.ID, map[string]string{"foo": "filled"})
|
||||
|
||||
out, err := computeReusableCallerOutputs(ctx, caller, childrenByParentOfRun(t, run.ID))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, map[string]string{"foo": "filled"}, out)
|
||||
})
|
||||
}
|
||||
|
||||
func TestFindTaskNeeds(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
task := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionTask{ID: 51})
|
||||
job := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: task.JobID})
|
||||
|
||||
ret, err := FindTaskNeeds(t.Context(), job)
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, ret, 1)
|
||||
assert.Contains(t, ret, "job1")
|
||||
assert.Len(t, ret["job1"].Outputs, 2)
|
||||
assert.Equal(t, "abc", ret["job1"].Outputs["output_a"])
|
||||
assert.Equal(t, "bbb", ret["job1"].Outputs["output_b"])
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package actions
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
actions_model "gitea.dev/models/actions"
|
||||
"gitea.dev/modules/actions/jobparser"
|
||||
"gitea.dev/modules/json"
|
||||
api "gitea.dev/modules/structs"
|
||||
)
|
||||
|
||||
func getWorkflowDispatchInputsFromRun(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
|
||||
}
|
||||
|
||||
// getInputsForJob returns the `inputs.*` top-level expression context for a job's evaluation.
|
||||
// - For top-level jobs, it falls back to the run's dispatch inputs (empty for non-dispatch events)
|
||||
// - For reusable workflow children (and nested callers), this is the direct parent caller's CallPayload.Inputs
|
||||
func getInputsForJob(ctx context.Context, run *actions_model.ActionRun, job *actions_model.ActionRunJob) (map[string]any, error) {
|
||||
if job.ParentJobID == 0 {
|
||||
return getWorkflowDispatchInputsFromRun(run)
|
||||
}
|
||||
|
||||
caller, err := actions_model.GetRunJobByRunAndID(ctx, run.ID, job.ParentJobID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load caller job %d: %w", job.ParentJobID, err)
|
||||
}
|
||||
if caller.CallPayload == "" {
|
||||
// should not happen - a child job cannot reach this point if its caller's CallPayload hasn't been evaluated
|
||||
return map[string]any{}, nil
|
||||
}
|
||||
var p api.WorkflowCallPayload
|
||||
if err := json.Unmarshal([]byte(caller.CallPayload), &p); err != nil {
|
||||
return nil, fmt.Errorf("decode caller %d payload: %w", caller.ID, err)
|
||||
}
|
||||
if p.Inputs == nil {
|
||||
return map[string]any{}, nil
|
||||
}
|
||||
return p.Inputs, nil
|
||||
}
|
||||
|
||||
// evaluateJobIf evaluates a job's `if:`
|
||||
func evaluateJobIf(ctx context.Context, run *actions_model.ActionRun, attempt *actions_model.ActionRunAttempt, job *actions_model.ActionRunJob, vars map[string]string, allNeedsSucceed bool) (bool, error) {
|
||||
parsedJob, err := job.ParseJob()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
// Empty `if:` reduces to implicit `success()` - true iff every need finished as Success.
|
||||
if len(parsedJob.If.Value) == 0 {
|
||||
return allNeedsSucceed, nil
|
||||
}
|
||||
jobResults, err := findJobNeedsAndFillJobResults(ctx, job)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
inputs, err := getInputsForJob(ctx, run, job)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
gitCtx := GenerateGiteaContext(ctx, run, attempt, job)
|
||||
return jobparser.EvaluateJobIfExpression(job.JobID, parsedJob, gitCtx, jobResults, vars, inputs)
|
||||
}
|
||||
|
||||
func findJobNeedsAndFillJobResults(ctx context.Context, job *actions_model.ActionRunJob) (map[string]*jobparser.JobResult, error) {
|
||||
taskNeeds, err := FindTaskNeeds(ctx, job)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("find task needs: %w", err)
|
||||
}
|
||||
jobResults := make(map[string]*jobparser.JobResult, len(taskNeeds))
|
||||
for jobID, taskNeed := range taskNeeds {
|
||||
jobResult := &jobparser.JobResult{
|
||||
Result: taskNeed.Result.String(),
|
||||
Outputs: taskNeed.Outputs,
|
||||
}
|
||||
jobResults[jobID] = jobResult
|
||||
}
|
||||
jobResults[job.JobID] = &jobparser.JobResult{
|
||||
Needs: job.Needs,
|
||||
}
|
||||
return jobResults, nil
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package actions
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
actions_model "gitea.dev/models/actions"
|
||||
"gitea.dev/modules/graceful"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/queue"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/util"
|
||||
notify_service "gitea.dev/services/notify"
|
||||
)
|
||||
|
||||
func initGlobalRunnerToken(ctx context.Context) error {
|
||||
// use the same env name as the runner, for consistency
|
||||
token := os.Getenv("GITEA_RUNNER_REGISTRATION_TOKEN")
|
||||
tokenFile := os.Getenv("GITEA_RUNNER_REGISTRATION_TOKEN_FILE")
|
||||
if token != "" && tokenFile != "" {
|
||||
return errors.New("both GITEA_RUNNER_REGISTRATION_TOKEN and GITEA_RUNNER_REGISTRATION_TOKEN_FILE are set, only one can be used")
|
||||
}
|
||||
if tokenFile != "" {
|
||||
file, err := os.ReadFile(tokenFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to read GITEA_RUNNER_REGISTRATION_TOKEN_FILE: %w", err)
|
||||
}
|
||||
token = strings.TrimSpace(string(file))
|
||||
}
|
||||
if token == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
if len(token) < 32 {
|
||||
return errors.New("GITEA_RUNNER_REGISTRATION_TOKEN must be at least 32 random characters")
|
||||
}
|
||||
|
||||
existing, err := actions_model.GetRunnerToken(ctx, token)
|
||||
if err != nil && !errors.Is(err, util.ErrNotExist) {
|
||||
return fmt.Errorf("unable to check existing token: %w", err)
|
||||
}
|
||||
if existing != nil {
|
||||
if !existing.IsActive {
|
||||
log.Warn("The token defined by GITEA_RUNNER_REGISTRATION_TOKEN is already invalidated, please use the latest one from web UI")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
_, err = actions_model.NewRunnerTokenWithValue(ctx, 0, 0, token)
|
||||
return err
|
||||
}
|
||||
|
||||
func Init(ctx context.Context) error {
|
||||
if !setting.Actions.Enabled {
|
||||
return nil
|
||||
}
|
||||
|
||||
jobEmitterQueue = queue.CreateUniqueQueue(graceful.GetManager().ShutdownContext(), "actions_ready_job", jobEmitterQueueHandler)
|
||||
if jobEmitterQueue == nil {
|
||||
return errors.New("unable to create actions_ready_job queue")
|
||||
}
|
||||
go graceful.GetManager().RunWithCancel(jobEmitterQueue)
|
||||
|
||||
notify_service.RegisterNotifier(NewNotifier())
|
||||
return initGlobalRunnerToken(ctx)
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package actions
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
actions_model "gitea.dev/models/actions"
|
||||
"gitea.dev/models/db"
|
||||
"gitea.dev/models/unittest"
|
||||
"gitea.dev/modules/util"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
unittest.MainTest(m)
|
||||
}
|
||||
|
||||
func TestInitToken(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
t.Run("NoToken", func(t *testing.T) {
|
||||
_, _ = db.Exec(t.Context(), "DELETE FROM action_runner_token")
|
||||
t.Setenv("GITEA_RUNNER_REGISTRATION_TOKEN", "")
|
||||
t.Setenv("GITEA_RUNNER_REGISTRATION_TOKEN_FILE", "")
|
||||
err := initGlobalRunnerToken(t.Context())
|
||||
require.NoError(t, err)
|
||||
notEmpty, err := db.IsTableNotEmpty(&actions_model.ActionRunnerToken{})
|
||||
require.NoError(t, err)
|
||||
assert.False(t, notEmpty)
|
||||
})
|
||||
|
||||
t.Run("EnvToken", func(t *testing.T) {
|
||||
tokenValue := util.CryptoRandomString(32)
|
||||
t.Setenv("GITEA_RUNNER_REGISTRATION_TOKEN", tokenValue)
|
||||
t.Setenv("GITEA_RUNNER_REGISTRATION_TOKEN_FILE", "")
|
||||
err := initGlobalRunnerToken(t.Context())
|
||||
require.NoError(t, err)
|
||||
token := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunnerToken{Token: tokenValue})
|
||||
assert.True(t, token.IsActive)
|
||||
|
||||
// init with the same token again, should not create a new token
|
||||
err = initGlobalRunnerToken(t.Context())
|
||||
require.NoError(t, err)
|
||||
token2 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunnerToken{Token: tokenValue})
|
||||
assert.Equal(t, token.ID, token2.ID)
|
||||
assert.True(t, token.IsActive)
|
||||
})
|
||||
|
||||
t.Run("EnvFileToken", func(t *testing.T) {
|
||||
tokenValue := util.CryptoRandomString(32)
|
||||
f := t.TempDir() + "/token"
|
||||
_ = os.WriteFile(f, []byte(tokenValue), 0o644)
|
||||
t.Setenv("GITEA_RUNNER_REGISTRATION_TOKEN", "")
|
||||
t.Setenv("GITEA_RUNNER_REGISTRATION_TOKEN_FILE", f)
|
||||
err := initGlobalRunnerToken(t.Context())
|
||||
require.NoError(t, err)
|
||||
token := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunnerToken{Token: tokenValue})
|
||||
assert.True(t, token.IsActive)
|
||||
|
||||
// if the env token is invalidated by another new token, then it shouldn't be active anymore
|
||||
_, err = actions_model.NewRunnerToken(t.Context(), 0, 0)
|
||||
require.NoError(t, err)
|
||||
token = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunnerToken{Token: tokenValue})
|
||||
assert.False(t, token.IsActive)
|
||||
})
|
||||
|
||||
t.Run("InvalidToken", func(t *testing.T) {
|
||||
t.Setenv("GITEA_RUNNER_REGISTRATION_TOKEN", "abc")
|
||||
err := initGlobalRunnerToken(t.Context())
|
||||
assert.ErrorContains(t, err, "must be at least")
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package actions
|
||||
|
||||
import "gitea.dev/services/context"
|
||||
|
||||
// API for actions of a repository or organization
|
||||
type API interface {
|
||||
// ListActionsSecrets list secrets
|
||||
ListActionsSecrets(*context.APIContext)
|
||||
// CreateOrUpdateSecret create or update a secret
|
||||
CreateOrUpdateSecret(*context.APIContext)
|
||||
// DeleteSecret delete a secret
|
||||
DeleteSecret(*context.APIContext)
|
||||
// ListVariables list variables
|
||||
ListVariables(*context.APIContext)
|
||||
// GetVariable get a variable
|
||||
GetVariable(*context.APIContext)
|
||||
// DeleteVariable delete a variable
|
||||
DeleteVariable(*context.APIContext)
|
||||
// CreateVariable create a variable
|
||||
CreateVariable(*context.APIContext)
|
||||
// UpdateVariable update a variable
|
||||
UpdateVariable(*context.APIContext)
|
||||
// CreateRegistrationToken get registration token
|
||||
CreateRegistrationToken(*context.APIContext)
|
||||
// ListRunners list runners
|
||||
ListRunners(*context.APIContext)
|
||||
// GetRunner get a runner
|
||||
GetRunner(*context.APIContext)
|
||||
// DeleteRunner delete runner
|
||||
DeleteRunner(*context.APIContext)
|
||||
// UpdateRunner update runner
|
||||
UpdateRunner(*context.APIContext)
|
||||
// ListWorkflowJobs list jobs
|
||||
ListWorkflowJobs(*context.APIContext)
|
||||
// ListWorkflowRuns list runs
|
||||
ListWorkflowRuns(*context.APIContext)
|
||||
}
|
||||
@@ -0,0 +1,485 @@
|
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package actions
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
actions_model "gitea.dev/models/actions"
|
||||
"gitea.dev/models/db"
|
||||
"gitea.dev/modules/container"
|
||||
"gitea.dev/modules/graceful"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/queue"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/util"
|
||||
|
||||
"xorm.io/builder"
|
||||
)
|
||||
|
||||
var jobEmitterQueue *queue.WorkerPoolQueue[*jobUpdate]
|
||||
|
||||
type jobUpdate struct {
|
||||
RunID int64
|
||||
}
|
||||
|
||||
func EmitJobsIfReadyByRun(runID int64) error {
|
||||
err := jobEmitterQueue.Push(&jobUpdate{
|
||||
RunID: runID,
|
||||
})
|
||||
if errors.Is(err, queue.ErrAlreadyInQueue) {
|
||||
return nil
|
||||
}
|
||||
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 := checkJobsByRunID(ctx, update.RunID); err != nil {
|
||||
log.Error("check run %d: %v", update.RunID, err)
|
||||
ret = append(ret, update)
|
||||
}
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
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 result jobsCheckResult
|
||||
if err := db.WithTx(ctx, func(ctx context.Context) error {
|
||||
// check jobs of the current run
|
||||
r, err := checkJobsOfCurrentRunAttempt(ctx, run)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
result.merge(r)
|
||||
|
||||
r, err = checkRunConcurrency(ctx, run)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
result.merge(r)
|
||||
return nil
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
// Re-emit AFTER the transaction commits; doing this inside WithTx would deadlock under
|
||||
// immediate-mode queues (the inline handler reopens checkJobsByRunID and asks for a
|
||||
// nested writer transaction while the outer one is still open).
|
||||
emitted := make(container.Set[int64])
|
||||
for _, rid := range result.RunIDsToReEmit {
|
||||
if !emitted.Add(rid) {
|
||||
continue
|
||||
}
|
||||
if err := EmitJobsIfReadyByRun(rid); err != nil {
|
||||
log.Error("re-emit run %d after caller expansion: %v", rid, err)
|
||||
}
|
||||
}
|
||||
NotifyWorkflowJobsAndRunsStatusUpdate(ctx, result.CancelledJobs)
|
||||
EmitJobsIfReadyByJobs(result.CancelledJobs)
|
||||
if err := createCommitStatusesForJobsByRun(ctx, result.Jobs); err != nil {
|
||||
return err
|
||||
}
|
||||
NotifyWorkflowJobsStatusUpdate(ctx, result.UpdatedJobs...)
|
||||
runJobs := make(map[int64][]*actions_model.ActionRunJob)
|
||||
for _, job := range result.Jobs {
|
||||
runJobs[job.RunID] = append(runJobs[job.RunID], job)
|
||||
}
|
||||
runUpdatedJobs := make(map[int64][]*actions_model.ActionRunJob)
|
||||
for _, uj := range result.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].RepoID, js[0].RunID)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func createCommitStatusesForJobsByRun(ctx context.Context, jobs []*actions_model.ActionRunJob) error {
|
||||
runJobs := make(map[int64][]*actions_model.ActionRunJob)
|
||||
for _, job := range jobs {
|
||||
runJobs[job.RunID] = append(runJobs[job.RunID], job)
|
||||
}
|
||||
|
||||
for jobRunID, jobList := range runJobs {
|
||||
run, err := actions_model.GetRunByRepoAndID(ctx, jobList[0].RepoID, jobRunID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get action run %d: %w", jobRunID, err)
|
||||
}
|
||||
CreateCommitStatusForRunJobs(ctx, run, jobList...)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// findBlockedRunIDByConcurrency finds a blocked concurrent run in a repo and returns 0 when there is no blocked run.
|
||||
func findBlockedRunIDByConcurrency(ctx context.Context, repoID int64, concurrencyGroup string) (int64, error) {
|
||||
if concurrencyGroup == "" {
|
||||
return 0, nil
|
||||
}
|
||||
cAttempts, cJobs, err := actions_model.GetConcurrentRunAttemptsAndJobs(ctx, repoID, concurrencyGroup, []actions_model.Status{actions_model.StatusBlocked})
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("find concurrent runs and jobs: %w", err)
|
||||
}
|
||||
|
||||
if len(cAttempts) > 0 {
|
||||
return cAttempts[0].RunID, nil
|
||||
}
|
||||
if len(cJobs) > 0 {
|
||||
return cJobs[0].RunID, nil
|
||||
}
|
||||
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func checkBlockedConcurrentRun(ctx context.Context, repoID, runID int64) (*jobsCheckResult, error) {
|
||||
concurrentRun, err := actions_model.GetRunByRepoAndID(ctx, repoID, runID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get run %d: %w", runID, err)
|
||||
}
|
||||
if concurrentRun.NeedApproval {
|
||||
return &jobsCheckResult{}, nil
|
||||
}
|
||||
|
||||
return checkJobsOfCurrentRunAttempt(ctx, concurrentRun)
|
||||
}
|
||||
|
||||
// checkRunConcurrency rechecks runs blocked by concurrency that may become unblocked after the current run releases a workflow-level or job-level concurrency group.
|
||||
// RunIDsToReEmit propagates from inner checkJobsOfCurrentRunAttempt calls; see that function's doc.
|
||||
func checkRunConcurrency(ctx context.Context, run *actions_model.ActionRun) (*jobsCheckResult, error) {
|
||||
result := &jobsCheckResult{}
|
||||
checkedConcurrencyGroup := make(container.Set[string])
|
||||
|
||||
collect := func(concurrencyGroup string) error {
|
||||
concurrentRunID, err := findBlockedRunIDByConcurrency(ctx, run.RepoID, concurrencyGroup)
|
||||
if err != nil {
|
||||
return fmt.Errorf("find blocked run by concurrency: %w", err)
|
||||
}
|
||||
if concurrentRunID > 0 {
|
||||
r, err := checkBlockedConcurrentRun(ctx, run.RepoID, concurrentRunID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
result.merge(r)
|
||||
}
|
||||
checkedConcurrencyGroup.Add(concurrencyGroup)
|
||||
return nil
|
||||
}
|
||||
|
||||
// check run (workflow-level) concurrency
|
||||
runConcurrencyGroup, _, err := run.GetEffectiveConcurrency(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("GetEffectiveConcurrency: %w", err)
|
||||
}
|
||||
if runConcurrencyGroup != "" {
|
||||
if err := collect(runConcurrencyGroup); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// check job concurrency
|
||||
runJobs, err := actions_model.GetLatestAttemptJobsByRepoAndRunID(ctx, run.RepoID, run.ID)
|
||||
if err != nil {
|
||||
return 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, err
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// checkJobsOfCurrentRunAttempt resolves blocked jobs of the run's latest attempt.
|
||||
func checkJobsOfCurrentRunAttempt(ctx context.Context, run *actions_model.ActionRun) (*jobsCheckResult, error) {
|
||||
jobs, err := actions_model.GetRunJobsByRunAndAttemptID(ctx, run.ID, run.LatestAttemptID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := &jobsCheckResult{Jobs: jobs}
|
||||
|
||||
var attempt *actions_model.ActionRunAttempt
|
||||
if run.LatestAttemptID > 0 {
|
||||
attempt, err = actions_model.GetRunAttemptByRepoAndID(ctx, run.RepoID, run.LatestAttemptID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// The resolver below only considers needs and job-level concurrency, so a run blocked
|
||||
// solely by run-level concurrency would have its jobs unblocked here. checkRunConcurrency
|
||||
// re-evaluates when the holding run finishes.
|
||||
if run.Status.IsBlocked() && attempt != nil {
|
||||
shouldBlock, err := shouldBlockRunByConcurrency(ctx, attempt)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("shouldBlockRunByConcurrency: %w", err)
|
||||
}
|
||||
if shouldBlock {
|
||||
return result, nil
|
||||
}
|
||||
}
|
||||
vars, err := actions_model.GetVariablesOfRun(ctx, run)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resolver := newJobStatusResolver(jobs, vars)
|
||||
|
||||
expandedAnyCaller := false
|
||||
if err = db.WithTx(ctx, func(ctx context.Context) error {
|
||||
for _, job := range jobs {
|
||||
job.Run = run
|
||||
}
|
||||
|
||||
updates := resolver.Resolve(ctx)
|
||||
for _, job := range jobs {
|
||||
status, ok := updates[job.ID]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
if job.IsReusableCaller {
|
||||
switch status {
|
||||
case actions_model.StatusWaiting:
|
||||
if err := expandReusableWorkflowCaller(ctx, run, attempt, job, vars); err != nil {
|
||||
return fmt.Errorf("trigger caller-ready %d: %w", job.ID, err)
|
||||
}
|
||||
// expandReusableWorkflowCaller inserts children as Blocked. They need a follow-up resolver pass.
|
||||
expandedAnyCaller = true
|
||||
case actions_model.StatusSkipped:
|
||||
job.Status = actions_model.StatusSkipped
|
||||
if _, err := actions_model.UpdateRunJob(ctx, job, nil, "status"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Non-caller: standard status update.
|
||||
job.Status = status
|
||||
if n, err := actions_model.UpdateRunJob(ctx, job, builder.Eq{"status": actions_model.StatusBlocked}, "status"); err != nil {
|
||||
return err
|
||||
} else if n != 1 {
|
||||
return fmt.Errorf("no affected for updating blocked job %v", job.ID)
|
||||
}
|
||||
result.UpdatedJobs = append(result.UpdatedJobs, job)
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if expandedAnyCaller {
|
||||
result.RunIDsToReEmit = append(result.RunIDsToReEmit, run.ID)
|
||||
}
|
||||
result.CancelledJobs = resolver.cancelledJobs
|
||||
return result, nil
|
||||
}
|
||||
|
||||
type jobStatusResolver struct {
|
||||
statuses map[int64]actions_model.Status
|
||||
needs map[int64][]int64
|
||||
jobMap map[int64]*actions_model.ActionRunJob
|
||||
vars map[string]string
|
||||
cancelledJobs []*actions_model.ActionRunJob
|
||||
}
|
||||
|
||||
func newJobStatusResolver(jobs actions_model.ActionJobList, vars map[string]string) *jobStatusResolver {
|
||||
// Scope-aware: needs are resolved within the same ParentJobID scope so the same
|
||||
// JobID in different reusable workflow calls does not cross-link.
|
||||
scopedIDToJobs := make(map[int64]map[string][]*actions_model.ActionRunJob)
|
||||
jobMap := make(map[int64]*actions_model.ActionRunJob)
|
||||
for _, job := range jobs {
|
||||
scope := scopedIDToJobs[job.ParentJobID]
|
||||
if scope == nil {
|
||||
scope = make(map[string][]*actions_model.ActionRunJob)
|
||||
scopedIDToJobs[job.ParentJobID] = scope
|
||||
}
|
||||
scope[job.JobID] = append(scope[job.JobID], job)
|
||||
jobMap[job.ID] = job
|
||||
}
|
||||
|
||||
statuses := make(map[int64]actions_model.Status, len(jobs))
|
||||
needs := make(map[int64][]int64, len(jobs))
|
||||
for _, job := range jobs {
|
||||
statuses[job.ID] = job.Status
|
||||
scope := scopedIDToJobs[job.ParentJobID]
|
||||
for _, need := range job.Needs {
|
||||
for _, v := range scope[need] {
|
||||
needs[job.ID] = append(needs[job.ID], v.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
return &jobStatusResolver{
|
||||
statuses: statuses,
|
||||
needs: needs,
|
||||
jobMap: jobMap,
|
||||
vars: vars,
|
||||
}
|
||||
}
|
||||
|
||||
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(ctx)
|
||||
if len(updated) == 0 {
|
||||
return ret
|
||||
}
|
||||
for k, v := range updated {
|
||||
ret[k] = v
|
||||
r.statuses[k] = v
|
||||
}
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
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) 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
|
||||
}
|
||||
// A child of a caller cannot start until the caller has become "ready" (children inserted, CallPayload populated).
|
||||
if actionRunJob.ParentJobID > 0 {
|
||||
if parent, ok := r.jobMap[actionRunJob.ParentJobID]; ok && !parent.IsExpanded {
|
||||
continue
|
||||
}
|
||||
}
|
||||
allDone, allSucceed := r.resolveCheckNeeds(id)
|
||||
if !allDone {
|
||||
continue
|
||||
}
|
||||
|
||||
// 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.
|
||||
// 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, err := evaluateJobIf(ctx, actionRunJob.Run, nil, actionRunJob, r.vars, allSucceed)
|
||||
if err != nil {
|
||||
// TODO: surface deterministic expression errors to users by failing the job with a message.
|
||||
log.Error("evaluateJobIf failed, job will stay blocked: job: %d, err: %v", id, err)
|
||||
continue
|
||||
}
|
||||
|
||||
newStatus := util.Iif(shouldStartJob, actions_model.StatusWaiting, actions_model.StatusSkipped)
|
||||
if newStatus == actions_model.StatusWaiting {
|
||||
var cancelledJobs []*actions_model.ActionRunJob
|
||||
newStatus, cancelledJobs, err = PrepareToStartJobWithConcurrency(ctx, actionRunJob)
|
||||
if err != nil {
|
||||
log.Error("ShouldBlockJobByConcurrency failed, this job will stay blocked: job: %d, err: %v", id, err)
|
||||
} else {
|
||||
r.cancelledJobs = append(r.cancelledJobs, cancelledJobs...)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// Legacy jobs (created before migration v331) have RunAttemptID=0 and no attempt record.
|
||||
var attempt *actions_model.ActionRunAttempt
|
||||
if actionRunJob.RunAttemptID > 0 {
|
||||
var err error
|
||||
attempt, err = actions_model.GetRunAttemptByRepoAndID(ctx, actionRunJob.RepoID, actionRunJob.RunAttemptID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("GetRunAttemptByRepoAndID: %w", err)
|
||||
}
|
||||
}
|
||||
if err := EvaluateJobConcurrencyFillModel(ctx, actionRunJob.Run, attempt, actionRunJob, vars, nil); err != nil {
|
||||
return fmt.Errorf("evaluate job concurrency: %w", err)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// jobsCheckResult bundles the output of the per-run job-check helpers.
|
||||
type jobsCheckResult struct {
|
||||
// Jobs are all jobs of the run's latest attempt that were inspected.
|
||||
Jobs []*actions_model.ActionRunJob
|
||||
// UpdatedJobs are jobs whose status was transitioned out of Blocked in this pass.
|
||||
UpdatedJobs []*actions_model.ActionRunJob
|
||||
// CancelledJobs are jobs cancelled by job-level concurrency while preparing to start.
|
||||
CancelledJobs []*actions_model.ActionRunJob
|
||||
// RunIDsToReEmit are runs whose newly expanded reusable workflow callers need another resolver pass.
|
||||
RunIDsToReEmit []int64
|
||||
}
|
||||
|
||||
// merge appends another result's contents into r in place.
|
||||
func (r *jobsCheckResult) merge(other *jobsCheckResult) {
|
||||
r.Jobs = append(r.Jobs, other.Jobs...)
|
||||
r.UpdatedJobs = append(r.UpdatedJobs, other.UpdatedJobs...)
|
||||
r.CancelledJobs = append(r.CancelledJobs, other.CancelledJobs...)
|
||||
r.RunIDsToReEmit = append(r.RunIDsToReEmit, other.RunIDsToReEmit...)
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package actions
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
actions_model "gitea.dev/models/actions"
|
||||
"gitea.dev/models/db"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/unittest"
|
||||
user_model "gitea.dev/models/user"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func Test_jobStatusResolver_Resolve(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
jobs actions_model.ActionJobList
|
||||
want map[int64]actions_model.Status
|
||||
}{
|
||||
{
|
||||
name: "no blocked",
|
||||
jobs: actions_model.ActionJobList{
|
||||
{ID: 1, JobID: "1", Status: actions_model.StatusWaiting, Needs: []string{}},
|
||||
{ID: 2, JobID: "2", Status: actions_model.StatusWaiting, Needs: []string{}},
|
||||
{ID: 3, JobID: "3", Status: actions_model.StatusWaiting, Needs: []string{}},
|
||||
},
|
||||
want: map[int64]actions_model.Status{},
|
||||
},
|
||||
{
|
||||
name: "single blocked",
|
||||
jobs: actions_model.ActionJobList{
|
||||
{ID: 1, JobID: "1", Status: actions_model.StatusSuccess, Needs: []string{}},
|
||||
{ID: 2, JobID: "2", Status: actions_model.StatusBlocked, Needs: []string{"1"}},
|
||||
{ID: 3, JobID: "3", Status: actions_model.StatusWaiting, Needs: []string{}},
|
||||
},
|
||||
want: map[int64]actions_model.Status{
|
||||
2: actions_model.StatusWaiting,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "multiple blocked",
|
||||
jobs: actions_model.ActionJobList{
|
||||
{ID: 1, JobID: "1", Status: actions_model.StatusSuccess, Needs: []string{}},
|
||||
{ID: 2, JobID: "2", Status: actions_model.StatusBlocked, Needs: []string{"1"}},
|
||||
{ID: 3, JobID: "3", Status: actions_model.StatusBlocked, Needs: []string{"1"}},
|
||||
},
|
||||
want: map[int64]actions_model.Status{
|
||||
2: actions_model.StatusWaiting,
|
||||
3: actions_model.StatusWaiting,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "chain blocked",
|
||||
jobs: actions_model.ActionJobList{
|
||||
{ID: 1, JobID: "1", Status: actions_model.StatusFailure, Needs: []string{}},
|
||||
{ID: 2, JobID: "2", Status: actions_model.StatusBlocked, Needs: []string{"1"}},
|
||||
{ID: 3, JobID: "3", Status: actions_model.StatusBlocked, Needs: []string{"2"}},
|
||||
},
|
||||
want: map[int64]actions_model.Status{
|
||||
2: actions_model.StatusSkipped,
|
||||
3: actions_model.StatusSkipped,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "loop need",
|
||||
jobs: actions_model.ActionJobList{
|
||||
{ID: 1, JobID: "1", Status: actions_model.StatusBlocked, Needs: []string{"3"}},
|
||||
{ID: 2, JobID: "2", Status: actions_model.StatusBlocked, Needs: []string{"1"}},
|
||||
{ID: 3, JobID: "3", Status: actions_model.StatusBlocked, Needs: []string{"2"}},
|
||||
},
|
||||
want: map[int64]actions_model.Status{},
|
||||
},
|
||||
{
|
||||
name: "`if` is not empty and all jobs in `needs` completed successfully",
|
||||
jobs: actions_model.ActionJobList{
|
||||
{ID: 1, JobID: "job1", Status: actions_model.StatusSuccess, Needs: []string{}},
|
||||
{ID: 2, JobID: "job2", Status: actions_model.StatusBlocked, Needs: []string{"job1"}, WorkflowPayload: []byte(
|
||||
`
|
||||
name: test
|
||||
on: push
|
||||
jobs:
|
||||
job2:
|
||||
runs-on: ubuntu-latest
|
||||
needs: job1
|
||||
if: ${{ always() && needs.job1.result == 'success' }}
|
||||
steps:
|
||||
- run: echo "will be checked by act_runner"
|
||||
`)},
|
||||
},
|
||||
want: map[int64]actions_model.Status{2: actions_model.StatusWaiting},
|
||||
},
|
||||
{
|
||||
name: "`if` is not empty and not all jobs in `needs` completed successfully",
|
||||
jobs: actions_model.ActionJobList{
|
||||
{ID: 1, JobID: "job1", Status: actions_model.StatusFailure, Needs: []string{}},
|
||||
{ID: 2, JobID: "job2", Status: actions_model.StatusBlocked, Needs: []string{"job1"}, WorkflowPayload: []byte(
|
||||
`
|
||||
name: test
|
||||
on: push
|
||||
jobs:
|
||||
job2:
|
||||
runs-on: ubuntu-latest
|
||||
needs: job1
|
||||
if: ${{ always() && needs.job1.result == 'failure' }}
|
||||
steps:
|
||||
- run: echo "will be checked by act_runner"
|
||||
`)},
|
||||
},
|
||||
want: map[int64]actions_model.Status{2: actions_model.StatusWaiting},
|
||||
},
|
||||
{
|
||||
name: "`if` is empty and not all jobs in `needs` completed successfully",
|
||||
jobs: actions_model.ActionJobList{
|
||||
{ID: 1, JobID: "job1", Status: actions_model.StatusFailure, Needs: []string{}},
|
||||
{ID: 2, JobID: "job2", Status: actions_model.StatusBlocked, Needs: []string{"job1"}, WorkflowPayload: []byte(
|
||||
`
|
||||
name: test
|
||||
on: push
|
||||
jobs:
|
||||
job2:
|
||||
runs-on: ubuntu-latest
|
||||
needs: job1
|
||||
steps:
|
||||
- run: echo "should be skipped"
|
||||
`)},
|
||||
},
|
||||
want: map[int64]actions_model.Status{2: actions_model.StatusSkipped},
|
||||
},
|
||||
}
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
ctx := t.Context()
|
||||
stubRun := &actions_model.ActionRun{TriggerUser: &user_model.User{}, Repo: &repo_model.Repository{}}
|
||||
for i, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Each subtest gets a unique RunID / RunAttemptID so jobs from different subtests don't bleed into each other's FindTaskNeeds queries
|
||||
runID := int64(9001 + i)
|
||||
attemptID := int64(9001 + i)
|
||||
|
||||
// Insert each test job (letting the DB assign IDs) and remember the testID -> dbID mapping so we can translate the expected map.
|
||||
idMap := make(map[int64]int64, len(tt.jobs))
|
||||
for _, j := range tt.jobs {
|
||||
origID := j.ID
|
||||
j.ID = 0
|
||||
j.RunID = runID
|
||||
j.RunAttemptID = attemptID
|
||||
j.Run = stubRun
|
||||
|
||||
// The resolver evaluates Blocked jobs via evaluateJobIf, which needs a valid YAML payload;
|
||||
// supply a minimal one when the case didn't.
|
||||
if j.Status == actions_model.StatusBlocked && len(j.WorkflowPayload) == 0 {
|
||||
j.WorkflowPayload = fmt.Appendf(nil, `name: test
|
||||
on: push
|
||||
jobs:
|
||||
%s:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo
|
||||
`, j.JobID)
|
||||
}
|
||||
|
||||
assert.NoError(t, db.Insert(ctx, j))
|
||||
idMap[origID] = j.ID
|
||||
}
|
||||
|
||||
want := make(map[int64]actions_model.Status, len(tt.want))
|
||||
for k, v := range tt.want {
|
||||
want[idMap[k]] = v
|
||||
}
|
||||
|
||||
r := newJobStatusResolver(tt.jobs, nil)
|
||||
assert.Equal(t, want, r.Resolve(ctx))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 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 of attempt A
|
||||
runA := &actions_model.ActionRun{
|
||||
RepoID: 4,
|
||||
OwnerID: 1,
|
||||
TriggerUserID: 1,
|
||||
WorkflowID: "test.yml",
|
||||
Index: 9901,
|
||||
Ref: "refs/heads/main",
|
||||
Status: actions_model.StatusRunning,
|
||||
}
|
||||
assert.NoError(t, db.Insert(ctx, runA))
|
||||
|
||||
// Attempt A: an attempt of run A with concurrency group "test-cg"
|
||||
runAAttempt := &actions_model.ActionRunAttempt{
|
||||
RepoID: 4,
|
||||
RunID: runA.ID,
|
||||
Attempt: 1,
|
||||
Status: actions_model.StatusRunning,
|
||||
ConcurrencyGroup: "test-cg",
|
||||
}
|
||||
assert.NoError(t, db.Insert(ctx, runAAttempt))
|
||||
_, err := db.Exec(t.Context(), "UPDATE `action_run` SET latest_attempt_id = ? WHERE id = ?", runAAttempt.ID, runA.ID)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// A done job for run A with the same ConcurrencyGroup.
|
||||
// This triggers the job-level concurrency check in checkRunConcurrency.
|
||||
jobADone := &actions_model.ActionRunJob{
|
||||
RunID: runA.ID,
|
||||
RunAttemptID: runAAttempt.ID,
|
||||
AttemptJobID: 1,
|
||||
RepoID: 4,
|
||||
OwnerID: 1,
|
||||
JobID: "job1",
|
||||
Name: "job1",
|
||||
Status: actions_model.StatusSuccess,
|
||||
ConcurrencyGroup: "test-cg",
|
||||
}
|
||||
assert.NoError(t, db.Insert(ctx, jobADone))
|
||||
|
||||
// Run B: a run blocked by concurrency
|
||||
runB := &actions_model.ActionRun{
|
||||
RepoID: 4,
|
||||
OwnerID: 1,
|
||||
TriggerUserID: 1,
|
||||
WorkflowID: "test.yml",
|
||||
Index: 9902,
|
||||
Ref: "refs/heads/main",
|
||||
Status: actions_model.StatusBlocked,
|
||||
}
|
||||
assert.NoError(t, db.Insert(ctx, runB))
|
||||
|
||||
// Attempt B: an blocked attempt of run B
|
||||
runBAttempt := &actions_model.ActionRunAttempt{
|
||||
RepoID: 4,
|
||||
RunID: runB.ID,
|
||||
Attempt: 1,
|
||||
Status: actions_model.StatusBlocked,
|
||||
ConcurrencyGroup: "test-cg",
|
||||
}
|
||||
assert.NoError(t, db.Insert(ctx, runBAttempt))
|
||||
_, err = db.Exec(t.Context(), "UPDATE `action_run` SET latest_attempt_id = ? WHERE id = ?", runBAttempt.ID, runB.ID)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// A blocked job belonging to run B (no job-level concurrency group).
|
||||
jobBBlocked := &actions_model.ActionRunJob{
|
||||
RunID: runB.ID,
|
||||
RunAttemptID: runBAttempt.ID,
|
||||
AttemptJobID: 1,
|
||||
RepoID: 4,
|
||||
OwnerID: 1,
|
||||
JobID: "job1",
|
||||
Name: "job1",
|
||||
Status: actions_model.StatusBlocked,
|
||||
}
|
||||
assert.NoError(t, db.Insert(ctx, jobBBlocked))
|
||||
|
||||
runA, _, _ = db.GetByID[actions_model.ActionRun](t.Context(), runA.ID)
|
||||
result, err := checkRunConcurrency(ctx, runA)
|
||||
assert.NoError(t, err)
|
||||
|
||||
if assert.Len(t, result.Jobs, 1) {
|
||||
assert.Equal(t, jobBBlocked.ID, result.Jobs[0].ID)
|
||||
}
|
||||
}
|
||||
|
||||
// Test_checkJobsOfCurrentRunAttempt_RunLevelConcurrencyKeepsJobsBlocked verifies that
|
||||
// the resolver does not transition a job out of Blocked while another run still holds
|
||||
// the workflow-level concurrency group. Regression for #37446.
|
||||
func Test_checkJobsOfCurrentRunAttempt_RunLevelConcurrencyKeepsJobsBlocked(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
ctx := t.Context()
|
||||
|
||||
const group = "test-run-level-concurrency-keeps-blocked"
|
||||
|
||||
// Holder run: Running attempt in the concurrency group.
|
||||
holderRun := &actions_model.ActionRun{
|
||||
RepoID: 4, OwnerID: 1, TriggerUserID: 1,
|
||||
WorkflowID: "test.yml", Index: 9911, Ref: "refs/heads/main",
|
||||
Status: actions_model.StatusRunning,
|
||||
}
|
||||
assert.NoError(t, db.Insert(ctx, holderRun))
|
||||
holderAttempt := &actions_model.ActionRunAttempt{
|
||||
RepoID: 4, RunID: holderRun.ID, Attempt: 1,
|
||||
Status: actions_model.StatusRunning, ConcurrencyGroup: group,
|
||||
}
|
||||
assert.NoError(t, db.Insert(ctx, holderAttempt))
|
||||
_, err := db.Exec(ctx, "UPDATE `action_run` SET latest_attempt_id = ? WHERE id = ?", holderAttempt.ID, holderRun.ID)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Blocked run: Blocked attempt in the same group, with one Blocked job that has
|
||||
// no needs and no job-level concurrency. Without the run-level guard in
|
||||
// checkJobsOfCurrentRunAttempt, the resolver would transition this job to Waiting.
|
||||
blockedRun := &actions_model.ActionRun{
|
||||
RepoID: 4, OwnerID: 1, TriggerUserID: 1,
|
||||
WorkflowID: "test.yml", Index: 9912, Ref: "refs/heads/main",
|
||||
Status: actions_model.StatusBlocked,
|
||||
}
|
||||
assert.NoError(t, db.Insert(ctx, blockedRun))
|
||||
blockedAttempt := &actions_model.ActionRunAttempt{
|
||||
RepoID: 4, RunID: blockedRun.ID, Attempt: 1,
|
||||
Status: actions_model.StatusBlocked, ConcurrencyGroup: group,
|
||||
}
|
||||
assert.NoError(t, db.Insert(ctx, blockedAttempt))
|
||||
_, err = db.Exec(ctx, "UPDATE `action_run` SET latest_attempt_id = ? WHERE id = ?", blockedAttempt.ID, blockedRun.ID)
|
||||
assert.NoError(t, err)
|
||||
blockedRun.LatestAttemptID = blockedAttempt.ID
|
||||
blockedJob := &actions_model.ActionRunJob{
|
||||
RunID: blockedRun.ID, RunAttemptID: blockedAttempt.ID, AttemptJobID: 1,
|
||||
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))
|
||||
|
||||
result, err := checkJobsOfCurrentRunAttempt(ctx, blockedRun)
|
||||
assert.NoError(t, err)
|
||||
assert.Empty(t, result.UpdatedJobs)
|
||||
|
||||
refreshed := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: blockedJob.ID})
|
||||
assert.Equal(t, actions_model.StatusBlocked, refreshed.Status)
|
||||
}
|
||||
@@ -0,0 +1,837 @@
|
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package actions
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
actions_model "gitea.dev/models/actions"
|
||||
issues_model "gitea.dev/models/issues"
|
||||
"gitea.dev/models/organization"
|
||||
packages_model "gitea.dev/models/packages"
|
||||
perm_model "gitea.dev/models/perm"
|
||||
access_model "gitea.dev/models/perm/access"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/git"
|
||||
"gitea.dev/modules/gitrepo"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/repository"
|
||||
"gitea.dev/modules/setting"
|
||||
api "gitea.dev/modules/structs"
|
||||
"gitea.dev/modules/util"
|
||||
webhook_module "gitea.dev/modules/webhook"
|
||||
"gitea.dev/services/convert"
|
||||
notify_service "gitea.dev/services/notify"
|
||||
)
|
||||
|
||||
type actionsNotifier struct {
|
||||
notify_service.NullNotifier
|
||||
}
|
||||
|
||||
var _ notify_service.Notifier = &actionsNotifier{}
|
||||
|
||||
// NewNotifier create a new actionsNotifier notifier
|
||||
func NewNotifier() notify_service.Notifier {
|
||||
return &actionsNotifier{}
|
||||
}
|
||||
|
||||
// NewIssue notifies issue created event
|
||||
func (n *actionsNotifier) NewIssue(ctx context.Context, issue *issues_model.Issue, _ []*user_model.User) {
|
||||
ctx = withMethod(ctx, "NewIssue")
|
||||
if err := issue.LoadRepo(ctx); err != nil {
|
||||
log.Error("issue.LoadRepo: %v", err)
|
||||
return
|
||||
}
|
||||
if err := issue.LoadPoster(ctx); err != nil {
|
||||
log.Error("issue.LoadPoster: %v", err)
|
||||
return
|
||||
}
|
||||
permission, _ := access_model.GetIndividualUserRepoPermission(ctx, issue.Repo, issue.Poster)
|
||||
|
||||
newNotifyInputFromIssue(issue, webhook_module.HookEventIssues).WithPayload(&api.IssuePayload{
|
||||
Action: api.HookIssueOpened,
|
||||
Index: issue.Index,
|
||||
Issue: convert.ToAPIIssue(ctx, issue.Poster, issue),
|
||||
Repository: convert.ToRepo(ctx, issue.Repo, permission),
|
||||
Sender: convert.ToUser(ctx, issue.Poster, nil),
|
||||
}).Notify(withMethod(ctx, "NewIssue"))
|
||||
}
|
||||
|
||||
// IssueChangeContent notifies change content of issue
|
||||
func (n *actionsNotifier) IssueChangeContent(ctx context.Context, doer *user_model.User, issue *issues_model.Issue, oldContent string) {
|
||||
ctx = withMethod(ctx, "IssueChangeContent")
|
||||
n.notifyIssueChangeWithTitleOrContent(ctx, doer, issue)
|
||||
}
|
||||
|
||||
func (n *actionsNotifier) IssueChangeTitle(ctx context.Context, doer *user_model.User, issue *issues_model.Issue, oldTitle string) {
|
||||
ctx = withMethod(ctx, "IssueChangeTitle")
|
||||
n.notifyIssueChangeWithTitleOrContent(ctx, doer, issue)
|
||||
}
|
||||
|
||||
func (n *actionsNotifier) notifyIssueChangeWithTitleOrContent(ctx context.Context, doer *user_model.User, issue *issues_model.Issue) {
|
||||
var err error
|
||||
if err = issue.LoadRepo(ctx); err != nil {
|
||||
log.Error("LoadRepo: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
permission, _ := access_model.GetIndividualUserRepoPermission(ctx, issue.Repo, issue.Poster)
|
||||
if issue.IsPull {
|
||||
if err = issue.LoadPullRequest(ctx); err != nil {
|
||||
log.Error("loadPullRequest: %v", err)
|
||||
return
|
||||
}
|
||||
newNotifyInputFromIssue(issue, webhook_module.HookEventPullRequest).
|
||||
WithDoer(doer).
|
||||
WithPayload(&api.PullRequestPayload{
|
||||
Action: api.HookIssueEdited,
|
||||
Index: issue.Index,
|
||||
PullRequest: convert.ToAPIPullRequest(ctx, issue.PullRequest, nil),
|
||||
Repository: convert.ToRepo(ctx, issue.Repo, access_model.Permission{AccessMode: perm_model.AccessModeNone}),
|
||||
Sender: convert.ToUser(ctx, doer, nil),
|
||||
}).
|
||||
WithPullRequest(issue.PullRequest).
|
||||
Notify(ctx)
|
||||
return
|
||||
}
|
||||
newNotifyInputFromIssue(issue, webhook_module.HookEventIssues).
|
||||
WithDoer(doer).
|
||||
WithPayload(&api.IssuePayload{
|
||||
Action: api.HookIssueEdited,
|
||||
Index: issue.Index,
|
||||
Issue: convert.ToAPIIssue(ctx, doer, issue),
|
||||
Repository: convert.ToRepo(ctx, issue.Repo, permission),
|
||||
Sender: convert.ToUser(ctx, doer, nil),
|
||||
}).
|
||||
Notify(ctx)
|
||||
}
|
||||
|
||||
// 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.GetIndividualUserRepoPermission(ctx, issue.Repo, issue.Poster)
|
||||
if issue.IsPull {
|
||||
if err := issue.LoadPullRequest(ctx); err != nil {
|
||||
log.Error("LoadPullRequest: %v", err)
|
||||
return
|
||||
}
|
||||
// Merge pull request calls issue.changeStatus so we need to handle separately.
|
||||
apiPullRequest := &api.PullRequestPayload{
|
||||
Index: issue.Index,
|
||||
PullRequest: convert.ToAPIPullRequest(ctx, issue.PullRequest, nil),
|
||||
Repository: convert.ToRepo(ctx, issue.Repo, permission),
|
||||
Sender: convert.ToUser(ctx, doer, nil),
|
||||
CommitID: commitID,
|
||||
}
|
||||
if isClosed {
|
||||
apiPullRequest.Action = api.HookIssueClosed
|
||||
} else {
|
||||
apiPullRequest.Action = api.HookIssueReOpened
|
||||
}
|
||||
newNotifyInputFromIssue(issue, webhook_module.HookEventPullRequest).
|
||||
WithDoer(doer).
|
||||
WithPayload(apiPullRequest).
|
||||
WithPullRequest(issue.PullRequest).
|
||||
Notify(ctx)
|
||||
return
|
||||
}
|
||||
apiIssue := &api.IssuePayload{
|
||||
Index: issue.Index,
|
||||
Issue: convert.ToAPIIssue(ctx, doer, issue),
|
||||
Repository: convert.ToRepo(ctx, issue.Repo, permission),
|
||||
Sender: convert.ToUser(ctx, doer, nil),
|
||||
}
|
||||
if isClosed {
|
||||
apiIssue.Action = api.HookIssueClosed
|
||||
} else {
|
||||
apiIssue.Action = api.HookIssueReOpened
|
||||
}
|
||||
newNotifyInputFromIssue(issue, webhook_module.HookEventIssues).
|
||||
WithDoer(doer).
|
||||
WithPayload(apiIssue).
|
||||
Notify(ctx)
|
||||
}
|
||||
|
||||
// IssueChangeAssignee notifies assigned or unassigned to notifiers
|
||||
func (n *actionsNotifier) IssueChangeAssignee(ctx context.Context, doer *user_model.User, issue *issues_model.Issue, assignee *user_model.User, removed bool, comment *issues_model.Comment) {
|
||||
ctx = withMethod(ctx, "IssueChangeAssignee")
|
||||
|
||||
var action api.HookIssueAction
|
||||
if removed {
|
||||
action = api.HookIssueUnassigned
|
||||
} else {
|
||||
action = api.HookIssueAssigned
|
||||
}
|
||||
|
||||
hookEvent := webhook_module.HookEventIssueAssign
|
||||
if issue.IsPull {
|
||||
hookEvent = webhook_module.HookEventPullRequestAssign
|
||||
}
|
||||
|
||||
notifyIssueChange(ctx, doer, issue, hookEvent, action, nil, nil)
|
||||
}
|
||||
|
||||
// IssueChangeMilestone notifies assignee to notifiers
|
||||
func (n *actionsNotifier) IssueChangeMilestone(ctx context.Context, doer *user_model.User, issue *issues_model.Issue, oldMilestoneID int64) {
|
||||
ctx = withMethod(ctx, "IssueChangeMilestone")
|
||||
|
||||
var action api.HookIssueAction
|
||||
if issue.MilestoneID > 0 {
|
||||
action = api.HookIssueMilestoned
|
||||
} else {
|
||||
action = api.HookIssueDemilestoned
|
||||
}
|
||||
|
||||
hookEvent := webhook_module.HookEventIssueMilestone
|
||||
if issue.IsPull {
|
||||
hookEvent = webhook_module.HookEventPullRequestMilestone
|
||||
}
|
||||
|
||||
notifyIssueChange(ctx, doer, issue, hookEvent, action, nil, nil)
|
||||
}
|
||||
|
||||
func (n *actionsNotifier) IssueChangeLabels(ctx context.Context, doer *user_model.User, issue *issues_model.Issue,
|
||||
addedLabels, removedLabels []*issues_model.Label,
|
||||
) {
|
||||
ctx = withMethod(ctx, "IssueChangeLabels")
|
||||
|
||||
hookEvent := webhook_module.HookEventIssueLabel
|
||||
if issue.IsPull {
|
||||
hookEvent = webhook_module.HookEventPullRequestLabel
|
||||
}
|
||||
|
||||
notifyIssueChange(ctx, doer, issue, hookEvent, api.HookIssueLabelUpdated, addedLabels, removedLabels)
|
||||
}
|
||||
|
||||
func notifyIssueChange(ctx context.Context, doer *user_model.User, issue *issues_model.Issue, event webhook_module.HookEventType, action api.HookIssueAction, addedLabels, removedLabels []*issues_model.Label) {
|
||||
var err error
|
||||
if err = issue.LoadRepo(ctx); err != nil {
|
||||
log.Error("LoadRepo: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if err = issue.LoadPoster(ctx); err != nil {
|
||||
log.Error("LoadPoster: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
var addedAPILabels []*api.Label
|
||||
if addedLabels != nil {
|
||||
addedAPILabels = make([]*api.Label, 0, len(addedLabels))
|
||||
for _, label := range addedLabels {
|
||||
addedAPILabels = append(addedAPILabels, convert.ToLabel(label, issue.Repo, doer))
|
||||
}
|
||||
}
|
||||
|
||||
// Get removed labels from context if present
|
||||
var removedAPILabels []*api.Label
|
||||
if removedLabels != nil {
|
||||
removedAPILabels = make([]*api.Label, 0, len(removedLabels))
|
||||
for _, label := range removedLabels {
|
||||
removedAPILabels = append(removedAPILabels, convert.ToLabel(label, issue.Repo, doer))
|
||||
}
|
||||
}
|
||||
|
||||
if issue.IsPull {
|
||||
if err = issue.LoadPullRequest(ctx); err != nil {
|
||||
log.Error("loadPullRequest: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
payload := &api.PullRequestPayload{
|
||||
Action: action,
|
||||
Index: issue.Index,
|
||||
PullRequest: convert.ToAPIPullRequest(ctx, issue.PullRequest, nil),
|
||||
Repository: convert.ToRepo(ctx, issue.Repo, access_model.Permission{AccessMode: perm_model.AccessModeNone}),
|
||||
Sender: convert.ToUser(ctx, doer, nil),
|
||||
Changes: &api.ChangesPayload{
|
||||
AddedLabels: addedAPILabels,
|
||||
RemovedLabels: removedAPILabels,
|
||||
},
|
||||
}
|
||||
|
||||
newNotifyInputFromIssue(issue, event).
|
||||
WithDoer(doer).
|
||||
WithPayload(payload).
|
||||
WithPullRequest(issue.PullRequest).
|
||||
Notify(ctx)
|
||||
return
|
||||
}
|
||||
|
||||
permission, _ := access_model.GetIndividualUserRepoPermission(ctx, issue.Repo, issue.Poster)
|
||||
payload := &api.IssuePayload{
|
||||
Action: action,
|
||||
Index: issue.Index,
|
||||
Issue: convert.ToAPIIssue(ctx, doer, issue),
|
||||
Repository: convert.ToRepo(ctx, issue.Repo, permission),
|
||||
Sender: convert.ToUser(ctx, doer, nil),
|
||||
Changes: &api.ChangesPayload{
|
||||
AddedLabels: addedAPILabels,
|
||||
RemovedLabels: removedAPILabels,
|
||||
},
|
||||
}
|
||||
|
||||
newNotifyInputFromIssue(issue, event).
|
||||
WithDoer(doer).
|
||||
WithPayload(payload).
|
||||
Notify(ctx)
|
||||
}
|
||||
|
||||
// CreateIssueComment notifies comment on an issue to notifiers
|
||||
func (n *actionsNotifier) CreateIssueComment(ctx context.Context, doer *user_model.User, repo *repo_model.Repository,
|
||||
issue *issues_model.Issue, comment *issues_model.Comment, _ []*user_model.User,
|
||||
) {
|
||||
ctx = withMethod(ctx, "CreateIssueComment")
|
||||
|
||||
if issue.IsPull {
|
||||
notifyIssueCommentChange(ctx, doer, comment, "", webhook_module.HookEventPullRequestComment, api.HookIssueCommentCreated)
|
||||
return
|
||||
}
|
||||
notifyIssueCommentChange(ctx, doer, comment, "", webhook_module.HookEventIssueComment, api.HookIssueCommentCreated)
|
||||
}
|
||||
|
||||
func (n *actionsNotifier) UpdateComment(ctx context.Context, doer *user_model.User, c *issues_model.Comment, oldContent string) {
|
||||
ctx = withMethod(ctx, "UpdateComment")
|
||||
|
||||
if c.Issue.IsPull {
|
||||
notifyIssueCommentChange(ctx, doer, c, oldContent, webhook_module.HookEventPullRequestComment, api.HookIssueCommentEdited)
|
||||
return
|
||||
}
|
||||
notifyIssueCommentChange(ctx, doer, c, oldContent, webhook_module.HookEventIssueComment, api.HookIssueCommentEdited)
|
||||
}
|
||||
|
||||
func (n *actionsNotifier) DeleteComment(ctx context.Context, doer *user_model.User, comment *issues_model.Comment) {
|
||||
ctx = withMethod(ctx, "DeleteComment")
|
||||
|
||||
if comment.Issue.IsPull {
|
||||
notifyIssueCommentChange(ctx, doer, comment, "", webhook_module.HookEventPullRequestComment, api.HookIssueCommentDeleted)
|
||||
return
|
||||
}
|
||||
notifyIssueCommentChange(ctx, doer, comment, "", webhook_module.HookEventIssueComment, api.HookIssueCommentDeleted)
|
||||
}
|
||||
|
||||
func notifyIssueCommentChange(ctx context.Context, doer *user_model.User, comment *issues_model.Comment, oldContent string, event webhook_module.HookEventType, action api.HookIssueCommentAction) {
|
||||
comment.Issue = nil // force issue to be loaded
|
||||
if err := comment.LoadIssue(ctx); err != nil {
|
||||
log.Error("LoadIssue: %v", err)
|
||||
return
|
||||
}
|
||||
if err := comment.Issue.LoadAttributes(ctx); err != nil {
|
||||
log.Error("LoadAttributes: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
permission, _ := access_model.GetDoerRepoPermission(ctx, comment.Issue.Repo, doer)
|
||||
|
||||
payload := &api.IssueCommentPayload{
|
||||
Action: action,
|
||||
Issue: convert.ToAPIIssue(ctx, doer, comment.Issue),
|
||||
Comment: convert.ToAPIComment(ctx, comment.Issue.Repo, comment),
|
||||
Repository: convert.ToRepo(ctx, comment.Issue.Repo, permission),
|
||||
Sender: convert.ToUser(ctx, doer, nil),
|
||||
IsPull: comment.Issue.IsPull,
|
||||
}
|
||||
|
||||
if action == api.HookIssueCommentEdited {
|
||||
payload.Changes = &api.ChangesPayload{
|
||||
Body: &api.ChangesFromPayload{
|
||||
From: oldContent,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
if comment.Issue.IsPull {
|
||||
if err := comment.Issue.LoadPullRequest(ctx); err != nil {
|
||||
log.Error("LoadPullRequest: %v", err)
|
||||
return
|
||||
}
|
||||
newNotifyInputFromIssue(comment.Issue, event).
|
||||
WithDoer(doer).
|
||||
WithPayload(payload).
|
||||
WithPullRequest(comment.Issue.PullRequest).
|
||||
Notify(ctx)
|
||||
return
|
||||
}
|
||||
|
||||
newNotifyInputFromIssue(comment.Issue, event).
|
||||
WithDoer(doer).
|
||||
WithPayload(payload).
|
||||
Notify(ctx)
|
||||
}
|
||||
|
||||
func (n *actionsNotifier) NewPullRequest(ctx context.Context, pull *issues_model.PullRequest, _ []*user_model.User) {
|
||||
ctx = withMethod(ctx, "NewPullRequest")
|
||||
|
||||
if err := pull.LoadIssue(ctx); err != nil {
|
||||
log.Error("pull.LoadIssue: %v", err)
|
||||
return
|
||||
}
|
||||
if err := pull.Issue.LoadRepo(ctx); err != nil {
|
||||
log.Error("pull.Issue.LoadRepo: %v", err)
|
||||
return
|
||||
}
|
||||
if err := pull.Issue.LoadPoster(ctx); err != nil {
|
||||
log.Error("pull.Issue.LoadPoster: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
permission, _ := access_model.GetIndividualUserRepoPermission(ctx, pull.Issue.Repo, pull.Issue.Poster)
|
||||
|
||||
newNotifyInputFromIssue(pull.Issue, webhook_module.HookEventPullRequest).
|
||||
WithPayload(&api.PullRequestPayload{
|
||||
Action: api.HookIssueOpened,
|
||||
Index: pull.Issue.Index,
|
||||
PullRequest: convert.ToAPIPullRequest(ctx, pull, nil),
|
||||
Repository: convert.ToRepo(ctx, pull.Issue.Repo, permission),
|
||||
Sender: convert.ToUser(ctx, pull.Issue.Poster, nil),
|
||||
}).
|
||||
WithPullRequest(pull).
|
||||
Notify(ctx)
|
||||
}
|
||||
|
||||
func (n *actionsNotifier) CreateRepository(ctx context.Context, doer, u *user_model.User, repo *repo_model.Repository) {
|
||||
ctx = withMethod(ctx, "CreateRepository")
|
||||
|
||||
newNotifyInput(repo, doer, webhook_module.HookEventRepository).WithPayload(&api.RepositoryPayload{
|
||||
Action: api.HookRepoCreated,
|
||||
Repository: convert.ToRepo(ctx, repo, access_model.Permission{AccessMode: perm_model.AccessModeOwner}),
|
||||
Organization: convert.ToUser(ctx, u, nil),
|
||||
Sender: convert.ToUser(ctx, doer, nil),
|
||||
}).Notify(ctx)
|
||||
}
|
||||
|
||||
func (n *actionsNotifier) ForkRepository(ctx context.Context, doer *user_model.User, oldRepo, repo *repo_model.Repository) {
|
||||
ctx = withMethod(ctx, "ForkRepository")
|
||||
|
||||
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{
|
||||
Forkee: convert.ToRepo(ctx, oldRepo, oldPermission),
|
||||
Repo: convert.ToRepo(ctx, repo, permission),
|
||||
Sender: convert.ToUser(ctx, doer, nil),
|
||||
}).Notify(ctx)
|
||||
|
||||
u := repo.MustOwner(ctx)
|
||||
|
||||
// Add to hook queue for created repo after session commit.
|
||||
if u.IsOrganization() {
|
||||
newNotifyInput(repo, doer, webhook_module.HookEventRepository).
|
||||
WithRef(git.RefNameFromBranch(oldRepo.DefaultBranch).String()).
|
||||
WithPayload(&api.RepositoryPayload{
|
||||
Action: api.HookRepoCreated,
|
||||
Repository: convert.ToRepo(ctx, repo, access_model.Permission{AccessMode: perm_model.AccessModeOwner}),
|
||||
Organization: convert.ToUser(ctx, u, nil),
|
||||
Sender: convert.ToUser(ctx, doer, nil),
|
||||
}).Notify(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
func (n *actionsNotifier) PullRequestReview(ctx context.Context, pr *issues_model.PullRequest, review *issues_model.Review, _ *issues_model.Comment, _ []*user_model.User) {
|
||||
ctx = withMethod(ctx, "PullRequestReview")
|
||||
|
||||
var reviewHookType webhook_module.HookEventType
|
||||
|
||||
switch review.Type {
|
||||
case issues_model.ReviewTypeApprove:
|
||||
reviewHookType = webhook_module.HookEventPullRequestReviewApproved
|
||||
case issues_model.ReviewTypeComment:
|
||||
reviewHookType = webhook_module.HookEventPullRequestReviewComment
|
||||
case issues_model.ReviewTypeReject:
|
||||
reviewHookType = webhook_module.HookEventPullRequestReviewRejected
|
||||
default:
|
||||
// unsupported review webhook type here
|
||||
log.Error("Unsupported review webhook type")
|
||||
return
|
||||
}
|
||||
|
||||
if err := pr.LoadIssue(ctx); err != nil {
|
||||
log.Error("pr.LoadIssue: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
permission, err := access_model.GetIndividualUserRepoPermission(ctx, review.Issue.Repo, review.Issue.Poster)
|
||||
if err != nil {
|
||||
log.Error("models.GetIndividualUserRepoPermission: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
newNotifyInput(review.Issue.Repo, review.Reviewer, reviewHookType).
|
||||
WithRef(review.CommitID).
|
||||
WithPayload(&api.PullRequestPayload{
|
||||
Action: api.HookIssueReviewed,
|
||||
Index: review.Issue.Index,
|
||||
PullRequest: convert.ToAPIPullRequest(ctx, pr, nil),
|
||||
Repository: convert.ToRepo(ctx, review.Issue.Repo, permission),
|
||||
Sender: convert.ToUser(ctx, review.Reviewer, nil),
|
||||
Review: &api.ReviewPayload{
|
||||
Type: string(reviewHookType),
|
||||
Content: review.Content,
|
||||
},
|
||||
}).Notify(ctx)
|
||||
}
|
||||
|
||||
func (n *actionsNotifier) PullRequestReviewRequest(ctx context.Context, doer *user_model.User, issue *issues_model.Issue, reviewer *user_model.User, isRequest bool, comment *issues_model.Comment) {
|
||||
if !issue.IsPull {
|
||||
log.Warn("PullRequestReviewRequest: issue is not a pull request: %v", issue.ID)
|
||||
return
|
||||
}
|
||||
|
||||
ctx = withMethod(ctx, "PullRequestReviewRequest")
|
||||
|
||||
permission, _ := access_model.GetDoerRepoPermission(ctx, issue.Repo, doer)
|
||||
if err := issue.LoadPullRequest(ctx); err != nil {
|
||||
log.Error("LoadPullRequest failed: %v", err)
|
||||
return
|
||||
}
|
||||
var action api.HookIssueAction
|
||||
if isRequest {
|
||||
action = api.HookIssueReviewRequested
|
||||
} else {
|
||||
action = api.HookIssueReviewRequestRemoved
|
||||
}
|
||||
newNotifyInputFromIssue(issue, webhook_module.HookEventPullRequestReviewRequest).
|
||||
WithDoer(doer).
|
||||
WithPayload(&api.PullRequestPayload{
|
||||
Action: action,
|
||||
Index: issue.Index,
|
||||
PullRequest: convert.ToAPIPullRequest(ctx, issue.PullRequest, nil),
|
||||
RequestedReviewer: convert.ToUser(ctx, reviewer, nil),
|
||||
Repository: convert.ToRepo(ctx, issue.Repo, permission),
|
||||
Sender: convert.ToUser(ctx, doer, nil),
|
||||
}).
|
||||
WithPullRequest(issue.PullRequest).
|
||||
Notify(ctx)
|
||||
}
|
||||
|
||||
func (*actionsNotifier) MergePullRequest(ctx context.Context, doer *user_model.User, pr *issues_model.PullRequest) {
|
||||
ctx = withMethod(ctx, "MergePullRequest")
|
||||
|
||||
// Reload pull request information.
|
||||
if err := pr.LoadAttributes(ctx); err != nil {
|
||||
log.Error("LoadAttributes: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := pr.LoadIssue(ctx); err != nil {
|
||||
log.Error("LoadAttributes: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := pr.Issue.LoadRepo(ctx); err != nil {
|
||||
log.Error("pr.Issue.LoadRepo: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
permission, err := access_model.GetDoerRepoPermission(ctx, pr.Issue.Repo, doer)
|
||||
if err != nil {
|
||||
log.Error("models.GetDoerRepoPermission: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Merge pull request calls issue.changeStatus so we need to handle separately.
|
||||
apiPullRequest := &api.PullRequestPayload{
|
||||
Index: pr.Issue.Index,
|
||||
PullRequest: convert.ToAPIPullRequest(ctx, pr, nil),
|
||||
Repository: convert.ToRepo(ctx, pr.Issue.Repo, permission),
|
||||
Sender: convert.ToUser(ctx, doer, nil),
|
||||
Action: api.HookIssueClosed,
|
||||
}
|
||||
|
||||
newNotifyInput(pr.Issue.Repo, doer, webhook_module.HookEventPullRequest).
|
||||
WithRef(pr.MergedCommitID).
|
||||
WithPayload(apiPullRequest).
|
||||
WithPullRequest(pr).
|
||||
Notify(ctx)
|
||||
}
|
||||
|
||||
func (n *actionsNotifier) PushCommits(ctx context.Context, pusher *user_model.User, repo *repo_model.Repository, opts *repository.PushUpdateOptions, commits *repository.PushCommits) {
|
||||
commitID, _ := git.NewIDFromString(opts.NewCommitID)
|
||||
if commitID.IsZero() {
|
||||
log.Trace("new commitID is empty")
|
||||
return
|
||||
}
|
||||
|
||||
ctx = withMethod(ctx, "PushCommits")
|
||||
|
||||
apiPusher := convert.ToUser(ctx, pusher, nil)
|
||||
apiCommits, apiHeadCommit, err := commits.ToAPIPayloadCommits(ctx, repo)
|
||||
if err != nil {
|
||||
log.Error("commits.ToAPIPayloadCommits failed: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
newNotifyInput(repo, pusher, webhook_module.HookEventPush).
|
||||
WithRef(opts.RefFullName.String()).
|
||||
WithPayload(&api.PushPayload{
|
||||
Ref: opts.RefFullName.String(),
|
||||
Before: opts.OldCommitID,
|
||||
After: opts.NewCommitID,
|
||||
CompareURL: setting.AppURL + commits.CompareURL,
|
||||
Commits: apiCommits,
|
||||
HeadCommit: apiHeadCommit,
|
||||
Repo: convert.ToRepo(ctx, repo, access_model.Permission{AccessMode: perm_model.AccessModeOwner}),
|
||||
Pusher: apiPusher,
|
||||
Sender: apiPusher,
|
||||
}).
|
||||
Notify(ctx)
|
||||
}
|
||||
|
||||
func (n *actionsNotifier) CreateRef(ctx context.Context, pusher *user_model.User, repo *repo_model.Repository, refFullName git.RefName, refID string) {
|
||||
ctx = withMethod(ctx, "CreateRef")
|
||||
|
||||
apiPusher := convert.ToUser(ctx, pusher, nil)
|
||||
apiRepo := convert.ToRepo(ctx, repo, access_model.Permission{AccessMode: perm_model.AccessModeNone})
|
||||
|
||||
newNotifyInput(repo, pusher, webhook_module.HookEventCreate).
|
||||
WithRef(refFullName.String()).
|
||||
WithPayload(&api.CreatePayload{
|
||||
Ref: refFullName.String(), // HINT: here is inconsistent with the Webhook's payload: webhook uses ShortName
|
||||
Sha: refID,
|
||||
RefType: string(refFullName.RefType()),
|
||||
Repo: apiRepo,
|
||||
Sender: apiPusher,
|
||||
}).
|
||||
Notify(ctx)
|
||||
}
|
||||
|
||||
func (n *actionsNotifier) DeleteRef(ctx context.Context, pusher *user_model.User, repo *repo_model.Repository, refFullName git.RefName) {
|
||||
ctx = withMethod(ctx, "DeleteRef")
|
||||
|
||||
apiPusher := convert.ToUser(ctx, pusher, nil)
|
||||
apiRepo := convert.ToRepo(ctx, repo, access_model.Permission{AccessMode: perm_model.AccessModeNone})
|
||||
|
||||
newNotifyInput(repo, pusher, webhook_module.HookEventDelete).
|
||||
WithPayload(&api.DeletePayload{
|
||||
Ref: refFullName.String(), // HINT: here is inconsistent with the Webhook's payload: webhook uses ShortName
|
||||
RefType: string(refFullName.RefType()),
|
||||
PusherType: api.PusherTypeUser,
|
||||
Repo: apiRepo,
|
||||
Sender: apiPusher,
|
||||
}).
|
||||
Notify(ctx)
|
||||
}
|
||||
|
||||
func (n *actionsNotifier) SyncPushCommits(ctx context.Context, pusher *user_model.User, repo *repo_model.Repository, opts *repository.PushUpdateOptions, commits *repository.PushCommits) {
|
||||
ctx = withMethod(ctx, "SyncPushCommits")
|
||||
|
||||
apiPusher := convert.ToUser(ctx, pusher, nil)
|
||||
apiCommits, apiHeadCommit, err := commits.ToAPIPayloadCommits(ctx, repo)
|
||||
if err != nil {
|
||||
log.Error("commits.ToAPIPayloadCommits failed: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
newNotifyInput(repo, pusher, webhook_module.HookEventPush).
|
||||
WithRef(opts.RefFullName.String()).
|
||||
WithPayload(&api.PushPayload{
|
||||
Ref: opts.RefFullName.String(),
|
||||
Before: opts.OldCommitID,
|
||||
After: opts.NewCommitID,
|
||||
CompareURL: setting.AppURL + commits.CompareURL,
|
||||
Commits: apiCommits,
|
||||
TotalCommits: commits.Len,
|
||||
HeadCommit: apiHeadCommit,
|
||||
Repo: convert.ToRepo(ctx, repo, access_model.Permission{AccessMode: perm_model.AccessModeOwner}),
|
||||
Pusher: apiPusher,
|
||||
Sender: apiPusher,
|
||||
}).
|
||||
Notify(ctx)
|
||||
}
|
||||
|
||||
func (n *actionsNotifier) SyncCreateRef(ctx context.Context, pusher *user_model.User, repo *repo_model.Repository, refFullName git.RefName, refID string) {
|
||||
ctx = withMethod(ctx, "SyncCreateRef")
|
||||
n.CreateRef(ctx, pusher, repo, refFullName, refID)
|
||||
}
|
||||
|
||||
func (n *actionsNotifier) SyncDeleteRef(ctx context.Context, pusher *user_model.User, repo *repo_model.Repository, refFullName git.RefName) {
|
||||
ctx = withMethod(ctx, "SyncDeleteRef")
|
||||
n.DeleteRef(ctx, pusher, repo, refFullName)
|
||||
}
|
||||
|
||||
func (n *actionsNotifier) NewRelease(ctx context.Context, rel *repo_model.Release) {
|
||||
ctx = withMethod(ctx, "NewRelease")
|
||||
notifyRelease(ctx, rel.Publisher, rel, api.HookReleasePublished)
|
||||
}
|
||||
|
||||
func (n *actionsNotifier) UpdateRelease(ctx context.Context, doer *user_model.User, rel *repo_model.Release) {
|
||||
ctx = withMethod(ctx, "UpdateRelease")
|
||||
notifyRelease(ctx, doer, rel, api.HookReleaseUpdated)
|
||||
}
|
||||
|
||||
func (n *actionsNotifier) DeleteRelease(ctx context.Context, doer *user_model.User, rel *repo_model.Release) {
|
||||
if rel.IsTag {
|
||||
// has sent same action in `PushCommits`, so skip it.
|
||||
return
|
||||
}
|
||||
ctx = withMethod(ctx, "DeleteRelease")
|
||||
notifyRelease(ctx, doer, rel, api.HookReleaseDeleted)
|
||||
}
|
||||
|
||||
func (n *actionsNotifier) PackageCreate(ctx context.Context, doer *user_model.User, pd *packages_model.PackageDescriptor) {
|
||||
ctx = withMethod(ctx, "PackageCreate")
|
||||
notifyPackage(ctx, doer, pd, api.HookPackageCreated)
|
||||
}
|
||||
|
||||
func (n *actionsNotifier) PackageDelete(ctx context.Context, doer *user_model.User, pd *packages_model.PackageDescriptor) {
|
||||
ctx = withMethod(ctx, "PackageDelete")
|
||||
notifyPackage(ctx, doer, pd, api.HookPackageDeleted)
|
||||
}
|
||||
|
||||
func (n *actionsNotifier) AutoMergePullRequest(ctx context.Context, doer *user_model.User, pr *issues_model.PullRequest) {
|
||||
ctx = withMethod(ctx, "AutoMergePullRequest")
|
||||
n.MergePullRequest(ctx, doer, pr)
|
||||
}
|
||||
|
||||
func (n *actionsNotifier) PullRequestSynchronized(ctx context.Context, doer *user_model.User, pr *issues_model.PullRequest, before, after string) {
|
||||
ctx = withMethod(ctx, "PullRequestSynchronized")
|
||||
|
||||
if err := pr.LoadIssue(ctx); err != nil {
|
||||
log.Error("LoadAttributes: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := pr.Issue.LoadRepo(ctx); err != nil {
|
||||
log.Error("pr.Issue.LoadRepo: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
newNotifyInput(pr.Issue.Repo, doer, webhook_module.HookEventPullRequestSync).
|
||||
WithPayload(&api.PullRequestPayload{
|
||||
Action: api.HookIssueSynchronized,
|
||||
Before: before,
|
||||
After: after,
|
||||
Index: pr.Issue.Index,
|
||||
PullRequest: convert.ToAPIPullRequest(ctx, pr, nil),
|
||||
Repository: convert.ToRepo(ctx, pr.Issue.Repo, access_model.Permission{AccessMode: perm_model.AccessModeNone}),
|
||||
Sender: convert.ToUser(ctx, doer, nil),
|
||||
}).
|
||||
WithPullRequest(pr).
|
||||
Notify(ctx)
|
||||
}
|
||||
|
||||
func (n *actionsNotifier) PullRequestChangeTargetBranch(ctx context.Context, doer *user_model.User, pr *issues_model.PullRequest, oldBranch string) {
|
||||
ctx = withMethod(ctx, "PullRequestChangeTargetBranch")
|
||||
|
||||
if err := pr.LoadIssue(ctx); err != nil {
|
||||
log.Error("LoadAttributes: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := pr.Issue.LoadRepo(ctx); err != nil {
|
||||
log.Error("pr.Issue.LoadRepo: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
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,
|
||||
Index: pr.Issue.Index,
|
||||
Changes: &api.ChangesPayload{
|
||||
Ref: &api.ChangesFromPayload{
|
||||
From: oldBranch,
|
||||
},
|
||||
},
|
||||
PullRequest: convert.ToAPIPullRequest(ctx, pr, nil),
|
||||
Repository: convert.ToRepo(ctx, pr.Issue.Repo, permission),
|
||||
Sender: convert.ToUser(ctx, doer, nil),
|
||||
}).
|
||||
WithPullRequest(pr).
|
||||
Notify(ctx)
|
||||
}
|
||||
|
||||
func (n *actionsNotifier) NewWikiPage(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, page, comment string) {
|
||||
ctx = withMethod(ctx, "NewWikiPage")
|
||||
|
||||
newNotifyInput(repo, doer, webhook_module.HookEventWiki).WithPayload(&api.WikiPayload{
|
||||
Action: api.HookWikiCreated,
|
||||
Repository: convert.ToRepo(ctx, repo, access_model.Permission{AccessMode: perm_model.AccessModeOwner}),
|
||||
Sender: convert.ToUser(ctx, doer, nil),
|
||||
Page: page,
|
||||
Comment: comment,
|
||||
}).Notify(ctx)
|
||||
}
|
||||
|
||||
func (n *actionsNotifier) EditWikiPage(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, page, comment string) {
|
||||
ctx = withMethod(ctx, "EditWikiPage")
|
||||
|
||||
newNotifyInput(repo, doer, webhook_module.HookEventWiki).WithPayload(&api.WikiPayload{
|
||||
Action: api.HookWikiEdited,
|
||||
Repository: convert.ToRepo(ctx, repo, access_model.Permission{AccessMode: perm_model.AccessModeOwner}),
|
||||
Sender: convert.ToUser(ctx, doer, nil),
|
||||
Page: page,
|
||||
Comment: comment,
|
||||
}).Notify(ctx)
|
||||
}
|
||||
|
||||
func (n *actionsNotifier) DeleteWikiPage(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, page string) {
|
||||
ctx = withMethod(ctx, "DeleteWikiPage")
|
||||
|
||||
newNotifyInput(repo, doer, webhook_module.HookEventWiki).WithPayload(&api.WikiPayload{
|
||||
Action: api.HookWikiDeleted,
|
||||
Repository: convert.ToRepo(ctx, repo, access_model.Permission{AccessMode: perm_model.AccessModeOwner}),
|
||||
Sender: convert.ToUser(ctx, doer, nil),
|
||||
Page: page,
|
||||
}).Notify(ctx)
|
||||
}
|
||||
|
||||
// MigrateRepository is used to detect workflows after a repository has been migrated
|
||||
func (n *actionsNotifier) MigrateRepository(ctx context.Context, doer, u *user_model.User, repo *repo_model.Repository) {
|
||||
ctx = withMethod(ctx, "MigrateRepository")
|
||||
|
||||
newNotifyInput(repo, doer, webhook_module.HookEventRepository).WithPayload(&api.RepositoryPayload{
|
||||
Action: api.HookRepoCreated,
|
||||
Repository: convert.ToRepo(ctx, repo, access_model.Permission{AccessMode: perm_model.AccessModeOwner}),
|
||||
Organization: convert.ToUser(ctx, u, nil),
|
||||
Sender: convert.ToUser(ctx, doer, nil),
|
||||
}).Notify(ctx)
|
||||
}
|
||||
|
||||
func (n *actionsNotifier) WorkflowRunStatusUpdate(ctx context.Context, repo *repo_model.Repository, sender *user_model.User, run *actions_model.ActionRun) {
|
||||
ctx = withMethod(ctx, "WorkflowRunStatusUpdate")
|
||||
|
||||
var org *api.Organization
|
||||
if repo.Owner.IsOrganization() {
|
||||
org = convert.ToOrganization(ctx, organization.OrgFromUser(repo.Owner))
|
||||
}
|
||||
|
||||
status := convert.ToWorkflowRunAction(run.Status)
|
||||
|
||||
gitRepo, err := gitrepo.OpenRepository(ctx, repo)
|
||||
if err != nil {
|
||||
log.Error("OpenRepository: %v", err)
|
||||
return
|
||||
}
|
||||
defer gitRepo.Close()
|
||||
|
||||
convertedWorkflow, err := convert.GetActionWorkflowByRef(ctx, gitRepo, repo, run.WorkflowID, git.RefName(run.Ref))
|
||||
if err != nil && errors.Is(err, util.ErrNotExist) {
|
||||
convertedWorkflow, err = convert.GetActionWorkflow(ctx, gitRepo, repo, run.WorkflowID)
|
||||
}
|
||||
if err != nil {
|
||||
log.Error("GetActionWorkflow: %v", err)
|
||||
return
|
||||
}
|
||||
run.Repo = repo
|
||||
convertedRun, err := convert.ToActionWorkflowRun(ctx, run, nil)
|
||||
if err != nil {
|
||||
log.Error("ToActionWorkflowRun: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,538 @@
|
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package actions
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
actions_model "gitea.dev/models/actions"
|
||||
"gitea.dev/models/db"
|
||||
issues_model "gitea.dev/models/issues"
|
||||
packages_model "gitea.dev/models/packages"
|
||||
access_model "gitea.dev/models/perm/access"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
unit_model "gitea.dev/models/unit"
|
||||
user_model "gitea.dev/models/user"
|
||||
actions_module "gitea.dev/modules/actions"
|
||||
"gitea.dev/modules/git"
|
||||
"gitea.dev/modules/gitrepo"
|
||||
"gitea.dev/modules/json"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/setting"
|
||||
api "gitea.dev/modules/structs"
|
||||
webhook_module "gitea.dev/modules/webhook"
|
||||
"gitea.dev/services/convert"
|
||||
|
||||
"gitea.com/gitea/runner/act/model"
|
||||
)
|
||||
|
||||
type methodCtxKeyType struct{}
|
||||
|
||||
var methodCtxKey methodCtxKeyType
|
||||
|
||||
// withMethod sets the notification method that this context currently executes.
|
||||
// Used for debugging/ troubleshooting purposes.
|
||||
func withMethod(ctx context.Context, method string) context.Context {
|
||||
// don't overwrite
|
||||
if v := ctx.Value(methodCtxKey); v != nil {
|
||||
if _, ok := v.(string); ok {
|
||||
return ctx
|
||||
}
|
||||
}
|
||||
return context.WithValue(ctx, methodCtxKey, method)
|
||||
}
|
||||
|
||||
// getMethod gets the notification method that this context currently executes.
|
||||
// Default: "notify"
|
||||
// Used for debugging/ troubleshooting purposes.
|
||||
func getMethod(ctx context.Context) string {
|
||||
if v := ctx.Value(methodCtxKey); v != nil {
|
||||
if s, ok := v.(string); ok {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return "notify"
|
||||
}
|
||||
|
||||
type notifyInput struct {
|
||||
// required
|
||||
Repo *repo_model.Repository
|
||||
Doer *user_model.User
|
||||
Event webhook_module.HookEventType
|
||||
|
||||
// optional
|
||||
Ref git.RefName
|
||||
Payload api.Payloader
|
||||
PullRequest *issues_model.PullRequest
|
||||
}
|
||||
|
||||
func newNotifyInput(repo *repo_model.Repository, doer *user_model.User, event webhook_module.HookEventType) *notifyInput {
|
||||
return ¬ifyInput{
|
||||
Repo: repo,
|
||||
Doer: doer,
|
||||
Event: event,
|
||||
}
|
||||
}
|
||||
|
||||
func newNotifyInputForSchedules(repo *repo_model.Repository) *notifyInput {
|
||||
// the doer here will be ignored as we force using action user when handling schedules
|
||||
return newNotifyInput(repo, user_model.NewActionsUser(), webhook_module.HookEventSchedule)
|
||||
}
|
||||
|
||||
func (input *notifyInput) WithDoer(doer *user_model.User) *notifyInput {
|
||||
input.Doer = doer
|
||||
return input
|
||||
}
|
||||
|
||||
func (input *notifyInput) WithRef(ref string) *notifyInput {
|
||||
input.Ref = git.RefName(ref)
|
||||
return input
|
||||
}
|
||||
|
||||
func (input *notifyInput) WithPayload(payload api.Payloader) *notifyInput {
|
||||
input.Payload = payload
|
||||
return input
|
||||
}
|
||||
|
||||
func (input *notifyInput) WithPullRequest(pr *issues_model.PullRequest) *notifyInput {
|
||||
input.PullRequest = pr
|
||||
if input.Ref == "" {
|
||||
input.Ref = git.RefName(pr.GetGitHeadRefName())
|
||||
}
|
||||
return input
|
||||
}
|
||||
|
||||
func (input *notifyInput) Notify(ctx context.Context) {
|
||||
log.Trace("execute %v for event %v whose doer is %v", getMethod(ctx), input.Event, input.Doer.Name)
|
||||
|
||||
if err := notify(ctx, input); err != nil {
|
||||
log.Error("an error occurred while executing the %s actions method: %v", getMethod(ctx), err)
|
||||
}
|
||||
}
|
||||
|
||||
func notify(ctx context.Context, input *notifyInput) error {
|
||||
shouldDetectSchedules := input.Event == webhook_module.HookEventPush && input.Ref.BranchName() == input.Repo.DefaultBranch
|
||||
if input.Doer.IsGiteaActions() {
|
||||
// avoiding triggering cyclically, for example:
|
||||
// a comment of an issue will trigger the runner to add a new comment as reply,
|
||||
// and the new comment will trigger the runner again.
|
||||
log.Debug("ignore executing %v for event %v whose doer is %v", getMethod(ctx), input.Event, input.Doer.Name)
|
||||
|
||||
// we should update schedule tasks in this case, because
|
||||
// 1. schedule tasks cannot be triggered by other events, so cyclic triggering will not occur
|
||||
// 2. some schedule tasks may update the repo periodically, so the refs of schedule tasks need to be updated
|
||||
if shouldDetectSchedules {
|
||||
return DetectAndHandleSchedules(ctx, input.Repo)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
if input.Repo.IsEmpty || input.Repo.IsArchived {
|
||||
return nil
|
||||
}
|
||||
if unit_model.TypeActions.UnitGlobalDisabled() {
|
||||
if err := CleanRepoScheduleTasks(ctx, input.Repo); err != nil {
|
||||
log.Error("CleanRepoScheduleTasks: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if err := input.Repo.LoadUnits(ctx); err != nil {
|
||||
return fmt.Errorf("repo.LoadUnits: %w", err)
|
||||
} else if !input.Repo.UnitEnabled(ctx, unit_model.TypeActions) {
|
||||
return nil
|
||||
}
|
||||
|
||||
gitRepo, err := gitrepo.OpenRepository(context.Background(), input.Repo)
|
||||
if err != nil {
|
||||
return fmt.Errorf("git.OpenRepository: %w", err)
|
||||
}
|
||||
defer gitRepo.Close()
|
||||
|
||||
ref := input.Ref
|
||||
if ref.BranchName() != input.Repo.DefaultBranch && actions_module.IsDefaultBranchWorkflow(input.Event) {
|
||||
if ref != "" {
|
||||
log.Warn("Event %q should only trigger workflows on the default branch, but its ref is %q. Will fall back to the default branch",
|
||||
input.Event, ref)
|
||||
}
|
||||
ref = git.RefNameFromBranch(input.Repo.DefaultBranch)
|
||||
}
|
||||
if ref == "" {
|
||||
log.Warn("Ref of event %q is empty, will fall back to the default branch", input.Event)
|
||||
ref = git.RefNameFromBranch(input.Repo.DefaultBranch)
|
||||
}
|
||||
|
||||
commitID, err := gitRepo.GetRefCommitID(ref.String())
|
||||
if err != nil {
|
||||
return fmt.Errorf("gitRepo.GetRefCommitID: %w", err)
|
||||
}
|
||||
|
||||
// Get the commit object for the ref
|
||||
commit, err := gitRepo.GetCommit(commitID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("gitRepo.GetCommit: %w", err)
|
||||
}
|
||||
|
||||
if skipWorkflows(ctx, input, commit) {
|
||||
return nil
|
||||
}
|
||||
|
||||
var detectedWorkflows []*actions_module.DetectedWorkflow
|
||||
actionsConfig := input.Repo.MustGetUnit(ctx, unit_model.TypeActions).ActionsConfig()
|
||||
workflows, schedules, err := actions_module.DetectWorkflows(gitRepo, commit,
|
||||
input.Event,
|
||||
input.Payload,
|
||||
shouldDetectSchedules,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("DetectWorkflows: %w", err)
|
||||
}
|
||||
|
||||
log.Trace("repo %s with commit %s event %s find %d workflows and %d schedules",
|
||||
input.Repo.RelativePath(),
|
||||
commit.ID,
|
||||
input.Event,
|
||||
len(workflows),
|
||||
len(schedules),
|
||||
)
|
||||
|
||||
for _, wf := range workflows {
|
||||
if actionsConfig.IsWorkflowDisabled(wf.EntryName) {
|
||||
log.Trace("repo %s has disable workflows %s", input.Repo.RelativePath(), wf.EntryName)
|
||||
continue
|
||||
}
|
||||
|
||||
if wf.TriggerEvent.Name != actions_module.GithubEventPullRequestTarget {
|
||||
detectedWorkflows = append(detectedWorkflows, wf)
|
||||
}
|
||||
}
|
||||
|
||||
if input.PullRequest != nil {
|
||||
// detect pull_request_target workflows
|
||||
baseRef := git.BranchPrefix + input.PullRequest.BaseBranch
|
||||
baseCommit, err := gitRepo.GetCommit(baseRef)
|
||||
if err != nil {
|
||||
return fmt.Errorf("gitRepo.GetCommit: %w", err)
|
||||
}
|
||||
baseWorkflows, _, err := actions_module.DetectWorkflows(gitRepo, baseCommit, input.Event, input.Payload, false)
|
||||
if err != nil {
|
||||
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.RelativePath(), baseCommit.ID)
|
||||
} else {
|
||||
for _, wf := range baseWorkflows {
|
||||
if wf.TriggerEvent.Name == actions_module.GithubEventPullRequestTarget {
|
||||
detectedWorkflows = append(detectedWorkflows, wf)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if shouldDetectSchedules {
|
||||
if err := handleSchedules(ctx, schedules, commit, input, ref); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return handleWorkflows(ctx, detectedWorkflows, commit, input, ref)
|
||||
}
|
||||
|
||||
func skipWorkflows(ctx context.Context, input *notifyInput, commit *git.Commit) bool {
|
||||
// skip workflow runs with a configured skip-ci string in commit message or pr title if the event is push or pull_request(_sync)
|
||||
// https://docs.github.com/en/actions/managing-workflow-runs/skipping-workflow-runs
|
||||
skipWorkflowEvents := []webhook_module.HookEventType{
|
||||
webhook_module.HookEventPush,
|
||||
webhook_module.HookEventPullRequest,
|
||||
webhook_module.HookEventPullRequestSync,
|
||||
}
|
||||
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.RelativePath(), input.PullRequest.Issue.ID, s)
|
||||
return true
|
||||
}
|
||||
if strings.Contains(commit.MessageRaw, s) {
|
||||
log.Debug("repo %s with commit %s: skipped run because of %s string", input.Repo.RelativePath(), commit.ID, s)
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
if input.Event == webhook_module.HookEventWorkflowRun {
|
||||
wrun, ok := input.Payload.(*api.WorkflowRunPayload)
|
||||
for i := 0; i < 5 && ok && wrun.WorkflowRun != nil; i++ {
|
||||
if wrun.WorkflowRun.Event != "workflow_run" {
|
||||
return false
|
||||
}
|
||||
r, err := actions_model.GetRunByRepoAndID(ctx, input.Repo.ID, wrun.WorkflowRun.ID)
|
||||
if err != nil {
|
||||
log.Error("GetRunByRepoAndID: %v", err)
|
||||
return true
|
||||
}
|
||||
wrun, err = r.GetWorkflowRunEventPayload()
|
||||
if err != nil {
|
||||
log.Error("GetWorkflowRunEventPayload: %v", err)
|
||||
return true
|
||||
}
|
||||
}
|
||||
// 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.RelativePath())
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func handleWorkflows(
|
||||
ctx context.Context,
|
||||
detectedWorkflows []*actions_module.DetectedWorkflow,
|
||||
commit *git.Commit,
|
||||
input *notifyInput,
|
||||
ref git.RefName,
|
||||
) error {
|
||||
if len(detectedWorkflows) == 0 {
|
||||
log.Trace("repo %s with commit %s couldn't find workflows", input.Repo.RelativePath(), commit.ID)
|
||||
return nil
|
||||
}
|
||||
|
||||
p, err := json.Marshal(input.Payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("json.Marshal: %w", err)
|
||||
}
|
||||
|
||||
isForkPullRequest := false
|
||||
if pr := input.PullRequest; pr != nil {
|
||||
switch pr.Flow {
|
||||
case issues_model.PullRequestFlowGithub:
|
||||
isForkPullRequest = pr.IsFromFork()
|
||||
case issues_model.PullRequestFlowAGit:
|
||||
// There is no fork concept in agit flow, anyone with read permission can push refs/for/<target-branch>/<topic-branch> to the repo.
|
||||
// So we can treat it as a fork pull request because it may be from an untrusted user
|
||||
isForkPullRequest = true
|
||||
default:
|
||||
// unknown flow, assume it's a fork pull request to be safe
|
||||
isForkPullRequest = true
|
||||
}
|
||||
}
|
||||
|
||||
for _, dwf := range detectedWorkflows {
|
||||
run := &actions_model.ActionRun{
|
||||
Title: commit.MessageTitle(),
|
||||
RepoID: input.Repo.ID,
|
||||
Repo: input.Repo,
|
||||
OwnerID: input.Repo.OwnerID,
|
||||
WorkflowID: dwf.EntryName,
|
||||
TriggerUserID: input.Doer.ID,
|
||||
TriggerUser: input.Doer,
|
||||
Ref: ref.String(),
|
||||
CommitSHA: commit.ID.String(),
|
||||
IsForkPullRequest: isForkPullRequest,
|
||||
Event: input.Event,
|
||||
EventPayload: string(p),
|
||||
TriggerEvent: dwf.TriggerEvent.Name,
|
||||
Status: actions_model.StatusWaiting,
|
||||
}
|
||||
|
||||
need, err := ifNeedApproval(ctx, run, input.Repo, input.Doer)
|
||||
if err != nil {
|
||||
log.Error("check if need approval for repo %d with user %d: %v", input.Repo.ID, input.Doer.ID, err)
|
||||
continue
|
||||
}
|
||||
|
||||
run.NeedApproval = need
|
||||
|
||||
if err := PrepareRunAndInsert(ctx, dwf.Content, run, nil); err != nil {
|
||||
log.Error("PrepareRunAndInsert: %v", err)
|
||||
continue
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func newNotifyInputFromIssue(issue *issues_model.Issue, event webhook_module.HookEventType) *notifyInput {
|
||||
return newNotifyInput(issue.Repo, issue.Poster, event)
|
||||
}
|
||||
|
||||
func notifyRelease(ctx context.Context, doer *user_model.User, rel *repo_model.Release, action api.HookReleaseAction) {
|
||||
if err := rel.LoadAttributes(ctx); err != nil {
|
||||
log.Error("LoadAttributes: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
permission, _ := access_model.GetDoerRepoPermission(ctx, rel.Repo, doer)
|
||||
|
||||
newNotifyInput(rel.Repo, doer, webhook_module.HookEventRelease).
|
||||
WithRef(git.RefNameFromTag(rel.TagName).String()).
|
||||
WithPayload(&api.ReleasePayload{
|
||||
Action: action,
|
||||
Release: convert.ToAPIRelease(ctx, rel.Repo, rel),
|
||||
Repository: convert.ToRepo(ctx, rel.Repo, permission),
|
||||
Sender: convert.ToUser(ctx, doer, nil),
|
||||
}).
|
||||
Notify(ctx)
|
||||
}
|
||||
|
||||
func notifyPackage(ctx context.Context, sender *user_model.User, pd *packages_model.PackageDescriptor, action api.HookPackageAction) {
|
||||
if pd.Repository == nil {
|
||||
// When a package is uploaded to an organization, it could trigger an event to notify.
|
||||
// So the repository could be nil, however, actions can't support that yet.
|
||||
// See https://github.com/go-gitea/gitea/pull/17940
|
||||
return
|
||||
}
|
||||
|
||||
apiPackage, err := convert.ToPackage(ctx, pd, sender)
|
||||
if err != nil {
|
||||
log.Error("Error converting package: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
newNotifyInput(pd.Repository, sender, webhook_module.HookEventPackage).
|
||||
WithPayload(&api.PackagePayload{
|
||||
Action: action,
|
||||
Package: apiPackage,
|
||||
Sender: convert.ToUser(ctx, sender, nil),
|
||||
}).
|
||||
Notify(ctx)
|
||||
}
|
||||
|
||||
func ifNeedApproval(ctx context.Context, run *actions_model.ActionRun, repo *repo_model.Repository, user *user_model.User) (bool, error) {
|
||||
// 1. don't need approval if it's not a fork PR
|
||||
// 2. don't need approval if the event is `pull_request_target` since the workflow will run in the context of base branch
|
||||
// see https://docs.github.com/en/actions/managing-workflow-runs/approving-workflow-runs-from-public-forks#about-workflow-runs-from-public-forks
|
||||
if !run.IsForkPullRequest || run.TriggerEvent == actions_module.GithubEventPullRequestTarget {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// always need approval if the user is restricted
|
||||
if user.IsRestricted {
|
||||
log.Trace("need approval because user %d is restricted", user.ID)
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// don't need approval if the user can write
|
||||
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
|
||||
}
|
||||
|
||||
// don't need approval if the user has been approved before
|
||||
if count, err := db.Count[actions_model.ActionRun](ctx, actions_model.FindRunOptions{
|
||||
RepoID: repo.ID,
|
||||
TriggerUserID: user.ID,
|
||||
Approved: true,
|
||||
}); err != nil {
|
||||
return false, fmt.Errorf("CountRuns: %w", err)
|
||||
} else if count > 0 {
|
||||
log.Trace("do not need approval because user %d has been approved before", user.ID)
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// otherwise, need approval
|
||||
log.Trace("need approval because it's the first time user %d triggered actions", user.ID)
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func handleSchedules(
|
||||
ctx context.Context,
|
||||
detectedWorkflows []*actions_module.DetectedWorkflow,
|
||||
commit *git.Commit,
|
||||
input *notifyInput,
|
||||
ref git.RefName,
|
||||
) error {
|
||||
if ref.BranchName() != input.Repo.DefaultBranch {
|
||||
log.Trace("commit branch is not default branch in repo")
|
||||
return nil
|
||||
}
|
||||
|
||||
if count, err := db.Count[actions_model.ActionSchedule](ctx, actions_model.FindScheduleOptions{RepoID: input.Repo.ID}); err != nil {
|
||||
log.Error("CountSchedules: %v", err)
|
||||
return err
|
||||
} else if count > 0 {
|
||||
if err := CleanRepoScheduleTasks(ctx, input.Repo); err != nil {
|
||||
log.Error("CleanRepoScheduleTasks: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
if len(detectedWorkflows) == 0 {
|
||||
log.Trace("repo %s with commit %s couldn't find schedules", input.Repo.RelativePath(), commit.ID)
|
||||
return nil
|
||||
}
|
||||
|
||||
p, err := json.Marshal(input.Payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("json.Marshal: %w", err)
|
||||
}
|
||||
|
||||
crons := make([]*actions_model.ActionSchedule, 0, len(detectedWorkflows))
|
||||
for _, dwf := range detectedWorkflows {
|
||||
// Check cron job condition. Only working in default branch
|
||||
workflow, err := model.ReadWorkflow(bytes.NewReader(dwf.Content))
|
||||
if err != nil {
|
||||
log.Error("ReadWorkflow: %v", err)
|
||||
continue
|
||||
}
|
||||
schedules := workflow.OnSchedule()
|
||||
if len(schedules) == 0 {
|
||||
log.Warn("no schedule event")
|
||||
continue
|
||||
}
|
||||
|
||||
run := &actions_model.ActionSchedule{
|
||||
Title: commit.MessageTitle(),
|
||||
RepoID: input.Repo.ID,
|
||||
Repo: input.Repo,
|
||||
OwnerID: input.Repo.OwnerID,
|
||||
WorkflowID: dwf.EntryName,
|
||||
TriggerUserID: user_model.ActionsUserID,
|
||||
TriggerUser: user_model.NewActionsUser(),
|
||||
Ref: ref.String(),
|
||||
CommitSHA: commit.ID.String(),
|
||||
Event: input.Event,
|
||||
EventPayload: string(p),
|
||||
Specs: schedules,
|
||||
Content: dwf.Content,
|
||||
}
|
||||
|
||||
crons = append(crons, run)
|
||||
}
|
||||
|
||||
return actions_model.CreateScheduleTask(ctx, crons)
|
||||
}
|
||||
|
||||
// DetectAndHandleSchedules detects the schedule workflows on the default branch and create schedule tasks
|
||||
func DetectAndHandleSchedules(ctx context.Context, repo *repo_model.Repository) error {
|
||||
if repo.IsEmpty || repo.IsArchived {
|
||||
return nil
|
||||
}
|
||||
|
||||
gitRepo, err := gitrepo.OpenRepository(context.Background(), repo)
|
||||
if err != nil {
|
||||
return fmt.Errorf("git.OpenRepository: %w", err)
|
||||
}
|
||||
defer gitRepo.Close()
|
||||
|
||||
// Only detect schedule workflows on the default branch
|
||||
commit, err := gitRepo.GetCommit(repo.DefaultBranch)
|
||||
if err != nil {
|
||||
return fmt.Errorf("gitRepo.GetCommit: %w", err)
|
||||
}
|
||||
scheduleWorkflows, err := actions_module.DetectScheduledWorkflows(gitRepo, commit)
|
||||
if err != nil {
|
||||
return fmt.Errorf("detect schedule workflows: %w", err)
|
||||
}
|
||||
if len(scheduleWorkflows) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// We need a notifyInput to call handleSchedules
|
||||
// if repo is a mirror, commit author maybe an external user,
|
||||
// so we use action user as the Doer of the notifyInput
|
||||
notifyInput := newNotifyInputForSchedules(repo)
|
||||
|
||||
return handleSchedules(ctx, scheduleWorkflows, commit, notifyInput, git.RefNameFromBranch(repo.DefaultBranch))
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package actions
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
actions_model "gitea.dev/models/actions"
|
||||
"gitea.dev/modules/log"
|
||||
notify_service "gitea.dev/services/notify"
|
||||
)
|
||||
|
||||
// NotifyWorkflowJobsAndRunsStatusUpdate notifies status changes for a batch of jobs and the runs they affect.
|
||||
// Use it when a workflow operation updates multiple jobs and runs.
|
||||
func NotifyWorkflowJobsAndRunsStatusUpdate(ctx context.Context, jobs []*actions_model.ActionRunJob) {
|
||||
if len(jobs) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// The input jobs may belong to different runs, so track each affected run ID
|
||||
// and reload it later to avoid notifying with stale aggregate status.
|
||||
runRepoIDs := make(map[int64]int64, len(jobs))
|
||||
jobsByRunID := make(map[int64][]*actions_model.ActionRunJob)
|
||||
|
||||
for _, job := range jobs {
|
||||
if err := job.LoadAttributes(ctx); err != nil {
|
||||
log.Error("Failed to load job attributes: %v", err)
|
||||
continue
|
||||
}
|
||||
CreateCommitStatusForRunJobs(ctx, job.Run, job)
|
||||
|
||||
runRepoIDs[job.RunID] = job.RepoID
|
||||
if _, ok := jobsByRunID[job.RunID]; !ok {
|
||||
jobsByRunID[job.RunID] = make([]*actions_model.ActionRunJob, 0)
|
||||
}
|
||||
jobsByRunID[job.RunID] = append(jobsByRunID[job.RunID], job)
|
||||
}
|
||||
|
||||
for runID, repoID := range runRepoIDs {
|
||||
NotifyWorkflowRunStatusUpdateWithReload(ctx, repoID, runID)
|
||||
}
|
||||
|
||||
for _, jobs := range jobsByRunID {
|
||||
NotifyWorkflowJobsStatusUpdate(ctx, jobs...)
|
||||
}
|
||||
}
|
||||
|
||||
// NotifyWorkflowRunStatusUpdateWithReload reloads the run before notifying its status update.
|
||||
// Use it when only repo/run IDs are available or when the in-memory run may be stale after job updates.
|
||||
func NotifyWorkflowRunStatusUpdateWithReload(ctx context.Context, repoID, runID int64) {
|
||||
run, err := actions_model.GetRunByRepoAndID(ctx, repoID, runID)
|
||||
if err != nil {
|
||||
log.Error("GetRunByRepoAndID: %v", err)
|
||||
return
|
||||
}
|
||||
NotifyWorkflowRunStatusUpdate(ctx, run)
|
||||
}
|
||||
|
||||
// NotifyWorkflowRunStatusUpdate notifies a run status update using the latest attempt trigger user when available.
|
||||
// Use it for run-level notifications when the caller already has the run model loaded.
|
||||
func NotifyWorkflowRunStatusUpdate(ctx context.Context, run *actions_model.ActionRun) {
|
||||
if err := run.LoadAttributes(ctx); err != nil {
|
||||
log.Error("run.LoadAttributes: %v", err)
|
||||
return
|
||||
}
|
||||
triggerUser := run.TriggerUser
|
||||
if run.LatestAttemptID > 0 {
|
||||
attempt, err := actions_model.GetRunAttemptByRepoAndID(ctx, run.RepoID, run.LatestAttemptID)
|
||||
if err != nil {
|
||||
log.Error("GetRunAttemptByRepoAndID: %v", err)
|
||||
return
|
||||
}
|
||||
if err := attempt.LoadAttributes(ctx); err != nil {
|
||||
log.Error("attempt.LoadAttributes: %v", err)
|
||||
return
|
||||
}
|
||||
triggerUser = attempt.TriggerUser
|
||||
}
|
||||
|
||||
notify_service.WorkflowRunStatusUpdate(ctx, run.Repo, triggerUser, run)
|
||||
|
||||
// Recomputes the repository's num_action_runs / num_closed_action_runs counters since the run's status changed
|
||||
actions_model.UpdateRepoRunsNumbers(ctx, run.RepoID)
|
||||
}
|
||||
|
||||
// NotifyWorkflowJobsStatusUpdate notifies status updates for jobs without task.
|
||||
// Use it for batch or single-job notifications after state changes.
|
||||
func NotifyWorkflowJobsStatusUpdate(ctx context.Context, jobs ...*actions_model.ActionRunJob) {
|
||||
jobsByAttempt := make(map[int64][]*actions_model.ActionRunJob)
|
||||
for _, job := range jobs {
|
||||
if _, ok := jobsByAttempt[job.RunAttemptID]; !ok {
|
||||
jobsByAttempt[job.RunAttemptID] = make([]*actions_model.ActionRunJob, 0)
|
||||
}
|
||||
jobsByAttempt[job.RunAttemptID] = append(jobsByAttempt[job.RunAttemptID], job)
|
||||
}
|
||||
|
||||
for attemptID, js := range jobsByAttempt {
|
||||
if attemptID == 0 {
|
||||
for _, job := range js {
|
||||
if err := job.LoadAttributes(ctx); err != nil {
|
||||
log.Error("job.LoadAttributes: %v", err)
|
||||
continue
|
||||
}
|
||||
notify_service.WorkflowJobStatusUpdate(ctx, job.Run.Repo, job.Run.TriggerUser, job, nil)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
attempt, err := actions_model.GetRunAttemptByRepoAndID(ctx, js[0].RepoID, attemptID)
|
||||
if err != nil {
|
||||
log.Error("GetRunAttemptByRepoAndID: %v", err)
|
||||
continue
|
||||
}
|
||||
if err := attempt.LoadAttributes(ctx); err != nil {
|
||||
log.Error("attempt.LoadAttributes: %v", err)
|
||||
continue
|
||||
}
|
||||
for _, job := range js {
|
||||
notify_service.WorkflowJobStatusUpdate(ctx, attempt.Run.Repo, attempt.TriggerUser, job, nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// NotifyWorkflowJobStatusUpdateWithTask notifies a single job status update when a concrete task is available.
|
||||
// Use it for runner/task lifecycle callbacks so the notification includes the originating task context.
|
||||
func NotifyWorkflowJobStatusUpdateWithTask(ctx context.Context, job *actions_model.ActionRunJob, task *actions_model.ActionTask) {
|
||||
if job.RunAttemptID == 0 {
|
||||
if err := job.LoadAttributes(ctx); err != nil {
|
||||
log.Error("job.LoadAttributes: %v", err)
|
||||
return
|
||||
}
|
||||
notify_service.WorkflowJobStatusUpdate(ctx, job.Run.Repo, job.Run.TriggerUser, job, task)
|
||||
return
|
||||
}
|
||||
|
||||
attempt, err := actions_model.GetRunAttemptByRepoAndID(ctx, job.RepoID, job.RunAttemptID)
|
||||
if err != nil {
|
||||
log.Error("GetRunAttemptByRepoAndID: %v", err)
|
||||
return
|
||||
}
|
||||
if err := attempt.LoadAttributes(ctx); err != nil {
|
||||
log.Error("attempt.LoadAttributes: %v", err)
|
||||
return
|
||||
}
|
||||
notify_service.WorkflowJobStatusUpdate(ctx, attempt.Run.Repo, attempt.TriggerUser, job, task)
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package actions
|
||||
|
||||
import (
|
||||
"gitea.dev/models/perm"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/unit"
|
||||
"gitea.dev/modules/actions/jobparser"
|
||||
"gitea.dev/modules/setting"
|
||||
|
||||
"go.yaml.in/yaml/v4"
|
||||
)
|
||||
|
||||
// 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"
|
||||
|
||||
"gitea.dev/models/perm"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/unit"
|
||||
"gitea.dev/modules/actions/jobparser"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"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))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,588 @@
|
||||
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package actions
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"slices"
|
||||
|
||||
actions_model "gitea.dev/models/actions"
|
||||
"gitea.dev/models/db"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/unit"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/container"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/util"
|
||||
|
||||
"gitea.com/gitea/runner/act/model"
|
||||
"go.yaml.in/yaml/v4"
|
||||
)
|
||||
|
||||
// GetFailedJobsForRerun returns the failed or cancelled jobs in a run.
|
||||
func GetFailedJobsForRerun(allJobs []*actions_model.ActionRunJob) []*actions_model.ActionRunJob {
|
||||
var jobsToRerun []*actions_model.ActionRunJob
|
||||
|
||||
for _, job := range allJobs {
|
||||
if job.Status == actions_model.StatusFailure || job.Status == actions_model.StatusCancelled {
|
||||
jobsToRerun = append(jobsToRerun, job)
|
||||
}
|
||||
}
|
||||
|
||||
return jobsToRerun
|
||||
}
|
||||
|
||||
// RerunWorkflowRunJobs reruns the given jobs of a workflow run.
|
||||
// An empty jobsToRerun means rerunning the whole run. Otherwise jobsToRerun contains only the user-requested target jobs;
|
||||
// downstream dependent jobs are expanded internally while building the rerun plan.
|
||||
//
|
||||
// The three stages below (legacy backfill, plan build, plan exec) deliberately run in separate DB transactions
|
||||
// rather than one big outer transaction:
|
||||
// - execRerunPlan performs slow work (loading variables, YAML unmarshal, concurrency expression evaluation)
|
||||
// before opening its own transaction, so the tx stays focused on inserts/updates.
|
||||
// (Exception: reusable workflow caller expansion runs inside the tx, see expandReusableWorkflowCaller's doc.)
|
||||
// - The legacy backfill is idempotent-friendly: if it succeeds but a later stage fails, a subsequent rerun
|
||||
// will observe run.LatestAttemptID != 0 and skip the backfill, continuing naturally. No data corruption
|
||||
// or stuck state results from partial progress.
|
||||
//
|
||||
// Fast validations that can catch failures early (workflow disabled, run not done, etc.) are therefore
|
||||
// pushed into validateRerun so we rarely enter createOriginalAttemptForLegacyRun only to fail afterwards.
|
||||
func RerunWorkflowRunJobs(ctx context.Context, repo *repo_model.Repository, run *actions_model.ActionRun, triggerUser *user_model.User, jobsToRerun []*actions_model.ActionRunJob) (*actions_model.ActionRunAttempt, error) {
|
||||
if err := validateRerun(ctx, run, repo, triggerUser, jobsToRerun); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if run.LatestAttemptID == 0 {
|
||||
if err := createOriginalAttemptForLegacyRun(ctx, run); err != nil {
|
||||
return nil, fmt.Errorf("create attempt for legacy run: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
plan, err := buildRerunPlan(ctx, run, triggerUser, jobsToRerun)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return execRerunPlan(ctx, plan)
|
||||
}
|
||||
|
||||
func validateRerun(ctx context.Context, run *actions_model.ActionRun, repo *repo_model.Repository, triggerUser *user_model.User, jobsToRerun []*actions_model.ActionRunJob) error {
|
||||
if !run.Status.IsDone() {
|
||||
return util.NewInvalidArgumentErrorf("this workflow run is not done")
|
||||
}
|
||||
if repo == nil {
|
||||
return util.NewInvalidArgumentErrorf("repo is required")
|
||||
}
|
||||
if run.RepoID != repo.ID {
|
||||
return util.NewInvalidArgumentErrorf("run %d does not belong to repo %d", run.ID, repo.ID)
|
||||
}
|
||||
for _, job := range jobsToRerun {
|
||||
if job.RunID != run.ID {
|
||||
return util.NewInvalidArgumentErrorf("job %d does not belong to workflow run %d", job.ID, run.ID)
|
||||
}
|
||||
}
|
||||
if triggerUser == nil {
|
||||
return util.NewInvalidArgumentErrorf("trigger user is required")
|
||||
}
|
||||
cfgUnit := repo.MustGetUnit(ctx, unit.TypeActions)
|
||||
cfg := cfgUnit.ActionsConfig()
|
||||
if cfg.IsWorkflowDisabled(run.WorkflowID) {
|
||||
return util.NewInvalidArgumentErrorf("workflow %s is disabled", run.WorkflowID)
|
||||
}
|
||||
|
||||
// Legacy runs (LatestAttemptID == 0) conceptually have only attempt 1, so they can never be at the cap.
|
||||
// For non-legacy runs, look up the latest attempt and reject when its number is already at the configured cap.
|
||||
if run.LatestAttemptID > 0 {
|
||||
latestAttempt, has, err := run.GetLatestAttempt(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("GetLatestAttempt: %w", err)
|
||||
}
|
||||
if has && latestAttempt.Attempt >= setting.Actions.MaxRerunAttempts {
|
||||
return util.NewInvalidArgumentErrorf("workflow run has reached the maximum of %d attempts", setting.Actions.MaxRerunAttempts)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// rerunPlan is a read-only snapshot of the inputs needed to execute a rerun.
|
||||
// It holds no to-be-persisted entities and no intermediate evaluation results;
|
||||
// execRerunPlan constructs and evaluates the new ActionRunAttempt itself.
|
||||
type rerunPlan struct {
|
||||
run *actions_model.ActionRun
|
||||
templateAttempt *actions_model.ActionRunAttempt
|
||||
templateJobs actions_model.ActionJobList
|
||||
triggerUser *user_model.User
|
||||
|
||||
// rerunAttemptJobIDs holds the AttemptJobIDs of jobs that will actually be re-run in the new attempt.
|
||||
// If a job here is a reusable caller, the whole subtree under it will be re-run.
|
||||
rerunAttemptJobIDs container.Set[int64]
|
||||
|
||||
// ancestorAttemptJobIDs holds the AttemptJobIDs of reusable caller jobs that have only some of their descendants being re-run:
|
||||
// the caller itself is NOT re-run as a whole, it stays pass-through and its non-rerun children stay pass-through too.
|
||||
ancestorAttemptJobIDs container.Set[int64]
|
||||
|
||||
// skipCloneTemplateJobIDs holds the template-attempt DB row IDs of descendants of any reusable caller in rerunAttemptJobIDs.
|
||||
// These jobs should not be cloned, since the caller's lazy expansion will re-insert them fresh.
|
||||
skipCloneTemplateJobIDs container.Set[int64]
|
||||
}
|
||||
|
||||
// buildRerunPlan constructs a rerunPlan for the given workflow run without writing to the database.
|
||||
// jobsToRerun contains only the user-requested target jobs. An empty jobsToRerun means the entire run should be rerun.
|
||||
// It loads the latest attempt as a template and expands jobsToRerun to include all transitive downstream dependents.
|
||||
// The construction of new-attempt and concurrency evaluation are deferred to execRerunPlan so that the plan remains a pure input snapshot.
|
||||
func buildRerunPlan(ctx context.Context, run *actions_model.ActionRun, triggerUser *user_model.User, jobsToRerun []*actions_model.ActionRunJob) (*rerunPlan, error) {
|
||||
if err := run.LoadAttributes(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
templateAttempt, hasTemplateAttempt, err := run.GetLatestAttempt(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !hasTemplateAttempt {
|
||||
return nil, util.NewNotExistErrorf("latest attempt not found")
|
||||
}
|
||||
|
||||
templateJobs, err := actions_model.GetRunJobsByRunAndAttemptID(ctx, run.ID, templateAttempt.ID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load template jobs: %w", err)
|
||||
}
|
||||
if len(templateJobs) == 0 {
|
||||
return nil, util.NewNotExistErrorf("no template jobs")
|
||||
}
|
||||
|
||||
plan := &rerunPlan{
|
||||
run: run,
|
||||
templateAttempt: templateAttempt,
|
||||
templateJobs: templateJobs,
|
||||
triggerUser: triggerUser,
|
||||
}
|
||||
|
||||
if err := plan.expandRerunJobIDs(jobsToRerun); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
plan.skipCloneTemplateJobIDs = plan.collectResetCallerDescendants()
|
||||
|
||||
return plan, nil
|
||||
}
|
||||
|
||||
// execRerunPlan executes the rerun plan built by buildRerunPlan.
|
||||
// It loads run variables, constructs the new ActionRunAttempt and evaluates run-level concurrency (all outside the transaction to keep the tx short).
|
||||
// Inside a single database transaction it then inserts the new attempt, clones all template jobs, evaluates job-level concurrency for rerun jobs,
|
||||
// and updates the run's latest_attempt_id.
|
||||
// Jobs not in the rerun set are cloned as pass-through: their status is preserved and SourceTaskID points to the original task so the UI can still display their results.
|
||||
// The attempt's final status is derived only from the rerun jobs, not the pass-through jobs.
|
||||
// Notifications and commit statuses are sent after the transaction commits.
|
||||
func execRerunPlan(ctx context.Context, plan *rerunPlan) (*actions_model.ActionRunAttempt, error) {
|
||||
vars, err := actions_model.GetVariablesOfRun(ctx, plan.run)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get run %d variables: %w", plan.run.ID, err)
|
||||
}
|
||||
|
||||
newAttempt := &actions_model.ActionRunAttempt{
|
||||
RepoID: plan.run.RepoID,
|
||||
RunID: plan.run.ID,
|
||||
Attempt: plan.templateAttempt.Attempt + 1,
|
||||
TriggerUserID: plan.triggerUser.ID,
|
||||
Status: actions_model.StatusWaiting,
|
||||
}
|
||||
|
||||
if plan.run.RawConcurrency != "" {
|
||||
var rawConcurrency model.RawConcurrency
|
||||
if err := yaml.Unmarshal([]byte(plan.run.RawConcurrency), &rawConcurrency); err != nil {
|
||||
return nil, fmt.Errorf("unmarshal raw concurrency: %w", err)
|
||||
}
|
||||
if err := EvaluateRunConcurrencyFillModel(ctx, plan.run, newAttempt, &rawConcurrency, vars, nil); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
var newJobs, newJobsToRerun actions_model.ActionJobList
|
||||
var cancelledConcurrencyJobs []*actions_model.ActionRunJob
|
||||
var hasWaitingCallerJobs bool
|
||||
|
||||
err = db.WithTx(ctx, func(ctx context.Context) error {
|
||||
newAttemptStatus, jobsToCancel, err := PrepareToStartRunWithConcurrency(ctx, newAttempt)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cancelledConcurrencyJobs = append(cancelledConcurrencyJobs, jobsToCancel...)
|
||||
newAttempt.Status = newAttemptStatus
|
||||
shouldBlock := newAttemptStatus == actions_model.StatusBlocked
|
||||
|
||||
if err := db.Insert(ctx, newAttempt); err != nil {
|
||||
if _, getErr := actions_model.GetRunAttemptByRunIDAndAttemptNum(ctx, plan.run.ID, newAttempt.Attempt); getErr == nil {
|
||||
return util.NewAlreadyExistErrorf("workflow run attempt %d for run %d already exists", newAttempt.Attempt, plan.run.ID)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
plan.run.LatestAttemptID = newAttempt.ID
|
||||
if err := actions_model.UpdateRun(ctx, plan.run, "latest_attempt_id"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
hasWaitingJobs := false
|
||||
newJobs = make(actions_model.ActionJobList, 0, len(plan.templateJobs))
|
||||
newJobsToRerun = make(actions_model.ActionJobList, 0, len(plan.rerunAttemptJobIDs))
|
||||
|
||||
// templateIDToNewID maps each template-attempt job's DB ID to its newly-inserted clone's DB ID
|
||||
templateIDToNewID := make(map[int64]int64, len(plan.templateJobs))
|
||||
|
||||
for _, templateJob := range plan.templateJobs {
|
||||
// descendants of a reset reusable caller are not cloned at all, the caller will re-insert them
|
||||
if plan.skipCloneTemplateJobIDs.Contains(templateJob.ID) {
|
||||
continue
|
||||
}
|
||||
|
||||
newJob := cloneRunJobForAttempt(templateJob, newAttempt)
|
||||
|
||||
// Remap ParentJobID from template attempts's DB ID -> new attempt's DB ID.
|
||||
if templateJob.ParentJobID != 0 {
|
||||
newParentID, ok := templateIDToNewID[templateJob.ParentJobID]
|
||||
if !ok {
|
||||
return fmt.Errorf("clone order violation: parent job %d not yet cloned for child %d",
|
||||
templateJob.ParentJobID, templateJob.ID)
|
||||
}
|
||||
newJob.ParentJobID = newParentID
|
||||
}
|
||||
|
||||
if plan.rerunAttemptJobIDs.Contains(templateJob.AttemptJobID) {
|
||||
shouldBlockJob := shouldBlock || plan.hasRerunDependency(templateJob)
|
||||
|
||||
newJob.Status = util.Iif(shouldBlockJob, actions_model.StatusBlocked, actions_model.StatusWaiting)
|
||||
newJob.TaskID = 0
|
||||
newJob.SourceTaskID = 0
|
||||
newJob.Started = 0
|
||||
newJob.Stopped = 0
|
||||
newJob.ConcurrencyGroup = ""
|
||||
newJob.ConcurrencyCancel = false
|
||||
newJob.IsConcurrencyEvaluated = false
|
||||
|
||||
if templateJob.IsReusableCaller {
|
||||
newJob.IsExpanded = false
|
||||
newJob.CallPayload = ""
|
||||
}
|
||||
|
||||
if newJob.RawConcurrency != "" && !shouldBlockJob {
|
||||
if err := EvaluateJobConcurrencyFillModel(ctx, plan.run, newAttempt, newJob, vars, nil); err != nil {
|
||||
return fmt.Errorf("evaluate job concurrency: %w", err)
|
||||
}
|
||||
newJob.Status, jobsToCancel, err = PrepareToStartJobWithConcurrency(ctx, newJob)
|
||||
if err != nil {
|
||||
return fmt.Errorf("prepare to start job with concurrency: %w", err)
|
||||
}
|
||||
cancelledConcurrencyJobs = append(cancelledConcurrencyJobs, jobsToCancel...)
|
||||
}
|
||||
|
||||
newJobsToRerun = append(newJobsToRerun, newJob)
|
||||
} else {
|
||||
newJob.TaskID = 0
|
||||
newJob.SourceTaskID = templateJob.EffectiveTaskID()
|
||||
|
||||
isAncestor := plan.ancestorAttemptJobIDs.Contains(templateJob.AttemptJobID)
|
||||
newJob.Started = util.Iif(isAncestor, 0, templateJob.Started)
|
||||
newJob.Stopped = util.Iif(isAncestor, 0, templateJob.Stopped)
|
||||
}
|
||||
|
||||
if err := db.Insert(ctx, newJob); err != nil {
|
||||
return err
|
||||
}
|
||||
templateIDToNewID[templateJob.ID] = newJob.ID
|
||||
|
||||
// expand reusable caller
|
||||
if newJob.IsReusableCaller && newJob.Status == actions_model.StatusWaiting && !newJob.IsExpanded {
|
||||
if err := expandReusableWorkflowCaller(ctx, plan.run, newAttempt, newJob, vars); err != nil {
|
||||
return fmt.Errorf("inline trigger caller %d ready: %w", newJob.ID, err)
|
||||
}
|
||||
// refresh the caller status
|
||||
if err := actions_model.RefreshReusableCallerStatus(ctx, newJob); err != nil {
|
||||
return fmt.Errorf("refresh caller %d status: %w", newJob.ID, err)
|
||||
}
|
||||
hasWaitingCallerJobs = true
|
||||
}
|
||||
|
||||
// A reusable caller is never dispatched to a runner, so it must not drive the task-version bump.
|
||||
hasWaitingJobs = hasWaitingJobs || (newJob.Status == actions_model.StatusWaiting && !newJob.IsReusableCaller)
|
||||
newJobs = append(newJobs, newJob)
|
||||
}
|
||||
|
||||
// Refresh each ancestor's status from its now-fresh children.
|
||||
// `newJobs` is appended top-down (caller before its children), so we walk it in reverse to refresh the deepest ancestor first.
|
||||
for _, ancestor := range slices.Backward(newJobs) {
|
||||
if !ancestor.IsReusableCaller || !plan.ancestorAttemptJobIDs.Contains(ancestor.AttemptJobID) {
|
||||
continue
|
||||
}
|
||||
if err := actions_model.RefreshReusableCallerStatus(ctx, ancestor); err != nil {
|
||||
return fmt.Errorf("refresh ancestor caller %d status: %w", ancestor.ID, err)
|
||||
}
|
||||
}
|
||||
|
||||
newAttempt.Status = actions_model.AggregateJobStatus(newJobsToRerun)
|
||||
if err := actions_model.UpdateRunAttempt(ctx, newAttempt, "status"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if hasWaitingJobs {
|
||||
if err := actions_model.IncreaseTaskVersion(ctx, plan.run.OwnerID, plan.run.RepoID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := plan.run.LoadAttributes(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
NotifyWorkflowJobsAndRunsStatusUpdate(ctx, cancelledConcurrencyJobs)
|
||||
EmitJobsIfReadyByJobs(cancelledConcurrencyJobs)
|
||||
|
||||
CreateCommitStatusForRunJobs(ctx, plan.run, newJobs...)
|
||||
NotifyWorkflowJobsAndRunsStatusUpdate(ctx, newJobsToRerun)
|
||||
|
||||
// Post-commit kick for expanded callers: let job_emitter resolve its child jobs
|
||||
if hasWaitingCallerJobs {
|
||||
if err := EmitJobsIfReadyByRun(plan.run.ID); err != nil {
|
||||
log.Error("emit run %d after rerun: %v", plan.run.ID, err)
|
||||
}
|
||||
}
|
||||
|
||||
return newAttempt, nil
|
||||
}
|
||||
|
||||
// expandRerunJobIDs computes rerunAttemptJobIDs and ancestorAttemptJobIDs from the user-selected jobsToRerun.
|
||||
func (p *rerunPlan) expandRerunJobIDs(jobsToRerun []*actions_model.ActionRunJob) error {
|
||||
// Empty jobsToRerun: rerun the whole latest attempt
|
||||
if len(jobsToRerun) == 0 {
|
||||
all := make(container.Set[int64], len(p.templateJobs))
|
||||
for _, job := range p.templateJobs {
|
||||
all.Add(job.AttemptJobID)
|
||||
}
|
||||
p.rerunAttemptJobIDs = all
|
||||
p.ancestorAttemptJobIDs = make(container.Set[int64])
|
||||
return nil
|
||||
}
|
||||
|
||||
byID := make(map[int64]*actions_model.ActionRunJob, len(p.templateJobs))
|
||||
byAttemptJobID := make(map[int64]*actions_model.ActionRunJob, len(p.templateJobs))
|
||||
for _, job := range p.templateJobs {
|
||||
byID[job.ID] = job
|
||||
byAttemptJobID[job.AttemptJobID] = job
|
||||
}
|
||||
|
||||
for _, job := range jobsToRerun {
|
||||
if _, ok := byID[job.ID]; !ok {
|
||||
return util.NewInvalidArgumentErrorf("job %q does not exist in the latest attempt", job.JobID)
|
||||
}
|
||||
}
|
||||
|
||||
rerunSet := make(container.Set[int64])
|
||||
ancestorSet := make(container.Set[int64])
|
||||
queue := make([]*actions_model.ActionRunJob, 0, len(jobsToRerun))
|
||||
|
||||
for _, job := range jobsToRerun {
|
||||
j := byID[job.ID]
|
||||
rerunSet.Add(j.AttemptJobID)
|
||||
queue = append(queue, j)
|
||||
}
|
||||
|
||||
for len(queue) > 0 {
|
||||
cur := queue[0]
|
||||
queue = queue[1:]
|
||||
|
||||
// same-scope downstream: siblings whose Needs reference cur.JobID join the rerun set
|
||||
for _, candidate := range p.templateJobs {
|
||||
if candidate.ParentJobID != cur.ParentJobID {
|
||||
continue
|
||||
}
|
||||
if rerunSet.Contains(candidate.AttemptJobID) || ancestorSet.Contains(candidate.AttemptJobID) {
|
||||
continue
|
||||
}
|
||||
if !slices.Contains(candidate.Needs, cur.JobID) {
|
||||
continue
|
||||
}
|
||||
rerunSet.Add(candidate.AttemptJobID)
|
||||
queue = append(queue, candidate)
|
||||
}
|
||||
|
||||
// escalate to parent caller as an ancestor so its own siblings get checked next round
|
||||
if cur.ParentJobID == 0 {
|
||||
continue
|
||||
}
|
||||
parent, ok := byID[cur.ParentJobID]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if rerunSet.Contains(parent.AttemptJobID) || ancestorSet.Contains(parent.AttemptJobID) {
|
||||
continue
|
||||
}
|
||||
ancestorSet.Add(parent.AttemptJobID)
|
||||
queue = append(queue, parent)
|
||||
}
|
||||
|
||||
// remove entries whose parent-caller chain already has a rerunSet member
|
||||
for atID := range ancestorSet {
|
||||
cur := byAttemptJobID[atID]
|
||||
for cur.ParentJobID != 0 {
|
||||
parent, ok := byID[cur.ParentJobID]
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
if rerunSet.Contains(parent.AttemptJobID) {
|
||||
delete(ancestorSet, atID)
|
||||
break
|
||||
}
|
||||
cur = parent
|
||||
}
|
||||
}
|
||||
|
||||
p.rerunAttemptJobIDs = rerunSet
|
||||
p.ancestorAttemptJobIDs = ancestorSet
|
||||
return nil
|
||||
}
|
||||
|
||||
// hasRerunDependency reports whether `job` has a needs-reference that points to a job which is itself being rerun (in rerunAttemptJobIDs)
|
||||
// or is an ancestor caller whose subtree is being rerun (in ancestorAttemptJobIDs).
|
||||
// Either case means `job` should start in Blocked status.
|
||||
func (p *rerunPlan) hasRerunDependency(job *actions_model.ActionRunJob) bool {
|
||||
if len(job.Needs) == 0 {
|
||||
return false
|
||||
}
|
||||
needSet := container.SetOf(job.Needs...)
|
||||
for _, sibling := range p.templateJobs {
|
||||
if sibling.ParentJobID != job.ParentJobID {
|
||||
continue
|
||||
}
|
||||
if !needSet.Contains(sibling.JobID) {
|
||||
continue
|
||||
}
|
||||
if p.rerunAttemptJobIDs.Contains(sibling.AttemptJobID) || p.ancestorAttemptJobIDs.Contains(sibling.AttemptJobID) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// collectResetCallerDescendants walks p.templateJobs and returns the DB IDs of every transitive descendant of any reusable caller whose AttemptJobID is in p.rerunAttemptJobIDs.
|
||||
// These descendants must NOT be cloned by execRerunPlan: the reset caller will re-insert them with template-matched AttemptJobIDs.
|
||||
func (p *rerunPlan) collectResetCallerDescendants() container.Set[int64] {
|
||||
out := make(container.Set[int64])
|
||||
for _, tj := range p.templateJobs {
|
||||
if !tj.IsReusableCaller || !p.rerunAttemptJobIDs.Contains(tj.AttemptJobID) {
|
||||
continue
|
||||
}
|
||||
// If this caller's row ID is already in `out`, it means an outer caller has already covered its whole subtree.
|
||||
// Skip the redundant walk.
|
||||
if out.Contains(tj.ID) {
|
||||
continue
|
||||
}
|
||||
for _, child := range actions_model.CollectAllDescendantJobs(tj, p.templateJobs) {
|
||||
out.Add(child.ID)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func cloneRunJobForAttempt(templateJob *actions_model.ActionRunJob, attempt *actions_model.ActionRunAttempt) *actions_model.ActionRunJob {
|
||||
return &actions_model.ActionRunJob{
|
||||
RunID: templateJob.RunID,
|
||||
RunAttemptID: attempt.ID,
|
||||
RepoID: templateJob.RepoID,
|
||||
OwnerID: templateJob.OwnerID,
|
||||
CommitSHA: templateJob.CommitSHA,
|
||||
IsForkPullRequest: templateJob.IsForkPullRequest,
|
||||
Name: templateJob.Name,
|
||||
Attempt: attempt.Attempt,
|
||||
WorkflowPayload: slices.Clone(templateJob.WorkflowPayload),
|
||||
JobID: templateJob.JobID,
|
||||
AttemptJobID: templateJob.AttemptJobID,
|
||||
Needs: slices.Clone(templateJob.Needs),
|
||||
RunsOn: slices.Clone(templateJob.RunsOn),
|
||||
Status: templateJob.Status,
|
||||
RawConcurrency: templateJob.RawConcurrency,
|
||||
IsConcurrencyEvaluated: templateJob.IsConcurrencyEvaluated,
|
||||
ConcurrencyGroup: templateJob.ConcurrencyGroup,
|
||||
ConcurrencyCancel: templateJob.ConcurrencyCancel,
|
||||
TokenPermissions: templateJob.TokenPermissions,
|
||||
|
||||
// reusable workflow fields
|
||||
IsReusableCaller: templateJob.IsReusableCaller,
|
||||
CallUses: templateJob.CallUses,
|
||||
ReusableWorkflowContent: slices.Clone(templateJob.ReusableWorkflowContent),
|
||||
CallSecrets: templateJob.CallSecrets,
|
||||
CallPayload: templateJob.CallPayload,
|
||||
IsExpanded: templateJob.IsExpanded,
|
||||
ParentJobID: templateJob.ParentJobID, // remapped by execRerunPlan
|
||||
WorkflowSourceRepoID: templateJob.WorkflowSourceRepoID,
|
||||
WorkflowSourceCommitSHA: templateJob.WorkflowSourceCommitSHA,
|
||||
}
|
||||
}
|
||||
|
||||
// createOriginalAttemptForLegacyRun creates a real attempt=1 for a legacy run and updates the existing legacy jobs and artifacts in place
|
||||
// so the original execution becomes attempt-aware before the rerun plan is built and all subsequent logic can use real attempts.
|
||||
// Tasks are not modified: they reference jobs by JobID, so updating jobs implicitly carries the new attempt linkage.
|
||||
func createOriginalAttemptForLegacyRun(ctx context.Context, run *actions_model.ActionRun) error {
|
||||
return db.WithTx(ctx, func(ctx context.Context) error {
|
||||
jobs, err := actions_model.GetRunJobsByRunAndAttemptID(ctx, run.ID, 0)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load legacy run jobs: %w", err)
|
||||
}
|
||||
if len(jobs) == 0 {
|
||||
return fmt.Errorf("run %d has no jobs", run.ID)
|
||||
}
|
||||
|
||||
originalAttempt := &actions_model.ActionRunAttempt{
|
||||
RepoID: run.RepoID,
|
||||
RunID: run.ID,
|
||||
Attempt: 1,
|
||||
TriggerUserID: run.TriggerUserID,
|
||||
|
||||
// Legacy concurrency fields on ActionRun are intentionally NOT backfilled onto this original attempt.
|
||||
// They only matter while a run is actively being scheduled, and backfilling them for completed legacy runs
|
||||
// would add migration/runtime cost without changing any future concurrency behavior.
|
||||
|
||||
Status: run.Status,
|
||||
Created: run.Created,
|
||||
Started: run.Started,
|
||||
Stopped: run.Stopped,
|
||||
}
|
||||
|
||||
// Use NoAutoTime so xorm does not overwrite Created with the current time on insert.
|
||||
if _, err := db.GetEngine(ctx).NoAutoTime().Insert(originalAttempt); err != nil {
|
||||
if _, getErr := actions_model.GetRunAttemptByRunIDAndAttemptNum(ctx, run.ID, originalAttempt.Attempt); getErr == nil {
|
||||
return util.NewAlreadyExistErrorf("workflow run attempt %d for run %d already exists", originalAttempt.Attempt, run.ID)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// backfill attempt related fields for jobs
|
||||
for i, job := range jobs {
|
||||
job.RunAttemptID = originalAttempt.ID
|
||||
job.Attempt = originalAttempt.Attempt
|
||||
job.AttemptJobID = int64(i + 1)
|
||||
if _, err := db.GetEngine(ctx).ID(job.ID).Cols("run_attempt_id", "attempt", "attempt_job_id").Update(job); err != nil {
|
||||
return fmt.Errorf("backfill legacy run jobs: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// backfill "run_attempt_id" field for artifacts
|
||||
if _, err := db.GetEngine(ctx).
|
||||
Where("run_id=? AND run_attempt_id=0", run.ID).
|
||||
Cols("run_attempt_id").
|
||||
Update(&actions_model.ActionArtifact{RunAttemptID: originalAttempt.ID}); err != nil {
|
||||
return fmt.Errorf("backfill legacy artifacts: %w", err)
|
||||
}
|
||||
|
||||
// update "latest_attempt_id" for the run
|
||||
run.LatestAttemptID = originalAttempt.ID
|
||||
return actions_model.UpdateRun(ctx, run, "latest_attempt_id")
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package actions
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
actions_model "gitea.dev/models/actions"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/container"
|
||||
"gitea.dev/modules/util"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestGetFailedJobsForRerun(t *testing.T) {
|
||||
makeJob := func(id int64, jobID string, status actions_model.Status, needs ...string) *actions_model.ActionRunJob {
|
||||
return &actions_model.ActionRunJob{ID: id, JobID: jobID, Status: status, Needs: needs}
|
||||
}
|
||||
|
||||
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, GetFailedJobsForRerun(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 := GetFailedJobsForRerun(jobs)
|
||||
assert.ElementsMatch(t, []*actions_model.ActionRunJob{job1}, result)
|
||||
})
|
||||
|
||||
t.Run("failed job does not pull in downstream dependents", func(t *testing.T) {
|
||||
job1 := makeJob(1, "job1", actions_model.StatusFailure)
|
||||
job2 := makeJob(2, "job2", actions_model.StatusSkipped, "job1")
|
||||
job3 := makeJob(3, "job3", actions_model.StatusSkipped, "job2")
|
||||
job4 := makeJob(4, "job4", actions_model.StatusSuccess) // unrelated, must not appear
|
||||
jobs := []*actions_model.ActionRunJob{job1, job2, job3, job4}
|
||||
|
||||
result := GetFailedJobsForRerun(jobs)
|
||||
assert.ElementsMatch(t, []*actions_model.ActionRunJob{job1}, result)
|
||||
})
|
||||
|
||||
t.Run("multiple failed jobs are returned directly", func(t *testing.T) {
|
||||
job1 := makeJob(1, "job1", actions_model.StatusFailure)
|
||||
job2 := makeJob(2, "job2", actions_model.StatusFailure)
|
||||
job3 := makeJob(3, "job3", actions_model.StatusSkipped, "job1")
|
||||
job4 := makeJob(4, "job4", actions_model.StatusSkipped, "job2")
|
||||
jobs := []*actions_model.ActionRunJob{job1, job2, job3, job4}
|
||||
|
||||
result := GetFailedJobsForRerun(jobs)
|
||||
assert.ElementsMatch(t, []*actions_model.ActionRunJob{job1, job2}, result)
|
||||
})
|
||||
|
||||
t.Run("shared downstream dependent is not included", func(t *testing.T) {
|
||||
job1 := makeJob(1, "job1", actions_model.StatusFailure)
|
||||
job2 := makeJob(2, "job2", actions_model.StatusFailure)
|
||||
job3 := makeJob(3, "job3", actions_model.StatusSkipped, "job1", "job2")
|
||||
jobs := []*actions_model.ActionRunJob{job1, job2, job3}
|
||||
|
||||
result := GetFailedJobsForRerun(jobs)
|
||||
assert.ElementsMatch(t, []*actions_model.ActionRunJob{job1, job2}, result)
|
||||
assert.Len(t, result, 2)
|
||||
})
|
||||
|
||||
t.Run("successful downstream job of a failed job is not included", func(t *testing.T) {
|
||||
job1 := makeJob(1, "job1", actions_model.StatusFailure)
|
||||
job2 := makeJob(2, "job2", actions_model.StatusSuccess, "job1")
|
||||
jobs := []*actions_model.ActionRunJob{job1, job2}
|
||||
|
||||
result := GetFailedJobsForRerun(jobs)
|
||||
assert.ElementsMatch(t, []*actions_model.ActionRunJob{job1}, 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(t.Context(), nil, runningRun, &user_model.User{ID: 1}, 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(t.Context(), nil, runningRun, &user_model.User{ID: 1}, GetFailedJobsForRerun(jobs))
|
||||
require.Error(t, err)
|
||||
assert.ErrorIs(t, err, util.ErrInvalidArgument)
|
||||
})
|
||||
}
|
||||
|
||||
func TestRerunPlan(t *testing.T) {
|
||||
// "verify" appears in two scopes (inner caller under deploy, and top-level) so scope-blind matching would fail here.
|
||||
|
||||
// build id=101, attemptJobID=1
|
||||
// test id=102, attemptJobID=2, needs=[build]
|
||||
// deploy id=103, attemptJobID=3, caller
|
||||
// ├── validate id=104, attemptJobID=4, parent=103
|
||||
// ├── push id=105, attemptJobID=5, parent=103, needs=[validate]
|
||||
// ├── verify id=106, attemptJobID=6, parent=103, caller, needs=[push]
|
||||
// │ ├── smoke-test id=107, attemptJobID=7, parent=106
|
||||
// │ └── cleanup id=108, attemptJobID=8, parent=106, needs=[smoke-test]
|
||||
// └── finish-deploy id=109, attemptJobID=9, parent=103, needs=[verify]
|
||||
// verify id=110, attemptJobID=10, needs=[deploy] (top-level, same JobID)
|
||||
|
||||
buildJob := templateJob(101, 1, "build", 0, false)
|
||||
testJob := templateJob(102, 2, "test", 0, false, "build")
|
||||
deployJob := templateJob(103, 3, "deploy", 0, true)
|
||||
validateJob := templateJob(104, 4, "validate", 103, false)
|
||||
pushJob := templateJob(105, 5, "push", 103, false, "validate")
|
||||
verifyInnerJob := templateJob(106, 6, "verify", 103, true, "push")
|
||||
smokeTestJob := templateJob(107, 7, "smoke-test", 106, false)
|
||||
cleanupJob := templateJob(108, 8, "cleanup", 106, false, "smoke-test")
|
||||
finishDeployJob := templateJob(109, 9, "finish-deploy", 103, false, "verify")
|
||||
verifyTopJob := templateJob(110, 10, "verify", 0, false, "deploy")
|
||||
|
||||
jobs := []*actions_model.ActionRunJob{
|
||||
buildJob, testJob, deployJob, validateJob, pushJob,
|
||||
verifyInnerJob, smokeTestJob, cleanupJob,
|
||||
finishDeployJob, verifyTopJob,
|
||||
}
|
||||
|
||||
t.Run("ExpandRerunJobIDs", func(t *testing.T) {
|
||||
t.Run("empty jobsToRerun reruns every template job, no ancestors", func(t *testing.T) {
|
||||
plan := &rerunPlan{templateJobs: jobs}
|
||||
require.NoError(t, plan.expandRerunJobIDs(nil))
|
||||
|
||||
assert.ElementsMatch(t, attemptJobIDsOf(jobs...), plan.rerunAttemptJobIDs.Values())
|
||||
assert.Empty(t, plan.ancestorAttemptJobIDs)
|
||||
})
|
||||
|
||||
t.Run("same-scope downstream BFS pulls in dependents", func(t *testing.T) {
|
||||
// a -> b -> c (chain), d unrelated.
|
||||
a := templateJob(101, 1, "a", 0, false)
|
||||
b := templateJob(102, 2, "b", 0, false, "a")
|
||||
c := templateJob(103, 3, "c", 0, false, "b")
|
||||
d := templateJob(104, 4, "d", 0, false)
|
||||
plan := &rerunPlan{templateJobs: []*actions_model.ActionRunJob{a, b, c, d}}
|
||||
require.NoError(t, plan.expandRerunJobIDs([]*actions_model.ActionRunJob{a}))
|
||||
|
||||
assert.ElementsMatch(t, attemptJobIDsOf(a, b, c), plan.rerunAttemptJobIDs.Values())
|
||||
assert.Empty(t, plan.ancestorAttemptJobIDs)
|
||||
})
|
||||
|
||||
t.Run("rerun a deep child escalates across reusable scopes", func(t *testing.T) {
|
||||
plan := &rerunPlan{templateJobs: jobs}
|
||||
require.NoError(t, plan.expandRerunJobIDs([]*actions_model.ActionRunJob{smokeTestJob}))
|
||||
|
||||
// rerun: smoke-test (selected), cleanup (same-scope downstream),
|
||||
// finish-deploy (deploy-scope sibling of inner verify ancestor),
|
||||
// top-level verify (top-scope sibling of deploy ancestor).
|
||||
assert.ElementsMatch(t,
|
||||
attemptJobIDsOf(smokeTestJob, cleanupJob, finishDeployJob, verifyTopJob),
|
||||
plan.rerunAttemptJobIDs.Values())
|
||||
|
||||
// ancestors: inner verify and deploy
|
||||
assert.ElementsMatch(t, attemptJobIDsOf(verifyInnerJob, deployJob), plan.ancestorAttemptJobIDs.Values())
|
||||
})
|
||||
|
||||
t.Run("rerun a top-level caller resets only itself and same-scope dependents", func(t *testing.T) {
|
||||
plan := &rerunPlan{templateJobs: jobs}
|
||||
require.NoError(t, plan.expandRerunJobIDs([]*actions_model.ActionRunJob{deployJob}))
|
||||
|
||||
// rerun: deploy (selected) + top-level verify (needs:[deploy]).
|
||||
assert.ElementsMatch(t, attemptJobIDsOf(deployJob, verifyTopJob), plan.rerunAttemptJobIDs.Values())
|
||||
// deploy is top-level so no ancestors.
|
||||
assert.Empty(t, plan.ancestorAttemptJobIDs)
|
||||
})
|
||||
|
||||
t.Run("rerun a nested caller escalates one level", func(t *testing.T) {
|
||||
plan := &rerunPlan{templateJobs: jobs}
|
||||
require.NoError(t, plan.expandRerunJobIDs([]*actions_model.ActionRunJob{verifyInnerJob}))
|
||||
|
||||
// inner verify (selected) -> finish-deploy (deploy-scope dep) -> top-level verify (top-scope dep of deploy).
|
||||
assert.ElementsMatch(t,
|
||||
attemptJobIDsOf(verifyInnerJob, finishDeployJob, verifyTopJob),
|
||||
plan.rerunAttemptJobIDs.Values())
|
||||
// deploy is the only ancestor (one level up from inner verify).
|
||||
assert.ElementsMatch(t, attemptJobIDsOf(deployJob), plan.ancestorAttemptJobIDs.Values())
|
||||
})
|
||||
|
||||
t.Run("selecting one same-name job leaves the other-scope same-name job alone", func(t *testing.T) {
|
||||
// Selecting the top-level "verify" must not pull in the same-named inner one or its descendants.
|
||||
plan := &rerunPlan{templateJobs: jobs}
|
||||
require.NoError(t, plan.expandRerunJobIDs([]*actions_model.ActionRunJob{verifyTopJob}))
|
||||
|
||||
// Only the top-level verify is rerun.
|
||||
assert.ElementsMatch(t, attemptJobIDsOf(verifyTopJob), plan.rerunAttemptJobIDs.Values())
|
||||
assert.Empty(t, plan.ancestorAttemptJobIDs)
|
||||
})
|
||||
|
||||
t.Run("a caller is rerun when a sibling it needs is selected", func(t *testing.T) {
|
||||
plan := &rerunPlan{templateJobs: jobs}
|
||||
require.NoError(t, plan.expandRerunJobIDs([]*actions_model.ActionRunJob{pushJob}))
|
||||
|
||||
assert.ElementsMatch(t,
|
||||
attemptJobIDsOf(pushJob, verifyInnerJob, finishDeployJob, verifyTopJob),
|
||||
plan.rerunAttemptJobIDs.Values())
|
||||
assert.ElementsMatch(t, attemptJobIDsOf(deployJob), plan.ancestorAttemptJobIDs.Values())
|
||||
|
||||
// Confirm the downstream effect: verify(inner) is a reset caller, so its children's DB row IDs are marked for skip-clone.
|
||||
assert.ElementsMatch(t, rowIDsOf(smokeTestJob, cleanupJob), plan.collectResetCallerDescendants().Values())
|
||||
})
|
||||
|
||||
t.Run("multiple selections converge", func(t *testing.T) {
|
||||
plan := &rerunPlan{templateJobs: jobs}
|
||||
require.NoError(t, plan.expandRerunJobIDs([]*actions_model.ActionRunJob{deployJob, smokeTestJob}))
|
||||
|
||||
assert.ElementsMatch(t, attemptJobIDsOf(deployJob, smokeTestJob, cleanupJob, finishDeployJob, verifyTopJob), plan.rerunAttemptJobIDs.Values())
|
||||
assert.Empty(t, plan.ancestorAttemptJobIDs)
|
||||
assert.ElementsMatch(t,
|
||||
rowIDsOf(validateJob, pushJob, verifyInnerJob, smokeTestJob, cleanupJob, finishDeployJob),
|
||||
plan.collectResetCallerDescendants().Values())
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("CollectResetCallerDescendants", func(t *testing.T) {
|
||||
planWith := func(rerunJobs ...*actions_model.ActionRunJob) *rerunPlan {
|
||||
set := make(container.Set[int64])
|
||||
for _, j := range rerunJobs {
|
||||
set.Add(j.AttemptJobID)
|
||||
}
|
||||
return &rerunPlan{templateJobs: jobs, rerunAttemptJobIDs: set}
|
||||
}
|
||||
|
||||
t.Run("non-caller in reset set is ignored", func(t *testing.T) {
|
||||
assert.Empty(t, planWith(smokeTestJob).collectResetCallerDescendants())
|
||||
})
|
||||
|
||||
t.Run("caller in reset set returns transitive descendants", func(t *testing.T) {
|
||||
out := planWith(deployJob).collectResetCallerDescendants()
|
||||
assert.ElementsMatch(t,
|
||||
rowIDsOf(validateJob, pushJob, verifyInnerJob, smokeTestJob, cleanupJob, finishDeployJob),
|
||||
out.Values())
|
||||
})
|
||||
|
||||
t.Run("multiple reset callers union their descendants", func(t *testing.T) {
|
||||
out := planWith(deployJob, verifyInnerJob).collectResetCallerDescendants()
|
||||
assert.ElementsMatch(t,
|
||||
rowIDsOf(validateJob, pushJob, verifyInnerJob, smokeTestJob, cleanupJob, finishDeployJob),
|
||||
out.Values())
|
||||
})
|
||||
|
||||
t.Run("nested-only reset returns just the nested subtree", func(t *testing.T) {
|
||||
out := planWith(verifyInnerJob).collectResetCallerDescendants()
|
||||
assert.ElementsMatch(t, rowIDsOf(smokeTestJob, cleanupJob), out.Values())
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("HasRerunDependency", func(t *testing.T) {
|
||||
t.Run("no needs returns false", func(t *testing.T) {
|
||||
plan := &rerunPlan{
|
||||
templateJobs: []*actions_model.ActionRunJob{buildJob},
|
||||
rerunAttemptJobIDs: make(container.Set[int64]),
|
||||
ancestorAttemptJobIDs: make(container.Set[int64]),
|
||||
}
|
||||
assert.False(t, plan.hasRerunDependency(buildJob))
|
||||
})
|
||||
|
||||
t.Run("dependency in rerun set returns true", func(t *testing.T) {
|
||||
plan := &rerunPlan{
|
||||
templateJobs: jobs,
|
||||
rerunAttemptJobIDs: container.SetOf(smokeTestJob.AttemptJobID),
|
||||
ancestorAttemptJobIDs: make(container.Set[int64]),
|
||||
}
|
||||
// cleanup `needs: [smoke-test]`, both in inner verify scope.
|
||||
assert.True(t, plan.hasRerunDependency(cleanupJob))
|
||||
})
|
||||
|
||||
t.Run("dependency in ancestor set returns true", func(t *testing.T) {
|
||||
plan := &rerunPlan{
|
||||
templateJobs: jobs,
|
||||
rerunAttemptJobIDs: container.SetOf(attemptJobIDsOf(smokeTestJob, cleanupJob)...),
|
||||
ancestorAttemptJobIDs: container.SetOf(verifyInnerJob.AttemptJobID),
|
||||
}
|
||||
assert.True(t, plan.hasRerunDependency(finishDeployJob))
|
||||
})
|
||||
|
||||
t.Run("dependency on unrelated sibling returns false", func(t *testing.T) {
|
||||
plan := &rerunPlan{
|
||||
templateJobs: jobs,
|
||||
rerunAttemptJobIDs: container.SetOf(smokeTestJob.AttemptJobID),
|
||||
ancestorAttemptJobIDs: make(container.Set[int64]),
|
||||
}
|
||||
assert.False(t, plan.hasRerunDependency(pushJob))
|
||||
})
|
||||
|
||||
t.Run("scope-bound: same JobID in another scope does not match", func(t *testing.T) {
|
||||
plan := &rerunPlan{
|
||||
templateJobs: jobs,
|
||||
rerunAttemptJobIDs: container.SetOf(verifyTopJob.AttemptJobID),
|
||||
ancestorAttemptJobIDs: make(container.Set[int64]),
|
||||
}
|
||||
assert.False(t, plan.hasRerunDependency(finishDeployJob))
|
||||
|
||||
// Sanity: swap to the inner verify and the same target now sees it.
|
||||
plan.rerunAttemptJobIDs = container.SetOf(verifyInnerJob.AttemptJobID)
|
||||
assert.True(t, plan.hasRerunDependency(finishDeployJob))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// templateJob is a small constructor for fixture jobs used by the rerunPlan unit tests.
|
||||
func templateJob(id, attemptJobID int64, jobID string, parentID int64, isCaller bool, needs ...string) *actions_model.ActionRunJob {
|
||||
return &actions_model.ActionRunJob{
|
||||
ID: id,
|
||||
AttemptJobID: attemptJobID,
|
||||
JobID: jobID,
|
||||
ParentJobID: parentID,
|
||||
IsReusableCaller: isCaller,
|
||||
Needs: needs,
|
||||
}
|
||||
}
|
||||
|
||||
func attemptJobIDsOf(jobs ...*actions_model.ActionRunJob) []int64 {
|
||||
out := make([]int64, len(jobs))
|
||||
for i, j := range jobs {
|
||||
out[i] = j.AttemptJobID
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func rowIDsOf(jobs ...*actions_model.ActionRunJob) []int64 {
|
||||
out := make([]int64, len(jobs))
|
||||
for i, j := range jobs {
|
||||
out[i] = j.ID
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package actions
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
actions_model "gitea.dev/models/actions"
|
||||
"gitea.dev/models/db"
|
||||
perm_model "gitea.dev/models/perm"
|
||||
access_model "gitea.dev/models/perm/access"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/modules/actions/jobparser"
|
||||
"gitea.dev/modules/container"
|
||||
"gitea.dev/modules/gitrepo"
|
||||
"gitea.dev/modules/json"
|
||||
api "gitea.dev/modules/structs"
|
||||
"gitea.dev/modules/util"
|
||||
"gitea.dev/services/convert"
|
||||
|
||||
"xorm.io/builder"
|
||||
)
|
||||
|
||||
// MaxReusableCallLevels caps how deep a reusable workflow can nest:
|
||||
// a top-level caller may have at most MaxReusableCallLevels nested callers below it.
|
||||
const MaxReusableCallLevels = 9
|
||||
|
||||
// loadReusableWorkflowSource resolves the workflow file referenced by a caller's `uses:` and returns its raw bytes,
|
||||
// along with the (repo_id, commit_sha) the file was loaded from.
|
||||
func loadReusableWorkflowSource(ctx context.Context, run *actions_model.ActionRun, caller *actions_model.ActionRunJob, ref *jobparser.UsesRef) (content []byte, sourceRepoID int64, sourceCommitSHA string, err error) {
|
||||
if err := run.LoadAttributes(ctx); err != nil {
|
||||
return nil, 0, "", err
|
||||
}
|
||||
|
||||
switch ref.Kind {
|
||||
case jobparser.UsesKindLocalSameRepo:
|
||||
// `./` is resolved against the workflow file containing the `uses:` - i.e. the caller's own source repo + commit.
|
||||
callerRepo, err := repo_model.GetRepositoryByID(ctx, caller.WorkflowSourceRepoID)
|
||||
if err != nil {
|
||||
return nil, 0, "", fmt.Errorf("look up caller source repo %d: %w", caller.WorkflowSourceRepoID, err)
|
||||
}
|
||||
bytes, resolvedSHA, err := readWorkflowFromRepo(ctx, callerRepo, caller.WorkflowSourceCommitSHA, ref.Path)
|
||||
if err != nil {
|
||||
return nil, 0, "", err
|
||||
}
|
||||
return bytes, callerRepo.ID, resolvedSHA, nil
|
||||
|
||||
case jobparser.UsesKindLocalCrossRepo:
|
||||
repo, err := repo_model.GetRepositoryByOwnerAndName(ctx, ref.Owner, ref.Repo)
|
||||
if err != nil {
|
||||
return nil, 0, "", fmt.Errorf("look up cross-repo workflow source %q: %w", ref.Owner+"/"+ref.Repo, err)
|
||||
}
|
||||
ok, err := access_model.CanReadWorkflowCrossRepo(ctx, repo, run)
|
||||
if err != nil {
|
||||
return nil, 0, "", err
|
||||
}
|
||||
if !ok {
|
||||
return nil, 0, "", fmt.Errorf("no permission to read reusable workflow from %s/%s", ref.Owner, ref.Repo)
|
||||
}
|
||||
bytes, resolvedSHA, err := readWorkflowFromRepo(ctx, repo, ref.Ref, ref.Path)
|
||||
if err != nil {
|
||||
return nil, 0, "", err
|
||||
}
|
||||
return bytes, repo.ID, resolvedSHA, nil
|
||||
}
|
||||
return nil, 0, "", fmt.Errorf("unsupported uses kind %d", ref.Kind)
|
||||
}
|
||||
|
||||
// readWorkflowFromRepo loads a workflow file from `repo` at `refOrSHA` and returns its content plus the resolved commit SHA.
|
||||
func readWorkflowFromRepo(ctx context.Context, repo *repo_model.Repository, refOrSHA, path string) ([]byte, string, error) {
|
||||
gitRepo, err := gitrepo.OpenRepository(ctx, repo)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("open repo %s: %w", repo.FullName(), err)
|
||||
}
|
||||
defer gitRepo.Close()
|
||||
|
||||
commit, err := gitRepo.GetCommit(refOrSHA)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("get commit %q in %s: %w", refOrSHA, repo.FullName(), err)
|
||||
}
|
||||
str, err := commit.GetFileContent(path, 1024*1024)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("read %s@%s:%s: %w", repo.FullName(), refOrSHA, path, err)
|
||||
}
|
||||
return []byte(str), commit.ID.String(), nil
|
||||
}
|
||||
|
||||
// checkCallerChain walks `caller`'s ancestor chain (via ParentJobID) and:
|
||||
// - rejects cycles (caller.CallUses appearing in any ancestor's CallUses)
|
||||
// - enforces MaxReusableCallLevels on the number of ancestors above `caller`
|
||||
//
|
||||
// Cycle detection is intentionally *syntactic* (string equality on CallUses), not semantic.
|
||||
// So `owner/repo/lib.yml@v1` and `owner/repo/lib.yml@refs/heads/v1` resolving to the same commit are NOT treated as the same node.
|
||||
// Going semantic (Owner, Repo, Path, ResolvedSHA tuples) would require extra git reads.
|
||||
func checkCallerChain(ctx context.Context, caller *actions_model.ActionRunJob) error {
|
||||
if caller.ParentJobID == 0 {
|
||||
return nil // top-level caller: depth 0, no ancestors to walk
|
||||
}
|
||||
|
||||
visited := make(container.Set[string])
|
||||
visited.Add(caller.CallUses)
|
||||
|
||||
depth := 0
|
||||
current := caller
|
||||
for current.ParentJobID != 0 {
|
||||
next, err := actions_model.GetRunJobByRunAndID(ctx, current.RunID, current.ParentJobID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("walk caller chain: %w", err)
|
||||
}
|
||||
current = next
|
||||
depth++
|
||||
if depth > MaxReusableCallLevels {
|
||||
return fmt.Errorf("reusable workflow call exceeds the maximum nesting level of %d at %q", MaxReusableCallLevels, caller.CallUses)
|
||||
}
|
||||
if current.IsReusableCaller && current.CallUses != "" {
|
||||
if visited.Contains(current.CallUses) {
|
||||
return fmt.Errorf("reusable workflow call cycle detected: %q", current.CallUses)
|
||||
}
|
||||
visited.Add(current.CallUses)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// expandReusableWorkflowCaller loads and parses the target reusable workflow and inserts the caller's direct child jobs.
|
||||
// It expands only ONE level: a child that is itself a reusable caller is inserted Blocked and expanded later by a subsequent resolver pass.
|
||||
// It does NOT schedule a follow-up resolver pass; the caller of this function is responsible for emitting.
|
||||
//
|
||||
// All call sites (PrepareRunAndInsert, execRerunPlan, checkJobsOfCurrentRunAttempt, ApproveRuns) invoke this inside their enclosing write transaction,
|
||||
// because the caller row update and the child-row inserts must commit atomically.
|
||||
// Be aware this is not cheap inside a tx: it does a git read, YAML parsing, and `${{ }}` expression evaluation.
|
||||
// None of the call sites is hot: each caller is expanded once per attempt.
|
||||
func expandReusableWorkflowCaller(ctx context.Context, run *actions_model.ActionRun, attempt *actions_model.ActionRunAttempt, caller *actions_model.ActionRunJob, vars map[string]string) error {
|
||||
// Already expanded by an earlier call, skip
|
||||
if caller.IsExpanded {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 1. Cycle + depth check via the ParentJobID chain.
|
||||
if err := checkCallerChain(ctx, caller); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 2. Parse the caller's own job (Uses, With, RawSecrets) from its WorkflowPayload.
|
||||
parsedJob, err := caller.ParseJob()
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse caller job %d: %w", caller.ID, err)
|
||||
}
|
||||
|
||||
// 3. Load called-workflow source.
|
||||
ref, err := jobparser.ParseUses(parsedJob.Uses)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse uses %q: %w", parsedJob.Uses, err)
|
||||
}
|
||||
content, contentSourceRepoID, contentSourceCommitSHA, err := loadReusableWorkflowSource(ctx, run, caller, ref)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 4. Parse the called workflow's spec (used by both secret validation and input evaluation).
|
||||
wcSpec, err := jobparser.ParseWorkflowCallSpec(content)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse called workflow spec: %w", err)
|
||||
}
|
||||
|
||||
// 5. Resolve caller's `secrets:` and validate it against the callee's schema.
|
||||
inherit, secretsMap, err := jobparser.ParseCallerSecrets(parsedJob.RawSecrets)
|
||||
if err != nil {
|
||||
return fmt.Errorf("caller secrets %q: %w", caller.JobID, err)
|
||||
}
|
||||
// Under `secrets: inherit` the caller forwards all of its own secrets verbatim and does NOT name them individually,
|
||||
// so required-secret presence cannot be verified at expansion time and a missing required secret will surface at job runtime.
|
||||
// This matches GitHub Actions' behavior.
|
||||
if !inherit {
|
||||
if err := jobparser.ValidateCallerSecrets(wcSpec, secretsMap); err != nil {
|
||||
return fmt.Errorf("caller %q secrets: %w", caller.JobID, err)
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case inherit:
|
||||
caller.CallSecrets = jobparser.SecretsInherit
|
||||
case len(secretsMap) > 0:
|
||||
mapBytes, err := json.Marshal(secretsMap)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal caller secret map: %w", err)
|
||||
}
|
||||
caller.CallSecrets = string(mapBytes)
|
||||
}
|
||||
caller.ReusableWorkflowContent = content
|
||||
|
||||
// 6. Evaluate caller's `with:`, then match against the callee schema.
|
||||
workflowCallInputs := map[string]any{}
|
||||
if len(wcSpec.Inputs) > 0 {
|
||||
jobResults, err := findJobNeedsAndFillJobResults(ctx, caller)
|
||||
if err != nil {
|
||||
return fmt.Errorf("find caller needs: %w", err)
|
||||
}
|
||||
parentInputs, err := getInputsForJob(ctx, run, caller)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
callerGitCtx := GenerateGiteaContext(ctx, run, attempt, caller)
|
||||
evaluated, err := jobparser.EvaluateCallerWith(
|
||||
caller.JobID, parsedJob,
|
||||
callerGitCtx, jobResults, vars, parentInputs,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("evaluate caller with: %w", err)
|
||||
}
|
||||
workflowCallInputs, err = jobparser.MatchCallerInputsAgainstSpec(wcSpec, evaluated)
|
||||
if err != nil {
|
||||
return fmt.Errorf("caller %q inputs: %w", caller.JobID, err)
|
||||
}
|
||||
}
|
||||
|
||||
// 7. Build CallPayload (persisted in step 9).
|
||||
callPayload, err := (&api.WorkflowCallPayload{
|
||||
Workflow: run.WorkflowID,
|
||||
Ref: run.Ref,
|
||||
Repository: convert.ToRepo(ctx, run.Repo, access_model.Permission{AccessMode: perm_model.AccessModeNone}),
|
||||
Sender: convert.ToUserWithAccessMode(ctx, run.TriggerUser, perm_model.AccessModeNone),
|
||||
Inputs: workflowCallInputs,
|
||||
}).JSONPayload()
|
||||
if err != nil {
|
||||
return fmt.Errorf("build call payload: %w", err)
|
||||
}
|
||||
|
||||
// 8. Insert direct children of this caller.
|
||||
existingChildren, err := actions_model.GetDirectChildJobsByParent(ctx, caller)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get existing children of caller %d: %w", caller.ID, err)
|
||||
}
|
||||
if len(existingChildren) > 0 {
|
||||
// Should not happen - child jobs cannot be expanded before the caller gets ready
|
||||
return fmt.Errorf("invariant violation: caller %d has %d pre-existing children", caller.ID, len(existingChildren))
|
||||
}
|
||||
if err := insertCallerChildren(ctx, run, attempt, caller, content, contentSourceRepoID, contentSourceCommitSHA, vars, workflowCallInputs); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 9. Update caller-related cols.
|
||||
caller.CallPayload = string(callPayload)
|
||||
caller.IsExpanded = true
|
||||
n, err := actions_model.UpdateRunJob(ctx, caller,
|
||||
builder.Eq{"is_expanded": false},
|
||||
"call_secrets", "reusable_workflow_content", "call_payload", "is_expanded")
|
||||
if err != nil {
|
||||
return fmt.Errorf("commit caller %d expansion: %w", caller.ID, err)
|
||||
}
|
||||
if n == 0 {
|
||||
return fmt.Errorf("caller %d already expanded by another writer", caller.ID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// insertCallerChildren parses the called workflow with the caller's resolved inputs and inserts each parsed job.
|
||||
func insertCallerChildren(ctx context.Context, run *actions_model.ActionRun, attempt *actions_model.ActionRunAttempt, caller *actions_model.ActionRunJob, content []byte, sourceRepoID int64, sourceCommitSHA string, vars map[string]string, inputs map[string]any) error {
|
||||
// Parse the called workflow with the caller's `inputs`
|
||||
gitCtx := GenerateGiteaContext(ctx, run, attempt, nil)
|
||||
if event, ok := gitCtx["event"].(map[string]any); ok {
|
||||
event["inputs"] = inputs
|
||||
}
|
||||
gitCtx["event_name"] = "workflow_call"
|
||||
|
||||
childWorkflows, err := jobparser.Parse(content,
|
||||
jobparser.WithVars(vars),
|
||||
jobparser.WithGitContext(gitCtx.ToGitHubContext()),
|
||||
jobparser.WithInputs(inputs),
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse called workflow for caller %d: %w", caller.ID, err)
|
||||
}
|
||||
if len(childWorkflows) == 0 {
|
||||
return fmt.Errorf("called workflow for caller %d (uses %q) has no jobs", caller.ID, caller.CallUses)
|
||||
}
|
||||
|
||||
priorChildren, err := actions_model.GetPriorAttemptChildrenByParent(ctx, run.ID, attempt.ID, caller.AttemptJobID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("lookup prior-attempt children of caller %d: %w", caller.ID, err)
|
||||
}
|
||||
|
||||
for _, sw := range childWorkflows {
|
||||
jobID, parsedChild := sw.Job()
|
||||
if parsedChild == nil {
|
||||
continue
|
||||
}
|
||||
needs := parsedChild.Needs()
|
||||
if err := sw.SetJob(jobID, parsedChild.EraseNeeds()); err != nil {
|
||||
return err
|
||||
}
|
||||
payload, err := sw.Marshal()
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal child %q under caller %d: %w", jobID, caller.ID, err)
|
||||
}
|
||||
|
||||
parsedChild.Name = util.EllipsisDisplayString(parsedChild.Name, 255)
|
||||
|
||||
// AttemptJobID: prefer a prior-attempt match by (JobID, Name) and fall back to a fresh allocator value for newly-appearing logical jobs.
|
||||
// The two-level key disambiguates matrix instances (same JobID, different Names) and distinct jobs that legally share the same Name (different JobIDs).
|
||||
var attemptJobID int64
|
||||
if priorChild, ok := priorChildren[jobID][parsedChild.Name]; ok {
|
||||
attemptJobID = priorChild.AttemptJobID
|
||||
} else {
|
||||
attemptJobID, err = actions_model.GetNextAttemptJobID(ctx, run.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("alloc attempt_job_id for child %q: %w", jobID, err)
|
||||
}
|
||||
}
|
||||
child := &actions_model.ActionRunJob{
|
||||
RunID: run.ID,
|
||||
RunAttemptID: attempt.ID,
|
||||
RepoID: run.RepoID,
|
||||
OwnerID: run.OwnerID,
|
||||
CommitSHA: run.CommitSHA,
|
||||
IsForkPullRequest: run.IsForkPullRequest,
|
||||
Name: parsedChild.Name,
|
||||
Attempt: attempt.Attempt,
|
||||
WorkflowPayload: payload,
|
||||
JobID: jobID,
|
||||
AttemptJobID: attemptJobID,
|
||||
Needs: needs,
|
||||
RunsOn: parsedChild.RunsOn(),
|
||||
Status: actions_model.StatusBlocked,
|
||||
ParentJobID: caller.ID,
|
||||
WorkflowSourceRepoID: sourceRepoID,
|
||||
WorkflowSourceCommitSHA: sourceCommitSHA,
|
||||
}
|
||||
if perms := ExtractJobPermissionsFromWorkflow(sw, parsedChild); perms != nil {
|
||||
child.TokenPermissions = perms
|
||||
}
|
||||
if parsedChild.Uses != "" {
|
||||
child.IsReusableCaller = true
|
||||
child.CallUses = parsedChild.Uses
|
||||
}
|
||||
if err := db.Insert(ctx, child); err != nil {
|
||||
return fmt.Errorf("insert child %q under caller %d: %w", jobID, caller.ID, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package actions
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
actions_model "gitea.dev/models/actions"
|
||||
"gitea.dev/models/db"
|
||||
"gitea.dev/models/unittest"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestCheckCallerChain_Cycle(t *testing.T) {
|
||||
t.Run("DirectCycle", func(t *testing.T) {
|
||||
require.NoError(t, unittest.PrepareTestDatabase())
|
||||
// A -> A: leaf's CallUses matches its direct parent's.
|
||||
chain := buildCallerChain(t,
|
||||
"./.gitea/workflows/a.yml",
|
||||
"./.gitea/workflows/a.yml",
|
||||
)
|
||||
err := checkCallerChain(t.Context(), chain[len(chain)-1])
|
||||
assert.ErrorContains(t, err, "cycle detected")
|
||||
})
|
||||
|
||||
t.Run("IndirectCycle", func(t *testing.T) {
|
||||
require.NoError(t, unittest.PrepareTestDatabase())
|
||||
// A -> B -> A: leaf's CallUses matches its grandparent's.
|
||||
chain := buildCallerChain(t,
|
||||
"./.gitea/workflows/a.yml",
|
||||
"./.gitea/workflows/b.yml",
|
||||
"./.gitea/workflows/a.yml",
|
||||
)
|
||||
err := checkCallerChain(t.Context(), chain[len(chain)-1])
|
||||
assert.ErrorContains(t, err, "cycle detected")
|
||||
})
|
||||
|
||||
t.Run("NoCycle", func(t *testing.T) {
|
||||
require.NoError(t, unittest.PrepareTestDatabase())
|
||||
// Sanity: linear chain with distinct CallUses must not trip cycle detection.
|
||||
chain := buildCallerChain(t,
|
||||
"./.gitea/workflows/a.yml",
|
||||
"./.gitea/workflows/b.yml",
|
||||
"./.gitea/workflows/c.yml",
|
||||
)
|
||||
require.NoError(t, checkCallerChain(t.Context(), chain[len(chain)-1]))
|
||||
})
|
||||
}
|
||||
|
||||
func TestCheckCallerChain_DepthLimit(t *testing.T) {
|
||||
// top + MaxReusableCallLevels nested callers is the longest accepted; one more exceeds the limit.
|
||||
makeDistinctUses := func(n int) []string {
|
||||
out := make([]string, n)
|
||||
for i := range out {
|
||||
out[i] = fmt.Sprintf("./.gitea/workflows/level%d.yml", i)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
t.Run("ExactlyAtLimit", func(t *testing.T) {
|
||||
require.NoError(t, unittest.PrepareTestDatabase())
|
||||
chain := buildCallerChain(t, makeDistinctUses(MaxReusableCallLevels+1)...)
|
||||
require.NoError(t, checkCallerChain(t.Context(), chain[len(chain)-1]))
|
||||
})
|
||||
|
||||
t.Run("OneOverLimit", func(t *testing.T) {
|
||||
require.NoError(t, unittest.PrepareTestDatabase())
|
||||
chain := buildCallerChain(t, makeDistinctUses(MaxReusableCallLevels+2)...)
|
||||
err := checkCallerChain(t.Context(), chain[len(chain)-1])
|
||||
assert.ErrorContains(t, err, "exceeds the maximum nesting level")
|
||||
})
|
||||
}
|
||||
|
||||
// buildCallerChain inserts a linear chain of reusable caller jobs in a single run+attempt.
|
||||
// callerUses[0] is the top-level caller (ParentJobID=0); each subsequent caller is inserted as a child of the previous one.
|
||||
// Returns the inserted jobs in order (index 0 = top, last = leaf).
|
||||
func buildCallerChain(t *testing.T, callerUses ...string) []*actions_model.ActionRunJob {
|
||||
t.Helper()
|
||||
require.NotEmpty(t, callerUses)
|
||||
ctx := t.Context()
|
||||
|
||||
run := &actions_model.ActionRun{
|
||||
Title: "caller-chain-test",
|
||||
RepoID: 4,
|
||||
OwnerID: 1,
|
||||
Index: 9601,
|
||||
WorkflowID: "test.yaml",
|
||||
TriggerUserID: 1,
|
||||
Ref: "refs/heads/master",
|
||||
CommitSHA: "c2d72f548424103f01ee1dc02889c1e2bff816b0",
|
||||
Event: "push",
|
||||
TriggerEvent: "push",
|
||||
EventPayload: "{}",
|
||||
Status: actions_model.StatusRunning,
|
||||
}
|
||||
require.NoError(t, db.Insert(ctx, run))
|
||||
|
||||
attempt := &actions_model.ActionRunAttempt{
|
||||
RepoID: run.RepoID,
|
||||
RunID: run.ID,
|
||||
Attempt: 1,
|
||||
TriggerUserID: 1,
|
||||
Status: actions_model.StatusRunning,
|
||||
}
|
||||
require.NoError(t, db.Insert(ctx, attempt))
|
||||
|
||||
jobs := make([]*actions_model.ActionRunJob, 0, len(callerUses))
|
||||
parentID := int64(0)
|
||||
for i, uses := range callerUses {
|
||||
job := &actions_model.ActionRunJob{
|
||||
RunID: run.ID,
|
||||
RunAttemptID: attempt.ID,
|
||||
RepoID: run.RepoID,
|
||||
OwnerID: run.OwnerID,
|
||||
CommitSHA: run.CommitSHA,
|
||||
Name: fmt.Sprintf("caller-%d", i),
|
||||
JobID: fmt.Sprintf("caller-%d", i),
|
||||
Attempt: 1,
|
||||
Status: actions_model.StatusBlocked,
|
||||
AttemptJobID: int64(i + 1),
|
||||
IsReusableCaller: true,
|
||||
CallUses: uses,
|
||||
ParentJobID: parentID,
|
||||
}
|
||||
require.NoError(t, db.Insert(ctx, job))
|
||||
jobs = append(jobs, job)
|
||||
parentID = job.ID
|
||||
}
|
||||
return jobs
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
// Copyright 2025 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package actions
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
actions_model "gitea.dev/models/actions"
|
||||
"gitea.dev/models/db"
|
||||
"gitea.dev/modules/actions/jobparser"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/util"
|
||||
|
||||
act_model "gitea.com/gitea/runner/act/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...)
|
||||
|
||||
NotifyWorkflowJobsAndRunsStatusUpdate(ctx, allJobs)
|
||||
|
||||
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 {
|
||||
var cancelledConcurrencyJobs []*actions_model.ActionRunJob
|
||||
var hasWaitingCallerJobs bool
|
||||
if err := db.WithTx(ctx, func(ctx context.Context) error {
|
||||
index, err := db.GetNextResourceIndex(ctx, "action_run_index", run.RepoID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
run.Index = index
|
||||
run.Title = util.EllipsisDisplayString(run.Title, 255)
|
||||
run.Status = actions_model.StatusWaiting
|
||||
|
||||
if wfRawConcurrency != nil {
|
||||
rawConcurrency, err := yaml.Marshal(wfRawConcurrency)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal raw concurrency: %w", err)
|
||||
}
|
||||
run.RawConcurrency = string(rawConcurrency)
|
||||
}
|
||||
|
||||
// Insert before parsing jobs or evaluating workflow-level concurrency
|
||||
// so that run.ID is populated. Expressions referencing github.run_id —
|
||||
// in run-name, job names, runs-on, or a workflow-level concurrency
|
||||
// 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
|
||||
}
|
||||
|
||||
runAttempt := &actions_model.ActionRunAttempt{
|
||||
RepoID: run.RepoID,
|
||||
RunID: run.ID,
|
||||
Attempt: 1,
|
||||
TriggerUserID: run.TriggerUserID,
|
||||
Status: actions_model.StatusWaiting,
|
||||
}
|
||||
|
||||
if wfRawConcurrency != nil {
|
||||
if err := EvaluateRunConcurrencyFillModel(ctx, run, runAttempt, wfRawConcurrency, vars, inputs); err != nil {
|
||||
return fmt.Errorf("EvaluateRunConcurrencyFillModel: %w", err)
|
||||
}
|
||||
// check run (workflow-level) concurrency
|
||||
var jobsToCancel []*actions_model.ActionRunJob
|
||||
runAttempt.Status, jobsToCancel, err = PrepareToStartRunWithConcurrency(ctx, runAttempt)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cancelledConcurrencyJobs = append(cancelledConcurrencyJobs, jobsToCancel...)
|
||||
}
|
||||
|
||||
if err := db.Insert(ctx, runAttempt); err != nil {
|
||||
return err
|
||||
}
|
||||
run.LatestAttemptID = runAttempt.ID
|
||||
|
||||
giteaCtx := GenerateGiteaContext(ctx, run, runAttempt, nil)
|
||||
jobs, err := jobparser.Parse(content, jobparser.WithVars(vars), jobparser.WithGitContext(giteaCtx.ToGitHubContext()), jobparser.WithInputs(inputs))
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse workflow: %w", err)
|
||||
}
|
||||
titleChanged := len(jobs) > 0 && jobs[0].RunName != ""
|
||||
if titleChanged {
|
||||
run.Title = util.EllipsisDisplayString(jobs[0].RunName, 255)
|
||||
}
|
||||
|
||||
cols := []string{"latest_attempt_id"}
|
||||
if titleChanged {
|
||||
cols = append(cols, "title")
|
||||
}
|
||||
if err := actions_model.UpdateRun(ctx, run, cols...); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
runJobs := make([]*actions_model.ActionRunJob, 0, len(jobs))
|
||||
var hasWaitingJobs bool
|
||||
|
||||
for _, v := range jobs {
|
||||
id, job := v.Job()
|
||||
needs := job.Needs()
|
||||
if err := v.SetJob(id, job.EraseNeeds()); err != nil {
|
||||
return err
|
||||
}
|
||||
payload, _ := v.Marshal()
|
||||
|
||||
isReusableWorkflowCaller := job.Uses != ""
|
||||
shouldBlockJob := runAttempt.Status == actions_model.StatusBlocked || len(needs) > 0 || run.NeedApproval
|
||||
|
||||
attemptJobID, err := actions_model.GetNextAttemptJobID(ctx, run.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("alloc attempt_job_id: %w", err)
|
||||
}
|
||||
|
||||
job.Name = util.EllipsisDisplayString(job.Name, 255)
|
||||
runJob := &actions_model.ActionRunJob{
|
||||
RunID: run.ID,
|
||||
RunAttemptID: runAttempt.ID,
|
||||
RepoID: run.RepoID,
|
||||
OwnerID: run.OwnerID,
|
||||
CommitSHA: run.CommitSHA,
|
||||
IsForkPullRequest: run.IsForkPullRequest,
|
||||
Name: job.Name,
|
||||
Attempt: runAttempt.Attempt,
|
||||
WorkflowPayload: payload,
|
||||
JobID: id,
|
||||
AttemptJobID: attemptJobID,
|
||||
Needs: needs,
|
||||
RunsOn: job.RunsOn(),
|
||||
Status: util.Iif(shouldBlockJob, actions_model.StatusBlocked, actions_model.StatusWaiting),
|
||||
WorkflowSourceRepoID: run.RepoID,
|
||||
WorkflowSourceCommitSHA: run.CommitSHA,
|
||||
}
|
||||
// Parse workflow/job permissions (no clamping here)
|
||||
if perms := ExtractJobPermissionsFromWorkflow(v, job); perms != nil {
|
||||
runJob.TokenPermissions = perms
|
||||
}
|
||||
|
||||
if isReusableWorkflowCaller {
|
||||
runJob.IsReusableCaller = true
|
||||
runJob.CallUses = job.Uses
|
||||
}
|
||||
|
||||
// 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, runAttempt, 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 {
|
||||
var jobsToCancel []*actions_model.ActionRunJob
|
||||
runJob.Status, jobsToCancel, err = PrepareToStartJobWithConcurrency(ctx, runJob)
|
||||
if err != nil {
|
||||
return fmt.Errorf("prepare to start job with concurrency: %w", err)
|
||||
}
|
||||
cancelledConcurrencyJobs = append(cancelledConcurrencyJobs, jobsToCancel...)
|
||||
}
|
||||
}
|
||||
|
||||
// A reusable caller is never dispatched to a runner, so it must not drive the task-version bump.
|
||||
hasWaitingJobs = hasWaitingJobs || (runJob.Status == actions_model.StatusWaiting && !isReusableWorkflowCaller)
|
||||
if err := db.Insert(ctx, runJob); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// expand reusable caller
|
||||
if isReusableWorkflowCaller && runJob.Status == actions_model.StatusWaiting {
|
||||
if err := expandReusableWorkflowCaller(ctx, run, runAttempt, runJob, vars); err != nil {
|
||||
return fmt.Errorf("inline trigger caller %d ready: %w", runJob.ID, err)
|
||||
}
|
||||
// refresh the caller status
|
||||
if err := actions_model.RefreshReusableCallerStatus(ctx, runJob); err != nil {
|
||||
return fmt.Errorf("refresh caller %d status: %w", runJob.ID, err)
|
||||
}
|
||||
hasWaitingCallerJobs = true
|
||||
}
|
||||
|
||||
runJobs = append(runJobs, runJob)
|
||||
}
|
||||
|
||||
runAttempt.Status = actions_model.AggregateJobStatus(runJobs)
|
||||
if err := actions_model.UpdateRunAttempt(ctx, runAttempt, "status"); 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
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
NotifyWorkflowJobsAndRunsStatusUpdate(ctx, cancelledConcurrencyJobs)
|
||||
EmitJobsIfReadyByJobs(cancelledConcurrencyJobs)
|
||||
|
||||
// Post-commit kick for expanded callers: let job_emitter resolve its child jobs
|
||||
if hasWaitingCallerJobs {
|
||||
if err := EmitJobsIfReadyByRun(run.ID); err != nil {
|
||||
log.Error("emit run %d after InsertRun: %v", run.ID, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package actions
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
actions_model "gitea.dev/models/actions"
|
||||
"gitea.dev/models/db"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/unit"
|
||||
"gitea.dev/modules/json"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/timeutil"
|
||||
webhook_module "gitea.dev/modules/webhook"
|
||||
)
|
||||
|
||||
// StartScheduleTasks start the task
|
||||
func StartScheduleTasks(ctx context.Context) error {
|
||||
return startTasks(ctx)
|
||||
}
|
||||
|
||||
// startTasks retrieves specifications in pages, creates a schedule task for each specification,
|
||||
// and updates the specification's next run time and previous run time.
|
||||
// The function returns an error if there's an issue with finding or updating the specifications.
|
||||
func startTasks(ctx context.Context) error {
|
||||
// Set the page size
|
||||
pageSize := 50
|
||||
|
||||
// Retrieve specs in pages until all specs have been retrieved
|
||||
now := time.Now()
|
||||
for page := 1; ; page++ {
|
||||
// Retrieve the specs for the current page
|
||||
specs, _, err := actions_model.FindSpecs(ctx, actions_model.FindSpecOptions{
|
||||
ListOptions: db.ListOptions{
|
||||
Page: page,
|
||||
PageSize: pageSize,
|
||||
},
|
||||
Next: now.Unix(),
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("find specs: %w", err)
|
||||
}
|
||||
|
||||
if err := specs.LoadRepos(ctx); err != nil {
|
||||
return fmt.Errorf("LoadRepos: %w", err)
|
||||
}
|
||||
|
||||
// Loop through each spec and create a schedule task for it
|
||||
for _, row := range specs {
|
||||
if row.Repo.IsArchived {
|
||||
// Skip if the repo is archived
|
||||
continue
|
||||
}
|
||||
|
||||
cfg, err := row.Repo.GetUnit(ctx, unit.TypeActions)
|
||||
if err != nil {
|
||||
if repo_model.IsErrUnitTypeNotExist(err) {
|
||||
// Skip the actions unit of this repo is disabled.
|
||||
continue
|
||||
}
|
||||
return fmt.Errorf("GetUnit: %w", err)
|
||||
}
|
||||
if cfg.ActionsConfig().IsWorkflowDisabled(row.Schedule.WorkflowID) {
|
||||
continue
|
||||
}
|
||||
|
||||
if err := CreateScheduleTask(ctx, row); err != nil {
|
||||
log.Error("CreateScheduleTask: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
// Parse the spec
|
||||
schedule, err := row.Parse()
|
||||
if err != nil {
|
||||
log.Error("Parse: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
// Update the spec's next run time and previous run time
|
||||
row.Prev = row.Next
|
||||
row.Next = timeutil.TimeStamp(schedule.Next(now.Add(1 * time.Minute)).Unix())
|
||||
if err := actions_model.UpdateScheduleSpec(ctx, row, "prev", "next"); err != nil {
|
||||
log.Error("UpdateScheduleSpec: %v", err)
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Stop if all specs have been retrieved
|
||||
if len(specs) < pageSize {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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, 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,
|
||||
RepoID: cron.RepoID,
|
||||
OwnerID: cron.OwnerID,
|
||||
WorkflowID: cron.WorkflowID,
|
||||
TriggerUserID: cron.TriggerUserID,
|
||||
Ref: cron.Ref,
|
||||
CommitSHA: cron.CommitSHA,
|
||||
Event: cron.Event,
|
||||
EventPayload: eventPayload,
|
||||
TriggerEvent: string(webhook_module.HookEventSchedule),
|
||||
ScheduleID: cron.ID,
|
||||
Status: actions_model.StatusWaiting,
|
||||
}
|
||||
|
||||
// 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 := PrepareRunAndInsert(ctx, cron.Content, run, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 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"
|
||||
|
||||
"gitea.dev/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)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package actions
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
runnerv1 "gitea.dev/actions-proto-go/runner/v1"
|
||||
actions_model "gitea.dev/models/actions"
|
||||
"gitea.dev/models/db"
|
||||
secret_model "gitea.dev/models/secret"
|
||||
|
||||
"google.golang.org/protobuf/types/known/structpb"
|
||||
)
|
||||
|
||||
func PickTask(ctx context.Context, runner *actions_model.ActionRunner) (*runnerv1.Task, bool, error) {
|
||||
var (
|
||||
task *runnerv1.Task
|
||||
job *actions_model.ActionRunJob
|
||||
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)
|
||||
// Let the runner retry the request, do not allow to proceed
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if has {
|
||||
if task.Status.In(actions_model.StatusWaiting, actions_model.StatusRunning, actions_model.StatusBlocked, actions_model.StatusCancelling) {
|
||||
return nil, false, nil
|
||||
}
|
||||
// task has been finished, remove it
|
||||
_, err = db.DeleteByID[actions_model.ActionRunner](ctx, runner.ID)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
return nil, false, errors.New("runner has been removed")
|
||||
}
|
||||
}
|
||||
|
||||
if err := db.WithTx(ctx, func(ctx context.Context) error {
|
||||
t, ok, err := actions_model.CreateTaskForRunner(ctx, runner)
|
||||
if err != nil {
|
||||
return fmt.Errorf("CreateTaskForRunner: %w", err)
|
||||
}
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := t.LoadAttributes(ctx); err != nil {
|
||||
return fmt.Errorf("task LoadAttributes: %w", err)
|
||||
}
|
||||
job = t.Job
|
||||
actionTask = t
|
||||
|
||||
secrets, err := secret_model.GetSecretsOfTask(ctx, t)
|
||||
if err != nil {
|
||||
return fmt.Errorf("GetSecretsOfTask: %w", err)
|
||||
}
|
||||
|
||||
vars, err := actions_model.GetVariablesOfRun(ctx, t.Job.Run)
|
||||
if err != nil {
|
||||
return fmt.Errorf("GetVariablesOfRun: %w", err)
|
||||
}
|
||||
|
||||
needs, err := findTaskNeeds(ctx, job)
|
||||
if err != nil {
|
||||
return fmt.Errorf("findTaskNeeds: %w", err)
|
||||
}
|
||||
|
||||
taskContext, err := generateTaskContext(ctx, t)
|
||||
if err != nil {
|
||||
return fmt.Errorf("generateTaskContext: %w", err)
|
||||
}
|
||||
|
||||
task = &runnerv1.Task{
|
||||
Id: t.ID,
|
||||
WorkflowPayload: t.Job.WorkflowPayload,
|
||||
Context: taskContext,
|
||||
Secrets: secrets,
|
||||
Vars: vars,
|
||||
Needs: needs,
|
||||
}
|
||||
|
||||
return nil
|
||||
}); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
if task == nil {
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
CreateCommitStatusForRunJobs(ctx, job.Run, job)
|
||||
NotifyWorkflowJobStatusUpdateWithTask(ctx, job, actionTask)
|
||||
// job.Run is loaded inside the transaction before UpdateRunJob sets run.Started,
|
||||
// so Started is zero only on the very first pick-up of that run.
|
||||
if job.Run.Started.IsZero() {
|
||||
NotifyWorkflowRunStatusUpdateWithReload(ctx, job.RepoID, job.RunID)
|
||||
}
|
||||
|
||||
return task, true, nil
|
||||
}
|
||||
|
||||
func generateTaskContext(ctx context.Context, t *actions_model.ActionTask) (*structpb.Struct, error) {
|
||||
giteaRuntimeToken, err := CreateAuthorizationToken(t.ID, t.Job.RunID, t.JobID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
gitCtx := GenerateGiteaContext(ctx, t.Job.Run, nil, t.Job)
|
||||
gitCtx["token"] = t.Token
|
||||
gitCtx["gitea_runtime_token"] = giteaRuntimeToken
|
||||
|
||||
return structpb.NewStruct(gitCtx)
|
||||
}
|
||||
|
||||
func findTaskNeeds(ctx context.Context, taskJob *actions_model.ActionRunJob) (map[string]*runnerv1.TaskNeed, error) {
|
||||
taskNeeds, err := FindTaskNeeds(ctx, taskJob)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ret := make(map[string]*runnerv1.TaskNeed, len(taskNeeds))
|
||||
for jobID, taskNeed := range taskNeeds {
|
||||
ret[jobID] = &runnerv1.TaskNeed{
|
||||
Outputs: taskNeed.Outputs,
|
||||
Result: runnerv1.Result(taskNeed.Result),
|
||||
}
|
||||
}
|
||||
return ret, 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.
|
||||
@@ -0,0 +1,63 @@
|
||||
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package actions
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
actions_model "gitea.dev/models/actions"
|
||||
"gitea.dev/modules/util"
|
||||
secret_service "gitea.dev/services/secrets"
|
||||
)
|
||||
|
||||
func CreateVariable(ctx context.Context, ownerID, repoID int64, name, data, description string) (*actions_model.ActionVariable, error) {
|
||||
if err := secret_service.ValidateName(name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
v, err := actions_model.InsertVariable(ctx, ownerID, repoID, name, util.NormalizeStringEOL(data), description)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return v, nil
|
||||
}
|
||||
|
||||
func UpdateVariableNameData(ctx context.Context, variable *actions_model.ActionVariable) (bool, error) {
|
||||
if err := secret_service.ValidateName(variable.Name); err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
variable.Data = util.NormalizeStringEOL(variable.Data)
|
||||
|
||||
return actions_model.UpdateVariableCols(ctx, variable, "name", "data", "description")
|
||||
}
|
||||
|
||||
func DeleteVariableByID(ctx context.Context, variableID int64) error {
|
||||
return actions_model.DeleteVariable(ctx, variableID)
|
||||
}
|
||||
|
||||
func DeleteVariableByName(ctx context.Context, ownerID, repoID int64, name string) error {
|
||||
v, err := GetVariable(ctx, actions_model.FindVariablesOpts{
|
||||
OwnerID: ownerID,
|
||||
RepoID: repoID,
|
||||
Name: name,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return actions_model.DeleteVariable(ctx, v.ID)
|
||||
}
|
||||
|
||||
func GetVariable(ctx context.Context, opts actions_model.FindVariablesOpts) (*actions_model.ActionVariable, error) {
|
||||
vars, err := actions_model.FindVariables(ctx, opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(vars) != 1 {
|
||||
return nil, util.NewNotExistErrorf("variable not found")
|
||||
}
|
||||
return vars[0], nil
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package actions
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
actions_model "gitea.dev/models/actions"
|
||||
"gitea.dev/models/perm"
|
||||
access_model "gitea.dev/models/perm/access"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/unit"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/actions"
|
||||
"gitea.dev/modules/actions/jobparser"
|
||||
"gitea.dev/modules/git"
|
||||
"gitea.dev/modules/reqctx"
|
||||
api "gitea.dev/modules/structs"
|
||||
"gitea.dev/modules/util"
|
||||
"gitea.dev/services/context"
|
||||
"gitea.dev/services/convert"
|
||||
|
||||
"gitea.com/gitea/runner/act/model"
|
||||
"go.yaml.in/yaml/v4"
|
||||
)
|
||||
|
||||
func EnableOrDisableWorkflow(ctx *context.APIContext, workflowID string, isEnable bool) error {
|
||||
workflow, err := convert.GetActionWorkflow(ctx, ctx.Repo.GitRepo, ctx.Repo.Repository, workflowID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cfgUnit := ctx.Repo.Repository.MustGetUnit(ctx, unit.TypeActions)
|
||||
cfg := cfgUnit.ActionsConfig()
|
||||
|
||||
if isEnable {
|
||||
cfg.EnableWorkflow(workflow.ID)
|
||||
} else {
|
||||
cfg.DisableWorkflow(workflow.ID)
|
||||
}
|
||||
|
||||
return repo_model.UpdateRepoUnitConfig(ctx, cfgUnit)
|
||||
}
|
||||
|
||||
func DispatchActionWorkflow(ctx reqctx.RequestContext, doer *user_model.User, repo *repo_model.Repository, gitRepo *git.Repository, workflowID, ref string, processInputs func(model *model.WorkflowDispatch, inputs map[string]any) error) (runID int64, _ error) {
|
||||
if workflowID == "" {
|
||||
return 0, util.ErrorWrapTranslatable(
|
||||
util.NewNotExistErrorf("workflowID is empty"),
|
||||
"actions.workflow.not_found", workflowID,
|
||||
)
|
||||
}
|
||||
|
||||
if ref == "" {
|
||||
return 0, util.ErrorWrapTranslatable(
|
||||
util.NewNotExistErrorf("ref is empty"),
|
||||
"form.target_ref_not_exist", ref,
|
||||
)
|
||||
}
|
||||
|
||||
// can not rerun job when workflow is disabled
|
||||
cfgUnit := repo.MustGetUnit(ctx, unit.TypeActions)
|
||||
cfg := cfgUnit.ActionsConfig()
|
||||
if cfg.IsWorkflowDisabled(workflowID) {
|
||||
return 0, util.ErrorWrapTranslatable(
|
||||
util.NewPermissionDeniedErrorf("workflow is disabled"),
|
||||
"actions.workflow.disabled",
|
||||
)
|
||||
}
|
||||
|
||||
// get target commit of run from specified ref
|
||||
refName := git.RefName(ref)
|
||||
var runTargetCommit *git.Commit
|
||||
var err error
|
||||
if refName.IsTag() {
|
||||
runTargetCommit, err = gitRepo.GetTagCommit(refName.TagName())
|
||||
} else if refName.IsBranch() {
|
||||
runTargetCommit, err = gitRepo.GetBranchCommit(refName.BranchName())
|
||||
} else {
|
||||
refName = git.RefNameFromBranch(ref)
|
||||
runTargetCommit, err = gitRepo.GetBranchCommit(ref)
|
||||
}
|
||||
if err != nil {
|
||||
return 0, util.ErrorWrapTranslatable(
|
||||
util.NewNotExistErrorf("ref %q doesn't exist", ref),
|
||||
"form.target_ref_not_exist", ref,
|
||||
)
|
||||
}
|
||||
|
||||
// get workflow entry from runTargetCommit
|
||||
_, entries, err := actions.ListWorkflows(runTargetCommit)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
// find workflow from commit
|
||||
var entry *git.TreeEntry
|
||||
|
||||
run := &actions_model.ActionRun{
|
||||
Title: runTargetCommit.MessageTitle(),
|
||||
RepoID: repo.ID,
|
||||
Repo: repo,
|
||||
OwnerID: repo.OwnerID,
|
||||
WorkflowID: workflowID,
|
||||
TriggerUserID: doer.ID,
|
||||
TriggerUser: doer,
|
||||
Ref: string(refName),
|
||||
CommitSHA: runTargetCommit.ID.String(),
|
||||
IsForkPullRequest: false,
|
||||
Event: "workflow_dispatch",
|
||||
TriggerEvent: "workflow_dispatch",
|
||||
Status: actions_model.StatusWaiting,
|
||||
}
|
||||
|
||||
for _, e := range entries {
|
||||
if e.Name() != workflowID {
|
||||
continue
|
||||
}
|
||||
entry = e
|
||||
break
|
||||
}
|
||||
|
||||
if entry == nil {
|
||||
return 0, util.ErrorWrapTranslatable(
|
||||
util.NewNotExistErrorf("workflow %q doesn't exist", workflowID),
|
||||
"actions.workflow.not_found", workflowID,
|
||||
)
|
||||
}
|
||||
|
||||
content, err := actions.GetContentFromEntry(entry)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
singleWorkflow := &jobparser.SingleWorkflow{}
|
||||
if err := yaml.Unmarshal(content, singleWorkflow); err != nil {
|
||||
return 0, fmt.Errorf("failed to unmarshal workflow content: %w", err)
|
||||
}
|
||||
// get inputs from post
|
||||
workflow := &model.Workflow{
|
||||
RawOn: singleWorkflow.RawOn,
|
||||
}
|
||||
workflowDispatch := workflow.WorkflowDispatchConfig()
|
||||
if workflowDispatch == nil {
|
||||
return 0, util.ErrorWrapTranslatable(
|
||||
util.NewInvalidArgumentErrorf("workflow %q has no workflow_dispatch event trigger", workflowID),
|
||||
"actions.workflow.has_no_workflow_dispatch", workflowID,
|
||||
)
|
||||
}
|
||||
|
||||
inputsWithDefaults := make(map[string]any)
|
||||
if err = processInputs(workflowDispatch, inputsWithDefaults); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
// 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
|
||||
workflowDispatchPayload := &api.WorkflowDispatchPayload{
|
||||
Workflow: workflowID,
|
||||
Ref: ref,
|
||||
Repository: convert.ToRepo(ctx, repo, access_model.Permission{AccessMode: perm.AccessModeNone}),
|
||||
Inputs: inputsWithDefaults,
|
||||
Sender: convert.ToUserWithAccessMode(ctx, doer, perm.AccessModeNone),
|
||||
}
|
||||
|
||||
var eventPayload []byte
|
||||
if eventPayload, err = workflowDispatchPayload.JSONPayload(); err != nil {
|
||||
return 0, fmt.Errorf("JSONPayload: %w", err)
|
||||
}
|
||||
run.EventPayload = string(eventPayload)
|
||||
|
||||
// Insert the action run and its associated jobs into the database
|
||||
if err := PrepareRunAndInsert(ctx, content, run, inputsWithDefaults); err != nil {
|
||||
return 0, fmt.Errorf("PrepareRun: %w", err)
|
||||
}
|
||||
return run.ID, nil
|
||||
}
|
||||
Reference in New Issue
Block a user