snapshot of https://github.com/go-gitea/gitea.git (main, 2026-09-15; blobs > 131072B omitted)
Some checks failed
cache-seeder / prune (push) Has been skipped
cache-seeder / frontend (push) Failing after 3s
cache-seeder / python (push) Failing after 3s
cache-seeder / gobuild (push) Failing after 4s
cache-seeder / lint (bindata, lint-backend) (push) Failing after 3s
giteabot backport / giteabot (push) Has been skipped
giteabot / giteabot (push) Has been skipped
cache-prune / prune (push) Has been skipped
release-nightly-snapcraft / build-and-publish (ubuntu-24.04) (push) Has been cancelled
release-nightly-snapcraft / build-and-publish (ubuntu-24.04-arm) (push) Has been cancelled
release-nightly / nightly-binary (push) Has been cancelled
release-nightly / nightly-container (push) Has been cancelled
cron-renovate / cron-renovate (push) Has been skipped

This commit is contained in:
upstream-mirror
2026-09-15 21:41:59 +05:00
commit 52ae0c5802
6280 changed files with 624193 additions and 0 deletions

149
services/actions/approve.go Normal file
View File

@@ -0,0 +1,149 @@
// 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"
"gitea.dev/modules/util"
)
// ApproveRuns returns the approved runs in the same order as runIDs.
func ApproveRuns(ctx context.Context, repo *repo_model.Repository, doer *user_model.User, runIDs []int64) ([]*actions_model.ActionRun, error) {
updatedJobs := make([]*actions_model.ActionRunJob, 0)
cancelledConcurrencyJobs := make([]*actions_model.ActionRunJob, 0)
// Track runs whose reusable callers were just expanded so we can re-emit after the tx commits.
expandedCallerRunIDs := make(container.Set[int64])
err := db.WithTx(ctx, func(ctx context.Context) (err error) {
for _, runID := range runIDs {
run, err := actions_model.GetRunByRepoAndID(ctx, repo.ID, runID)
if err != nil {
return err
}
if !run.NeedApproval {
continue
}
run.NeedApproval = false
run.ApprovedBy = doer.ID
if err := actions_model.UpdateRun(ctx, run, "need_approval", "approved_by"); err != nil {
return err
}
jobs, err := actions_model.GetLatestAttemptJobsByRun(ctx, run)
if err != nil {
return err
}
// approval unblocks every job at once, so max-parallel has to cap them here too
slots := maxParallelSlots{}
for _, job := range jobs {
slots.hold(job, job.Status)
}
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
}
// Only a job this approval unblocks competes for a slot, one that is already
// active was counted by the seeding loop above and must not take a second.
isUnblocking := job.Status == actions_model.StatusBlocked
// A slot-starved job cannot start, skip the following checks.
if isUnblocking && !slots.available(job) {
continue
}
var jobsToCancel []*actions_model.ActionRunJob
job.Status, jobsToCancel, err = PrepareToStartJobWithConcurrency(ctx, job)
if err != nil {
return err
}
cancelledConcurrencyJobs = append(cancelledConcurrencyJobs, jobsToCancel...)
if isUnblocking {
applyMaxParallel(job, slots)
}
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 nil, 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)
// The batches above already notified every run whose jobs changed, which is the only way
// approving alters a run's status, so reload purely to answer the caller.
reloaded, err := actions_model.GetRunsByRepoAndID(ctx, repo.ID, runIDs)
if err != nil {
return nil, fmt.Errorf("GetRunsByRepoAndID: %w", err)
}
runsByID := make(map[int64]*actions_model.ActionRun, len(reloaded))
for _, run := range reloaded {
run.Repo = repo // the caller resolved runIDs against this repo, so spare every consumer a reload
runsByID[run.ID] = run
}
approvedRuns := make([]*actions_model.ActionRun, 0, len(runIDs))
for _, runID := range runIDs {
run := runsByID[runID]
if run == nil {
return nil, util.NewNotExistErrorf("run %d no longer exists after approval", runID)
}
approvedRuns = append(approvedRuns, run)
}
return approvedRuns, nil
}

View File

@@ -0,0 +1,95 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package actions
import (
"testing"
actions_model "gitea.dev/models/actions"
"gitea.dev/models/db"
repo_model "gitea.dev/models/repo"
"gitea.dev/models/unittest"
user_model "gitea.dev/models/user"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestApproveRuns(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
doer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
insertRun := func(index int64, status actions_model.Status, needApproval bool, approvedBy int64) *actions_model.ActionRun {
run := &actions_model.ActionRun{
Title: "approve-run", RepoID: repo.ID, OwnerID: repo.OwnerID, WorkflowID: "test.yaml", Index: index,
TriggerUserID: doer.ID, Ref: "refs/heads/main",
CommitSHA: "c2d72f548424103f01ee1dc02889c1e2bff816b0", Event: "push", TriggerEvent: "push",
Status: status, NeedApproval: needApproval, ApprovedBy: approvedBy,
}
require.NoError(t, db.Insert(t.Context(), run))
return run
}
insertJob := func(run *actions_model.ActionRun, status actions_model.Status, needs ...string) *actions_model.ActionRunJob {
job := &actions_model.ActionRunJob{
RunID: run.ID, RepoID: run.RepoID, OwnerID: run.OwnerID, CommitSHA: run.CommitSHA,
Name: "job1", Attempt: 1, JobID: "job1", Status: status,
RunsOn: []string{"ubuntu-latest"}, Needs: needs,
}
require.NoError(t, db.Insert(t.Context(), job))
return job
}
t.Run("approve unblocks a job with no dependencies", func(t *testing.T) {
run := insertRun(1001, actions_model.StatusBlocked, true, 0)
job := insertJob(run, actions_model.StatusBlocked)
approved, err := ApproveRuns(t.Context(), repo, doer, []int64{run.ID})
require.NoError(t, err)
require.Len(t, approved, 1)
assert.False(t, approved[0].NeedApproval)
assert.Equal(t, doer.ID, approved[0].ApprovedBy)
assert.Equal(t, actions_model.StatusWaiting, unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: job.ID}).Status)
})
t.Run("a job with unmet dependencies stays blocked", func(t *testing.T) {
run := insertRun(1002, actions_model.StatusBlocked, true, 0)
job := insertJob(run, actions_model.StatusBlocked, "some-other-job")
approved, err := ApproveRuns(t.Context(), repo, doer, []int64{run.ID})
require.NoError(t, err)
require.Len(t, approved, 1)
assert.False(t, approved[0].NeedApproval)
assert.Equal(t, actions_model.StatusBlocked, unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: job.ID}).Status)
})
t.Run("re-approving an approved run is a no-op", func(t *testing.T) {
run := insertRun(1005, actions_model.StatusRunning, false, 4)
job := insertJob(run, actions_model.StatusRunning)
approved, err := ApproveRuns(t.Context(), repo, doer, []int64{run.ID})
require.NoError(t, err)
require.Len(t, approved, 1)
assert.EqualValues(t, 4, approved[0].ApprovedBy, "approver must not be overwritten")
assert.Equal(t, actions_model.StatusRunning, unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: job.ID}).Status, "started job must not be reset to waiting")
})
t.Run("approving several runs returns them in the requested order", func(t *testing.T) {
run1 := insertRun(1003, actions_model.StatusBlocked, true, 0)
insertJob(run1, actions_model.StatusBlocked)
run2 := insertRun(1004, actions_model.StatusBlocked, true, 0)
insertJob(run2, actions_model.StatusBlocked)
approved, err := ApproveRuns(t.Context(), repo, doer, []int64{run2.ID, run1.ID})
require.NoError(t, err)
require.Len(t, approved, 2)
assert.Equal(t, run2.ID, approved[0].ID)
assert.Equal(t, run1.ID, approved[1].ID)
assert.False(t, approved[0].NeedApproval)
assert.False(t, approved[1].NeedApproval)
})
}

View File

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

108
services/actions/auth.go Normal file
View File

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

View File

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

View File

@@ -0,0 +1,54 @@
// 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"
)
// CancelRun cancels a run's cancellable jobs and returns the run's post-cancellation state.
// A runner that supports it gets to run its post-cancel cleanup before the job reaches its final status.
func CancelRun(ctx context.Context, run *actions_model.ActionRun, jobs []*actions_model.ActionRunJob) (*actions_model.ActionRun, error) {
return cancelRun(ctx, run, jobs, false)
}
// ForceCancelRun cancels a run like CancelRun, but does not wait for the runners to acknowledge it:
// the jobs are marked cancelled at once and whatever a runner reports for them afterwards is discarded.
func ForceCancelRun(ctx context.Context, run *actions_model.ActionRun, jobs []*actions_model.ActionRunJob) (*actions_model.ActionRun, error) {
return cancelRun(ctx, run, jobs, true)
}
func cancelRun(ctx context.Context, run *actions_model.ActionRun, jobs []*actions_model.ActionRunJob, force bool) (*actions_model.ActionRun, error) {
var updatedJobs []*actions_model.ActionRunJob
if err := db.WithTx(ctx, func(ctx context.Context) (err error) {
updatedJobs, err = actions_model.CancelJobs(ctx, jobs, force)
if err != nil {
return fmt.Errorf("CancelJobs: %w", err)
}
if len(updatedJobs) > 0 {
return nil // a job update already refreshed the run
}
return actions_model.SettleRunAfterCancel(ctx, run)
}); err != nil {
return nil, err
}
// updatedJobs, not jobs: cancelOneJob re-reads the cancelled rows, the input ones still carry their pre-cancel status
CreateCommitStatusForRunJobs(ctx, run, updatedJobs...)
EmitJobsIfReadyByJobs(updatedJobs)
NotifyWorkflowJobsStatusUpdate(ctx, updatedJobs...)
reloaded, err := actions_model.GetRunByRepoAndID(ctx, run.RepoID, run.ID)
if err != nil {
return nil, fmt.Errorf("GetRunByRepoAndID: %w", err)
}
if len(updatedJobs) > 0 || reloaded.Status != run.Status {
NotifyWorkflowRunStatusUpdate(ctx, reloaded)
}
return reloaded, nil
}

325
services/actions/cleanup.go Normal file
View File

@@ -0,0 +1,325 @@
// 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"
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 Actions 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("Actions 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 Actions 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("Actions artifact %d is deleted (due to pending deletion)", artifact.ID)
}
if len(artifacts) < deleteArtifactBatchSize {
log.Debug("No more Actions 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 {
if setting.Actions.LogRetentionDays <= 0 {
return nil
}
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 expired Actions 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`")
_, err := db.GetEngine(ctx).Exec(b)
return err
}
// 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`")
_, err := db.GetEngine(ctx).Exec(b)
return err
}
// DeleteRun deletes workflow run, including all logs and artifacts.
func DeleteRun(ctx context.Context, run *actions_model.ActionRun) error {
if !run.Status.IsDone() {
// callers guarantee a terminal status, but in production delete it anyway
setting.PanicInDevOrTesting("DeleteRun called on non-terminal run %d with status %s", run.ID, run.Status)
}
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,
})
recordsToDelete = append(recordsToDelete, &actions_model.ActionRunJobSummary{
RepoID: repoID,
RunID: run.ID,
})
if err := db.WithTx(ctx, func(ctx context.Context) error {
// TODO: Deleting task records could break current ephemeral runner implementation. This is a temporary workaround suggested by ChristopherHX.
// Since you delete potentially the only task an ephemeral runner has ever run, please delete the affected runners first.
// one of
// call cleanup ephemeral runners first
// delete affected ephemeral 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 {
// don't return any error since the database records have been deleted
log.Error("remove artifact file %q: %v", art.StoragePath, err)
}
}
return nil
}
var cleanupOldRunsBatchSize = 50
// CleanupOldRuns deletes completed runs older than RUN_RETENTION_DAYS, along with everything under them.
func CleanupOldRuns(ctx context.Context) error {
if setting.Actions.RunRetentionDays <= 0 {
return nil
}
olderThan := timeutil.TimeStampNow().AddDuration(-time.Duration(setting.Actions.RunRetentionDays) * 24 * time.Hour)
doneStatuses := []actions_model.Status{
actions_model.StatusSuccess,
actions_model.StatusFailure,
actions_model.StatusCancelled,
actions_model.StatusSkipped,
}
total, err := cleanupOldRuns(ctx, olderThan, doneStatuses, DeleteRun)
if err != nil {
return err
}
log.Info("Deleted %d old Actions runs before %s", total, olderThan.Format(time.RFC3339))
return nil
}
func cleanupOldRuns(ctx context.Context, olderThan timeutil.TimeStamp, doneStatuses []actions_model.Status, deleteRun func(context.Context, *actions_model.ActionRun) error) (int, error) {
total := 0
failed := container.Set[int64]{} // skipping these stops the outer loop refetching them forever
for {
runs, err := actions_model.FindOldestRuns(ctx, doneStatuses, olderThan, cleanupOldRunsBatchSize)
if err != nil {
return total, fmt.Errorf("FindOldestRuns: %w", err)
}
realDeleted := 0
for _, run := range runs {
if failed.Contains(run.ID) {
continue
}
if err := deleteRun(ctx, run); err != nil {
setting.PanicInDevOrTesting("failed to delete old action run %d: %v", run.ID, err)
failed.Add(run.ID)
continue
}
total++
realDeleted++
log.Trace("Deleted old action run %d (created at %s)", run.ID, run.Created.AsTime())
}
if realDeleted == 0 {
if len(runs) != 0 {
log.Error("Too many actions runs are unable to delete, please figure out and fix the failures")
}
break
}
}
return total, nil
}

View File

@@ -0,0 +1,150 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package actions
import (
"context"
"errors"
"testing"
"time"
actions_model "gitea.dev/models/actions"
"gitea.dev/models/db"
repo_model "gitea.dev/models/repo"
"gitea.dev/models/unittest"
"gitea.dev/modules/container"
"gitea.dev/modules/optional"
"gitea.dev/modules/setting"
"gitea.dev/modules/test"
"gitea.dev/modules/timeutil"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func insertCleanupRun(t *testing.T, index int64, status actions_model.Status, created timeutil.TimeStamp) *actions_model.ActionRun {
t.Helper()
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
run := &actions_model.ActionRun{
Title: "cleanup-run", RepoID: repo.ID, OwnerID: repo.OwnerID, WorkflowID: "test.yaml", Index: index,
TriggerUserID: 1, Ref: "refs/heads/main",
CommitSHA: "c2d72f548424103f01ee1dc02889c1e2bff816b0", Event: "push", TriggerEvent: "push",
Status: status,
}
require.NoError(t, db.Insert(t.Context(), run))
// XORM's "created" tag ignores explicit updates to the column even with NoAutoTime, so backdate it with raw SQL.
_, err := db.GetEngine(t.Context()).Exec("UPDATE action_run SET created = ? WHERE id = ?", created, run.ID)
require.NoError(t, err)
return run
}
func deleteAllRuns(t *testing.T) {
t.Helper()
_, err := db.GetEngine(t.Context()).Exec("DELETE FROM action_run")
require.NoError(t, err)
}
func TestCleanupOldRuns(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
defer test.MockVariableValue(&setting.Actions.RunRetentionDays, 30)()
now := timeutil.TimeStampNow()
old := now.AddDuration(-40 * 24 * time.Hour)
deleteAllRuns(t)
t.Run("disabled retention is a no-op", func(t *testing.T) {
defer test.MockVariableValue(&setting.Actions.RunRetentionDays, 0)()
run := insertCleanupRun(t, 2001, actions_model.StatusSuccess, old)
require.NoError(t, CleanupOldRuns(t.Context()))
unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: run.ID})
})
t.Run("deletes old done runs, keeps recent and in-progress runs", func(t *testing.T) {
defer test.MockVariableValue(&cleanupOldRunsBatchSize, 3)() // also test the batch
oldSuccess := insertCleanupRun(t, 2002, actions_model.StatusSuccess, old)
oldFailure := insertCleanupRun(t, 2003, actions_model.StatusFailure, old)
recent := insertCleanupRun(t, 2004, actions_model.StatusSuccess, now.AddDuration(-24*time.Hour))
oldRunning := insertCleanupRun(t, 2005, actions_model.StatusRunning, old)
oldCanceled := insertCleanupRun(t, 2006, actions_model.StatusCancelled, old)
oldSkipped := insertCleanupRun(t, 2007, actions_model.StatusSkipped, old)
require.NoError(t, CleanupOldRuns(t.Context()))
unittest.AssertNotExistsBean(t, &actions_model.ActionRun{ID: oldSuccess.ID})
unittest.AssertNotExistsBean(t, &actions_model.ActionRun{ID: oldFailure.ID})
unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: recent.ID})
unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: oldRunning.ID})
unittest.AssertNotExistsBean(t, &actions_model.ActionRun{ID: oldCanceled.ID})
unittest.AssertNotExistsBean(t, &actions_model.ActionRun{ID: oldSkipped.ID})
})
t.Run("error during deleting", func(t *testing.T) {
defer test.MockVariableValue(&cleanupOldRunsBatchSize, 3)()
defer test.MockVariableValue(&setting.IsInTesting, false)() // skip the panic
deleteAllRuns(t)
for i := range int64(6) {
insertCleanupRun(t, 3000+i, actions_model.StatusSuccess, old)
}
var deletedIndices []int64
deleteRun := func(ctx context.Context, run *actions_model.ActionRun) error {
if run.Index%2 == 0 {
return errors.New("some error")
}
deletedIndices = append(deletedIndices, run.Index)
_, err := db.DeleteByID[actions_model.ActionRun](ctx, run.ID)
return err
}
total, err := cleanupOldRuns(t.Context(), now, []actions_model.Status{actions_model.StatusSuccess}, deleteRun)
require.NoError(t, err)
// 3000/3002/3004 keep failing and fill up the batch, so the loop stops after 3001 and 3003
assert.Equal(t, 2, total)
assert.Equal(t, []int64{3001, 3003}, deletedIndices)
})
}
func TestCleanupRetentionZeroKeepsForever(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
defer test.MockVariableValue(&setting.Actions.LogRetentionDays, 0)()
defer test.MockVariableValue(&setting.Actions.ArtifactRetentionDays, 0)()
liveLogs := unittest.Cond("stopped > 0 AND log_expired = ?", false)
t.Run("logs", func(t *testing.T) {
before := unittest.GetCount(t, &actions_model.ActionTask{}, liveLogs)
require.Positive(t, before)
require.NoError(t, CleanupExpiredLogs(t.Context()))
assert.Equal(t, before, unittest.GetCount(t, &actions_model.ActionTask{}, liveLogs))
})
t.Run("artifacts", func(t *testing.T) {
task := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionTask{ID: 47})
art, err := actions_model.CreateArtifact(t.Context(), task, "never-expires", "a.txt", optional.None[timeutil.TimeStamp]())
require.NoError(t, err)
assert.Zero(t, art.ExpiredUnix)
// a workflow-requested expiry is honored, only the instance default may mean never
asked, err := actions_model.CreateArtifact(t.Context(), task, "client-asked", "b.txt", optional.Some(timeutil.TimeStampNow()))
require.NoError(t, err)
assert.Positive(t, asked.ExpiredUnix)
// re-uploading refreshes the expiry and returns it
reuploaded, err := actions_model.CreateArtifact(t.Context(), task, "client-asked", "b.txt", optional.None[timeutil.TimeStamp]())
require.NoError(t, err)
assert.Zero(t, reuploaded.ExpiredUnix)
// a past expiry must stay reapable, not land on the sentinel
past, err := actions_model.CreateArtifact(t.Context(), task, "long-gone", "c.txt", optional.Some(timeutil.TimeStamp(-1000)))
require.NoError(t, err)
assert.Positive(t, past.ExpiredUnix)
_, err = db.GetEngine(t.Context()).In("id", art.ID, past.ID).Cols("status").
Update(&actions_model.ActionArtifact{Status: actions_model.ArtifactStatusUploadConfirmed})
require.NoError(t, err)
expiring, err := actions_model.ListNeedExpiredArtifacts(t.Context())
require.NoError(t, err)
ids := container.FilterSlice(expiring, func(a *actions_model.ActionArtifact) (int64, bool) { return a.ID, true })
assert.NotContains(t, ids, art.ID)
assert.Contains(t, ids, past.ID)
})
}

View File

@@ -0,0 +1,194 @@
// 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 running tasks with continuous updates that don't end for a long time.
// StatusRunning only: the threshold is the task's *start* time, so including StatusCancelling would kill a
// task mid post-cancel cleanup. StopZombieTasks covers a stalled one, keying off the last update instead.
func StopEndlessTasks(ctx context.Context) error {
return stopTasksByStatuses(ctx, actions_model.FindTaskOptions{
StartedBefore: timeutil.TimeStamp(time.Now().Add(-setting.Actions.EndlessTaskTimeout).Unix()),
}, actions_model.StatusRunning)
}
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, false)
if err != nil {
log.Warn("cancel abandoned jobs: %v", err)
}
NotifyWorkflowJobsAndRunsStatusUpdate(ctx, updatedJobs)
EmitJobsIfReadyByJobs(updatedJobs)
return nil
}

View File

@@ -0,0 +1,143 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package actions
import (
"fmt"
"testing"
"time"
actions_model "gitea.dev/models/actions"
"gitea.dev/models/db"
"gitea.dev/models/unittest"
"gitea.dev/modules/setting"
"gitea.dev/modules/test"
"gitea.dev/modules/timeutil"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func createConflictingCancellingJob(t *testing.T, concurrencyGroup string, runIndex int64) *actions_model.ActionRunJob {
t.Helper()
run := &actions_model.ActionRun{
RepoID: 1,
OwnerID: 2,
TriggerUserID: 2,
WorkflowID: "test.yml",
Index: runIndex,
Ref: "refs/heads/main",
Status: actions_model.StatusBlocked,
}
require.NoError(t, db.Insert(t.Context(), run))
attempt := &actions_model.ActionRunAttempt{
RepoID: run.RepoID,
RunID: run.ID,
Attempt: 1,
TriggerUserID: run.TriggerUserID,
Status: actions_model.StatusBlocked,
ConcurrencyGroup: concurrencyGroup,
}
require.NoError(t, db.Insert(t.Context(), attempt))
job := &actions_model.ActionRunJob{
RunID: run.ID,
RunAttemptID: attempt.ID,
AttemptJobID: 1,
RepoID: run.RepoID,
OwnerID: run.OwnerID,
CommitSHA: "c2d72f548424103f01ee1dc02889c1e2bff816b0",
Name: "conflicting-cancelling-job",
JobID: "conflicting-cancelling-job",
Status: actions_model.StatusCancelling,
ConcurrencyGroup: concurrencyGroup,
}
require.NoError(t, db.Insert(t.Context(), job))
return job
}
func TestShouldBlockJobByConcurrency_CancellingJobBlocks(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
const concurrencyGroup = "test-cancelling-job-blocks"
createConflictingCancellingJob(t, concurrencyGroup, 9903)
job := &actions_model.ActionRunJob{
RepoID: 1,
RawConcurrency: concurrencyGroup,
IsConcurrencyEvaluated: true,
ConcurrencyGroup: concurrencyGroup,
}
shouldBlock, err := shouldBlockJobByConcurrency(t.Context(), job)
require.NoError(t, err)
assert.True(t, shouldBlock)
}
func TestShouldBlockRunByConcurrency_CancellingJobBlocks(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
const concurrencyGroup = "test-cancelling-run-blocks"
createConflictingCancellingJob(t, concurrencyGroup, 9904)
attempt := &actions_model.ActionRunAttempt{
RepoID: 1,
ConcurrencyGroup: concurrencyGroup,
}
shouldBlock, err := shouldBlockRunByConcurrency(t.Context(), attempt)
require.NoError(t, err)
assert.True(t, shouldBlock)
}
// TestStopEndlessTasksSkipsCancelling verifies that a task running its post-cancel cleanup is not
// force-stopped by the endless-task sweep just because the job started long ago.
func TestStopEndlessTasksSkipsCancelling(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
// StopEndlessTasks emits ready jobs onto the emitter queue, mock it
defer test.MockVariableValue(&EmitJobsIfReadyByRun, func(runID int64) error { return nil })()
// well past the endless-task threshold, keyed on the task's start time
longAgo := timeutil.TimeStamp(time.Now().Add(-2 * setting.Actions.EndlessTaskTimeout).Unix())
var seq int64
newTaskWithJob := func(status actions_model.Status) *actions_model.ActionTask {
seq++
run := &actions_model.ActionRun{
RepoID: 1, OwnerID: 2, TriggerUserID: 2, WorkflowID: "test.yml",
Index: 99500 + seq, Ref: "refs/heads/main", Status: actions_model.StatusRunning,
}
require.NoError(t, db.Insert(t.Context(), run))
attempt := &actions_model.ActionRunAttempt{
RepoID: run.RepoID, RunID: run.ID, Attempt: 1, TriggerUserID: run.TriggerUserID, Status: actions_model.StatusRunning,
}
require.NoError(t, db.Insert(t.Context(), attempt))
job := &actions_model.ActionRunJob{
RunID: run.ID, RunAttemptID: attempt.ID, AttemptJobID: 1, RepoID: run.RepoID, OwnerID: run.OwnerID,
CommitSHA: "c2d72f548424103f01ee1dc02889c1e2bff816b0", Name: "j", JobID: "j", Status: status,
}
require.NoError(t, db.Insert(t.Context(), job))
task := &actions_model.ActionTask{
JobID: job.ID, RepoID: run.RepoID, OwnerID: run.OwnerID,
CommitSHA: job.CommitSHA, Status: status, Started: longAgo,
TokenHash: fmt.Sprintf("endless-test-token-%d", seq), TokenSalt: "salt",
}
require.NoError(t, db.Insert(t.Context(), task))
return task
}
running := newTaskWithJob(actions_model.StatusRunning)
cancelling := newTaskWithJob(actions_model.StatusCancelling)
require.NoError(t, StopEndlessTasks(t.Context()))
runningAfter := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionTask{ID: running.ID})
cancellingAfter := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionTask{ID: cancelling.ID})
assert.Equal(t, actions_model.StatusFailure, runningAfter.Status, "long-running task should be force-stopped")
assert.Equal(t, actions_model.StatusCancelling, cancellingAfter.Status, "cancelling task should keep running its cleanup")
}

View File

@@ -0,0 +1,343 @@
// Copyright 2023 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package actions
import (
"context"
"errors"
"fmt"
"slices"
actions_model "gitea.dev/models/actions"
"gitea.dev/models/db"
git_model "gitea.dev/models/git"
repo_model "gitea.dev/models/repo"
user_model "gitea.dev/models/user"
actions_module "gitea.dev/modules/actions"
"gitea.dev/modules/actions/jobparser"
"gitea.dev/modules/commitstatus"
"gitea.dev/modules/glob"
"gitea.dev/modules/log"
api "gitea.dev/modules/structs"
"gitea.dev/modules/util"
webhook_module "gitea.dev/modules/webhook"
pull_service "gitea.dev/services/pull"
commitstatus_service "gitea.dev/services/repository/commitstatus"
)
// CreateCommitStatusForRunJobs creates a commit status for the given job if it has a supported event and related commit.
// 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
}
// Compute the scoped source-repo prefix once per run; it is identical for every job.
var scopedPrefix string
if run.IsScopedRun {
scopedPrefix = actions_model.ScopedStatusContextPrefix(ctx, run.WorkflowRepoID)
}
for _, job := range jobs {
// A deferred-matrix placeholder's name changes when it expands, so a status created while it
// waits would be orphaned. The emitter reloads the jobs after expanding and creates them
// then. A placeholder that reached a final status (skipped by its `if:`, cancelled with the
// run, or failed to expand) never expands, so it keeps its own name and still deserves a status.
if job.IsMatrixDeferred && !job.Status.IsDone() {
continue
}
if err = createCommitStatus(ctx, run.Repo, event, commitID, scopedPrefix, run, job); err != nil {
log.Error("Failed to create commit status for job %d: %v", job.ID, err)
}
}
}
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, scopedPrefix string, run *actions_model.ActionRun, job *actions_model.ActionRunJob) error {
displayName := actions_module.WorkflowDisplayName(run.WorkflowID, job.WorkflowPayload)
ctxName := actions_module.WorkflowStatusContextName(displayName, job.Name, event) // git_model.NewCommitStatus also trims spaces
if run.IsScopedRun {
// A scoped run is prefixed with its source repo (set off by a colon) so it stays distinct from a same-named repo-level workflow.
// scopedPrefix is computed once per run by the caller. The settings page derives the same string to preview expected checks.
ctxName = actions_module.ScopedWorkflowStatusContextName(scopedPrefix, displayName, job.Name, event)
}
targetURL := fmt.Sprintf("%s/jobs/%d", run.Link(), job.ID)
return createWorkflowCommitStatus(ctx, repo, commitID, ctxName, run.WorkflowID, toCommitStatus(job.Status), targetURL, toCommitStatusDescription(job))
}
// getAllRequiredStatusContextGlobs returns the compiled globs of every status-check context required in the repo:
// its protected branch rules' contexts and its required scoped workflows' patterns (see EffectiveRequiredContexts).
func getAllRequiredStatusContextGlobs(ctx context.Context, repo *repo_model.Repository) ([]glob.Glob, error) {
rules, err := git_model.FindRepoProtectedBranchRules(ctx, repo.ID)
if err != nil {
return nil, fmt.Errorf("FindRepoProtectedBranchRules: %w", err)
}
required, err := pull_service.EffectiveRequiredContexts(ctx, repo, rules...)
if err != nil {
return nil, fmt.Errorf("EffectiveRequiredContexts: %w", err)
}
globs := make([]glob.Glob, 0, len(required))
for _, c := range required {
if gp, err := glob.Compile(c); err != nil {
log.Error("glob.Compile %q: %v", c, err)
} else {
globs = append(globs, gp)
}
}
return globs, nil
}
// CreateSkippedCommitStatusForFilteredWorkflow posts a skipped commit status for each job of a
// workflow that matched the triggering event but was excluded by a branch/paths filter.
// This lets a required status check tied to that context be satisfied without the workflow running.
// Only contexts matching requiredGlobs are posted; a non-required context gets no skipped status.
// No ActionRun is created, so the status has no target URL (there is no run/job to link to).
// A non-empty scopedPrefix prefixes each context with its source repo, matching scoped runs.
//
// TODO: requiredGlobs over-approximates by including every protected branch rule's required contexts.
// If possible, the set should be narrowed to the required contexts of a specific branch protection rule (and the contexts from required scoped workflows).
// Currently, a `push` event must keep the repo-wide union since its future pull request base branch is unknown.
func CreateSkippedCommitStatusForFilteredWorkflow(ctx context.Context, repo *repo_model.Repository, event webhook_module.HookEventType, triggerEvent, workflowID string, content []byte, payload api.Payloader, scopedPrefix string, requiredGlobs []glob.Glob) error {
if len(requiredGlobs) == 0 {
return nil // nothing is required, so no skipped status is needed
}
// Derive the status event name and target commit from the payload.
// TODO: this mirrors getCommitStatusEventNameAndCommitID, which derives the same from a persisted run. Should merge the logic if possible.
var statusEvent, commitID string
switch event {
case webhook_module.HookEventPush:
if p, ok := payload.(*api.PushPayload); ok && p.HeadCommit != nil {
statusEvent, commitID = "push", p.HeadCommit.ID
}
case webhook_module.HookEventPullRequest,
webhook_module.HookEventPullRequestSync,
webhook_module.HookEventPullRequestAssign,
webhook_module.HookEventPullRequestLabel,
webhook_module.HookEventPullRequestReviewRequest,
webhook_module.HookEventPullRequestMilestone:
if p, ok := payload.(*api.PullRequestPayload); ok && p.PullRequest != nil && p.PullRequest.Head != nil {
statusEvent, commitID = "pull_request", p.PullRequest.Head.Sha
if triggerEvent == actions_module.GithubEventPullRequestTarget {
statusEvent = "pull_request_target"
}
}
}
if statusEvent == "" || commitID == "" {
return nil // unsupported event or missing commit id, nothing to post
}
workflows, err := jobparser.Parse(content)
if err != nil {
return fmt.Errorf("jobparser.Parse: %w", err)
}
displayName := actions_module.WorkflowDisplayName(workflowID, content)
for _, sw := range workflows {
_, job := sw.Job()
if job == nil {
continue
}
jobName := util.EllipsisDisplayString(job.Name, 255) // run creation truncates job names the same way
ctxName := actions_module.WorkflowStatusContextName(displayName, jobName, statusEvent)
if scopedPrefix != "" {
ctxName = actions_module.ScopedWorkflowStatusContextName(scopedPrefix, displayName, jobName, statusEvent)
}
// Only a required context needs the skipped status.
if !slices.ContainsFunc(requiredGlobs, func(gp glob.Glob) bool { return gp.Match(ctxName) }) {
continue
}
// "Skipped" mirrors toCommitStatusDescription for StatusSkipped.
if err := createWorkflowCommitStatus(ctx, repo, commitID, ctxName, workflowID, commitstatus.CommitStatusSkipped, "", "Skipped"); err != nil {
return err
}
}
return nil
}
// createWorkflowCommitStatus posts the commit status for one workflow-job context.
func createWorkflowCommitStatus(ctx context.Context, repo *repo_model.Repository, commitID, ctxName, workflowID string, state commitstatus.CommitStatusState, targetURL, description string) error {
// Mix the workflow file path into the hash so two workflow files that
// share the same `name:` and job name produce distinct commit statuses
// even though they render identically — matching GitHub's behavior
// (issue #35699).
ctxHash := git_model.HashCommitStatusContext(ctxName + "\x00" + workflowID)
// Pre-fix rows were hashed from Context alone. If a pre-existing row with
// the legacy hash is still the "latest" for this SHA, reuse that hash so
// the new row supersedes it; otherwise the old pending status would stay
// stuck forever (it lives in its own dedupe group). Only relevant for
// in-flight workflows at upgrade time.
legacyHash := git_model.HashCommitStatusContext(ctxName)
statuses, err := git_model.GetLatestCommitStatus(ctx, repo.ID, commitID, db.ListOptionsAll)
if err != nil {
return fmt.Errorf("GetLatestCommitStatus: %w", err)
}
for _, v := range statuses {
// Only adopt the legacy (Context-only) hash from a row the Actions user itself created before the
// #35699 fix, i.e. a pre-upgrade in-flight run. Adopting it from an external integration or the API
// would collapse two same-named workflows back into a single check.
if v.ContextHash == legacyHash && v.Context == ctxName && v.CreatorID == user_model.ActionsUserID {
ctxHash = legacyHash
break
}
}
for _, v := range statuses {
if v.ContextHash == ctxHash {
if v.State == state && v.TargetURL == targetURL && v.Description == description {
return nil
}
break
}
}
creator := user_model.NewActionsUser()
status := git_model.CommitStatus{
SHA: commitID,
TargetURL: targetURL,
Description: description,
Context: ctxName,
ContextHash: ctxHash,
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
}
}

View File

@@ -0,0 +1,433 @@
// 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"
user_model "gitea.dev/models/user"
actions_module "gitea.dev/modules/actions"
"gitea.dev/modules/commitstatus"
"gitea.dev/modules/git"
"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 := git.OpenRepository(t.Context(), repo)
require.NoError(t, err)
defer gitRepo.Close()
commit, err := gitRepo.GetBranchCommit(t.Context(), 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]))
}
// TestCreateCommitStatus_DistinctWorkflowFilesSameName covers issue #35699:
// two workflow files with the same `name:` and same job name must produce
// two distinct commit statuses, not be deduplicated into one.
func TestCreateCommitStatus_DistinctWorkflowFilesSameName(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 4})
branch := unittest.AssertExistsAndLoadBean(t, &git_model.Branch{RepoID: repo.ID, Name: repo.DefaultBranch})
payload := []byte(`
name: test-run
on: pull_request
jobs:
my-test:
runs-on: ubuntu-latest
steps:
- run: echo hi
`)
for _, spec := range []struct {
workflowID string
runID, jobID int64
}{
{"workflow1.yaml", 99101, 99201},
{"workflow2.yaml", 99102, 99202},
} {
run := &actions_model.ActionRun{
ID: spec.runID, Index: spec.runID, RepoID: repo.ID, Repo: repo, OwnerID: repo.OwnerID, TriggerUserID: repo.OwnerID,
WorkflowID: spec.workflowID, CommitSHA: branch.CommitID,
}
require.NoError(t, db.Insert(t.Context(), run))
job := &actions_model.ActionRunJob{
ID: spec.jobID, RunID: run.ID, RepoID: repo.ID, OwnerID: repo.OwnerID,
Name: "my-test", Status: actions_model.StatusWaiting,
WorkflowPayload: payload,
}
require.NoError(t, db.Insert(t.Context(), job))
require.NoError(t, createCommitStatus(t.Context(), repo, "pull_request", branch.CommitID, "", run, job))
}
statuses, err := git_model.GetLatestCommitStatus(t.Context(), repo.ID, branch.CommitID, db.ListOptionsAll)
require.NoError(t, err)
// Both workflow files should produce a row even though the display
// Context is identical — matching GitHub's behavior.
hashes := map[string]struct{}{}
targets := map[string]struct{}{}
for _, st := range statuses {
hashes[st.ContextHash] = struct{}{}
targets[st.TargetURL] = struct{}{}
assert.Equal(t, "test-run / my-test (pull_request)", st.Context)
}
assert.Len(t, hashes, 2, "expected distinct ContextHash per workflow file")
assert.Len(t, targets, 2, "expected distinct TargetURL per workflow file")
}
// TestCreateCommitStatus_LegacyHashRecovery covers the upgrade path: a pending
// status created before the fix (hashed from Context alone) must still be
// superseded by a follow-up event, instead of being orphaned in its own dedupe
// group while a new row accumulates under the new hash.
func TestCreateCommitStatus_LegacyHashRecovery(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 4})
branch := unittest.AssertExistsAndLoadBean(t, &git_model.Branch{RepoID: repo.ID, Name: repo.DefaultBranch})
ctxName := "legacy.yaml / my-job (push)"
legacyHash := git_model.HashCommitStatusContext(ctxName)
sha, err := git.NewIDFromString(branch.CommitID)
require.NoError(t, err)
// Pre-#35699 in-flight rows were posted by the Actions user with the Context-only hash.
creator := user_model.NewActionsUser()
require.NoError(t, git_model.NewCommitStatus(t.Context(), git_model.NewCommitStatusOptions{
Repo: repo,
Creator: creator,
SHA: sha,
CommitStatus: &git_model.CommitStatus{
State: commitstatus.CommitStatusPending,
Context: ctxName,
ContextHash: legacyHash,
TargetURL: "https://example.invalid/legacy",
Description: "Waiting to run",
},
}))
run := &actions_model.ActionRun{
ID: 99301, Index: 99301, RepoID: repo.ID, Repo: repo, OwnerID: repo.OwnerID, TriggerUserID: repo.OwnerID,
WorkflowID: "legacy.yaml", CommitSHA: branch.CommitID,
}
require.NoError(t, db.Insert(t.Context(), run))
job := &actions_model.ActionRunJob{
ID: 99302, RunID: run.ID, RepoID: repo.ID, OwnerID: repo.OwnerID,
Name: "my-job", Status: actions_model.StatusSuccess,
}
require.NoError(t, db.Insert(t.Context(), job))
require.NoError(t, createCommitStatus(t.Context(), repo, "push", branch.CommitID, "", run, job))
latest, err := git_model.GetLatestCommitStatus(t.Context(), repo.ID, branch.CommitID, db.ListOptionsAll)
require.NoError(t, err)
// The new row must reuse the legacy hash so GetLatestCommitStatus returns
// only one entry for this Context — the success, not the orphaned pending.
matches := 0
for _, s := range latest {
if s.Context == ctxName {
matches++
assert.Equal(t, legacyHash, s.ContextHash)
assert.Equal(t, commitstatus.CommitStatusSuccess, s.State)
}
}
assert.Equal(t, 1, matches)
}
// TestCreateCommitStatus_LegacyHashExternalNotAdopted: a status from a non-Actions creator sharing a
// workflow's Context must not pull the workflow into the legacy Context-only hash group.
func TestCreateCommitStatus_LegacyHashExternalNotAdopted(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 4})
branch := unittest.AssertExistsAndLoadBean(t, &git_model.Branch{RepoID: repo.ID, Name: repo.DefaultBranch})
workflowID := "external.yaml"
ctxName := "external.yaml / my-job (push)"
legacyHash := git_model.HashCommitStatusContext(ctxName)
distinctHash := git_model.HashCommitStatusContext(ctxName + "\x00" + workflowID)
sha, err := git.NewIDFromString(branch.CommitID)
require.NoError(t, err)
// An external status (posted by a real user, not the Actions user) sharing the same Context.
externalCreator := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
require.NoError(t, git_model.NewCommitStatus(t.Context(), git_model.NewCommitStatusOptions{
Repo: repo,
Creator: externalCreator,
SHA: sha,
CommitStatus: &git_model.CommitStatus{
State: commitstatus.CommitStatusSuccess,
Context: ctxName,
ContextHash: legacyHash,
TargetURL: "https://example.invalid/external",
Description: "external check",
},
}))
run := &actions_model.ActionRun{
ID: 99311, Index: 99311, RepoID: repo.ID, Repo: repo, OwnerID: repo.OwnerID, TriggerUserID: repo.OwnerID,
WorkflowID: workflowID, CommitSHA: branch.CommitID,
}
require.NoError(t, db.Insert(t.Context(), run))
job := &actions_model.ActionRunJob{
ID: 99312, RunID: run.ID, RepoID: repo.ID, OwnerID: repo.OwnerID,
Name: "my-job", Status: actions_model.StatusSuccess,
}
require.NoError(t, db.Insert(t.Context(), job))
require.NoError(t, createCommitStatus(t.Context(), repo, "push", branch.CommitID, "", run, job))
latest, err := git_model.GetLatestCommitStatus(t.Context(), repo.ID, branch.CommitID, db.ListOptionsAll)
require.NoError(t, err)
// The external status and the workflow status must coexist under distinct hashes.
var external, workflow *git_model.CommitStatus
for _, s := range latest {
switch s.ContextHash {
case legacyHash:
external = s
case distinctHash:
workflow = s
}
}
require.NotNil(t, external, "external status must be preserved under the legacy hash")
require.NotNil(t, workflow, "workflow status must use its own distinct hash, not the external legacy hash")
assert.Equal(t, "https://example.invalid/external", external.TargetURL)
}
// TestCreateCommitStatus_UnnamedWorkflowUsesFileName: a workflow with no
// non-blank `name:` uses the file name in the Context, not an empty
// "/ job (event)" — covers both an omitted and a whitespace-only name.
func TestCreateCommitStatus_UnnamedWorkflowUsesFileName(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 4})
branch := unittest.AssertExistsAndLoadBean(t, &git_model.Branch{RepoID: repo.ID, Name: repo.DefaultBranch})
for _, tc := range []struct {
workflowID string
runID, jobID int64
payload string
}{
{"unnamed.yaml", 99401, 99411, "on: push\n"},
{"blank.yaml", 99402, 99412, "name: \" \"\non: push\n"},
} {
run := &actions_model.ActionRun{
ID: tc.runID, Index: tc.runID, RepoID: repo.ID, Repo: repo, OwnerID: repo.OwnerID, TriggerUserID: repo.OwnerID,
WorkflowID: tc.workflowID, CommitSHA: branch.CommitID,
}
require.NoError(t, db.Insert(t.Context(), run))
job := &actions_model.ActionRunJob{
ID: tc.jobID, RunID: run.ID, RepoID: repo.ID, OwnerID: repo.OwnerID,
Name: "my-test", Status: actions_model.StatusWaiting,
WorkflowPayload: []byte(tc.payload + `jobs:
my-test:
runs-on: ubuntu-latest
steps:
- run: echo hi
`),
}
require.NoError(t, db.Insert(t.Context(), job))
require.NoError(t, createCommitStatus(t.Context(), repo, "push", branch.CommitID, "", run, job))
statuses := findCommitStatusesForContext(t, repo.ID, branch.CommitID, tc.workflowID+" / my-test (push)")
require.Len(t, statuses, 1)
assert.Equal(t, commitstatus.CommitStatusPending, statuses[0].State)
}
}
// TestCreateCommitStatus_ScopedSourcePrefix: a scoped run's commit status Context is prefixed with the source repo's full name,
// so it is distinct (display AND hash) from a same-named repo-level workflow.
func TestCreateCommitStatus_ScopedSourcePrefix(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
consumer := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 4})
source := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
branch := unittest.AssertExistsAndLoadBean(t, &git_model.Branch{RepoID: consumer.ID, Name: consumer.DefaultBranch})
payload := []byte(`name: ci
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- run: echo hi
`)
// A repo-level run and a scoped run share the same workflow name and job name;
// only the scoped one points its content source at another repo (WorkflowRepoID=source.ID, IsScopedRun=true).
for _, spec := range []struct {
runID, jobID int64
scoped bool
}{
{99501, 99511, false},
{99502, 99512, true},
} {
workflowRepoID := consumer.ID
if spec.scoped {
workflowRepoID = source.ID
}
run := &actions_model.ActionRun{
ID: spec.runID, Index: spec.runID, RepoID: consumer.ID, Repo: consumer, OwnerID: consumer.OwnerID, TriggerUserID: consumer.OwnerID,
WorkflowID: "ci.yaml", CommitSHA: branch.CommitID,
WorkflowRepoID: workflowRepoID, WorkflowCommitSHA: branch.CommitID, IsScopedRun: spec.scoped,
}
require.NoError(t, db.Insert(t.Context(), run))
job := &actions_model.ActionRunJob{
ID: spec.jobID, RunID: run.ID, RepoID: consumer.ID, OwnerID: consumer.OwnerID,
Name: "build", Status: actions_model.StatusWaiting, WorkflowPayload: payload,
}
require.NoError(t, db.Insert(t.Context(), job))
// mirror CreateCommitStatusForRunJobs: compute the scoped prefix once per run
scopedPrefix := ""
if run.IsScopedRun {
scopedPrefix = actions_model.ScopedStatusContextPrefix(t.Context(), run.WorkflowRepoID)
}
require.NoError(t, createCommitStatus(t.Context(), consumer, "push", branch.CommitID, scopedPrefix, run, job))
}
// repo-level Context is the bare "<display name> / <job> (<event>)"; the scoped one is the same but sets off the source repo with a colon,
// so the two stay distinct (and have different hashes) despite the same `name:`.
repoStatuses := findCommitStatusesForContext(t, consumer.ID, branch.CommitID, "ci / build (push)")
require.Len(t, repoStatuses, 1)
scopedStatuses := findCommitStatusesForContext(t, consumer.ID, branch.CommitID, source.FullName()+": ci / build (push)")
require.Len(t, scopedStatuses, 1)
assert.NotEqual(t, repoStatuses[0].ContextHash, scopedStatuses[0].ContextHash,
"scoped status must not collide with the same-named repo-level workflow")
}
func findCommitStatusesForContext(t *testing.T, repoID int64, sha, context string) []*git_model.CommitStatus {
t.Helper()
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
}

View File

@@ -0,0 +1,86 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package actions
import (
"context"
"fmt"
act_model "gitea.dev/actionslib/pkg/model"
actions_model "gitea.dev/models/actions"
"gitea.dev/modules/actions/jobparser"
"gitea.dev/modules/setting"
"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.
// Callers must resolve `inputs`, there is no job in scope here to read `on: workflow_dispatch` from.
// See https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#concurrency
func EvaluateRunConcurrencyFillModel(ctx context.Context, run *actions_model.ActionRun, 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 {
if run.Event == "workflow_dispatch" {
setting.PanicInDevOrTesting("run %d: workflow_dispatch inputs must be resolved by the caller", run.ID)
}
inputs = map[string]any{}
}
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
}

367
services/actions/context.go Normal file
View File

@@ -0,0 +1,367 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package actions
import (
"context"
"fmt"
"strconv"
"gitea.dev/actionslib/pkg/model"
actions_model "gitea.dev/models/actions"
"gitea.dev/models/db"
git_model "gitea.dev/models/git"
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"
)
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.Ref
sha = pullPayload.PullRequest.Base.Sha
}
}
refName := git.RefName(ref)
refProtected, err := git_model.IsRefProtected(ctx, run.RepoID, refName)
if err != nil {
log.Error("GenerateGiteaContext: check protection for ref %q: %v", refName, err)
refProtected = false
}
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": refProtected, // boolean, true if protection rules 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)
// Only the trigger user is needed for triggering_actor. attempt.LoadAttributes would also re-load
// the run this function already holds as a parameter, on the hot task-dispatch path.
if err := attempt.LoadTriggerUser(ctx); err != nil {
log.Error("GenerateGiteaContext: load trigger user of attempt %d: %v", attempt.ID, err)
}
if attempt.TriggerUser != nil {
gitContext["triggering_actor"] = attempt.TriggerUser.Name
}
}
// Fallback for first-run parse time: no job, no attempt (LatestAttemptID==0). github.run_attempt
// is 1-based per the documented contract, so emit "1" rather than leaving it empty.
if gitContext["run_attempt"] == "" {
gitContext["run_attempt"] = "1"
}
return gitContext
}
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 contextMapValueOrDefault[T any](m map[string]any, key string, defaultValue T) T {
if value, ok := m[key]; ok {
if v, ok := value.(T); ok {
return v
}
}
return defaultValue
}
func (g *GiteaContext) ToGitHubContext() *model.GithubContext {
return &model.GithubContext{
Event: contextMapValueOrDefault(*g, "event", map[string]any(nil)),
EventPath: contextMapValueOrDefault(*g, "event_path", ""),
Workflow: contextMapValueOrDefault(*g, "workflow", ""),
RunID: contextMapValueOrDefault(*g, "run_id", ""),
RunNumber: contextMapValueOrDefault(*g, "run_number", ""),
Actor: contextMapValueOrDefault(*g, "actor", ""),
Repository: contextMapValueOrDefault(*g, "repository", ""),
EventName: contextMapValueOrDefault(*g, "event_name", ""),
Sha: contextMapValueOrDefault(*g, "sha", ""),
Ref: contextMapValueOrDefault(*g, "ref", ""),
RefName: contextMapValueOrDefault(*g, "ref_name", ""),
RefType: contextMapValueOrDefault(*g, "ref_type", ""),
HeadRef: contextMapValueOrDefault(*g, "head_ref", ""),
BaseRef: contextMapValueOrDefault(*g, "base_ref", ""),
Token: "", // deliberately omitted for security
Workspace: contextMapValueOrDefault(*g, "workspace", ""),
Action: contextMapValueOrDefault(*g, "action", ""),
ActionPath: contextMapValueOrDefault(*g, "action_path", ""),
ActionRef: contextMapValueOrDefault(*g, "action_ref", ""),
ActionRepository: contextMapValueOrDefault(*g, "action_repository", ""),
Job: contextMapValueOrDefault(*g, "job", ""),
JobName: "", // not present in GiteaContext
RepositoryOwner: contextMapValueOrDefault(*g, "repository_owner", ""),
RetentionDays: contextMapValueOrDefault(*g, "retention_days", ""),
RunnerPerflog: "", // not present in GiteaContext
RunnerTrackingID: "", // not present in GiteaContext
ServerURL: contextMapValueOrDefault(*g, "server_url", ""),
APIURL: contextMapValueOrDefault(*g, "api_url", ""),
GraphQLURL: contextMapValueOrDefault(*g, "graphql_url", ""),
}
}

View File

@@ -0,0 +1,435 @@
// Copyright 2024 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package actions
import (
"strconv"
"testing"
act_model "gitea.dev/actionslib/pkg/model"
actions_model "gitea.dev/models/actions"
"gitea.dev/models/db"
git_model "gitea.dev/models/git"
repo_model "gitea.dev/models/repo"
"gitea.dev/models/unittest"
user_model "gitea.dev/models/user"
actions_module "gitea.dev/modules/actions"
"gitea.dev/modules/json"
api "gitea.dev/modules/structs"
webhook_module "gitea.dev/modules/webhook"
"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.True(t, attemptA.ConcurrencyCancel)
assert.Contains(t, attemptA.ConcurrencyGroup, "791")
assert.Contains(t, attemptB.ConcurrencyGroup, "792")
assert.NotEqual(t, attemptA.ConcurrencyGroup, attemptB.ConcurrencyGroup)
}
func TestPrepareRunAndInsert_ExpressionsSeeRunID(t *testing.T) {
// Regression for the cross-branch concurrency leak: github.run_id must be available during both
// jobparser.Parse (run-name) and 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: "{}",
WorkflowRepoID: 4,
WorkflowCommitSHA: "c2d72f548424103f01ee1dc02889c1e2bff816b0",
}
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"])
}
func TestGenerateGiteaContextPullRequestTarget(t *testing.T) {
payload := api.PullRequestPayload{
PullRequest: &api.PullRequest{
Base: &api.PRBranchInfo{
Name: "owner:main",
Ref: "main",
Sha: "1234567890abcdef",
},
Head: &api.PRBranchInfo{
Name: "fork:feature",
Ref: "feature",
Sha: "fedcba0987654321",
},
},
}
payloadBytes, err := json.Marshal(payload)
assert.NoError(t, err)
run := &actions_model.ActionRun{
Event: webhook_module.HookEventPullRequest,
TriggerEvent: string(actions_module.GithubEventPullRequestTarget),
EventPayload: string(payloadBytes),
TriggerUser: &user_model.User{Name: "test-user"},
Repo: &repo_model.Repository{Name: "test-repo", OwnerName: "test-owner"},
}
giteaCtx := GenerateGiteaContext(t.Context(), run, nil, nil)
assert.Equal(t, "refs/heads/main", giteaCtx["ref"])
assert.Equal(t, "main", giteaCtx["ref_name"])
}
func TestGenerateGiteaContextRefProtected(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 4})
require.NoError(t, git_model.UpdateProtectBranch(t.Context(), repo, &git_model.ProtectedBranch{
RepoID: repo.ID,
RuleName: "master",
}, git_model.WhitelistOptions{}))
require.NoError(t, git_model.InsertProtectedTag(t.Context(), &git_model.ProtectedTag{
RepoID: repo.ID,
NamePattern: "v*",
}))
gitCtx := GenerateGiteaContext(t.Context(), &actions_model.ActionRun{
RepoID: repo.ID,
Repo: repo,
TriggerUser: &user_model.User{Name: "test-user"},
Ref: "refs/heads/master",
}, nil, nil)
assert.Equal(t, true, gitCtx["ref_protected"])
tagCtx := GenerateGiteaContext(t.Context(), &actions_model.ActionRun{
RepoID: repo.ID,
Repo: repo,
TriggerUser: &user_model.User{Name: "test-user"},
Ref: "refs/tags/v1.0.0",
}, nil, nil)
assert.Equal(t, true, tagCtx["ref_protected"])
unprotectedTagCtx := GenerateGiteaContext(t.Context(), &actions_model.ActionRun{
RepoID: repo.ID,
Repo: repo,
TriggerUser: &user_model.User{Name: "test-user"},
Ref: "refs/tags/other",
}, nil, nil)
assert.Equal(t, false, unprotectedTagCtx["ref_protected"])
}
// TestGenerateGiteaContext_NilAttempt verifies that, with no explicit attempt,
// use GetLatestAttempt to load the latest attempt and resolve attempt-related context variables.
func TestGenerateGiteaContext_NilAttempt(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 4})
require.NoError(t, repo.LoadOwner(t.Context()))
actor := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}) // initiated the run
triggerer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) // initiated the latest attempt
run := &actions_model.ActionRun{
RepoID: repo.ID, Repo: repo, OwnerID: repo.OwnerID,
TriggerUserID: actor.ID, TriggerUser: actor,
WorkflowID: "test.yml", Index: 99600, Ref: "refs/heads/main",
CommitSHA: "c2d72f548424103f01ee1dc02889c1e2bff816b0", TriggerEvent: "push",
Status: actions_model.StatusRunning,
}
require.NoError(t, db.Insert(t.Context(), run))
attempt := &actions_model.ActionRunAttempt{
RepoID: repo.ID, RunID: run.ID, Attempt: 3, TriggerUserID: triggerer.ID, Status: actions_model.StatusRunning,
}
require.NoError(t, db.Insert(t.Context(), attempt))
run.LatestAttemptID = attempt.ID
job := &actions_model.ActionRunJob{
RunID: run.ID, RunAttemptID: attempt.ID, AttemptJobID: 1, RepoID: repo.ID, OwnerID: repo.OwnerID,
Name: "j", JobID: "j", Attempt: attempt.Attempt, Status: actions_model.StatusRunning,
}
require.NoError(t, db.Insert(t.Context(), job))
// attempt == nil forces the fallback lookup via run.GetLatestAttempt.
gitCtx := GenerateGiteaContext(t.Context(), run, nil, job)
assert.Equal(t, actor.Name, gitCtx["actor"])
assert.Equal(t, triggerer.Name, gitCtx["triggering_actor"])
assert.Equal(t, "3", gitCtx["run_attempt"])
}

148
services/actions/helper.go Normal file
View File

@@ -0,0 +1,148 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package actions
import (
"context"
"fmt"
actions_model "gitea.dev/models/actions"
actions_module "gitea.dev/modules/actions"
"gitea.dev/modules/actions/jobparser"
"gitea.dev/modules/json"
"gitea.dev/modules/log"
api "gitea.dev/modules/structs"
"gitea.dev/modules/util"
)
// dispatchInputsForJob types a top-level job's `inputs.*` from EventPayload, empty for other events.
func dispatchInputsForJob(run *actions_model.ActionRun, job *actions_model.ActionRunJob) (map[string]any, error) {
if run.Event != "workflow_dispatch" {
return map[string]any{}, nil
}
var payload api.WorkflowDispatchPayload
if err := json.Unmarshal([]byte(run.EventPayload), &payload); err != nil {
return nil, err
}
if payload.Inputs == nil {
payload.Inputs = map[string]any{} // nil reads as "unresolved" in EvaluateRunConcurrencyFillModel
}
swf, _, err := jobparser.ParseRawSingleWorkflow(job.WorkflowPayload)
if err != nil {
return nil, util.NewInvalidArgumentErrorf("parse job %d workflow payload: %v", job.ID, err)
}
dispatch := swf.WorkflowDispatchConfig()
if dispatch == nil { // without it the values would silently stay untyped
return nil, util.NewInvalidArgumentErrorf("job %d payload declares no workflow_dispatch", job.ID)
}
coerceDispatchInputTypes(dispatch, payload.Inputs)
return payload.Inputs, nil
}
// dispatchInputsForRunJobs answers for the whole run, off any top-level job's workflow header.
func dispatchInputsForRunJobs(run *actions_model.ActionRun, jobs []*actions_model.ActionRunJob) (map[string]any, error) {
for _, job := range jobs {
if job.ParentJobID == 0 {
return dispatchInputsForJob(run, job)
}
}
return nil, fmt.Errorf("run %d: no top-level job to read the workflow_dispatch declaration from", run.ID)
}
// getInputsForJob returns the `inputs.*` top-level expression context for a job's evaluation.
// - For top-level jobs, it falls back to the run's dispatch inputs (empty for non-dispatch events)
// - For reusable workflow children (and nested callers), this is the direct parent caller's CallPayload.Inputs
func getInputsForJob(ctx context.Context, run *actions_model.ActionRun, job *actions_model.ActionRunJob) (map[string]any, error) {
if job.ParentJobID == 0 {
return dispatchInputsForJob(run, job)
}
caller, err := actions_model.GetRunJobByRunAndID(ctx, run.ID, job.ParentJobID)
if err != nil {
return nil, fmt.Errorf("load caller job %d: %w", job.ParentJobID, err)
}
if caller.CallPayload == "" {
// should not happen - a child job cannot reach this point if its caller's CallPayload hasn't been evaluated
return map[string]any{}, nil
}
var p api.WorkflowCallPayload
if err := json.Unmarshal([]byte(caller.CallPayload), &p); err != nil {
return nil, util.NewInvalidArgumentErrorf("decode caller %d payload: %v", caller.ID, err)
}
if p.Inputs == nil {
return map[string]any{}, nil
}
return p.Inputs, nil
}
// pullRequestTargetBaseSHA returns the base branch commit of a pull_request_target run, and whether the run is one.
func pullRequestTargetBaseSHA(run *actions_model.ActionRun) (string, bool) {
if run.TriggerEvent != actions_module.GithubEventPullRequestTarget {
return "", false
}
payload, err := run.GetPullRequestEventPayload()
if err != nil {
log.Error("run %d: get pull request event payload: %v", run.ID, err)
return "", false
}
if payload.PullRequest == nil || payload.PullRequest.Base == nil || payload.PullRequest.Base.Sha == "" {
return "", false
}
return payload.PullRequest.Base.Sha, true
}
// evaluateJobIf evaluates a job's `if:`
func evaluateJobIf(ctx context.Context, run *actions_model.ActionRun, attempt *actions_model.ActionRunAttempt, job *actions_model.ActionRunJob, vars map[string]string, allNeedsSucceed bool) (bool, error) {
parsedJob, err := job.ParseJob()
if err != nil {
return false, err
}
// Empty `if:` reduces to implicit `success()` - true iff every need finished as Success.
if len(parsedJob.If.Value) == 0 {
return allNeedsSucceed, nil
}
// A deferred-matrix placeholder has no combination yet, so an `if:` reading `matrix.*` can only be
// decided by the emitter's post-expansion pass, against each combination's own values.
// always()/failure()/cancelled() opt out of the needs gate this falls back to.
if job.IsMatrixDeferred && jobparser.ExpressionReadsMatrix(parsedJob.If.Value) {
return allNeedsSucceed || jobparser.ExpressionIgnoresNeedResults(parsedJob.If.Value), nil
}
jobResults, err := findJobNeedsAndFillJobResults(ctx, job)
if err != nil {
return false, err
}
inputs, err := getInputsForJob(ctx, run, job)
if err != nil {
return false, err
}
// GenerateGiteaContext dereferences the run's repo and trigger user, so load them here instead of
// relying on whatever the caller happened to load before.
if err := run.LoadRepo(ctx); err != nil {
return false, err
}
if err := run.LoadTriggerUser(ctx); err != nil {
return false, err
}
gitCtx := GenerateGiteaContext(ctx, run, attempt, job)
return jobparser.EvaluateJobIfExpression(job.JobID, parsedJob, gitCtx, jobResults, vars, inputs, job.IsMatrixDeferred)
}
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
}

View File

@@ -0,0 +1,137 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package actions
import (
"fmt"
"testing"
actions_model "gitea.dev/models/actions"
actions_module "gitea.dev/modules/actions"
"gitea.dev/modules/json"
api "gitea.dev/modules/structs"
webhook_module "gitea.dev/modules/webhook"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestEvaluateJobIfDefersMatrixExpression(t *testing.T) {
// A placeholder's `if:` reading `matrix.*` can only be decided per combination once the matrix is expanded.
emptyRun := &actions_model.ActionRun{} // if the gate is removed, the emptyRun will cause an error
deferredJob := func(ifExpr string) *actions_model.ActionRunJob {
return &actions_model.ActionRunJob{
ID: 1, JobID: "build", Needs: []string{"setup"}, IsMatrixDeferred: true,
WorkflowPayload: fmt.Appendf(nil, `name: test
on: push
jobs:
build:
runs-on: ubuntu-latest
if: %s
strategy:
matrix:
value: ${{ fromJson(needs.setup.outputs.m) }}
steps:
- run: echo
`, ifExpr),
}
}
for _, tt := range []struct {
ifExpr string
// wantNeedsFailed is what the `if:` decides when the needs did not all succeed.
wantNeedsFailed bool
}{
// These hold only for a real combination, so none can be decided before the matrix expands,
// and the fallback is the needs gate: a job whose needs did not succeed is still skipped.
{ifExpr: "${{ matrix.value == 1 }}"},
{ifExpr: "matrix.value == 1"}, // an `if:` may omit the `${{ }}`
// always() asks to run whatever the needs did, so it must not fall back to their gate.
{ifExpr: "${{ always() && matrix.value == 1 }}", wantNeedsFailed: true},
} {
t.Run(tt.ifExpr, func(t *testing.T) {
got, err := evaluateJobIf(t.Context(), emptyRun, nil, deferredJob(tt.ifExpr), nil, true)
require.NoError(t, err)
assert.True(t, got, "must reach the expansion that gates each combination on its own values")
got, err = evaluateJobIf(t.Context(), emptyRun, nil, deferredJob(tt.ifExpr), nil, false)
require.NoError(t, err)
assert.Equal(t, tt.wantNeedsFailed, got)
})
}
}
func TestDispatchInputsForRunJobs(t *testing.T) {
// a child carries the callee's `on: workflow_call`, so only a top-level job answers for the run
run := &actions_model.ActionRun{Event: "workflow_dispatch", EventPayload: `{"inputs":{"deploy":"true"}}`}
job := &actions_model.ActionRunJob{
ID: 1, JobID: "deploy",
WorkflowPayload: []byte("on: {workflow_dispatch: {inputs: {deploy: {type: boolean}}}}\njobs:\n deploy:\n steps: [{run: echo}]\n"),
}
child := &actions_model.ActionRunJob{
ID: 2, JobID: "called", ParentJobID: job.ID,
WorkflowPayload: []byte("on: workflow_call\njobs:\n called:\n steps: [{run: echo}]\n"),
}
inputs, err := dispatchInputsForRunJobs(run, []*actions_model.ActionRunJob{child, job})
require.NoError(t, err)
assert.Equal(t, true, inputs["deploy"])
}
func TestPullRequestTargetBaseSHA(t *testing.T) {
prPayload := func(baseSHA string) string {
payload, err := json.Marshal(api.PullRequestPayload{
PullRequest: &api.PullRequest{
Base: &api.PRBranchInfo{Sha: baseSHA},
},
})
require.NoError(t, err)
return string(payload)
}
t.Run("pull_request_target with base SHA", func(t *testing.T) {
run := &actions_model.ActionRun{
Event: webhook_module.HookEventPullRequest,
TriggerEvent: actions_module.GithubEventPullRequestTarget,
EventPayload: prPayload("base-sha"),
}
got, ok := pullRequestTargetBaseSHA(run)
assert.True(t, ok)
assert.Equal(t, "base-sha", got)
})
t.Run("non pull_request_target trigger", func(t *testing.T) {
run := &actions_model.ActionRun{
Event: webhook_module.HookEventPullRequest,
TriggerEvent: actions_module.GithubEventPullRequest,
EventPayload: prPayload("base-sha"),
}
got, ok := pullRequestTargetBaseSHA(run)
assert.False(t, ok)
assert.Empty(t, got)
})
t.Run("missing base SHA", func(t *testing.T) {
run := &actions_model.ActionRun{
Event: webhook_module.HookEventPullRequest,
TriggerEvent: actions_module.GithubEventPullRequestTarget,
EventPayload: prPayload(""),
}
got, ok := pullRequestTargetBaseSHA(run)
assert.False(t, ok)
assert.Empty(t, got)
})
t.Run("invalid payload", func(t *testing.T) {
run := &actions_model.ActionRun{
Event: webhook_module.HookEventPullRequest,
TriggerEvent: actions_module.GithubEventPullRequestTarget,
EventPayload: "{",
}
got, ok := pullRequestTargetBaseSHA(run)
assert.False(t, ok)
assert.Empty(t, got)
})
}

71
services/actions/init.go Normal file
View File

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

View File

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

View File

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

View File

@@ -0,0 +1,617 @@
// Copyright 2022 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package actions
import (
"context"
"errors"
"fmt"
"slices"
actions_model "gitea.dev/models/actions"
"gitea.dev/models/db"
"gitea.dev/modules/container"
"gitea.dev/modules/graceful"
"gitea.dev/modules/log"
"gitea.dev/modules/queue"
"gitea.dev/modules/setting"
"gitea.dev/modules/timeutil"
"xorm.io/builder"
)
var jobEmitterQueue *queue.WorkerPoolQueue[*jobUpdate]
type jobUpdate struct {
RunID int64
}
var EmitJobsIfReadyByRun = func(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 err != nil {
return fmt.Errorf("get action run: %w", err)
}
if !exist {
// a deleted run never comes back, returning an error here would requeue the update forever
log.Debug("check run %d: run no longer exists, dropping the queued update", runID)
return nil
}
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: %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
}
// findConcurrencyWaiterToWake returns a run (other than excludeRunID) blocked on the group that can be woken now
func findConcurrencyWaiterToWake(ctx context.Context, repoID, excludeRunID int64, concurrencyGroup string) (int64, error) {
if concurrencyGroup == "" {
return 0, nil
}
// The slot should be free before any waiter can proceed.
holderAttempts, holderJobs, err := actions_model.GetConcurrentRunAttemptsAndJobs(ctx, repoID, concurrencyGroup, []actions_model.Status{actions_model.StatusRunning, actions_model.StatusCancelling})
if err != nil {
return 0, fmt.Errorf("find concurrency-group holders: %w", err)
}
if len(holderAttempts) > 0 || len(holderJobs) > 0 {
return 0, nil
}
cAttempts, cJobs, err := actions_model.GetConcurrentRunAttemptsAndJobs(ctx, repoID, concurrencyGroup, []actions_model.Status{actions_model.StatusBlocked})
if err != nil {
return 0, fmt.Errorf("find blocked concurrent runs: %w", err)
}
for _, a := range cAttempts {
if a.RunID != excludeRunID {
return a.RunID, nil
}
}
for _, j := range cJobs {
if j.RunID != excludeRunID {
return j.RunID, nil
}
}
return 0, nil
}
// checkRunConcurrency wakes a run blocked by concurrency that may become runnable now that
// the current run's activity may have freed a workflow-level or job-level concurrency group.
func checkRunConcurrency(ctx context.Context, run *actions_model.ActionRun) (*jobsCheckResult, error) {
result := &jobsCheckResult{}
checkedConcurrencyGroup := make(container.Set[string])
collect := func(concurrencyGroup string) error {
checkedConcurrencyGroup.Add(concurrencyGroup)
// Exclude run.ID: this run's own jobs are resolved by checkJobsOfCurrentRunAttempt, no need to re-emit.
concurrentRunID, err := findConcurrencyWaiterToWake(ctx, run.RepoID, run.ID, concurrencyGroup)
if err != nil {
return err
}
if concurrentRunID == 0 {
return nil
}
concurrentRun, err := actions_model.GetRunByRepoAndID(ctx, run.RepoID, concurrentRunID)
if err != nil {
return fmt.Errorf("get concurrent run %d: %w", concurrentRunID, err)
}
// A run awaiting approval is not advanced by concurrency; ApproveRuns emits it once approved.
if concurrentRun.NeedApproval {
return nil
}
result.RunIDsToReEmit = append(result.RunIDsToReEmit, concurrentRunID)
return nil
}
// check run (workflow-level) concurrency
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) {
// Approval is the only transition allowed to release an approval-pending run.
if run.NeedApproval {
return &jobsCheckResult{}, nil
}
jobs, err := actions_model.GetRunJobsByRunAndAttemptID(ctx, run.ID, run.LatestAttemptID)
if err != nil {
return nil, err
}
result := &jobsCheckResult{Jobs: jobs}
var attempt *actions_model.ActionRunAttempt
if run.LatestAttemptID > 0 {
attempt, err = actions_model.GetRunAttemptByRepoAndID(ctx, run.RepoID, run.LatestAttemptID)
if err != nil {
return nil, err
}
}
// The resolver below only considers needs and job-level concurrency, so a run blocked
// solely by run-level concurrency would have its jobs unblocked here. checkRunConcurrency
// re-evaluates when the holding run finishes.
if run.Status.IsBlocked() && 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, err := resolver.Resolve(ctx)
if err != nil {
return err
}
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 {
// Terminal expansion failure (an invalid/unresolvable reusable workflow): fail this caller.
log.Warn("caller %d cannot be expanded: %v", job.ID, err)
job.Status = actions_model.StatusFailure
job.Stopped = timeutil.TimeStampNow()
if n, uerr := actions_model.UpdateRunJob(ctx, job, builder.Eq{"status": actions_model.StatusBlocked, "is_expanded": false}, "status", "stopped"); uerr != nil {
return fmt.Errorf("mark unexpandable caller %d failed: %w", job.ID, uerr)
} else if n == 1 {
log.Warn("unexpandable caller %d has been marked as failed", job.ID)
result.UpdatedJobs = append(result.UpdatedJobs, job)
// Re-emit so the failed caller's dependents get resolved on the next pass.
expandedAnyCaller = true
} else {
// A concurrent writer advanced the caller; restore the in-memory state.
log.Warn("unexpandable caller %d has been advanced by a concurrent writer, not marking it failed", job.ID)
job.Status = actions_model.StatusBlocked
job.Stopped = 0
}
} else {
expandedAnyCaller = true
}
case actions_model.StatusSkipped:
job.Status = actions_model.StatusSkipped
if _, err := actions_model.UpdateRunJob(ctx, job, nil, "status"); err != nil {
return err
}
}
continue
}
// Non-caller: standard status update.
job.Status = status
if n, err := actions_model.UpdateRunJob(ctx, job, builder.Eq{"status": actions_model.StatusBlocked}, "status"); err != nil {
return err
} else if n != 1 {
return fmt.Errorf("no affected for updating blocked job %v", job.ID)
}
result.UpdatedJobs = append(result.UpdatedJobs, job)
}
return nil
}); err != nil {
return nil, err
}
result.UpdatedJobs = append(result.UpdatedJobs, resolver.matrixUpdatedJobs...)
// Caller and matrix expansion both insert Blocked jobs, which only a follow-up pass resolves.
// Like the caller's children, matrix siblings are left out of result.Jobs and picked up there.
if expandedAnyCaller || resolver.matrixChanged {
result.RunIDsToReEmit = append(result.RunIDsToReEmit, run.ID)
}
result.CancelledJobs = resolver.cancelledJobs
return result, nil
}
type jobStatusResolver struct {
statuses map[int64]actions_model.Status
// sortedIDs are the keys of statuses, so blocked jobs are resolved in insertion order.
// Resolve only ever rewrites statuses values, never its key set.
sortedIDs []int64
needs map[int64][]int64
jobMap map[int64]*actions_model.ActionRunJob
vars map[string]string
cancelledJobs []*actions_model.ActionRunJob
// matrixChanged is set when matrix expansion inserted siblings or failed a placeholder, both of
// which need a follow-up pass to resolve the dependents.
matrixChanged bool
// matrixInserted is set when matrix expansion inserted sibling rows, which Resolve stops on.
matrixInserted bool
// matrixUpdatedJobs holds jobs whose status matrix expansion persisted itself, so they are
// notified like the ones the caller updates from the resolved status map.
matrixUpdatedJobs []*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))
sortedIDs := make([]int64, 0, len(jobs))
for _, job := range jobs {
statuses[job.ID] = job.Status
sortedIDs = append(sortedIDs, job.ID)
scope := scopedIDToJobs[job.ParentJobID]
for _, need := range job.Needs {
for _, v := range scope[need] {
needs[job.ID] = append(needs[job.ID], v.ID)
}
}
}
slices.Sort(sortedIDs)
return &jobStatusResolver{
statuses: statuses,
sortedIDs: sortedIDs,
needs: needs,
jobMap: jobMap,
vars: vars,
}
}
func (r *jobStatusResolver) Resolve(ctx context.Context) (map[int64]actions_model.Status, error) {
ret := map[int64]actions_model.Status{}
for i := 0; i < len(r.statuses); i++ {
updated, err := r.resolve(ctx)
if err != nil {
return nil, err
}
if len(updated) == 0 {
return ret, nil
}
for k, v := range updated {
ret[k] = v
r.statuses[k] = v
}
if r.matrixInserted {
// Matrix expansion inserted sibling rows this round. They are not in statuses/needs, so
// another round would resolve a dependent of the expanded job against the placeholder's
// own combination alone: if that combination was just skipped by its `if:`, the dependent
// sees all its needs done and gets skipped before any sibling has even started. Stop here
// and let the re-emit, which reloads the full job set, resolve them.
return ret, nil
}
}
return ret, nil
}
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
}
// A failed need with continue-on-error:true is treated as success, matching AggregateJobStatus,
// so a downstream job with an implicit `success()` is not skipped.
if needJob := r.jobMap[need]; needJob != nil && needJob.ContinueOnError && needStatus == actions_model.StatusFailure {
continue
}
if needStatus.In(actions_model.StatusFailure, actions_model.StatusCancelled, actions_model.StatusSkipped) {
allSucceed = false
}
}
return allDone, allSucceed
}
func (r *jobStatusResolver) resolve(ctx context.Context) (map[int64]actions_model.Status, error) {
ret := map[int64]actions_model.Status{}
slots := maxParallelSlots{}
for id, status := range r.statuses {
slots.hold(r.jobMap[id], status)
}
for _, id := range r.sortedIDs {
status := r.statuses[id]
actionRunJob := r.jobMap[id]
if status != actions_model.StatusBlocked {
continue
}
// An expanded caller has been resolved in an earlier pass, skip.
if actionRunJob.IsReusableCaller && actionRunJob.IsExpanded {
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
}
// Decide whether the job runs at all before expanding a deferred matrix: a job whose needs
// failed or were skipped has to be skipped too, not failed for a matrix those needs never
// produced the outputs for. An `if:` that reads `matrix.*` cannot be decided this early, so
// evaluateJobIf reduces it to that needs gate and the pass below decides it per combination.
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
}
if !shouldStartJob {
ret[id] = actions_model.StatusSkipped
continue
}
// Expand a needs-dependent matrix now that its needs are done and the job is going to run.
wasDeferred := actionRunJob.IsMatrixDeferred
siblings, err := expandDeferredMatrix(ctx, actionRunJob, r.vars)
if err != nil {
// Aborting the pass is required: once the placeholder is claimed as the first combination,
// committing here would drop the remaining ones for good. Before the claim it is what gets
// the pass retried by the job-emitter queue, since a run whose needs are all done has
// nothing left to trigger another pass on its own.
return nil, fmt.Errorf("expand matrix of job %d: %w", id, err)
}
if actionRunJob.Status != actions_model.StatusBlocked {
// expandDeferredMatrix already persisted the failure, so it bypasses `ret`.
r.statuses[id] = actionRunJob.Status
r.matrixUpdatedJobs = append(r.matrixUpdatedJobs, actionRunJob)
r.matrixChanged = true
continue
}
if actionRunJob.IsMatrixDeferred {
continue // could not be expanded yet, it stays blocked and is retried on the next pass
}
if len(siblings) > 0 {
r.matrixChanged, r.matrixInserted = true, true
}
if wasDeferred {
// This row is now the first combination, and the `if:` can be evaluated.
// Gate it on its own combination here, as the siblings will be on the next pass.
shouldStartJob, err := evaluateJobIf(ctx, actionRunJob.Run, nil, actionRunJob, r.vars, allSucceed)
if err != nil {
log.Error("evaluateJobIf failed after matrix expansion, job will stay blocked: job: %d, err: %v", id, err)
continue
}
if !shouldStartJob {
ret[id] = actions_model.StatusSkipped
continue
}
}
// A slot-starved job cannot start, skip the following checks.
if !slots.available(actionRunJob) {
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
}
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.StatusWaiting && !slots.take(actionRunJob) {
continue // no free slot, leave blocked
}
if newStatus != actions_model.StatusBlocked {
ret[id] = newStatus
}
}
return ret, nil
}
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 that need another resolver pass in their own transaction.
RunIDsToReEmit []int64
}
// merge appends another result's contents into r in place.
func (r *jobsCheckResult) merge(other *jobsCheckResult) {
r.Jobs = append(r.Jobs, other.Jobs...)
r.UpdatedJobs = append(r.UpdatedJobs, other.UpdatedJobs...)
r.CancelledJobs = append(r.CancelledJobs, other.CancelledJobs...)
r.RunIDsToReEmit = append(r.RunIDsToReEmit, other.RunIDsToReEmit...)
}

View File

@@ -0,0 +1,695 @@
// 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"
"gitea.dev/modules/util"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func minimalWorkflowPayload(jobID string) []byte {
return fmt.Appendf(nil, `name: test
on: push
jobs:
%s:
runs-on: ubuntu-latest
steps:
- run: echo
`, jobID)
}
func Test_jobStatusResolver_Resolve(t *testing.T) {
tests := []struct {
name string
run *actions_model.ActionRun // defaults to stubRun
jobs actions_model.ActionJobList
want map[int64]actions_model.Status
}{
{
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 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 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},
},
{
name: "max-parallel: a freed slot promotes the lowest blocked job id",
jobs: actions_model.ActionJobList{
{ID: 1, JobID: "build", Status: actions_model.StatusSuccess, Needs: []string{}, MaxParallel: 1},
{ID: 2, JobID: "build", Status: actions_model.StatusBlocked, Needs: []string{}, MaxParallel: 1},
{ID: 3, JobID: "build", Status: actions_model.StatusBlocked, Needs: []string{}, MaxParallel: 1},
},
want: map[int64]actions_model.Status{2: actions_model.StatusWaiting},
},
{
name: "max-parallel: a cancelling job still holds its slot",
jobs: actions_model.ActionJobList{
{ID: 1, JobID: "test", Status: actions_model.StatusRunning, Needs: []string{}, MaxParallel: 2},
{ID: 2, JobID: "test", Status: actions_model.StatusCancelling, Needs: []string{}, MaxParallel: 2},
{ID: 3, JobID: "test", Status: actions_model.StatusBlocked, Needs: []string{}, MaxParallel: 2},
},
want: map[int64]actions_model.Status{},
},
{
name: "max-parallel: two freed slots promote two jobs",
jobs: actions_model.ActionJobList{
{ID: 1, JobID: "test", Status: actions_model.StatusCancelled, Needs: []string{}, MaxParallel: 2},
{ID: 2, JobID: "test", Status: actions_model.StatusSuccess, Needs: []string{}, MaxParallel: 2},
{ID: 3, JobID: "test", Status: actions_model.StatusBlocked, Needs: []string{}, MaxParallel: 2},
{ID: 4, JobID: "test", Status: actions_model.StatusBlocked, Needs: []string{}, MaxParallel: 2},
},
want: map[int64]actions_model.Status{3: actions_model.StatusWaiting, 4: actions_model.StatusWaiting},
},
{
name: "max-parallel: slots are counted per job id",
jobs: actions_model.ActionJobList{
{ID: 1, JobID: "build", Status: actions_model.StatusRunning, Needs: []string{}, MaxParallel: 1},
{ID: 2, JobID: "build", Status: actions_model.StatusBlocked, Needs: []string{}, MaxParallel: 1},
{ID: 3, JobID: "test", Status: actions_model.StatusBlocked, Needs: []string{}, MaxParallel: 1},
},
want: map[int64]actions_model.Status{3: actions_model.StatusWaiting},
},
{
name: "max-parallel: slots are scoped per reusable workflow caller",
jobs: actions_model.ActionJobList{
{ID: 1, JobID: "build", ParentJobID: 10, Status: actions_model.StatusRunning, Needs: []string{}, MaxParallel: 1},
{ID: 2, JobID: "build", ParentJobID: 10, Status: actions_model.StatusBlocked, Needs: []string{}, MaxParallel: 1},
{ID: 3, JobID: "build", ParentJobID: 20, Status: actions_model.StatusBlocked, Needs: []string{}, MaxParallel: 1},
},
want: map[int64]actions_model.Status{3: actions_model.StatusWaiting},
},
{
name: "max-parallel: a caller promoted in an earlier round keeps its slot",
jobs: actions_model.ActionJobList{
{ID: 1, JobID: "call", Status: actions_model.StatusBlocked, Needs: []string{}, MaxParallel: 1, IsReusableCaller: true},
{ID: 2, JobID: "call", Status: actions_model.StatusBlocked, Needs: []string{}, MaxParallel: 1, IsReusableCaller: true},
},
want: map[int64]actions_model.Status{1: actions_model.StatusWaiting},
},
{
name: "max-parallel: an expanded caller aggregated back to Blocked keeps its slot",
jobs: actions_model.ActionJobList{
{ID: 1, JobID: "call", Status: actions_model.StatusBlocked, Needs: []string{}, MaxParallel: 1, IsReusableCaller: true, IsExpanded: true},
{ID: 2, JobID: "call", Status: actions_model.StatusBlocked, Needs: []string{}, MaxParallel: 1, IsReusableCaller: true},
},
want: map[int64]actions_model.Status{},
},
{
name: "`if` is empty and a failed need has continue-on-error",
jobs: actions_model.ActionJobList{
{ID: 1, JobID: "job1", Status: actions_model.StatusFailure, ContinueOnError: true, Needs: []string{}},
{ID: 2, JobID: "job2", Status: actions_model.StatusBlocked, Needs: []string{"job1"}, WorkflowPayload: []byte(
`
name: test
on: push
jobs:
job2:
runs-on: ubuntu-latest
needs: job1
steps:
- run: echo "should run, job1 failure is masked by continue-on-error"
`)},
},
want: map[int64]actions_model.Status{2: actions_model.StatusWaiting},
},
{
// a needs-gated job is evaluated server-side, so a mistyped input silently leaves it blocked
name: "`if` compares a workflow_dispatch boolean input",
run: &actions_model.ActionRun{
TriggerUser: &user_model.User{}, Repo: &repo_model.Repository{},
Event: "workflow_dispatch",
EventPayload: `{"inputs":{"deploy":"true"}}`,
},
jobs: actions_model.ActionJobList{
{ID: 1, JobID: "job1", Status: actions_model.StatusSuccess, Needs: []string{}},
{ID: 2, JobID: "job2", Status: actions_model.StatusBlocked, Needs: []string{"job1"}, WorkflowPayload: []byte(
`
on:
workflow_dispatch:
inputs:
deploy:
type: boolean
jobs:
job2:
runs-on: ubuntu-latest
needs: job1
if: ${{ inputs.deploy == true && github.event.inputs.deploy == 'true' }}
steps:
- run: echo
`)},
},
want: map[int64]actions_model.Status{2: actions_model.StatusWaiting},
},
}
assert.NoError(t, unittest.PrepareTestDatabase())
ctx := t.Context()
stubRun := &actions_model.ActionRun{TriggerUser: &user_model.User{}, Repo: &repo_model.Repository{}}
for i, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Each subtest gets a unique RunID / RunAttemptID so jobs from different subtests don't bleed into each other's FindTaskNeeds queries
runID := int64(9001 + i)
attemptID := int64(9001 + i)
run := util.IfZero(tt.run, stubRun)
// Insert each test job (letting the DB assign IDs) and remember the testID -> dbID mapping so we can translate the expected map.
idMap := make(map[int64]int64, len(tt.jobs))
for _, j := range tt.jobs {
origID := j.ID
j.ID = 0
j.RunID = runID
j.RunAttemptID = attemptID
j.Run = run
// The resolver evaluates Blocked jobs via evaluateJobIf, which needs a valid YAML payload;
// supply a minimal one when the case didn't.
if j.Status == actions_model.StatusBlocked && len(j.WorkflowPayload) == 0 {
j.WorkflowPayload = minimalWorkflowPayload(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)
got, err := r.Resolve(ctx)
require.NoError(t, err)
assert.Equal(t, want, got)
})
}
}
// Test_maxParallelConverges covers the liveness property the status table cannot express:
// repeated resolve cycles never stall and never overshoot the cap.
func Test_maxParallelConverges(t *testing.T) {
ctx := t.Context()
const totalJobs, maxParallel = 5, 2
jobs := make(actions_model.ActionJobList, totalJobs)
for i := range jobs {
jobs[i] = &actions_model.ActionRunJob{
ID: int64(i + 1), JobID: "matrix", Status: actions_model.StatusBlocked, MaxParallel: maxParallel,
WorkflowPayload: minimalWorkflowPayload("matrix"),
}
}
for cycle := range totalJobs + 1 {
updates, err := newJobStatusResolver(jobs, nil).Resolve(ctx)
require.NoError(t, err)
for id, status := range updates {
jobs[id-1].Status = status
}
counts := statusCounts(jobs)
remaining := totalJobs - counts[actions_model.StatusSuccess]
assert.Equal(t, min(remaining, maxParallel), counts[actions_model.StatusWaiting]+counts[actions_model.StatusRunning],
"cycle %d: active jobs must fill every free slot without exceeding max-parallel", cycle)
for _, job := range jobs { // a runner picks up every waiting job
if job.Status == actions_model.StatusWaiting {
job.Status = actions_model.StatusRunning
}
}
for _, job := range jobs { // the first running job finishes
if job.Status == actions_model.StatusRunning {
job.Status = actions_model.StatusSuccess
break
}
}
}
assert.Equal(t, totalJobs, statusCounts(jobs)[actions_model.StatusSuccess])
}
// 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. It is done, so it no longer holds "test-cg", which is what lets checkRunConcurrency wake the blocked waiter.
runA := &actions_model.ActionRun{
RepoID: 4,
OwnerID: 1,
TriggerUserID: 1,
WorkflowID: "test.yml",
Index: 9901,
Ref: "refs/heads/main",
Status: actions_model.StatusSuccess,
}
assert.NoError(t, db.Insert(ctx, runA))
// Attempt A: a done attempt of run A with concurrency group "test-cg"
runAAttempt := &actions_model.ActionRunAttempt{
RepoID: 4,
RunID: runA.ID,
Attempt: 1,
Status: actions_model.StatusSuccess,
ConcurrencyGroup: "test-cg",
}
assert.NoError(t, db.Insert(ctx, runAAttempt))
_, err := db.Exec(t.Context(), "UPDATE `action_run` SET latest_attempt_id = ? WHERE id = ?", runAAttempt.ID, runA.ID)
assert.NoError(t, err)
// A done job for run A with the same ConcurrencyGroup.
// This triggers the job-level concurrency check in checkRunConcurrency.
jobADone := &actions_model.ActionRunJob{
RunID: runA.ID,
RunAttemptID: runAAttempt.ID,
AttemptJobID: 1,
RepoID: 4,
OwnerID: 1,
JobID: "job1",
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)
// "test-cg" is free, so the single blocked waiter (run B) is collected for re-emit.
if assert.Len(t, result.RunIDsToReEmit, 1) {
assert.Equal(t, runB.ID, result.RunIDsToReEmit[0])
}
assert.Empty(t, result.Jobs)
}
// Test_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)
}
func Test_checkJobsOfCurrentRunAttempt_NeedApprovalKeepsJobsBlocked(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
ctx := t.Context()
run := &actions_model.ActionRun{
RepoID: 4, OwnerID: 1, TriggerUserID: 1,
WorkflowID: "test.yml", Index: 9913, Ref: "refs/heads/main",
Status: actions_model.StatusBlocked, NeedApproval: true,
}
assert.NoError(t, db.Insert(ctx, run))
attempt := &actions_model.ActionRunAttempt{
RepoID: 4, RunID: run.ID, Attempt: 1, Status: actions_model.StatusBlocked,
}
assert.NoError(t, db.Insert(ctx, attempt))
_, err := db.Exec(ctx, "UPDATE `action_run` SET latest_attempt_id = ? WHERE id = ?", attempt.ID, run.ID)
assert.NoError(t, err)
run.LatestAttemptID = attempt.ID
job := &actions_model.ActionRunJob{
RunID: run.ID, RunAttemptID: attempt.ID, AttemptJobID: 1,
RepoID: 4, OwnerID: 1, JobID: "job1", Name: "job1", Status: actions_model.StatusBlocked,
}
assert.NoError(t, db.Insert(ctx, job))
result, err := checkJobsOfCurrentRunAttempt(ctx, run)
assert.NoError(t, err)
assert.Empty(t, result.UpdatedJobs)
assert.Equal(t, actions_model.StatusBlocked, unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: job.ID}).Status)
}
// Test_checkRunConcurrency_HeldGroupDoesNotWake verifies that only an unoccupied concurrency group can wake up a blocked run/job.
func Test_checkRunConcurrency_HeldGroupDoesNotWake(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
ctx := t.Context()
// Run A holds "test-cg": its attempt is still running.
runA := &actions_model.ActionRun{
RepoID: 4, OwnerID: 1, TriggerUserID: 1, WorkflowID: "test.yml",
Index: 9911, Ref: "refs/heads/main", Status: actions_model.StatusRunning,
}
assert.NoError(t, db.Insert(ctx, runA))
runAAttempt := &actions_model.ActionRunAttempt{
RepoID: 4, RunID: runA.ID, Attempt: 1, Status: actions_model.StatusRunning, ConcurrencyGroup: "test-cg",
}
assert.NoError(t, db.Insert(ctx, runAAttempt))
_, err := db.Exec(ctx, "UPDATE `action_run` SET latest_attempt_id = ? WHERE id = ?", runAAttempt.ID, runA.ID)
assert.NoError(t, err)
// Run B is blocked on the same group.
runB := &actions_model.ActionRun{
RepoID: 4, OwnerID: 1, TriggerUserID: 1, WorkflowID: "test.yml",
Index: 9912, Ref: "refs/heads/main", Status: actions_model.StatusBlocked,
}
assert.NoError(t, db.Insert(ctx, runB))
runBAttempt := &actions_model.ActionRunAttempt{
RepoID: 4, RunID: runB.ID, Attempt: 1, Status: actions_model.StatusBlocked, ConcurrencyGroup: "test-cg",
}
assert.NoError(t, db.Insert(ctx, runBAttempt))
_, err = db.Exec(ctx, "UPDATE `action_run` SET latest_attempt_id = ? WHERE id = ?", runBAttempt.ID, runB.ID)
assert.NoError(t, err)
runA, _, _ = db.GetByID[actions_model.ActionRun](ctx, runA.ID)
result, err := checkRunConcurrency(ctx, runA)
assert.NoError(t, err)
// The group is held by run A, so run B must not be woken; A will wake it when it releases the group.
assert.Empty(t, result.RunIDsToReEmit)
}
// Test_findConcurrencyWaiterToWake covers the finder's contract: it skips the run being processed (excludeRunID),
// returns another blocked waiter when the group is free, and returns 0 while the group is still held.
func Test_findConcurrencyWaiterToWake(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
ctx := t.Context()
const repoID int64 = 4
seed := func(index int64, group string, status actions_model.Status) *actions_model.ActionRun {
run := &actions_model.ActionRun{
RepoID: repoID, OwnerID: 1, TriggerUserID: 1, WorkflowID: "test.yml",
Index: index, Ref: "refs/heads/main", Status: status,
}
assert.NoError(t, db.Insert(ctx, run))
assert.NoError(t, db.Insert(ctx, &actions_model.ActionRunAttempt{
RepoID: repoID, RunID: run.ID, Attempt: 1, Status: status, ConcurrencyGroup: group,
}))
return run
}
// Free group "excl-cg" with two blocked runs: excluding self returns the other waiter, not self.
self := seed(99701, "excl-cg", actions_model.StatusBlocked)
other := seed(99702, "excl-cg", actions_model.StatusBlocked)
id, err := findConcurrencyWaiterToWake(ctx, repoID, self.ID, "excl-cg")
assert.NoError(t, err)
assert.Equal(t, other.ID, id)
// Free group "solo-cg" with only self blocked: excluding it leaves no waiter.
solo := seed(99703, "solo-cg", actions_model.StatusBlocked)
id, err = findConcurrencyWaiterToWake(ctx, repoID, solo.ID, "solo-cg")
assert.NoError(t, err)
assert.Equal(t, int64(0), id)
// Held group "held-cg" (a running holder) has a blocked waiter, but nothing is woken while held.
seed(99704, "held-cg", actions_model.StatusRunning)
seed(99705, "held-cg", actions_model.StatusBlocked)
id, err = findConcurrencyWaiterToWake(ctx, repoID, 0, "held-cg")
assert.NoError(t, err)
assert.Equal(t, int64(0), id)
}
func Test_maxParallelReusableCallerLifecycle(t *testing.T) {
ctx := t.Context()
const callerJobNum = 3
callers := make(actions_model.ActionJobList, callerJobNum)
idToCaller := make(map[int64]*actions_model.ActionRunJob, callerJobNum)
for i := range callers {
callers[i] = &actions_model.ActionRunJob{
ID: int64(i + 1), JobID: "call", Status: actions_model.StatusBlocked, MaxParallel: 1,
IsReusableCaller: true, WorkflowPayload: minimalWorkflowPayload("call"),
}
idToCaller[callers[i].ID] = callers[i]
}
underway := func() (n int) {
for _, caller := range callers {
if caller.IsExpanded && !caller.Status.IsDone() {
n++
}
}
return n
}
for cycle := range 2 * len(callers) {
promoted, err := newJobStatusResolver(callers, nil).Resolve(ctx)
require.NoError(t, err)
for id, status := range promoted {
caller := idToCaller[id]
assert.False(t, caller.IsExpanded, "cycle %d: resolver re-promoted already-expanded caller %d", cycle, id)
assert.Equal(t, actions_model.StatusWaiting, status, "cycle %d: caller %d", cycle, id)
caller.IsExpanded = true // the emitter expands the caller; children insert as Blocked, so the aggregate keeps it Blocked
}
assert.LessOrEqual(t, underway(), 1, "cycle %d: at most one caller may be underway", cycle)
if len(promoted) == 0 { // steady state: the underway caller's children finish and cascade Success to it
for _, caller := range callers {
if caller.IsExpanded && !caller.Status.IsDone() {
caller.Status = actions_model.StatusSuccess
break
}
}
}
}
assert.Equal(t, len(callers), statusCounts(callers)[actions_model.StatusSuccess])
}
// Test_jobStatusResolverStopsAfterMatrixInsert covers the invariant that keeps a dynamic matrix's
// dependents honest: a round resolved after an insert would judge them against a job set that is
// missing the siblings. See Resolve for why that is wrong.
func Test_jobStatusResolverStopsAfterMatrixInsert(t *testing.T) {
ctx := t.Context()
// build (2) stands for the expanded anchor: it reaches a terminal status this round, which is
// what would let report (3) resolve in the next one.
newChain := func() actions_model.ActionJobList {
return actions_model.ActionJobList{
{ID: 1, JobID: "generate", Status: actions_model.StatusFailure, WorkflowPayload: minimalWorkflowPayload("generate")},
{ID: 2, JobID: "build", Status: actions_model.StatusBlocked, Needs: []string{"generate"}, WorkflowPayload: minimalWorkflowPayload("build")},
{ID: 3, JobID: "report", Status: actions_model.StatusBlocked, Needs: []string{"build"}, WorkflowPayload: minimalWorkflowPayload("report")},
}
}
t.Run("without an insert the whole chain resolves in one pass", func(t *testing.T) {
got, err := newJobStatusResolver(newChain(), nil).Resolve(ctx)
require.NoError(t, err)
assert.Equal(t, map[int64]actions_model.Status{
2: actions_model.StatusSkipped,
3: actions_model.StatusSkipped,
}, got)
})
t.Run("an insert stops the pass before the dependents are resolved", func(t *testing.T) {
r := newJobStatusResolver(newChain(), nil)
r.matrixInserted = true // as resolve() sets it once expansion has inserted siblings
got, err := r.Resolve(ctx)
require.NoError(t, err)
assert.Equal(t, map[int64]actions_model.Status{2: actions_model.StatusSkipped}, got,
"report must wait for the re-emit, which sees the sibling combinations too")
})
}
// https://github.com/go-gitea/gitea/issues/39034
func Test_jobEmitterQueueHandler_DeletedRunIsNotRequeued(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
assert.Empty(t, jobEmitterQueueHandler(&jobUpdate{RunID: unittest.NonexistentID}),
"an update for a deleted run must be dropped, not returned as unhandled")
}

217
services/actions/matrix.go Normal file
View File

@@ -0,0 +1,217 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package actions
import (
"context"
"errors"
"fmt"
"slices"
actions_model "gitea.dev/models/actions"
"gitea.dev/models/db"
"gitea.dev/modules/actions/jobparser"
"gitea.dev/modules/container"
"gitea.dev/modules/log"
"gitea.dev/modules/timeutil"
"gitea.dev/modules/util"
"xorm.io/builder"
)
// expandDeferredMatrix expands a deferred-matrix placeholder once its needs are done and the job is
// known to run, using the needs' outputs: the placeholder becomes the first combination and the rest
// are returned as inserted siblings, all left Blocked so the caller's resolver still applies the
// concurrency gate.
//
// It runs inside the caller's transaction (job_emitter's resolver) and must not open a nested
// db.WithTx, which would reuse the ambient session and roll the whole emitter pass back on error.
// The three outcomes are:
// - expanded: IsMatrixDeferred is cleared and the job stays StatusBlocked.
// - the workflow's fault (a matrix that cannot resolve, or one too large): a terminal status is
// persisted here, reported by the job leaving StatusBlocked. IsMatrixDeferred stays set, marking
// the payload as still unexpanded for a later rerun to re-derive.
// - not now: losing the claim to a concurrent pass leaves the job deferred and blocked, for the
// pass that won the claim to carry through.
//
// Every other failure is returned, which aborts the emitter pass and lets the job-emitter queue retry
// it. Swallowing one would strand the placeholder: nothing re-emits a run whose needs have all already
// finished, so there would be no next pass to retry in. After the placeholder has been claimed a
// returned error additionally has to roll the caller's transaction back, because committing it would
// drop the remaining combinations for good.
func expandDeferredMatrix(ctx context.Context, job *actions_model.ActionRunJob, vars map[string]string) ([]*actions_model.ActionRunJob, error) {
if !job.IsMatrixDeferred {
return nil, nil
}
// failTerminal fails the job here rather than through the resolver's status map, which a
// reusable caller (a job may be both) would drop: its branch only handles waiting and skipped.
// The commit status is unaffected by the surviving flag: suppression only applies while the job
// is not done.
failTerminal := func(cause error) error {
log.Warn("Matrix expansion failed for job %d (JobID: %s): %v", job.ID, job.JobID, cause)
prevStatus, prevStopped := job.Status, job.Stopped
job.Status = actions_model.StatusFailure
job.Stopped = timeutil.TimeStampNow()
// The flag survives, so it alone no longer tells a fresh placeholder from one a concurrent
// pass already failed: the status has to be part of the condition.
affected, err := actions_model.UpdateRunJob(ctx, job,
builder.Eq{"is_matrix_deferred": true, "status": actions_model.StatusBlocked},
"status", "stopped")
if err != nil {
return fmt.Errorf("fail deferred matrix job %d: %w", job.ID, err)
}
if affected != 1 {
// A concurrent pass already advanced the row. Restore the in-memory state so this pass
// does not report a failure that was never persisted.
job.Status, job.Stopped = prevStatus, prevStopped
}
return nil
}
// The resolver only calls this once every need is done, as it does for job concurrency.
results, err := findJobNeedsAndFillJobResults(ctx, job)
if err != nil {
return nil, fmt.Errorf("find needs: %w", err)
}
if err := job.LoadAttributes(ctx); err != nil {
return nil, fmt.Errorf("load attributes: %w", err)
}
// The payload still carries the raw, unevaluated matrix: planning only erases the needs.
baseSWF, parsedJob, err := jobparser.ParseRawSingleWorkflow(job.WorkflowPayload)
if err != nil {
return nil, failTerminal(fmt.Errorf("parse payload: %w", err))
}
// `strategy` may reference the inputs context as well as needs, so resolve it like `if:` does.
inputs, err := getInputsForJob(ctx, job.Run, job)
if err != nil {
if errors.Is(err, util.ErrInvalidArgument) {
// A malformed payload never becomes readable, so retrying would requeue the run forever.
return nil, failTerminal(fmt.Errorf("get inputs: %w", err))
}
return nil, fmt.Errorf("get inputs: %w", err)
}
existingJobs, err := actions_model.CountRunJobsByRunAndAttemptID(ctx, job.RunID, job.RunAttemptID)
if err != nil {
return nil, fmt.Errorf("count jobs of attempt %d: %w", job.RunAttemptID, err)
}
// The placeholder is reused as the first combination, so the attempt only grows by len-1.
maxCombinations := int(actions_model.MaxJobNumPerRun - existingJobs + 1)
giteaCtx := GenerateGiteaContext(ctx, job.Run, nil, job)
expandedJobs, err := jobparser.ExpandMatrixWithNeeds(job.JobID, parsedJob, giteaCtx.ToGitHubContext(), results, vars, inputs, maxCombinations)
if err != nil {
return nil, failTerminal(fmt.Errorf("expand matrix: %w", err))
}
// Combinations differ only in what the matrix feeds: the name, the payload, and a
// runs-on/continue-on-error that may interpolate matrix.*.
applyCombo := func(dst *actions_model.ActionRunJob, combo *jobparser.Job) error {
swf := baseSWF.CloneHeader()
if err := swf.SetJob(job.JobID, combo.EraseNeeds()); err != nil {
return fmt.Errorf("set expanded job: %w", err)
}
payload, err := swf.Marshal()
if err != nil {
return fmt.Errorf("marshal expanded job: %w", err)
}
dst.Name = util.EllipsisDisplayString(combo.Name, 255)
dst.WorkflowPayload, dst.RunsOn = payload, combo.RunsOn()
dst.ContinueOnError = combo.GetContinueOnError()
return nil
}
siblings := make([]*actions_model.ActionRunJob, 0, len(expandedJobs)-1)
for _, combo := range expandedJobs[1:] {
// Inherit from the placeholder rather than listing fields, so a sibling cannot silently lose
// one (scope, permissions, `uses:`) as the job model grows.
sibling := *job
sibling.ID, sibling.TaskID, sibling.SourceTaskID, sibling.AttemptJobID = 0, 0, 0, 0
sibling.Started, sibling.Stopped, sibling.IsMatrixDeferred = 0, 0, false
sibling.Status = actions_model.StatusBlocked
sibling.Needs = slices.Clone(job.Needs)
// Only the placeholder keeps the raw payload, which is what identifies it as the group's anchor.
sibling.DeferredMatrixPayload = nil
if err := applyCombo(&sibling, combo); err != nil {
return nil, failTerminal(err)
}
siblings = append(siblings, &sibling)
}
// Keep AttemptJobIDs stable across attempts (best-effort). A single combination reuses the
// placeholder's row, so there is nothing to look up.
if len(siblings) > 0 {
parentAttemptJobID := int64(0)
if job.ParentJobID > 0 {
parent, err := actions_model.GetRunJobByRunAndID(ctx, job.RunID, job.ParentJobID)
if err != nil {
return nil, fmt.Errorf("load parent of job %d: %w", job.ID, err)
}
parentAttemptJobID = parent.AttemptJobID
}
priorCombos, err := actions_model.GetPriorAttemptMatrixCombos(ctx, job.RunID, job.RunAttemptID, parentAttemptJobID, job.JobID)
if err != nil {
return nil, fmt.Errorf("lookup prior attempt combos of job %d: %w", job.ID, err)
}
usedIDs := container.SetOf(job.AttemptJobID)
for _, sibling := range siblings {
if prior, ok := priorCombos[sibling.Name]; ok && usedIDs.Add(prior.AttemptJobID) {
sibling.AttemptJobID = prior.AttemptJobID
}
}
}
// Reusing the placeholder leaves no phantom skipped job behind to poison downstream needs. The
// conditional update is an atomic claim: only the caller that flips IsMatrixDeferred inserts.
beforeClaim := *job
if err := applyCombo(job, expandedJobs[0]); err != nil {
return nil, failTerminal(err)
}
job.IsMatrixDeferred = false
affected, err := actions_model.UpdateRunJob(ctx, job,
builder.Eq{"is_matrix_deferred": true, "status": actions_model.StatusBlocked},
"name", "workflow_payload", "runs_on", "continue_on_error", "is_matrix_deferred")
if err != nil {
return nil, fmt.Errorf("claim placeholder of job %d: %w", job.ID, err)
}
if affected != 1 {
// A concurrent pass won the claim and owns the siblings. Restore the in-memory state so this
// pass leaves the job alone and picks the winner's rows up on the next one.
*job = beforeClaim
return nil, nil
}
if len(siblings) == 0 {
return nil, nil
}
for _, sibling := range siblings {
if sibling.AttemptJobID != 0 {
continue // matched a prior attempt above
}
if sibling.AttemptJobID, err = actions_model.GetNextAttemptJobID(ctx, job.RunID); err != nil {
return nil, fmt.Errorf("alloc attempt_job_id for job %d: %w", job.ID, err)
}
}
if err := db.Insert(ctx, siblings); err != nil {
return nil, fmt.Errorf("insert matrix siblings of job %d: %w", job.ID, err)
}
return siblings, nil
}
// restoreDeferredMatrixPlaceholder rewinds a rerun clone of a dynamic-matrix combination into the unexpanded placeholder it grew from
func restoreDeferredMatrixPlaceholder(clone *actions_model.ActionRunJob) error {
_, parsed, err := jobparser.ParseRawSingleWorkflow(clone.DeferredMatrixPayload)
if err != nil {
return fmt.Errorf("parse deferred matrix payload: %w", err)
}
clone.Name = util.EllipsisDisplayString(parsed.Name, 255)
clone.WorkflowPayload = slices.Clone(clone.DeferredMatrixPayload)
clone.RunsOn = parsed.RunsOn()
clone.ContinueOnError = parsed.GetContinueOnError()
clone.IsMatrixDeferred = true
return nil
}

View File

@@ -0,0 +1,271 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package actions
import (
"fmt"
"strings"
"testing"
actions_model "gitea.dev/models/actions"
"gitea.dev/models/db"
"gitea.dev/models/unittest"
"gitea.dev/modules/actions/jobparser"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// testRunIndex hands out the per-repo run index, which is unique per action_run row.
var testRunIndex int64 = 9100
// setupDeferredMatrixJob plants a completed `generate` job exposing outputs and the blocked `build`
// placeholder that depends on them, and returns the placeholder. Both are children of a reusable
// workflow caller, the case where a sibling losing ParentJobID would break needs resolution.
// jobIf is the `build` job's `if:` expression, omitted entirely when empty.
func setupDeferredMatrixJob(t *testing.T, matrixValue, jobIf string, outputs map[string]string) *actions_model.ActionRunJob {
t.Helper()
ctx := t.Context()
ifLine := ""
if jobIf != "" {
ifLine = " if: " + jobIf + "\n"
}
// The `build` job takes its matrix from `generate`'s outputs, so Parse defers it.
workflows, err := jobparser.Parse(fmt.Appendf(nil, `
on: push
jobs:
generate:
steps: [{run: echo}]
build:
needs: generate
%s strategy:
matrix:
value: %s
steps: [{run: echo}]
`, ifLine, matrixValue))
require.NoError(t, err)
var placeholder *jobparser.SingleWorkflow
for _, workflow := range workflows {
if id, _ := workflow.Job(); id == "build" {
placeholder = workflow
}
}
require.NotNil(t, placeholder, "the build job must be planned as a single deferred placeholder")
id, job := placeholder.Job()
require.NoError(t, placeholder.SetJob(id, job.EraseNeeds()))
payload, err := placeholder.Marshal()
require.NoError(t, err)
testRunIndex++
run := &actions_model.ActionRun{
RepoID: 4, OwnerID: 1, TriggerUserID: 1, Index: testRunIndex,
WorkflowID: "dynamic.yml", Ref: "refs/heads/main", Status: actions_model.StatusRunning,
}
require.NoError(t, db.Insert(ctx, run))
attempt := &actions_model.ActionRunAttempt{RepoID: 4, RunID: run.ID, Attempt: 1, Status: actions_model.StatusRunning}
require.NoError(t, db.Insert(ctx, attempt))
run.LatestAttemptID = attempt.ID
require.NoError(t, actions_model.UpdateRun(ctx, run, "latest_attempt_id"))
// Needs outputs are read straight from action_task_output, so no action_task row is required;
// the run's id doubles as a task id that is unique across subtests.
taskID := run.ID
for key, value := range outputs {
require.NoError(t, db.Insert(ctx, &actions_model.ActionTaskOutput{TaskID: taskID, OutputKey: key, OutputValue: value}))
}
newJob := func(jobID string) *actions_model.ActionRunJob {
attemptJobID, err := actions_model.GetNextAttemptJobID(ctx, run.ID)
require.NoError(t, err)
return &actions_model.ActionRunJob{
RunID: run.ID, RunAttemptID: attempt.ID, AttemptJobID: attemptJobID, RepoID: 4, OwnerID: 1,
JobID: jobID, Name: jobID, Status: actions_model.StatusRunning,
}
}
caller := newJob("call")
caller.IsReusableCaller, caller.IsExpanded = true, true
require.NoError(t, db.Insert(ctx, caller))
generate := newJob("generate")
generate.ParentJobID, generate.Status, generate.TaskID = caller.ID, actions_model.StatusSuccess, taskID
require.NoError(t, db.Insert(ctx, generate))
build := newJob("build")
build.ParentJobID, build.Status = caller.ID, actions_model.StatusBlocked
build.Needs, build.WorkflowPayload, build.IsMatrixDeferred = []string{"generate"}, payload, true
build.DeferredMatrixPayload = payload
// Values a sibling must inherit rather than silently reset.
build.WorkflowSourceRepoID, build.WorkflowSourceCommitSHA = 42, "abc123"
require.NoError(t, db.Insert(ctx, build))
return build
}
func TestExpandDeferredMatrix(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
t.Run("expands into siblings", func(t *testing.T) {
job := setupDeferredMatrixJob(t, "${{ fromJson(needs.generate.outputs.values) }}", "", map[string]string{"values": `["a","b","c"]`})
siblings, err := expandDeferredMatrix(t.Context(), job, nil)
require.NoError(t, err)
require.Len(t, siblings, 2)
// The placeholder is reused as the first combination and stays blocked for the `if:` gate.
assert.Equal(t, "build (a)", job.Name)
assert.False(t, job.IsMatrixDeferred)
assert.Equal(t, actions_model.StatusBlocked, job.Status)
names := []string{job.Name}
for _, sibling := range siblings {
names = append(names, sibling.Name)
assert.Equal(t, actions_model.StatusBlocked, sibling.Status)
assert.False(t, sibling.IsMatrixDeferred, "a sibling must not be expanded again")
assert.Equal(t, []string{"generate"}, sibling.Needs)
assert.Equal(t, job.ParentJobID, sibling.ParentJobID, "needs resolution is scoped by ParentJobID")
assert.Equal(t, int64(42), sibling.WorkflowSourceRepoID)
assert.Equal(t, "abc123", sibling.WorkflowSourceCommitSHA)
assert.Greater(t, sibling.AttemptJobID, job.AttemptJobID, "siblings take fresh ids from the run-wide counter")
assert.Empty(t, sibling.DeferredMatrixPayload, "only the placeholder anchors the group with the raw payload")
}
assert.ElementsMatch(t, []string{"build (a)", "build (b)", "build (c)"}, names)
reloaded := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: job.ID})
assert.Equal(t, "build (a)", reloaded.Name)
assert.False(t, reloaded.IsMatrixDeferred)
assert.NotEmpty(t, reloaded.DeferredMatrixPayload, "the claim must not erase the raw payload")
})
// A matrix that can never produce runnable combinations fails the job instead of rolling the
// emitter pass back, so it is not retried forever.
for _, tt := range []struct {
name, matrixValue string
outputs map[string]string
prepare func(t *testing.T, job *actions_model.ActionRunJob)
}{
{name: "unresolvable matrix", matrixValue: "${{ fromJson(needs.generate.outputs.missing) }}"},
{
name: "expansion exceeding the per-attempt job limit",
matrixValue: "${{ fromJson(needs.generate.outputs.many) }}",
// One combination more than the attempt has room for: the cap is MaxJobNumPerRun less
// the rows the setup plants, plus the placeholder the first combination reuses.
outputs: map[string]string{"many": "[" + strings.Repeat("0,", actions_model.MaxJobNumPerRun-2) + "0]"},
},
{
// A malformed payload fails the same way on every pass, so returning it would requeue the
// run forever instead of ever reaching a terminal status.
name: "unreadable caller inputs",
matrixValue: "${{ fromJson(needs.generate.outputs.values) }}",
prepare: func(t *testing.T, job *actions_model.ActionRunJob) {
_, err := db.Exec(t.Context(), "UPDATE `action_run_job` SET call_payload = ? WHERE id = ?", "{", job.ParentJobID)
require.NoError(t, err)
},
},
} {
t.Run(tt.name+" fails the job", func(t *testing.T) {
outputs := tt.outputs
if outputs == nil {
outputs = map[string]string{"values": `["a","b","c"]`}
}
job := setupDeferredMatrixJob(t, tt.matrixValue, "", outputs)
if tt.prepare != nil {
tt.prepare(t, job)
}
siblings, err := expandDeferredMatrix(t.Context(), job, nil)
require.NoError(t, err)
assert.Empty(t, siblings)
assert.Equal(t, actions_model.StatusFailure, job.Status)
assert.True(t, job.IsMatrixDeferred, "the flag survives, marking the payload as still unexpanded for reruns")
assert.NotZero(t, job.Stopped)
// A rejected expansion must not leave the first combination's name behind.
assert.Equal(t, "build", unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: job.ID}).Name)
})
}
}
// TestDeferredMatrixResolverGating covers the resolver deciding a placeholder's fate around the
// expansion. A need that did not succeed leaves no outputs to build the matrix from, so the job is
// skipped like any other job with such a need rather than failed over a matrix it never had to
// evaluate, and no combination is inserted. A job that does run is then gated by its own
// combination: the placeholder reused as the first one is judged by `matrix.*`, not by the raw
// expression the `if:` would have seen before expansion.
func TestDeferredMatrixResolverGating(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
for _, tt := range []struct {
name string
needStatus actions_model.Status
jobIf string
outputs map[string]string
wantBuilds []string
}{
{name: "failed need", needStatus: actions_model.StatusFailure, wantBuilds: []string{"build"}},
{name: "skipped need", needStatus: actions_model.StatusSkipped, wantBuilds: []string{"build"}},
{
name: "`if:` gated per combination", needStatus: actions_model.StatusSuccess,
jobIf: "${{ matrix.value == 'b' }}", outputs: map[string]string{"values": `["a","b"]`},
wantBuilds: []string{"build (a)", "build (b)"},
},
{
// The same gate without the `${{ }}`, which must expand rather than skip the whole job.
name: "brace-less `if:` gated per combination", needStatus: actions_model.StatusSuccess,
jobIf: "matrix.value == 'b'", outputs: map[string]string{"values": `["a","b"]`},
wantBuilds: []string{"build (a)", "build (b)"},
},
} {
t.Run(tt.name, func(t *testing.T) {
ctx := t.Context()
job := setupDeferredMatrixJob(t, "${{ fromJson(needs.generate.outputs.values) }}", tt.jobIf, tt.outputs)
_, err := db.Exec(ctx, "UPDATE `action_run_job` SET status = ? WHERE run_id = ? AND job_id = ?", int(tt.needStatus), job.RunID, "generate")
require.NoError(t, err)
jobs := runJobs(t, job.RunID, job.RunAttemptID)
require.NoError(t, jobs.LoadRuns(ctx, false))
updates, err := newJobStatusResolver(jobs, nil).Resolve(ctx)
require.NoError(t, err)
assert.Equal(t, actions_model.StatusSkipped, updates[job.ID])
var names []string
for _, runJob := range runJobs(t, job.RunID, job.RunAttemptID) {
if runJob.JobID == "build" {
names = append(names, runJob.Name)
}
}
assert.ElementsMatch(t, tt.wantBuilds, names)
})
}
}
func TestDeferredMatrixResolverDefersDependents(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
ctx := t.Context()
// `build (a)` is skipped by the `if:` once it carries its own combination, `build (b)` runs.
build := setupDeferredMatrixJob(t, "${{ fromJson(needs.generate.outputs.values) }}",
"${{ matrix.value != 'a' }}", map[string]string{"values": `["a","b"]`})
attemptJobID, err := actions_model.GetNextAttemptJobID(ctx, build.RunID)
require.NoError(t, err)
report := &actions_model.ActionRunJob{
RunID: build.RunID, RunAttemptID: build.RunAttemptID, AttemptJobID: attemptJobID,
RepoID: build.RepoID, OwnerID: build.OwnerID, ParentJobID: build.ParentJobID,
JobID: "report", Name: "report", Status: actions_model.StatusBlocked,
Needs: []string{"build"}, WorkflowPayload: minimalWorkflowPayload("report"),
}
require.NoError(t, db.Insert(ctx, report))
jobs := runJobs(t, build.RunID, build.RunAttemptID)
require.NoError(t, jobs.LoadRuns(ctx, false))
updates, err := newJobStatusResolver(jobs, nil).Resolve(ctx)
require.NoError(t, err)
assert.Equal(t, actions_model.StatusSkipped, updates[build.ID], "the combination the `if:` excludes")
assert.NotContains(t, updates, report.ID, "report must wait for the re-emit, which sees `build (b)` too")
// The sibling the pass would otherwise have resolved report against is there, and still to run.
sibling := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: build.RunID, Name: "build (b)"})
assert.Equal(t, actions_model.StatusBlocked, sibling.Status)
}

View File

@@ -0,0 +1,87 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package actions
import (
"math"
"strconv"
"strings"
actions_model "gitea.dev/models/actions"
"gitea.dev/modules/log"
)
// parseMaxParallel returns strategy.max-parallel for a job, 0 meaning unlimited.
// GitHub accepts any YAML number here and casts it to an int, so 1.5 truncates to 1.
// Expressions are not evaluated yet and fall back to unlimited.
func parseMaxParallel(jobID, maxParallelString string) int {
if maxParallelString == "" {
return 0
}
maxParallel, err := strconv.ParseFloat(maxParallelString, 64)
if err != nil || math.IsNaN(maxParallel) {
// Both fall back to unlimited, but an expression is a gap in Gitea while a non-number is the
// author's mistake, so dropping the cap must not be reported the same way.
if strings.Contains(maxParallelString, "${{") {
// TODO: evaluate it against the contexts `if:` and the matrix already resolve, so that an
// expression can actually cap a job instead of quietly disabling the cap.
log.Debug("job %s: max-parallel %q is an expression, which is not evaluated yet: treating as unlimited", jobID, maxParallelString)
} else {
log.Warn("job %s: max-parallel %q is not a number, treating as unlimited", jobID, maxParallelString)
}
return 0
}
// a run can never hold more jobs than MaxJobNumPerRun, so clamping there keeps the cast total
return int(min(max(maxParallel, 0), actions_model.MaxJobNumPerRun))
}
// maxParallelSlots counts the jobs holding a max-parallel slot. Slots are scoped by ParentJobID as
// well as JobID so the same job name in two reusable workflow calls does not cross-link, matching
// how jobStatusResolver scopes needs.
type maxParallelSlots map[maxParallelKey]int
type maxParallelKey struct {
parentJobID int64
jobID string
}
// hold seeds a job's slot from a status it already holds, bypassing the limit check.
// The status is passed separately because the job emitter tracks statuses outside the job model.
func (s maxParallelSlots) hold(job *actions_model.ActionRunJob, status actions_model.Status) {
if job.MaxParallel <= 0 {
return // an unlimited job's count is never read by take, don't accumulate it
}
// A Cancelling job still owns its runner, so it keeps its slot until cleanup finishes.
// An expanded reusable caller is underway even while its children aggregate it back to Blocked, so it holds a slot too.
if status.In(actions_model.StatusRunning, actions_model.StatusWaiting, actions_model.StatusCancelling) ||
(job.IsReusableCaller && job.IsExpanded && !status.IsDone()) {
s[maxParallelKey{job.ParentJobID, job.JobID}]++
}
}
// available reports whether a slot is free without reserving it.
// Peek it before running the concurrency check: DO NOT cancel the group's other pending jobs if no available slots.
func (s maxParallelSlots) available(job *actions_model.ActionRunJob) bool {
return job.MaxParallel <= 0 || s[maxParallelKey{job.ParentJobID, job.JobID}] < job.MaxParallel
}
// take reserves a slot and reports whether one was free. A job without a limit always succeeds.
func (s maxParallelSlots) take(job *actions_model.ActionRunJob) bool {
if !s.available(job) {
return false
}
if job.MaxParallel > 0 {
s[maxParallelKey{job.ParentJobID, job.JobID}]++
}
return true
}
// applyMaxParallel demotes a ready job to Blocked when its slots are full, leaving the job emitter to
// promote it once one frees. Call it after concurrency evaluation so a concurrency-blocked job does
// not consume a slot, and before the insert so a held-back reusable caller is not expanded.
func applyMaxParallel(job *actions_model.ActionRunJob, slots maxParallelSlots) {
if job.Status == actions_model.StatusWaiting && !slots.take(job) {
job.Status = actions_model.StatusBlocked
}
}

View File

@@ -0,0 +1,255 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package actions
import (
"testing"
actions_model "gitea.dev/models/actions"
"gitea.dev/models/db"
repo_model "gitea.dev/models/repo"
"gitea.dev/models/unittest"
user_model "gitea.dev/models/user"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestParseMaxParallel(t *testing.T) {
tests := []struct {
input string
want int
}{
{"", 0},
{"3", 3},
{"0", 0},
{"-1", 0},
{"1.5", 1}, // GitHub casts the YAML number to int
{"-1.5", 0}, // truncates to -1, which means unlimited
{"1e3", 256}, // clamped to MaxJobNumPerRun
{"nan", 0}, // must not reach the int cast
{"${{ vars.n }}", 0}, // expressions are not evaluated yet, logged as such
{"abc", 0}, // a plain workflow error, warned about rather than hidden
}
for _, tt := range tests {
assert.Equal(t, tt.want, parseMaxParallel("job", tt.input), "input %q", tt.input)
}
}
// a 5-job matrix capped at 2, so 2 jobs start and 3 stay blocked
const maxParallelWorkflow = `name: max-parallel
on: push
jobs:
build:
runs-on: ubuntu-latest
strategy:
max-parallel: 2
matrix:
version: [1, 2, 3, 4, 5]
steps:
- run: echo hi
`
func insertMaxParallelRun(t *testing.T, workflow string, needApproval bool) *actions_model.ActionRun {
t.Helper()
run := &actions_model.ActionRun{
Title: "max-parallel",
RepoID: 4,
OwnerID: 1,
WorkflowID: "max-parallel.yaml",
TriggerUserID: 1,
Ref: "refs/heads/master",
CommitSHA: "c2d72f548424103f01ee1dc02889c1e2bff816b0",
Event: "push",
TriggerEvent: "push",
EventPayload: "{}",
NeedApproval: needApproval,
WorkflowRepoID: 4,
WorkflowCommitSHA: "c2d72f548424103f01ee1dc02889c1e2bff816b0",
}
require.NoError(t, PrepareRunAndInsert(t.Context(), []byte(workflow), run, nil))
return run
}
func runJobs(t *testing.T, runID, attemptID int64) actions_model.ActionJobList {
t.Helper()
jobs, err := actions_model.GetRunJobsByRunAndAttemptID(t.Context(), runID, attemptID)
require.NoError(t, err)
return jobs
}
func statusCounts(jobs actions_model.ActionJobList) map[actions_model.Status]int {
counts := map[actions_model.Status]int{}
for _, job := range jobs {
counts[job.Status]++
}
return counts
}
func TestPrepareRunAndInsert_MaxParallel(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
run := insertMaxParallelRun(t, maxParallelWorkflow, false)
jobs := runJobs(t, run.ID, run.LatestAttemptID)
assert.Equal(t, map[actions_model.Status]int{
actions_model.StatusWaiting: 2,
actions_model.StatusBlocked: 3,
}, statusCounts(jobs))
for _, job := range jobs {
assert.Equal(t, 2, job.MaxParallel, "every matrix sibling carries the limit")
}
}
// A reusable workflow declares its own strategy, so the limit must reach the child jobs.
func TestInsertCallerChildren_MaxParallel(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
ctx := t.Context()
run := &actions_model.ActionRun{
RepoID: 4, OwnerID: 1, Index: 9701, TriggerUserID: 1, TriggerEvent: "push", EventPayload: "{}",
}
require.NoError(t, db.Insert(ctx, run))
require.NoError(t, run.LoadAttributes(ctx))
attempt := &actions_model.ActionRunAttempt{RepoID: run.RepoID, RunID: run.ID, Attempt: 1}
require.NoError(t, db.Insert(ctx, attempt))
caller := &actions_model.ActionRunJob{
RunID: run.ID, RunAttemptID: attempt.ID, RepoID: run.RepoID, OwnerID: run.OwnerID,
JobID: "caller", AttemptJobID: 1, IsReusableCaller: true,
}
require.NoError(t, db.Insert(ctx, caller))
called := []byte(`name: called
on: workflow_call
jobs:
build:
runs-on: ubuntu-latest
strategy:
max-parallel: 2
matrix:
version: [1, 2, 3]
steps:
- run: echo hi
`)
require.NoError(t, insertCallerChildren(ctx, run, attempt, caller, called, run.RepoID, run.CommitSHA, nil, nil))
children := 0
for _, job := range runJobs(t, run.ID, attempt.ID) {
if job.ParentJobID == caller.ID {
children++
assert.Equal(t, 2, job.MaxParallel)
}
}
assert.Equal(t, 3, children)
}
// An approval-gated run inserts every job as Blocked, so the cap has to be applied on approval.
func TestApproveRuns_MaxParallel(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
run := insertMaxParallelRun(t, maxParallelWorkflow, true)
assert.Equal(t, map[actions_model.Status]int{actions_model.StatusBlocked: 5}, statusCounts(runJobs(t, run.ID, run.LatestAttemptID)))
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: run.RepoID})
doer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
_, err := ApproveRuns(t.Context(), repo, doer, []int64{run.ID})
require.NoError(t, err)
assert.Equal(t, map[actions_model.Status]int{
actions_model.StatusWaiting: 2,
actions_model.StatusBlocked: 3,
}, statusCounts(runJobs(t, run.ID, run.LatestAttemptID)))
}
func TestPrepareRunAndInsert_MaxParallelStarvedSkipsConcurrency(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
holder := insertConcurrencyHolder(t, 9702, "cluster-b")
run := insertMaxParallelRun(t, maxParallelConcurrencyWorkflow, false)
assert.Equal(t, map[actions_model.Status]int{
actions_model.StatusWaiting: 1,
actions_model.StatusBlocked: 1,
}, statusCounts(runJobs(t, run.ID, run.LatestAttemptID)))
holder = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: holder.ID})
assert.Equal(t, actions_model.StatusRunning, holder.Status, "the starved job must not cancel the group holder")
}
func Test_jobStatusResolver_MaxParallelStarvedSkipsConcurrency(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
holder := insertConcurrencyHolder(t, 9703, "cluster-b")
run := insertMaxParallelRun(t, maxParallelConcurrencyWorkflow, false)
jobs := runJobs(t, run.ID, run.LatestAttemptID)
for _, job := range jobs {
job.Run = run
}
updates, err := newJobStatusResolver(jobs, nil).Resolve(t.Context())
assert.NoError(t, err)
assert.Empty(t, updates, "the starved job must stay blocked")
holder = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: holder.ID})
assert.Equal(t, actions_model.StatusRunning, holder.Status)
}
func TestApproveRuns_MaxParallelStarvedSkipsConcurrency(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
holder := insertConcurrencyHolder(t, 9704, "cluster-b")
run := insertMaxParallelRun(t, maxParallelConcurrencyWorkflow, true)
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: run.RepoID})
doer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
_, err := ApproveRuns(t.Context(), repo, doer, []int64{run.ID})
require.NoError(t, err)
assert.Equal(t, map[actions_model.Status]int{
actions_model.StatusWaiting: 1,
actions_model.StatusBlocked: 1,
}, statusCounts(runJobs(t, run.ID, run.LatestAttemptID)))
holder = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: holder.ID})
assert.Equal(t, actions_model.StatusRunning, holder.Status)
}
// a 2-job matrix capped at 1 where each matrix job has its own concurrency group that cancels in progress
const maxParallelConcurrencyWorkflow = `name: max-parallel-concurrency
on: push
jobs:
deploy:
runs-on: ubuntu-latest
strategy:
max-parallel: 1
matrix:
cluster: [a, b]
concurrency:
group: cluster-${{ matrix.cluster }}
cancel-in-progress: true
steps:
- run: echo hi
`
// insertConcurrencyHolder inserts a running job occupying the given concurrency group
func insertConcurrencyHolder(t *testing.T, index int64, group string) *actions_model.ActionRunJob {
t.Helper()
ctx := t.Context()
run := &actions_model.ActionRun{
RepoID: 4, OwnerID: 1, Index: index, TriggerUserID: 1, 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, Status: actions_model.StatusRunning}
require.NoError(t, db.Insert(ctx, attempt))
holder := &actions_model.ActionRunJob{
RunID: run.ID, RunAttemptID: attempt.ID, RepoID: run.RepoID, OwnerID: run.OwnerID,
JobID: "holder", AttemptJobID: 1, Status: actions_model.StatusRunning,
ConcurrencyGroup: group, IsConcurrencyEvaluated: true,
}
require.NoError(t, db.Insert(ctx, holder))
return holder
}

View File

@@ -0,0 +1,831 @@
// 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/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
}
newPullRequestReviewNotifyInput(review.Issue.Repo, review.Reviewer, reviewHookType, review.CommitID, pr).
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)
convertedWorkflow, err := convert.ResolveActionWorkflowForRun(ctx, repo, run)
if err != nil {
if errors.Is(err, util.ErrNotExist) {
// The workflow definition is gone (e.g. a scoped source repo/file was deleted, or the file no longer exists at the recorded commit), skip
log.Debug("WorkflowRunStatusUpdate: workflow %q for run %d not found: %v", run.WorkflowID, run.ID, err)
return
}
log.Error("WorkflowRunStatusUpdate resolve workflow: %v", err)
return
}
run.Repo = repo
convertedRun, err := convert.ToActionWorkflowRun(ctx, run, nil, false)
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)
}

View File

@@ -0,0 +1,766 @@
// Copyright 2022 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package actions
import (
"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/actions/jobparser"
"gitea.dev/modules/container"
"gitea.dev/modules/git"
"gitea.dev/modules/json"
"gitea.dev/modules/log"
"gitea.dev/modules/setting"
api "gitea.dev/modules/structs"
webhook_module "gitea.dev/modules/webhook"
"gitea.dev/services/convert"
)
type methodCtxKeyType struct{}
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 &notifyInput{
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 newPullRequestReviewNotifyInput(repo *repo_model.Repository, reviewer *user_model.User, event webhook_module.HookEventType, commitID string, pr *issues_model.PullRequest) *notifyInput {
return newNotifyInput(repo, reviewer, event).
WithRef(commitID).
WithPullRequest(pr)
}
func (input *notifyInput) WithDoer(doer *user_model.User) *notifyInput {
input.Doer = doer
return input
}
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.ID == user_model.ActionsUserID {
// 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 := git.OpenRepository(ctx, 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(ctx, ref.String())
if err != nil {
return fmt.Errorf("gitRepo.GetRefCommitID: %w", err)
}
// Get the commit object for the ref
commit, err := gitRepo.GetCommit(ctx, commitID)
if err != nil {
return fmt.Errorf("gitRepo.GetCommit: %w", err)
}
if skipWorkflows(ctx, input, commit) {
return nil
}
var detectedWorkflows []*actions_module.DetectedWorkflow
var filteredWorkflows []*actions_module.DetectedWorkflow
actionsConfig := input.Repo.MustGetUnit(ctx, unit_model.TypeActions).ActionsConfig()
workflows, schedules, filtered, err := actions_module.DetectWorkflows(ctx, 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.FullName(),
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.FullName(), wf.EntryName)
continue
}
if wf.TriggerEvent.Name != actions_module.GithubEventPullRequestTarget {
detectedWorkflows = append(detectedWorkflows, wf)
}
}
for _, wf := range filtered {
if actionsConfig.IsWorkflowDisabled(wf.EntryName) {
log.Trace("repo %s has disable workflows %s", input.Repo.FullName(), wf.EntryName)
continue
}
if wf.TriggerEvent.Name != actions_module.GithubEventPullRequestTarget {
filteredWorkflows = append(filteredWorkflows, wf)
}
}
if input.PullRequest != nil {
// detect pull_request_target workflows
baseRef := git.BranchPrefix + input.PullRequest.BaseBranch
baseCommit, err := gitRepo.GetCommit(ctx, baseRef)
if err != nil {
return fmt.Errorf("gitRepo.GetCommit: %w", err)
}
baseWorkflows, _, baseFiltered, err := actions_module.DetectWorkflows(ctx, 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.FullName(), baseCommit.ID)
} else {
for _, wf := range baseWorkflows {
if actionsConfig.IsWorkflowDisabled(wf.EntryName) {
log.Trace("repo %s has disable workflows %s", input.Repo.FullName(), wf.EntryName)
continue
}
if wf.TriggerEvent.Name == actions_module.GithubEventPullRequestTarget {
detectedWorkflows = append(detectedWorkflows, wf)
}
}
}
for _, wf := range baseFiltered {
if actionsConfig.IsWorkflowDisabled(wf.EntryName) {
log.Trace("repo %s has disable workflows %s", input.Repo.FullName(), wf.EntryName)
continue
}
if wf.TriggerEvent.Name == actions_module.GithubEventPullRequestTarget {
filteredWorkflows = append(filteredWorkflows, wf)
}
}
}
if shouldDetectSchedules {
if err := handleSchedules(ctx, schedules, commit, input, ref); err != nil {
return err
}
}
if err := handleWorkflows(ctx, detectedWorkflows, commit, input, ref); err != nil {
return err
}
handleFilteredWorkflows(ctx, input, filteredWorkflows)
return detectAndHandleScopedWorkflows(ctx, input, ref, gitRepo, commit)
}
func skipWorkflows(ctx context.Context, input *notifyInput, commit *git.Commit) bool {
// 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.FullName(), 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.FullName(), 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.FullName())
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.FullName(), commit.ID)
return nil
}
p, err := json.Marshal(input.Payload)
if err != nil {
return fmt.Errorf("json.Marshal: %w", err)
}
isForkPullRequest := isForkPullRequestInput(input)
for _, dwf := range detectedWorkflows {
// repo-level run: the workflow content is this repo at dwf.SourceCommitSHA
if err := buildApproveAndInsertRun(ctx, input, ref, commit, string(p), isForkPullRequest, dwf, input.Repo.ID, false); err != nil {
log.Error("repo %s: %v", input.Repo.FullName(), err)
continue
}
}
return nil
}
// buildApproveAndInsertRun assembles an ActionRun for a detected workflow, runs the
// fork-PR approval gate, and inserts it. Repo-level and scoped runs share this path so
// run construction and the approval flow have a single implementation that can't drift.
// workflowRepoID and dwf.SourceCommitSHA point at the repo+commit the workflow content comes
// from (the repo itself for repo-level runs, the source repo for scoped runs).
func buildApproveAndInsertRun(
ctx context.Context,
input *notifyInput,
ref git.RefName,
commit *git.Commit,
payload string,
isForkPullRequest bool,
dwf *actions_module.DetectedWorkflow,
workflowRepoID int64,
isScopedRun bool,
) error {
if dwf.SourceCommitSHA == "" {
// unreachable in the normal flow; catches a test case that builds a DetectedWorkflow without it
setting.PanicInDevOrTesting("workflow %q has no source commit", dwf.EntryName)
}
run := &actions_model.ActionRun{
Title: commit.MessageTitle(),
RepoID: input.Repo.ID,
Repo: input.Repo,
OwnerID: input.Repo.OwnerID,
WorkflowID: dwf.EntryName,
TriggerUserID: input.Doer.ID,
TriggerUser: input.Doer,
Ref: ref.String(),
CommitSHA: commit.ID.String(),
IsForkPullRequest: isForkPullRequest,
Event: input.Event,
EventPayload: payload,
TriggerEvent: dwf.TriggerEvent.Name,
Status: actions_model.StatusWaiting,
WorkflowRepoID: workflowRepoID,
WorkflowCommitSHA: dwf.SourceCommitSHA,
IsScopedRun: isScopedRun,
}
need, err := ifNeedApproval(ctx, run, input.Repo, input.Doer)
if err != nil {
return fmt.Errorf("check if need approval for user %d: %w", input.Doer.ID, err)
}
run.NeedApproval = need
if err := PrepareRunAndInsert(ctx, dwf.Content, run, nil); err != nil {
return fmt.Errorf("PrepareRunAndInsert: %w", err)
}
return nil
}
// handleFilteredWorkflows posts a skipped commit status for each filtered-out workflow whose context is a required status check;
// a non-required one posts nothing, so it cannot leak into a pull request.
func handleFilteredWorkflows(ctx context.Context, input *notifyInput, filteredWorkflows []*actions_module.DetectedWorkflow) {
if len(filteredWorkflows) == 0 {
return
}
requiredGlobs, err := getAllRequiredStatusContextGlobs(ctx, input.Repo)
if err != nil {
log.Error("repo %s: required status contexts: %v", input.Repo.FullName(), err)
return
}
if len(requiredGlobs) == 0 {
return
}
for _, dwf := range filteredWorkflows {
if !shouldCreateSkippedCommitStatusForFilteredWorkflow(input, dwf) {
continue
}
if err := CreateSkippedCommitStatusForFilteredWorkflow(ctx, input.Repo, input.Event, dwf.TriggerEvent.Name, dwf.EntryName, dwf.Content, input.Payload, "", requiredGlobs); err != nil {
log.Error("repo %s: skipped commit status for workflow %s: %v", input.Repo.FullName(), dwf.EntryName, err)
continue
}
}
}
func shouldCreateSkippedCommitStatusForFilteredWorkflow(input *notifyInput, workflow *actions_module.DetectedWorkflow) bool {
return !isForkPullRequestInput(input) || workflow.TriggerEvent.Name == actions_module.GithubEventPullRequestTarget
}
func newNotifyInputFromIssue(issue *issues_model.Issue, event webhook_module.HookEventType) *notifyInput {
return newNotifyInput(issue.Repo, issue.Poster, event)
}
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) {
canWrite := func(ctx context.Context, repo *repo_model.Repository, user *user_model.User) (bool, error) {
perm, err := access_model.GetDoerRepoPermission(ctx, repo, user)
if err != nil {
return false, err
}
return perm.CanWrite(unit_model.TypeActions), nil
}
return ifNeedApprovalWith(ctx, run, repo, user, canWrite, issues_model.HasMergedPullRequestInRepo)
}
func ifNeedApprovalWith(
ctx context.Context,
run *actions_model.ActionRun,
repo *repo_model.Repository,
user *user_model.User,
canWriteActions func(context.Context, *repo_model.Repository, *user_model.User) (bool, error),
hasMergedPR func(context.Context, int64, int64) (bool, error),
) (bool, error) {
// 1. don't need approval if it's not a fork PR
// 2. don't need approval if the event is `pull_request_target` since the workflow will run in the context of base branch
// see https://docs.github.com/en/actions/managing-workflow-runs/approving-workflow-runs-from-public-forks#about-workflow-runs-from-public-forks
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 ok, err := canWriteActions(ctx, repo, user); err != nil {
return false, fmt.Errorf("GetDoerRepoPermission: %w", err)
} else if ok {
log.Trace("do not need approval because user %d can write", user.ID)
return false, nil
}
// trust the user only after a merged PR — matching GitHub Actions. Approving one
// fork PR's run must not implicitly trust later fork PRs that replace the workflow.
if merged, err := hasMergedPR(ctx, repo.ID, user.ID); err != nil {
return false, fmt.Errorf("HasMergedPullRequestInRepo: %w", err)
} else if merged {
log.Trace("do not need approval because user %d has a merged pull request in repo %d", user.ID, repo.ID)
return false, nil
}
// otherwise, need approval
log.Trace("need approval because user %d has no merged pull request in repo %d", user.ID, repo.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.FullName(), 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 := jobparser.ReadWorkflow(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.CreateScheduleTaskBySchedules(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 := git.OpenRepository(ctx, 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(ctx, repo.DefaultBranch)
if err != nil {
return fmt.Errorf("gitRepo.GetCommit: %w", err)
}
scheduleWorkflows, err := actions_module.DetectScheduledWorkflows(ctx, 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))
}
// isForkPullRequestInput reports whether the run should be treated as a fork pull request.
func isForkPullRequestInput(input *notifyInput) bool {
pr := input.PullRequest
if pr == nil {
return false
}
switch pr.Flow {
case issues_model.PullRequestFlowGithub:
return pr.IsFromFork()
case issues_model.PullRequestFlowAGit:
// There is no fork concept in agit flow, anyone with read permission can push refs/for/<target-branch>/<topic-branch> to the repo.
// So we can treat it as a fork pull request because it may be from an untrusted user
return true
default:
// unknown flow, assume it's a fork pull request to be safe
return true
}
}
// detectAndHandleScopedWorkflows detects scoped workflows registered for the consuming repo
func detectAndHandleScopedWorkflows(
ctx context.Context,
input *notifyInput,
ref git.RefName,
consumerGitRepo *git.Repository,
consumerCommit *git.Commit,
) error {
// TODO: support workflow_run and schedule
if input.Event == webhook_module.HookEventWorkflowRun || input.Event == webhook_module.HookEventSchedule {
return nil
}
sources, err := actions_model.GetEffectiveScopedWorkflowSources(ctx, input.Repo.OwnerID)
if err != nil {
return fmt.Errorf("GetEffectiveScopedWorkflowSources: %w", err)
}
if len(sources) == 0 {
return nil
}
p, err := json.Marshal(input.Payload)
if err != nil {
return fmt.Errorf("json.Marshal: %w", err)
}
isForkPullRequest := isForkPullRequestInput(input)
actionsConfig := input.Repo.MustGetUnit(ctx, unit_model.TypeActions).ActionsConfig()
// A filtered-out scoped workflow only posts a skipped status when its context is a required check.
requiredGlobs, err := getAllRequiredStatusContextGlobs(ctx, input.Repo)
if err != nil {
log.Error("scoped workflows: required status contexts for %s: %v", input.Repo.FullName(), err)
}
// The same source repo may be registered at both the owner and instance level; dedup
// the IDs and batch-load them in one query instead of one round-trip per source.
seen := make(container.Set[int64], len(sources))
for _, source := range sources {
seen.Add(source.SourceRepoID)
}
sourceRepoIDs := seen.Values()
sourceRepos, err := repo_model.GetRepositoriesMapByIDs(ctx, sourceRepoIDs)
if err != nil {
return fmt.Errorf("GetRepositoriesMapByIDs: %w", err)
}
for _, sourceRepoID := range sourceRepoIDs {
sourceRepo := sourceRepos[sourceRepoID]
if sourceRepo == nil {
// don't abort the other effective sources for this event
log.Error("scoped workflows: source repo %d for consumer %s not found", sourceRepoID, input.Repo.FullName())
continue
}
if sourceRepo.IsEmpty {
continue
}
detected, filtered, err := detectScopedWorkflowsForSource(ctx, input, consumerGitRepo, consumerCommit, sourceRepo)
if err != nil {
log.Error("scoped workflows: source %d for consumer %s: %v", sourceRepoID, input.Repo.FullName(), err)
continue
}
for _, dwf := range detected {
// A consuming repo can opt out of a non-required scoped workflow.
// A required workflow (marked required at any effective level) can never be opted out.
if actions_model.ScopedWorkflowOptedOut(actionsConfig, sources, sourceRepo.ID, dwf.EntryName) {
continue
}
if err := buildApproveAndInsertRun(ctx, input, ref, consumerCommit, string(p), isForkPullRequest, dwf, sourceRepo.ID, true); err != nil {
log.Error("scoped workflows: source %s workflow %s: %v", sourceRepo.FullName(), dwf.EntryName, err)
continue
}
}
// A filtered-out scoped workflow posts a skipped commit status for its required-check contexts.
if len(filtered) > 0 && len(requiredGlobs) > 0 {
scopedPrefix := actions_model.ScopedStatusContextPrefix(ctx, sourceRepo.ID)
for _, dwf := range filtered {
if actions_model.ScopedWorkflowOptedOut(actionsConfig, sources, sourceRepo.ID, dwf.EntryName) {
continue
}
if err := CreateSkippedCommitStatusForFilteredWorkflow(ctx, input.Repo, input.Event, dwf.TriggerEvent.Name, dwf.EntryName, dwf.Content, input.Payload, scopedPrefix, requiredGlobs); err != nil {
log.Error("scoped workflows: skipped commit status for source %s workflow %s: %v", sourceRepo.FullName(), dwf.EntryName, err)
continue
}
}
}
}
return nil
}
// detectScopedWorkflowsForSource detects the scoped workflows from the source repo at its default branch.
// detected are the workflows to run; filtered matched the event but were excluded by a branch/paths
// filter, and later post a skipped commit status only for a required-check context.
func detectScopedWorkflowsForSource(
ctx context.Context,
input *notifyInput,
consumerGitRepo *git.Repository,
consumerCommit *git.Commit,
sourceRepo *repo_model.Repository,
) (detected, filtered []*actions_module.DetectedWorkflow, err error) {
// scoped workflow content is always taken from the source repo's default branch; the parse is cached per (source, default-branch SHA) and reused across consuming repos/events
sourceCommitSHA, parsed, err := LoadParsedScopedWorkflows(ctx, sourceRepo)
if err != nil {
return nil, nil, err
}
detected, filtered = actions_module.MatchScopedWorkflows(ctx, parsed, sourceCommitSHA, consumerGitRepo, consumerCommit, input.Event, input.Payload)
return detected, filtered, nil
}

View File

@@ -0,0 +1,126 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package actions
import (
"context"
"errors"
"testing"
actions_model "gitea.dev/models/actions"
issues_model "gitea.dev/models/issues"
repo_model "gitea.dev/models/repo"
user_model "gitea.dev/models/user"
actions_module "gitea.dev/modules/actions"
"gitea.dev/modules/actions/jobparser"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestIfNeedApproval(t *testing.T) {
alwaysWrite := func(_ context.Context, _ *repo_model.Repository, _ *user_model.User) (bool, error) {
return true, nil
}
neverWrite := func(_ context.Context, _ *repo_model.Repository, _ *user_model.User) (bool, error) {
return false, nil
}
hasMerged := func(_ context.Context, _, _ int64) (bool, error) { return true, nil }
noMerged := func(_ context.Context, _, _ int64) (bool, error) { return false, nil }
errPerm := errors.New("perm error")
errMerge := errors.New("merge error")
forkRun := &actions_model.ActionRun{IsForkPullRequest: true, TriggerEvent: actions_module.GithubEventPullRequest}
nonForkRun := &actions_model.ActionRun{IsForkPullRequest: false, TriggerEvent: actions_module.GithubEventPullRequest}
prTargetRun := &actions_model.ActionRun{IsForkPullRequest: true, TriggerEvent: actions_module.GithubEventPullRequestTarget}
repo := &repo_model.Repository{ID: 1}
normalUser := &user_model.User{ID: 10}
restrictedUser := &user_model.User{ID: 11, IsRestricted: true}
t.Run("not a fork PR never needs approval", func(t *testing.T) {
need, err := ifNeedApprovalWith(t.Context(), nonForkRun, repo, normalUser, alwaysWrite, hasMerged)
require.NoError(t, err)
assert.False(t, need)
})
t.Run("pull_request_target never needs approval even when fork", func(t *testing.T) {
need, err := ifNeedApprovalWith(t.Context(), prTargetRun, repo, normalUser, alwaysWrite, hasMerged)
require.NoError(t, err)
assert.False(t, need)
})
t.Run("restricted user always needs approval", func(t *testing.T) {
need, err := ifNeedApprovalWith(t.Context(), forkRun, repo, restrictedUser, alwaysWrite, hasMerged)
require.NoError(t, err)
assert.True(t, need)
})
t.Run("fork PR with write permission does not need approval", func(t *testing.T) {
need, err := ifNeedApprovalWith(t.Context(), forkRun, repo, normalUser, alwaysWrite, noMerged)
require.NoError(t, err)
assert.False(t, need)
})
t.Run("fork PR with merged PR but no write permission does not need approval", func(t *testing.T) {
need, err := ifNeedApprovalWith(t.Context(), forkRun, repo, normalUser, neverWrite, hasMerged)
require.NoError(t, err)
assert.False(t, need)
})
t.Run("fork PR with no write and no merged PR needs approval", func(t *testing.T) {
need, err := ifNeedApprovalWith(t.Context(), forkRun, repo, normalUser, neverWrite, noMerged)
require.NoError(t, err)
assert.True(t, need)
})
t.Run("canWriteActions error is propagated", func(t *testing.T) {
failWrite := func(_ context.Context, _ *repo_model.Repository, _ *user_model.User) (bool, error) {
return false, errPerm
}
_, err := ifNeedApprovalWith(t.Context(), forkRun, repo, normalUser, failWrite, noMerged)
require.ErrorIs(t, err, errPerm)
})
t.Run("hasMergedPR error is propagated", func(t *testing.T) {
failMerge := func(_ context.Context, _, _ int64) (bool, error) { return false, errMerge }
_, err := ifNeedApprovalWith(t.Context(), forkRun, repo, normalUser, neverWrite, failMerge)
require.ErrorIs(t, err, errMerge)
})
t.Run("restricted user skips permission check entirely", func(t *testing.T) {
// The perm and merge functions must not be called for a restricted user.
called := false
trackWrite := func(_ context.Context, _ *repo_model.Repository, _ *user_model.User) (bool, error) {
called = true
return true, nil
}
need, err := ifNeedApprovalWith(t.Context(), forkRun, repo, restrictedUser, trackWrite, noMerged)
require.NoError(t, err)
assert.True(t, need)
assert.False(t, called, "permission check must not run for restricted user")
})
}
func TestFilteredWorkflowCommitStatusForForkPullRequest(t *testing.T) {
forkPR := &issues_model.PullRequest{
Flow: issues_model.PullRequestFlowGithub,
BaseRepoID: 1,
HeadRepoID: 2,
}
input := newPullRequestReviewNotifyInput(&repo_model.Repository{ID: 1}, &user_model.User{ID: 2}, actions_module.GithubEventPullRequest, "refs/pull/1/head", forkPR)
assert.True(t, isForkPullRequestInput(input))
assert.Equal(t, "refs/pull/1/head", input.Ref.String())
assert.False(t, shouldCreateSkippedCommitStatusForFilteredWorkflow(input, &actions_module.DetectedWorkflow{
TriggerEvent: &jobparser.Event{Name: actions_module.GithubEventPullRequest},
}))
assert.True(t, shouldCreateSkippedCommitStatusForFilteredWorkflow(input, &actions_module.DetectedWorkflow{
TriggerEvent: &jobparser.Event{Name: actions_module.GithubEventPullRequestTarget},
}))
assert.True(t, shouldCreateSkippedCommitStatusForFilteredWorkflow(newNotifyInput(&repo_model.Repository{ID: 1}, &user_model.User{ID: 2}, actions_module.GithubEventPullRequest), &actions_module.DetectedWorkflow{
TriggerEvent: &jobparser.Event{Name: actions_module.GithubEventPullRequest},
}))
}

147
services/actions/notify.go Normal file
View File

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

View File

@@ -0,0 +1,142 @@
// 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
node := rawPerms
for node.Kind == yaml.DocumentNode {
if len(node.Content) == 0 {
return nil
}
node = node.Content[0]
}
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
}
}

View File

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

680
services/actions/rerun.go Normal file
View File

@@ -0,0 +1,680 @@
// Copyright 2024 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package actions
import (
"context"
"fmt"
"slices"
"gitea.dev/actionslib/pkg/model"
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"
"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 run.IsScopedRun {
// a required scoped workflow can never be opted out, so a stale disabled flag must not block rerun
optedOut, err := actions_model.IsScopedWorkflowOptedOut(ctx, cfg, repo.OwnerID, run.WorkflowRepoID, run.WorkflowID)
if err != nil {
return err
}
if optedOut {
return util.NewInvalidArgumentErrorf("scoped workflow %s is disabled", run.WorkflowID)
}
} else if cfg.IsWorkflowDisabled(run.WorkflowID) {
return util.NewInvalidArgumentErrorf("workflow %s is disabled", run.WorkflowID)
}
// Legacy runs (LatestAttemptID == 0) conceptually have only attempt 1, so they can never be at the cap.
// For non-legacy runs, look up the latest attempt and reject when its number is already at the configured cap.
if run.LatestAttemptID > 0 {
latestAttempt, has, err := run.GetLatestAttempt(ctx)
if err != nil {
return fmt.Errorf("GetLatestAttempt: %w", err)
}
if has && latestAttempt.Attempt >= setting.Actions.MaxRerunAttempts {
return util.NewInvalidArgumentErrorf("workflow run has reached the maximum of %d attempts", setting.Actions.MaxRerunAttempts)
}
}
return nil
}
// rerunPlan is a read-only snapshot of the inputs needed to execute a rerun.
// It holds no to-be-persisted entities and no intermediate evaluation results;
// execRerunPlan constructs and evaluates the new ActionRunAttempt itself.
type rerunPlan struct {
run *actions_model.ActionRun
templateAttempt *actions_model.ActionRunAttempt
templateJobs actions_model.ActionJobList
triggerUser *user_model.User
// rerunAttemptJobIDs holds the AttemptJobIDs of jobs that will actually be re-run in the new attempt.
// If a job here is a reusable caller, the whole subtree under it will be re-run.
rerunAttemptJobIDs container.Set[int64]
// ancestorAttemptJobIDs holds the AttemptJobIDs of reusable caller jobs that have only some of their descendants being re-run:
// the caller itself is NOT re-run as a whole, it stays pass-through and its non-rerun children stay pass-through too.
ancestorAttemptJobIDs container.Set[int64]
// skipCloneTemplateJobIDs holds the template-attempt DB row IDs of descendants of any reusable caller in rerunAttemptJobIDs.
// These jobs should not be cloned, since the caller's lazy expansion will re-insert them fresh.
skipCloneTemplateJobIDs container.Set[int64]
// matrixPlaceholderTemplateIDs holds, per dynamic-matrix job whose matrix must be re-derived in the new attempt,
// the template DB row ID to clone as a restored unexpanded placeholder.
// The group's remaining combination rows are in matrixSiblingSkipTemplateIDs and are not cloned: they'll be re-expanded.
matrixPlaceholderTemplateIDs container.Set[int64]
matrixSiblingSkipTemplateIDs 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()
plan.collectMatrixCollapse()
return plan, nil
}
// execRerunPlan executes the rerun plan built by buildRerunPlan.
// It loads run variables, constructs the new ActionRunAttempt and evaluates run-level concurrency (all outside the transaction to keep the tx short).
// Inside a single database transaction it then inserts the new attempt, clones all template jobs, evaluates job-level concurrency for rerun jobs,
// and updates the run's latest_attempt_id.
// Jobs not in the rerun set are cloned as pass-through: their status is preserved and SourceTaskID points to the original task so the UI can still display their results.
// The attempt's final status is derived only from the rerun jobs, not the pass-through jobs.
// Notifications and commit statuses are sent after the transaction commits.
func execRerunPlan(ctx context.Context, plan *rerunPlan) (*actions_model.ActionRunAttempt, error) {
vars, err := actions_model.GetVariablesOfRun(ctx, plan.run)
if err != nil {
return nil, fmt.Errorf("get run %d variables: %w", plan.run.ID, err)
}
newAttempt := &actions_model.ActionRunAttempt{
RepoID: plan.run.RepoID,
RunID: plan.run.ID,
Attempt: plan.templateAttempt.Attempt + 1,
TriggerUserID: plan.triggerUser.ID,
Status: actions_model.StatusWaiting,
}
if plan.run.RawConcurrency != "" {
var rawConcurrency model.RawConcurrency
if err := yaml.Unmarshal([]byte(plan.run.RawConcurrency), &rawConcurrency); err != nil {
return nil, fmt.Errorf("unmarshal raw concurrency: %w", err)
}
inputs, err := dispatchInputsForRunJobs(plan.run, plan.templateJobs)
if err != nil {
return nil, err
}
if err := EvaluateRunConcurrencyFillModel(ctx, plan.run, newAttempt, &rawConcurrency, vars, inputs); err != nil {
return nil, err
}
}
var newJobs, newJobsToRerun actions_model.ActionJobList
var cancelledConcurrencyJobs []*actions_model.ActionRunJob
var hasWaitingCallerJobs bool
err = db.WithTx(ctx, func(ctx context.Context) error {
newAttemptStatus, jobsToCancel, err := PrepareToStartRunWithConcurrency(ctx, newAttempt)
if err != nil {
return err
}
cancelledConcurrencyJobs = append(cancelledConcurrencyJobs, jobsToCancel...)
newAttempt.Status = newAttemptStatus
shouldBlock := newAttemptStatus == actions_model.StatusBlocked
if err := db.Insert(ctx, newAttempt); err != nil {
if _, getErr := actions_model.GetRunAttemptByRunIDAndAttemptNum(ctx, plan.run.ID, newAttempt.Attempt); getErr == nil {
return util.NewAlreadyExistErrorf("workflow run attempt %d for run %d already exists", newAttempt.Attempt, plan.run.ID)
}
return err
}
plan.run.LatestAttemptID = newAttempt.ID
if err := actions_model.UpdateRun(ctx, plan.run, "latest_attempt_id"); err != nil {
return err
}
hasWaitingJobs := false
newJobs = make(actions_model.ActionJobList, 0, len(plan.templateJobs))
newJobsToRerun = make(actions_model.ActionJobList, 0, len(plan.rerunAttemptJobIDs))
// templateIDToNewID maps each template-attempt job's DB ID to its newly-inserted clone's DB ID
templateIDToNewID := make(map[int64]int64, len(plan.templateJobs))
slots := maxParallelSlots{}
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
}
// siblings of a collapsed dynamic-matrix job are not cloned either: re-expansion re-inserts them
if plan.matrixSiblingSkipTemplateIDs.Contains(templateJob.ID) {
continue
}
newJob := cloneRunJobForAttempt(templateJob, newAttempt)
if plan.matrixPlaceholderTemplateIDs.Contains(templateJob.ID) {
if err := restoreDeferredMatrixPlaceholder(newJob); err != nil {
return fmt.Errorf("restore matrix placeholder from job %d: %w", templateJob.ID, err)
}
}
// 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) {
// A deferred-matrix placeholder must go through the emitter, which is the only place
// that expands it: dispatching it directly would hand the runner the raw payload.
shouldBlockJob := shouldBlock || plan.hasRerunDependency(templateJob) || newJob.IsMatrixDeferred
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 = ""
}
// A slot-starved job must not cancel its group peers.
if newJob.RawConcurrency != "" && !shouldBlockJob && slots.available(newJob) {
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...)
}
applyMaxParallel(newJob, slots)
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 and restored matrix placeholders: let job_emitter
// resolve child jobs, and re-expand a placeholder whose needs may all be pass-through and done.
if hasWaitingCallerJobs || len(plan.matrixPlaceholderTemplateIDs) > 0 {
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),
ContinueOnError: templateJob.ContinueOnError,
IsMatrixDeferred: templateJob.IsMatrixDeferred,
DeferredMatrixPayload: slices.Clone(templateJob.DeferredMatrixPayload),
Status: templateJob.Status,
RawConcurrency: templateJob.RawConcurrency,
IsConcurrencyEvaluated: templateJob.IsConcurrencyEvaluated,
ConcurrencyGroup: templateJob.ConcurrencyGroup,
ConcurrencyCancel: templateJob.ConcurrencyCancel,
TokenPermissions: templateJob.TokenPermissions,
MaxParallel: templateJob.MaxParallel,
// 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")
})
}
// collectMatrixCollapse decides, per dynamic-matrix job in the rerun set, whether the new
// attempt must re-derive the matrix instead of reusing the previous attempt's combinations,
// and fills matrixPlaceholderTemplateIDs / matrixSiblingSkipTemplateIDs accordingly.
func (p *rerunPlan) collectMatrixCollapse() {
p.matrixPlaceholderTemplateIDs = make(container.Set[int64])
p.matrixSiblingSkipTemplateIDs = make(container.Set[int64])
// Group every template row by the key the rows of one matrix job share.
type groupKey struct {
parentJobID int64
jobID string
}
groups := make(map[groupKey][]*actions_model.ActionRunJob)
for _, tj := range p.templateJobs {
key := groupKey{tj.ParentJobID, tj.JobID}
groups[key] = append(groups[key], tj)
}
for _, rows := range groups {
anchorIdx := slices.IndexFunc(rows, func(j *actions_model.ActionRunJob) bool {
return len(j.DeferredMatrixPayload) > 0
})
if anchorIdx < 0 {
continue // not a dynamic-matrix job: a plain job, or a matrix expanded at plan time
}
anchor := rows[anchorIdx]
// Descendants of a reset caller are not cloned at all; the caller's expansion re-inserts the job.
if p.skipCloneTemplateJobIDs.Contains(anchor.ID) {
continue
}
// Gate on the anchor rather than on any row of the group: execRerunPlan only resets a row whose
// own AttemptJobID is in the rerun set, so restoring the placeholder onto an anchor it treats
// as pass-through would clone a row that keeps its old terminal status while carrying the raw,
// unexpanded payload, and nothing expands a job that is not blocked. An anchor can be left
// out of the rerun set while a sibling is in it: partially re-running the subtree of a
// matrix-expanded reusable caller puts that combination in ancestorAttemptJobIDs instead.
if !p.rerunAttemptJobIDs.Contains(anchor.AttemptJobID) {
continue // pass-through anchor, the group is cloned as-is
}
unexpanded := len(rows) == 1 && anchor.IsMatrixDeferred
if !unexpanded && !p.hasRerunDependency(anchor) {
continue // needs keep their outputs, reuse the combinations
}
p.matrixPlaceholderTemplateIDs.Add(anchor.ID)
for _, row := range rows {
if row.ID != anchor.ID {
p.matrixSiblingSkipTemplateIDs.Add(row.ID)
}
}
}
}

View File

@@ -0,0 +1,455 @@
// 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 TestCloneRunJobForAttempt(t *testing.T) {
attempt := &actions_model.ActionRunAttempt{ID: 42, Attempt: 2}
t.Run("preserves continue-on-error", func(t *testing.T) {
template := &actions_model.ActionRunJob{ContinueOnError: true, Status: actions_model.StatusFailure}
clone := cloneRunJobForAttempt(template, attempt)
assert.True(t, clone.ContinueOnError)
})
t.Run("defaults to false when template has it unset", func(t *testing.T) {
template := &actions_model.ActionRunJob{ContinueOnError: false}
clone := cloneRunJobForAttempt(template, attempt)
assert.False(t, clone.ContinueOnError)
})
// without this the rerun of a capped matrix would run unlimited
t.Run("preserves max-parallel", func(t *testing.T) {
template := &actions_model.ActionRunJob{MaxParallel: 3}
clone := cloneRunJobForAttempt(template, attempt)
assert.Equal(t, 3, clone.MaxParallel)
})
}
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
}
func TestCollectMatrixCollapse(t *testing.T) {
// A dynamic-matrix group is the anchor (the combination that kept DeferredMatrixPayload) plus its
// sibling combinations. Collapsing it rewinds the anchor to an unexpanded placeholder and drops
// the siblings, so the new attempt re-derives the matrix from the fresh needs outputs.
matrixRow := func(id, attemptJobID int64, jobID string, parentID int64, isAnchor, isCaller bool, needs ...string) *actions_model.ActionRunJob {
job := templateJob(id, attemptJobID, jobID, parentID, isCaller, needs...)
if isAnchor {
job.DeferredMatrixPayload = []byte("name: t\non: push\njobs:\n build:\n steps: [{run: echo}]\n")
}
return job
}
t.Run("a rerun of the needs collapses the group", func(t *testing.T) {
generate := templateJob(101, 1, "generate", 0, false)
build1 := matrixRow(102, 2, "build", 0, true, false, "generate")
build2 := matrixRow(103, 3, "build", 0, false, false, "generate")
plan := &rerunPlan{templateJobs: []*actions_model.ActionRunJob{generate, build1, build2}}
require.NoError(t, plan.expandRerunJobIDs([]*actions_model.ActionRunJob{generate}))
plan.skipCloneTemplateJobIDs = plan.collectResetCallerDescendants()
plan.collectMatrixCollapse()
assert.ElementsMatch(t, rowIDsOf(build1), plan.matrixPlaceholderTemplateIDs.Values())
assert.ElementsMatch(t, rowIDsOf(build2), plan.matrixSiblingSkipTemplateIDs.Values())
})
t.Run("re-running only a combination keeps the group as it is", func(t *testing.T) {
// generate is not re-run, so its outputs still stand and the combinations stay valid.
generate := templateJob(101, 1, "generate", 0, false)
build1 := matrixRow(102, 2, "build", 0, true, false, "generate")
build2 := matrixRow(103, 3, "build", 0, false, false, "generate")
plan := &rerunPlan{templateJobs: []*actions_model.ActionRunJob{generate, build1, build2}}
require.NoError(t, plan.expandRerunJobIDs([]*actions_model.ActionRunJob{build2}))
plan.skipCloneTemplateJobIDs = plan.collectResetCallerDescendants()
plan.collectMatrixCollapse()
assert.Empty(t, plan.matrixPlaceholderTemplateIDs)
assert.Empty(t, plan.matrixSiblingSkipTemplateIDs)
})
t.Run("a pass-through anchor is never rewound", func(t *testing.T) {
// build is a matrix-expanded reusable caller. Re-running a job inside build (1)'s subtree
// together with generate leaves build (1) an ancestor rather than a rerun job, while build (2)
// does join the rerun set. execRerunPlan clones an ancestor with its old terminal status, so
// restoring the placeholder onto it would leave a done job holding the raw, unexpanded payload
// that nothing ever expands - and drop build (2) on top of it.
generate := templateJob(101, 1, "generate", 0, false)
build1 := matrixRow(102, 2, "build", 0, true, true, "generate")
build2 := matrixRow(103, 3, "build", 0, false, true, "generate")
inner := templateJob(104, 4, "inner", 102, false)
plan := &rerunPlan{templateJobs: []*actions_model.ActionRunJob{generate, build1, build2, inner}}
require.NoError(t, plan.expandRerunJobIDs([]*actions_model.ActionRunJob{inner, generate}))
plan.skipCloneTemplateJobIDs = plan.collectResetCallerDescendants()
require.Contains(t, plan.ancestorAttemptJobIDs, build1.AttemptJobID)
require.NotContains(t, plan.rerunAttemptJobIDs, build1.AttemptJobID)
require.Contains(t, plan.rerunAttemptJobIDs, build2.AttemptJobID)
plan.collectMatrixCollapse()
assert.Empty(t, plan.matrixPlaceholderTemplateIDs)
assert.Empty(t, plan.matrixSiblingSkipTemplateIDs)
})
t.Run("an unexpanded placeholder is rewound whenever it is re-run", func(t *testing.T) {
// The previous attempt never got to expand it, so there is nothing to reuse.
generate := templateJob(101, 1, "generate", 0, false)
build := matrixRow(102, 2, "build", 0, true, false, "generate")
build.IsMatrixDeferred = true
plan := &rerunPlan{templateJobs: []*actions_model.ActionRunJob{generate, build}}
require.NoError(t, plan.expandRerunJobIDs([]*actions_model.ActionRunJob{build}))
plan.skipCloneTemplateJobIDs = plan.collectResetCallerDescendants()
plan.collectMatrixCollapse()
assert.ElementsMatch(t, rowIDsOf(build), plan.matrixPlaceholderTemplateIDs.Values())
assert.Empty(t, plan.matrixSiblingSkipTemplateIDs)
})
t.Run("a plan-time matrix is left alone", func(t *testing.T) {
generate := templateJob(101, 1, "generate", 0, false)
build1 := matrixRow(102, 2, "build", 0, false, false, "generate")
build2 := matrixRow(103, 3, "build", 0, false, false, "generate")
plan := &rerunPlan{templateJobs: []*actions_model.ActionRunJob{generate, build1, build2}}
require.NoError(t, plan.expandRerunJobIDs(nil))
plan.skipCloneTemplateJobIDs = plan.collectResetCallerDescendants()
plan.collectMatrixCollapse()
assert.Empty(t, plan.matrixPlaceholderTemplateIDs)
assert.Empty(t, plan.matrixSiblingSkipTemplateIDs)
})
}

View File

@@ -0,0 +1,458 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package actions
import (
"context"
"errors"
"fmt"
"strings"
actions_model "gitea.dev/models/actions"
"gitea.dev/models/db"
perm_model "gitea.dev/models/perm"
access_model "gitea.dev/models/perm/access"
repo_model "gitea.dev/models/repo"
actions_module "gitea.dev/modules/actions"
"gitea.dev/modules/actions/jobparser"
"gitea.dev/modules/container"
"gitea.dev/modules/git"
"gitea.dev/modules/httplib"
"gitea.dev/modules/json"
"gitea.dev/modules/log"
"gitea.dev/modules/setting"
api "gitea.dev/modules/structs"
"gitea.dev/modules/util"
"gitea.dev/services/convert"
"xorm.io/builder"
)
// MaxReusableCallLevels caps how deep a reusable workflow can nest:
// a top-level caller may have at most MaxReusableCallLevels nested callers below it.
const MaxReusableCallLevels = 9
// checkRunJobLimit rejects an expansion that would push the attempt over actions_model.MaxJobNumPerRun.
// checkCallerChain bounds nesting *depth*, but a reusable graph also fans out in *breadth*: without a
// cumulative cap a tiny set of files can drive exponential job-row insertion and exhaust the database.
func checkRunJobLimit(ctx context.Context, runID, attemptID int64, adding int) error {
existing, err := actions_model.CountRunJobsByRunAndAttemptID(ctx, runID, attemptID)
if err != nil {
return fmt.Errorf("count existing jobs of run %d attempt %d: %w", runID, attemptID, err)
}
if existing+int64(adding) > actions_model.MaxJobNumPerRun {
return fmt.Errorf("workflow run exceeds the maximum of %d jobs", actions_model.MaxJobNumPerRun)
}
return nil
}
// loadReusableWorkflowSource resolves the workflow file referenced by a caller's `uses:` and returns its raw bytes,
// along with the (repo_id, commit_sha) the file was loaded from.
func loadReusableWorkflowSource(ctx context.Context, run *actions_model.ActionRun, caller *actions_model.ActionRunJob, ref *jobparser.UsesRef) (content []byte, sourceRepoID int64, sourceCommitSHA string, err error) {
if err := run.LoadAttributes(ctx); err != nil {
return nil, 0, "", err
}
switch ref.Kind {
case jobparser.UsesKindLocalSameRepo:
// `./` and `$/` are resolved against the workflow file containing the `uses:` - i.e. the caller's own source repo + commit.
callerRepo, err := repo_model.GetRepositoryByID(ctx, caller.WorkflowSourceRepoID)
if err != nil {
return nil, 0, "", fmt.Errorf("look up caller source repo %d: %w", caller.WorkflowSourceRepoID, err)
}
sourceCommitSHA := resolveSameRepoWorkflowSourceCommit(run, caller)
if sourceCommitSHA != caller.WorkflowSourceCommitSHA {
log.Warn("run %d (pull_request_target) records workflow source commit %s, resolving %q at base commit %s instead", run.ID, caller.WorkflowSourceCommitSHA, ref.Path, sourceCommitSHA)
}
bytes, resolvedSHA, err := readWorkflowFromRepo(ctx, callerRepo, sourceCommitSHA, ref.Path)
if err != nil {
return nil, 0, "", err
}
return bytes, callerRepo.ID, resolvedSHA, nil
case jobparser.UsesKindLocalCrossRepo:
repo, err := repo_model.GetRepositoryByOwnerAndName(ctx, ref.Owner, ref.Repo)
if err != nil {
return nil, 0, "", fmt.Errorf("look up cross-repo workflow source %q: %w", ref.Owner+"/"+ref.Repo, err)
}
ok, err := access_model.CanReadWorkflowCrossRepo(ctx, repo, run)
if err != nil {
return nil, 0, "", err
}
if !ok {
if run.IsScopedRun {
// A scoped workflow's cross-repo "uses:" is resolved with the consuming repo's read permission,
// so the referenced repo must be readable by every consumer. Make that explicit in the failure.
return nil, 0, "", fmt.Errorf("no permission to read reusable workflow %s/%s: a scoped workflow's cross-repo \"uses:\" is resolved with the consuming repository %q read permission", ref.Owner, ref.Repo, run.Repo.FullName())
}
return nil, 0, "", fmt.Errorf("no permission to read reusable workflow from %s/%s", ref.Owner, ref.Repo)
}
bytes, resolvedSHA, err := readWorkflowFromRepo(ctx, repo, ref.Ref, ref.Path)
if err != nil {
return nil, 0, "", err
}
return bytes, repo.ID, resolvedSHA, nil
}
return nil, 0, "", fmt.Errorf("unsupported uses kind %d", ref.Kind)
}
// resolveSameRepoWorkflowSourceCommit returns the commit to read a same-repo reusable workflow from.
// pull_request_target runs must resolve local `uses:` at the PR base commit, not a stored head SHA.
func resolveSameRepoWorkflowSourceCommit(run *actions_model.ActionRun, caller *actions_model.ActionRunJob) string {
// only a SHA copied from the run row can be the polluted head one; a SHA resolved from a `uses:` ref is right by construction
if run.IsScopedRun || caller.WorkflowSourceRepoID != run.RepoID || caller.WorkflowSourceCommitSHA != run.WorkflowCommitSHA {
return caller.WorkflowSourceCommitSHA
}
if baseSHA, ok := pullRequestTargetBaseSHA(run); ok && baseSHA != caller.WorkflowSourceCommitSHA {
return baseSHA
}
return caller.WorkflowSourceCommitSHA
}
// readWorkflowFromRepo loads a workflow file from `repo` at `refOrSHA` and returns its content plus the resolved commit SHA.
func readWorkflowFromRepo(ctx context.Context, repo *repo_model.Repository, refOrSHA, path string) ([]byte, string, error) {
gitRepo, err := git.OpenRepository(ctx, repo)
if err != nil {
return nil, "", fmt.Errorf("open repo %s: %w", repo.FullName(), err)
}
defer gitRepo.Close()
commit, err := gitRepo.GetCommit(ctx, refOrSHA)
if err != nil {
return nil, "", fmt.Errorf("get commit %q in %s: %w", refOrSHA, repo.FullName(), err)
}
str, err := commit.GetFileContent(ctx, gitRepo, 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 canonicalCallUses), 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 := container.SetOf(canonicalCallUses(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 != "" && !visited.Add(canonicalCallUses(current.CallUses)) {
return fmt.Errorf("reusable workflow call cycle detected: %q", current.CallUses)
}
}
return nil
}
// canonicalCallUses folds the two same-repo prefixes into one key, because `$/x.yml` and `./x.yml` name the same file.
func canonicalCallUses(uses string) string {
if ref, err := jobparser.ParseUses(uses); err == nil && ref.Kind == jobparser.UsesKindLocalSameRepo {
return "./" + ref.Path
}
return uses
}
// expandReusableWorkflowCaller loads and parses the target reusable workflow and inserts the caller's direct child jobs.
// It expands only ONE level: a child that is itself a reusable caller is inserted Blocked and expanded later by a subsequent resolver pass.
// It does NOT schedule a follow-up resolver pass; the caller of this function is responsible for emitting.
//
// All call sites (PrepareRunAndInsert, execRerunPlan, checkJobsOfCurrentRunAttempt, ApproveRuns) invoke this inside their enclosing write transaction,
// because the caller row update and the child-row inserts must commit atomically.
// Be aware this is not cheap inside a tx: it does a git read, YAML parsing, and `${{ }}` expression evaluation.
// None of the call sites is hot: each caller is expanded once per attempt.
func expandReusableWorkflowCaller(ctx context.Context, run *actions_model.ActionRun, attempt *actions_model.ActionRunAttempt, caller *actions_model.ActionRunJob, vars map[string]string) error {
// Already expanded by an earlier call, skip
if caller.IsExpanded {
return nil
}
// 1. Cycle + depth check via the ParentJobID chain.
if err := checkCallerChain(ctx, caller); err != nil {
return err
}
// 2. Parse the caller's own job (Uses, With, RawSecrets) from its WorkflowPayload.
parsedJob, err := caller.ParseJob()
if err != nil {
return fmt.Errorf("parse caller job %d: %w", caller.ID, err)
}
// 3. Resolve `uses` and load called-workflow source.
ref, err := ResolveUses(ctx, parsedJob.Uses)
if err != nil {
return fmt.Errorf("resolve uses %q: %w", parsedJob.Uses, err)
}
content, contentSourceRepoID, contentSourceCommitSHA, err := loadReusableWorkflowSource(ctx, run, caller, ref)
if err != nil {
return err
}
// 4. Parse the called workflow's spec (used by both secret validation and input evaluation).
wcSpec, err := jobparser.ParseWorkflowCallSpec(content)
if err != nil {
return fmt.Errorf("parse called workflow spec: %w", err)
}
// 5. Resolve caller's `secrets:` and validate it against the callee's schema.
inherit, secretsMap, err := jobparser.ParseCallerSecrets(parsedJob.RawSecrets)
if err != nil {
return fmt.Errorf("caller secrets %q: %w", caller.JobID, err)
}
// Under `secrets: inherit` the caller forwards all of its own secrets verbatim and does NOT name them individually,
// so required-secret presence cannot be verified at expansion time and a missing required secret will surface at job runtime.
// This matches GitHub Actions' behavior.
if !inherit {
if err := jobparser.ValidateCallerSecrets(wcSpec, secretsMap); err != nil {
return fmt.Errorf("caller %q secrets: %w", caller.JobID, err)
}
}
switch {
case inherit:
caller.CallSecrets = jobparser.SecretsInherit
case len(secretsMap) > 0:
mapBytes, err := json.Marshal(secretsMap)
if err != nil {
return fmt.Errorf("marshal caller secret map: %w", err)
}
caller.CallSecrets = string(mapBytes)
}
caller.ReusableWorkflowContent = content
// 6. Evaluate caller's `with:`, then match against the callee schema.
workflowCallInputs := map[string]any{}
if len(wcSpec.Inputs) > 0 {
jobResults, err := findJobNeedsAndFillJobResults(ctx, caller)
if err != nil {
return fmt.Errorf("find caller needs: %w", err)
}
parentInputs, err := getInputsForJob(ctx, run, caller)
if err != nil {
return err
}
callerGitCtx := GenerateGiteaContext(ctx, run, attempt, caller)
evaluated, err := jobparser.EvaluateCallerWith(
caller.JobID, parsedJob,
callerGitCtx, jobResults, vars, parentInputs,
)
if err != nil {
return fmt.Errorf("evaluate caller with: %w", err)
}
workflowCallInputs, err = jobparser.MatchCallerInputsAgainstSpec(wcSpec, evaluated)
if err != nil {
return fmt.Errorf("caller %q inputs: %w", caller.JobID, err)
}
}
// 7. Build CallPayload (persisted in step 9).
callPayload, err := (&api.WorkflowCallPayload{
Workflow: run.WorkflowID,
Ref: run.Ref,
Repository: convert.ToRepo(ctx, run.Repo, access_model.Permission{AccessMode: perm_model.AccessModeNone}),
Sender: convert.ToUserWithAccessMode(ctx, run.TriggerUser, perm_model.AccessModeNone),
Inputs: workflowCallInputs,
}).JSONPayload()
if err != nil {
return fmt.Errorf("build call payload: %w", err)
}
// 8. Claim the expansion by flipping is_expanded false->true BEFORE inserting any children.
// Two concurrent expanders serialize on this row: exactly one winner matches (n==1) and owns the expansion.
// Children are only ever inserted by the claim winner, so no duplicate child rows can arise.
caller.IsExpanded = true
n, err := actions_model.UpdateRunJob(ctx, caller, builder.And(
builder.Eq{"is_expanded": false},
builder.In("status", actions_model.StatusBlocked, actions_model.StatusWaiting),
), "is_expanded")
if err != nil {
caller.IsExpanded = false // the claim was not established
return fmt.Errorf("claim caller %d expansion: %w", caller.ID, err)
}
if n == 0 {
// Another writer won the expansion, or the caller has been moved to a terminal status (e.g. failed/cancelled).
return nil
}
// 9. We own the expansion: insert the direct children.
if err := insertCallerChildren(ctx, run, attempt, caller, content, contentSourceRepoID, contentSourceCommitSHA, vars, workflowCallInputs); err != nil {
// On failure, undo the partial expansion so an error return always leaves the caller unexpanded and childless.
return errors.Join(err, undoExpansion(ctx, caller))
}
// 10. Persist the remaining caller metadata (the row is already ours via the claim above).
caller.CallPayload = string(callPayload)
if _, err := actions_model.UpdateRunJob(ctx, caller, nil, "call_secrets", "reusable_workflow_content", "call_payload"); err != nil {
return errors.Join(fmt.Errorf("persist caller %d expansion metadata: %w", caller.ID, err), undoExpansion(ctx, caller))
}
return nil
}
// insertCallerChildren parses the called workflow with the caller's resolved inputs and inserts each parsed job.
func insertCallerChildren(ctx context.Context, run *actions_model.ActionRun, attempt *actions_model.ActionRunAttempt, caller *actions_model.ActionRunJob, content []byte, sourceRepoID int64, sourceCommitSHA string, vars map[string]string, inputs map[string]any) error {
// Parse the called workflow with the caller's `inputs`
gitCtx := GenerateGiteaContext(ctx, run, attempt, nil)
if event, ok := gitCtx["event"].(map[string]any); ok {
event["inputs"] = inputs
}
gitCtx["event_name"] = "workflow_call"
childWorkflows, err := jobparser.Parse(content,
jobparser.WithVars(vars),
jobparser.WithGitContext(gitCtx.ToGitHubContext()),
jobparser.WithInputs(inputs),
)
if err != nil {
return fmt.Errorf("parse called workflow for caller %d: %w", caller.ID, err)
}
if len(childWorkflows) == 0 {
return fmt.Errorf("called workflow for caller %d (uses %q) has no jobs", caller.ID, caller.CallUses)
}
if err := checkRunJobLimit(ctx, run.ID, attempt.ID, len(childWorkflows)); err != nil {
return err
}
priorChildren, err := actions_model.GetPriorAttemptChildrenByParent(ctx, run.ID, attempt.ID, caller.AttemptJobID)
if err != nil {
return fmt.Errorf("lookup prior-attempt children of caller %d: %w", caller.ID, err)
}
for _, sw := range childWorkflows {
jobID, parsedChild := sw.Job()
if parsedChild == nil {
continue
}
needs := parsedChild.Needs()
isMatrixDeferred := jobparser.HasDeferredMatrix(parsedChild)
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 and fall back to a fresh allocator value for newly-appearing logical jobs.
var attemptJobID int64
if priorID, ok := priorAttemptJobID(priorChildren[jobID], parsedChild.Name, isMatrixDeferred); ok {
attemptJobID = priorID
} else {
attemptJobID, err = actions_model.GetNextAttemptJobID(ctx, run.ID)
if err != nil {
return fmt.Errorf("alloc attempt_job_id for child %q: %w", jobID, err)
}
}
child := &actions_model.ActionRunJob{
RunID: run.ID,
RunAttemptID: attempt.ID,
RepoID: run.RepoID,
OwnerID: run.OwnerID,
CommitSHA: run.CommitSHA,
IsForkPullRequest: run.IsForkPullRequest,
Name: parsedChild.Name,
Attempt: attempt.Attempt,
WorkflowPayload: payload,
JobID: jobID,
AttemptJobID: attemptJobID,
Needs: needs,
RunsOn: parsedChild.RunsOn(),
ContinueOnError: parsedChild.GetContinueOnError(),
MaxParallel: parseMaxParallel(jobID, parsedChild.Strategy.MaxParallelString),
Status: actions_model.StatusBlocked,
ParentJobID: caller.ID,
WorkflowSourceRepoID: sourceRepoID,
WorkflowSourceCommitSHA: sourceCommitSHA,
IsMatrixDeferred: isMatrixDeferred,
}
if isMatrixDeferred {
// Expansion overwrites WorkflowPayload; keep the raw payload so a rerun can re-derive the matrix.
child.DeferredMatrixPayload = payload
}
if perms := ExtractJobPermissionsFromWorkflow(sw, parsedChild); perms != nil {
child.TokenPermissions = perms
}
if parsedChild.Uses != "" {
child.IsReusableCaller = true
child.CallUses = parsedChild.Uses
}
if err := db.Insert(ctx, child); err != nil {
return fmt.Errorf("insert child %q under caller %d: %w", jobID, caller.ID, err)
}
}
return nil
}
// ResolveUses normalizes and parses a reusable workflow `uses:` value.
// It first rewrites an absolute URL pointing to this instance into the cross-repo form (rejecting external URLs),
// then validates the syntax via jobparser.ParseUses.
func ResolveUses(ctx context.Context, uses string) (*jobparser.UsesRef, error) {
// Rewrite a local-instance URL to the equivalent cross-repo form "owner/repo/.gitea/workflows/file.yml@ref".
if strings.HasPrefix(uses, "http://") || strings.HasPrefix(uses, "https://") {
// ParseGiteaSiteURL returns nil for URLs that do not belong to this instance.
gsu := httplib.ParseGiteaSiteURL(ctx, uses)
if gsu == nil {
return nil, fmt.Errorf("unsupported reusable workflow URL %q: an absolute URL must point to this Gitea instance (%s)", uses, setting.AppURL)
}
// RoutePath is the instance-relative path (AppSubURL already stripped), e.g. "/owner/repo/.gitea/workflows/file.yml@ref".
uses = strings.TrimPrefix(gsu.RoutePath, "/")
}
ref, err := jobparser.ParseUses(uses)
if err != nil {
return nil, err
}
// jobparser only validates syntax; enforce the (instance-configurable) directory allowlist here.
if !actions_module.IsWorkflowOrScopedWorkflow(ref.Path) {
return nil, fmt.Errorf(`"uses:" path %q must be under a configured workflow directory (WORKFLOW_DIRS or SCOPED_WORKFLOW_DIRS)`, ref.Path)
}
return ref, nil
}
// undoExpansion rolls back a partial expansion owned by the current transaction:
// it removes the inserted children and releases the is_expanded claim itself.
func undoExpansion(ctx context.Context, caller *actions_model.ActionRunJob) error {
if err := actions_model.DeleteDirectChildJobsByParent(ctx, caller); err != nil {
return fmt.Errorf("delete children of caller %d: %w", caller.ID, err)
}
caller.IsExpanded = false
if _, err := actions_model.UpdateRunJob(ctx, caller, nil, "is_expanded"); err != nil {
return fmt.Errorf("release caller %d expansion claim: %w", caller.ID, err)
}
return nil
}
// priorAttemptJobID returns the AttemptJobID a re-inserted child should reuse,
// given the prior attempt's rows of the same JobID indexed by Name.
func priorAttemptJobID(priorSameJobID map[string]*actions_model.ActionRunJob, name string, isMatrixDeferred bool) (int64, bool) {
if isMatrixDeferred {
for _, prior := range priorSameJobID {
if len(prior.DeferredMatrixPayload) > 0 {
return prior.AttemptJobID, true
}
}
return 0, false
}
prior, ok := priorSameJobID[name]
if !ok {
return 0, false
}
return prior.AttemptJobID, true
}

View File

@@ -0,0 +1,366 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package actions
import (
"fmt"
"testing"
actions_model "gitea.dev/models/actions"
"gitea.dev/models/db"
"gitea.dev/models/unittest"
actions_module "gitea.dev/modules/actions"
"gitea.dev/modules/actions/jobparser"
"gitea.dev/modules/json"
"gitea.dev/modules/setting"
api "gitea.dev/modules/structs"
"gitea.dev/modules/test"
webhook_module "gitea.dev/modules/webhook"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestCheckCallerChain_Cycle(t *testing.T) {
t.Run("DirectCycle", func(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
// A -> A: leaf's CallUses matches its direct parent's.
chain := buildCallerChain(t,
"./.gitea/workflows/a.yml",
"./.gitea/workflows/a.yml",
)
err := checkCallerChain(t.Context(), chain[len(chain)-1])
assert.ErrorContains(t, err, "cycle detected")
})
t.Run("IndirectCycle", func(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
// A -> B -> A: leaf's CallUses matches its grandparent's.
chain := buildCallerChain(t,
"./.gitea/workflows/a.yml",
"./.gitea/workflows/b.yml",
"./.gitea/workflows/a.yml",
)
err := checkCallerChain(t.Context(), chain[len(chain)-1])
assert.ErrorContains(t, err, "cycle detected")
})
t.Run("MixedPrefixCycle", func(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
// A -> A written with both same-repo prefixes: they name the same file.
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("NoCycle", func(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
// Sanity: linear chain with distinct CallUses must not trip cycle detection.
chain := buildCallerChain(t,
"./.gitea/workflows/a.yml",
"./.gitea/workflows/b.yml",
"./.gitea/workflows/c.yml",
)
require.NoError(t, checkCallerChain(t.Context(), chain[len(chain)-1]))
})
}
func TestCheckCallerChain_DepthLimit(t *testing.T) {
// top + MaxReusableCallLevels nested callers is the longest accepted; one more exceeds the limit.
makeDistinctUses := func(n int) []string {
out := make([]string, n)
for i := range out {
out[i] = fmt.Sprintf("./.gitea/workflows/level%d.yml", i)
}
return out
}
t.Run("ExactlyAtLimit", func(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
chain := buildCallerChain(t, makeDistinctUses(MaxReusableCallLevels+1)...)
require.NoError(t, checkCallerChain(t.Context(), chain[len(chain)-1]))
})
t.Run("OneOverLimit", func(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
chain := buildCallerChain(t, makeDistinctUses(MaxReusableCallLevels+2)...)
err := checkCallerChain(t.Context(), chain[len(chain)-1])
assert.ErrorContains(t, err, "exceeds the maximum nesting level")
})
}
// buildCallerChain inserts a linear chain of reusable caller jobs in a single run+attempt.
// callerUses[0] is the top-level caller (ParentJobID=0); each subsequent caller is inserted as a child of the previous one.
// Returns the inserted jobs in order (index 0 = top, last = leaf).
func buildCallerChain(t *testing.T, callerUses ...string) []*actions_model.ActionRunJob {
t.Helper()
require.NotEmpty(t, callerUses)
ctx := t.Context()
run := &actions_model.ActionRun{
Title: "caller-chain-test",
RepoID: 4,
OwnerID: 1,
Index: 9601,
WorkflowID: "test.yaml",
TriggerUserID: 1,
Ref: "refs/heads/master",
CommitSHA: "c2d72f548424103f01ee1dc02889c1e2bff816b0",
Event: "push",
TriggerEvent: "push",
EventPayload: "{}",
Status: actions_model.StatusRunning,
}
require.NoError(t, db.Insert(ctx, run))
attempt := &actions_model.ActionRunAttempt{
RepoID: run.RepoID,
RunID: run.ID,
Attempt: 1,
TriggerUserID: 1,
Status: actions_model.StatusRunning,
}
require.NoError(t, db.Insert(ctx, attempt))
jobs := make([]*actions_model.ActionRunJob, 0, len(callerUses))
parentID := int64(0)
for i, uses := range callerUses {
job := &actions_model.ActionRunJob{
RunID: run.ID,
RunAttemptID: attempt.ID,
RepoID: run.RepoID,
OwnerID: run.OwnerID,
CommitSHA: run.CommitSHA,
Name: fmt.Sprintf("caller-%d", i),
JobID: fmt.Sprintf("caller-%d", i),
Attempt: 1,
Status: actions_model.StatusBlocked,
AttemptJobID: int64(i + 1),
IsReusableCaller: true,
CallUses: uses,
ParentJobID: parentID,
}
require.NoError(t, db.Insert(ctx, job))
jobs = append(jobs, job)
parentID = job.ID
}
return jobs
}
func TestResolveUses(t *testing.T) {
defer test.MockVariableValue(&setting.AppURL, "https://gitea.example.com/sub/")()
defer test.MockVariableValue(&setting.AppSubURL, "/sub")()
defer test.MockVariableValue(&setting.Actions.WorkflowDirs, []string{".gitea/workflows", ".github/workflows"})()
defer test.MockVariableValue(&setting.Actions.ScopedWorkflowDirs, []string{".gitea/scoped_workflows"})()
ctx := t.Context()
t.Run("LocalForms", func(t *testing.T) {
// Same-repo and cross-repo forms are not URLs and are parsed as-is.
ref, err := ResolveUses(ctx, "./.gitea/workflows/build.yml")
require.NoError(t, err)
assert.Equal(t, jobparser.UsesRef{Kind: jobparser.UsesKindLocalSameRepo, Path: ".gitea/workflows/build.yml"}, *ref)
ref, err = ResolveUses(ctx, "owner/repo/.gitea/workflows/build.yml@v1")
require.NoError(t, err)
assert.Equal(t, jobparser.UsesRef{Kind: jobparser.UsesKindLocalCrossRepo, Owner: "owner", Repo: "repo", Path: ".gitea/workflows/build.yml", Ref: "v1"}, *ref)
})
t.Run("DirectoryAllowlist", func(t *testing.T) {
// SCOPED_WORKFLOW_DIRS is allowed (local and cross-repo).
ref, err := ResolveUses(ctx, "./.gitea/scoped_workflows/lib.yml")
require.NoError(t, err)
assert.Equal(t, ".gitea/scoped_workflows/lib.yml", ref.Path)
ref, err = ResolveUses(ctx, "owner/repo/.gitea/scoped_workflows/lib.yml@v1")
require.NoError(t, err)
assert.Equal(t, ".gitea/scoped_workflows/lib.yml", ref.Path)
// A directory that is neither WORKFLOW_DIRS nor SCOPED_WORKFLOW_DIRS parses but is rejected by the allowlist.
_, err = ResolveUses(ctx, "./not-workflows/build.yml")
require.Error(t, err)
_, err = ResolveUses(ctx, "owner/repo/lib/build.yml@v1")
require.Error(t, err)
})
t.Run("ConfigurableWorkflowDirs", func(t *testing.T) {
// A non-default WORKFLOW_DIRS is honored (the hardcoded ".gitea/workflows" is no longer special).
defer test.MockVariableValue(&setting.Actions.WorkflowDirs, []string{".gitea/ci"})()
ref, err := ResolveUses(ctx, "./.gitea/ci/build.yml")
require.NoError(t, err)
assert.Equal(t, ".gitea/ci/build.yml", ref.Path)
_, err = ResolveUses(ctx, "./.gitea/workflows/build.yml") // no longer a configured dir
require.Error(t, err)
})
t.Run("LocalInstanceURL", func(t *testing.T) {
// An absolute URL on this instance (incl. AppSubURL) resolves to the equivalent cross-repo ref.
ref, err := ResolveUses(ctx, "https://gitea.example.com/sub/owner/repo/.gitea/workflows/ci.yml@refs/heads/main")
require.NoError(t, err)
assert.Equal(t, jobparser.UsesRef{Kind: jobparser.UsesKindLocalCrossRepo, Owner: "owner", Repo: "repo", Path: ".gitea/workflows/ci.yml", Ref: "refs/heads/main"}, *ref)
})
t.Run("InvalidSyntax", func(t *testing.T) {
for _, in := range []string{
"owner/.gitea/workflows/foo.yml", // missing repo segment
"owner/repo/.gitea/workflows/foo.yml", // missing @ref
"https://gitea.example.com/sub/repo/.gitea/workflows/ci.yml@refs/heads/main", // local absolute URL but missing owner
"not a valid uses at all",
} {
_, err := ResolveUses(ctx, in)
require.Error(t, err, "in = %s", in)
}
})
t.Run("ForeignURL", func(t *testing.T) {
_, err := ResolveUses(ctx, "https://other.gitea-example.com/owner/repo/.gitea/workflows/ci.yaml@v1")
assert.ErrorContains(t, err, "must point to this Gitea instance")
})
}
func TestCheckRunJobLimit(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
const (
runID = 900100
attemptA = 910001
attemptB = 910002
)
seed := func(attemptID int64, n int) {
for i := range n {
name := fmt.Sprintf("job-%d-%d", attemptID, i)
require.NoError(t, db.Insert(t.Context(), &actions_model.ActionRunJob{
RunID: runID,
RunAttemptID: attemptID,
RepoID: 1,
OwnerID: 1,
CommitSHA: "abcdef",
Name: name,
JobID: name,
AttemptJobID: attemptID*1000 + int64(i),
Status: actions_model.StatusBlocked,
}))
}
}
seed(attemptA, 5)
seed(attemptB, 3) // a different attempt of the same run must not count toward attempt A
limit := actions_model.MaxJobNumPerRun
// attempt A already holds 5 jobs: filling up to the cap is allowed, one more is rejected.
require.NoError(t, checkRunJobLimit(t.Context(), runID, attemptA, limit-5))
require.ErrorContains(t, checkRunJobLimit(t.Context(), runID, attemptA, limit-4), "maximum")
require.ErrorContains(t, checkRunJobLimit(t.Context(), runID, attemptA, limit), "maximum")
// the count is scoped to the attempt: attempt B only holds 3 jobs, so attempt A's 5 must not leak in.
require.NoError(t, checkRunJobLimit(t.Context(), runID, attemptB, limit-3))
require.ErrorContains(t, checkRunJobLimit(t.Context(), runID, attemptB, limit-2), "maximum")
}
func TestUndoExpansion(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
ctx := t.Context()
// A claimed caller with two children inserted by the aborted expansion, plus a sibling that must survive.
caller := &actions_model.ActionRunJob{
RunID: 991, RepoID: 4, OwnerID: 1, JobID: "caller", Name: "caller",
Status: actions_model.StatusBlocked, IsReusableCaller: true, IsExpanded: true,
}
require.NoError(t, db.Insert(ctx, caller))
for _, jobID := range []string{"child1", "child2"} {
require.NoError(t, db.Insert(ctx, &actions_model.ActionRunJob{
RunID: 991, RepoID: 4, OwnerID: 1, JobID: jobID, Name: jobID,
Status: actions_model.StatusBlocked, ParentJobID: caller.ID,
}))
}
sibling := &actions_model.ActionRunJob{
RunID: 991, RepoID: 4, OwnerID: 1, JobID: "sibling", Name: "sibling",
Status: actions_model.StatusBlocked,
}
require.NoError(t, db.Insert(ctx, sibling))
require.NoError(t, undoExpansion(ctx, caller))
assert.Equal(t, 0, unittest.GetCount(t, &actions_model.ActionRunJob{ParentJobID: caller.ID}))
assert.False(t, caller.IsExpanded)
refreshed := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: caller.ID})
assert.False(t, refreshed.IsExpanded)
unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: sibling.ID})
}
func TestResolveSameRepoWorkflowSourceCommit(t *testing.T) {
prtRun := func(baseSHA string) *actions_model.ActionRun {
payload, err := json.Marshal(api.PullRequestPayload{
PullRequest: &api.PullRequest{
Base: &api.PRBranchInfo{Sha: baseSHA},
},
})
require.NoError(t, err)
// a run recorded before the fix points at the PR head commit
return &actions_model.ActionRun{
ID: 42,
RepoID: 1,
Event: webhook_module.HookEventPullRequest,
TriggerEvent: actions_module.GithubEventPullRequestTarget,
EventPayload: string(payload),
WorkflowCommitSHA: "head-sha",
}
}
pushRun := &actions_model.ActionRun{
RepoID: 1,
TriggerEvent: "push",
WorkflowCommitSHA: "head-sha",
}
caller := func(sourceRepoID int64, sourceCommitSHA string) *actions_model.ActionRunJob {
return &actions_model.ActionRunJob{WorkflowSourceRepoID: sourceRepoID, WorkflowSourceCommitSHA: sourceCommitSHA}
}
t.Run("pull_request_target pins to base commit", func(t *testing.T) {
got := resolveSameRepoWorkflowSourceCommit(prtRun("base-sha"), caller(1, "head-sha"))
assert.Equal(t, "base-sha", got)
})
t.Run("legacy nested caller (with head-sha) pins to base commit", func(t *testing.T) {
nested := caller(1, "head-sha")
nested.ParentJobID = 99
got := resolveSameRepoWorkflowSourceCommit(prtRun("base-sha"), nested)
assert.Equal(t, "base-sha", got)
})
t.Run("pull_request_target keeps stored SHA when already base", func(t *testing.T) {
run := prtRun("base-sha")
run.WorkflowCommitSHA = "base-sha"
got := resolveSameRepoWorkflowSourceCommit(run, caller(1, "base-sha"))
assert.Equal(t, "base-sha", got)
})
t.Run("non pull_request_target keeps stored SHA", func(t *testing.T) {
got := resolveSameRepoWorkflowSourceCommit(pushRun, caller(1, "head-sha"))
assert.Equal(t, "head-sha", got)
})
t.Run("scoped run keeps stored SHA", func(t *testing.T) {
run := prtRun("base-sha")
run.IsScopedRun = true
got := resolveSameRepoWorkflowSourceCommit(run, caller(1, "head-sha"))
assert.Equal(t, "head-sha", got)
})
t.Run("cross-repo caller keeps stored SHA", func(t *testing.T) {
got := resolveSameRepoWorkflowSourceCommit(prtRun("base-sha"), caller(2, "head-sha"))
assert.Equal(t, "head-sha", got)
})
t.Run("caller resolved from a uses: ref keeps its own SHA", func(t *testing.T) {
nested := caller(1, "tag-v1-sha")
nested.ParentJobID = 99
got := resolveSameRepoWorkflowSourceCommit(prtRun("base-sha"), nested)
assert.Equal(t, "tag-v1-sha", got)
})
}

312
services/actions/run.go Normal file
View File

@@ -0,0 +1,312 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package actions
import (
"context"
"fmt"
act_model "gitea.dev/actionslib/pkg/model"
actions_model "gitea.dev/models/actions"
"gitea.dev/models/db"
"gitea.dev/modules/actions/jobparser"
"gitea.dev/modules/log"
"gitea.dev/modules/util"
"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 run.WorkflowRepoID == 0 {
return fmt.Errorf("WorkflowRepoID must be set before insert (repo %d, workflow %q)", run.RepoID, run.WorkflowID)
}
if err := run.LoadAttributes(ctx); err != nil {
return fmt.Errorf("LoadAttributes: %w", err)
}
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 needPostCommitEmit bool
if err := db.WithTx(ctx, func(ctx context.Context) error {
index, err := db.GetNextResourceIndex(ctx, "action_run_index", run.RepoID)
if err != nil {
return err
}
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
slots := maxParallelSlots{}
for _, v := range jobs {
runJob, jobsToCancel, jobNeedsPostCommitEmit, err := insertRunJob(ctx, run, runAttempt, v, vars, inputs, slots)
if err != nil {
return err
}
cancelledConcurrencyJobs = append(cancelledConcurrencyJobs, jobsToCancel...)
needPostCommitEmit = needPostCommitEmit || jobNeedsPostCommitEmit
// A reusable caller is never dispatched to a runner, so it must not drive the task-version bump.
hasWaitingJobs = hasWaitingJobs || (runJob.Status == actions_model.StatusWaiting && !runJob.IsReusableCaller)
runJobs = append(runJobs, runJob)
}
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: let the job emitter resolve jobs if needed
if needPostCommitEmit {
if err := EmitJobsIfReadyByRun(run.ID); err != nil {
log.Error("emit run %d after InsertRun: %v", run.ID, err)
}
}
return nil
}
// insertRunJob builds a single run job from a parsed workflow job, evaluates its
// job-level concurrency, inserts it, and — for a ready no-needs reusable caller —
// inline-expands (or skips) it. It returns the inserted job, any jobs cancelled by
// job concurrency, and whether a post-commit emitter pass is needed to resolve the
// caller's dependents.
func insertRunJob(ctx context.Context, run *actions_model.ActionRun, runAttempt *actions_model.ActionRunAttempt, workflowJob *jobparser.SingleWorkflow, vars map[string]string, inputs map[string]any, slots maxParallelSlots) (*actions_model.ActionRunJob, []*actions_model.ActionRunJob, bool, error) {
id, job := workflowJob.Job()
needs := job.Needs()
isMatrixDeferred := jobparser.HasDeferredMatrix(job)
if err := workflowJob.SetJob(id, job.EraseNeeds()); err != nil {
return nil, nil, false, err
}
payload, _ := workflowJob.Marshal()
isReusableWorkflowCaller := job.Uses != ""
shouldBlockJob := runAttempt.Status == actions_model.StatusBlocked || len(needs) > 0 || run.NeedApproval
attemptJobID, err := actions_model.GetNextAttemptJobID(ctx, run.ID)
if err != nil {
return nil, nil, false, fmt.Errorf("alloc attempt_job_id: %w", err)
}
job.Name = util.EllipsisDisplayString(job.Name, 255)
runJob := &actions_model.ActionRunJob{
RunID: run.ID,
RunAttemptID: runAttempt.ID,
RepoID: run.RepoID,
OwnerID: run.OwnerID,
CommitSHA: run.CommitSHA,
IsForkPullRequest: run.IsForkPullRequest,
Name: job.Name,
Attempt: runAttempt.Attempt,
WorkflowPayload: payload,
JobID: id,
AttemptJobID: attemptJobID,
Needs: needs,
RunsOn: job.RunsOn(),
Status: util.Iif(shouldBlockJob, actions_model.StatusBlocked, actions_model.StatusWaiting),
WorkflowSourceRepoID: run.WorkflowRepoID,
WorkflowSourceCommitSHA: run.WorkflowCommitSHA,
ContinueOnError: job.GetContinueOnError(),
IsMatrixDeferred: isMatrixDeferred,
MaxParallel: parseMaxParallel(id, job.Strategy.MaxParallelString),
}
if isMatrixDeferred {
// Expansion overwrites WorkflowPayload; keep the raw payload so a rerun can re-derive the matrix.
runJob.DeferredMatrixPayload = payload
}
// Parse workflow/job permissions (no clamping here)
if perms := ExtractJobPermissionsFromWorkflow(workflowJob, job); perms != nil {
runJob.TokenPermissions = perms
}
if isReusableWorkflowCaller {
runJob.IsReusableCaller = true
runJob.CallUses = job.Uses
}
var cancelledConcurrencyJobs []*actions_model.ActionRunJob
// check job concurrency
if job.RawConcurrency != nil {
rawConcurrency, err := yaml.Marshal(job.RawConcurrency)
if err != nil {
return nil, nil, false, fmt.Errorf("marshal raw concurrency: %w", err)
}
runJob.RawConcurrency = string(rawConcurrency)
// do not evaluate job concurrency when it requires `needs`, the jobs with `needs` will be evaluated later by job emitter
if len(needs) == 0 {
if err := EvaluateJobConcurrencyFillModel(ctx, run, runAttempt, runJob, vars, inputs); err != nil {
return nil, nil, false, fmt.Errorf("evaluate job concurrency: %w", err)
}
}
// If a job needs other jobs ("needs" is not empty), its status is set to StatusBlocked at the entry of the loop
// No need to check job concurrency for a blocked job (it will be checked by job emitter later)
// A slot-starved job skips the check too: it will not start, so it must not cancel its group peers.
if runJob.Status == actions_model.StatusWaiting && slots.available(runJob) {
var jobsToCancel []*actions_model.ActionRunJob
runJob.Status, jobsToCancel, err = PrepareToStartJobWithConcurrency(ctx, runJob)
if err != nil {
return nil, nil, false, fmt.Errorf("prepare to start job with concurrency: %w", err)
}
cancelledConcurrencyJobs = append(cancelledConcurrencyJobs, jobsToCancel...)
}
}
applyMaxParallel(runJob, slots)
if err := db.Insert(ctx, runJob); err != nil {
return nil, nil, false, err
}
// expand reusable caller
var needPostCommitEmit bool
if isReusableWorkflowCaller && runJob.Status == actions_model.StatusWaiting {
if err := processInlineReusableCaller(ctx, run, runAttempt, runJob, vars); err != nil {
return nil, nil, false, err
}
// A processed caller always needs a resolver pass:
// - if the caller is expanded, resolve its children jobs;
// - if the caller is skipped, propagate its state to its dependents
needPostCommitEmit = true
}
return runJob, cancelledConcurrencyJobs, needPostCommitEmit, nil
}
// processInlineReusableCaller evaluates a no-needs reusable caller's own `if:` and
// either inline-expands it into child jobs or marks it skipped.
// (A caller with needs is Blocked and gets its `if:` evaluated by the job emitter instead.)
func processInlineReusableCaller(ctx context.Context, run *actions_model.ActionRun, runAttempt *actions_model.ActionRunAttempt, caller *actions_model.ActionRunJob, vars map[string]string) error {
shouldStart, err := evaluateJobIf(ctx, run, runAttempt, caller, vars, true)
if err != nil {
return fmt.Errorf("evaluate caller %d if: %w", caller.ID, err)
}
if shouldStart {
if err := expandReusableWorkflowCaller(ctx, run, runAttempt, caller, vars); err != nil {
return fmt.Errorf("inline trigger caller %d ready: %w", caller.ID, err)
}
// refresh the caller status
if err := actions_model.RefreshReusableCallerStatus(ctx, caller); err != nil {
return fmt.Errorf("refresh caller %d status: %w", caller.ID, err)
}
return nil
}
caller.Status = actions_model.StatusSkipped
if _, err := actions_model.UpdateRunJob(ctx, caller, nil, "status"); err != nil {
return fmt.Errorf("skip caller %d: %w", caller.ID, err)
}
return nil
}

View File

@@ -0,0 +1,186 @@
// Copyright 2023 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package actions
import (
"context"
"fmt"
"maps"
"time"
actions_model "gitea.dev/models/actions"
"gitea.dev/models/db"
"gitea.dev/models/organization"
perm_model "gitea.dev/models/perm"
access_model "gitea.dev/models/perm/access"
repo_model "gitea.dev/models/repo"
"gitea.dev/models/unit"
user_model "gitea.dev/models/user"
"gitea.dev/modules/json"
"gitea.dev/modules/log"
"gitea.dev/modules/timeutil"
webhook_module "gitea.dev/modules/webhook"
"gitea.dev/services/convert"
"xorm.io/builder"
)
// StartScheduleTasks start the task
func StartScheduleTasks(ctx context.Context) error {
return startTasks(ctx)
}
// startTasks starts every due spec and returns an error if any of them failed.
func startTasks(ctx context.Context) error {
var failed int
now := time.Now()
err := db.Iterate(ctx,
builder.And(builder.Gt{"next": 0}, builder.Lte{"next": now.Unix()}),
func(ctx context.Context, row *actions_model.ActionScheduleSpec) error {
// one failing spec must not abort the pass, or a single broken workflow stops every other schedule
if err := startTask(ctx, row, now); err != nil {
failed++
log.Error("start schedule spec %d (repo %d, schedule %d): %v", row.ID, row.RepoID, row.ScheduleID, err)
}
return nil
})
if err != nil {
return fmt.Errorf("iterate specs: %w", err)
}
// surfaces as an admin notice through the cron task, once per occurrence rather than once per pass
if failed > 0 {
return fmt.Errorf("%d schedule(s) could not be started", failed)
}
return nil
}
// startTask advances the spec to its next occurrence before creating the run, so a failing workflow
// retries on its own schedule instead of on every pass, and a failed update cannot duplicate the run.
func startTask(ctx context.Context, row *actions_model.ActionScheduleSpec, now time.Time) error {
cronSchedule, err := row.Parse()
if err != nil {
return fmt.Errorf("parse %q: %w", row.Spec, err)
}
row.Prev = row.Next
row.Next = timeutil.TimeStamp(cronSchedule.Next(now.Add(time.Minute)).Unix())
if err := actions_model.UpdateScheduleSpec(ctx, row, "prev", "next"); err != nil {
return fmt.Errorf("update spec: %w", err)
}
// a spec whose schedule or repo row is gone is skipped, not reported on every occurrence
schedule, exist, err := db.GetByID[actions_model.ActionSchedule](ctx, row.ScheduleID)
if err != nil {
return fmt.Errorf("get schedule %d: %w", row.ScheduleID, err)
} else if !exist {
return nil
}
repo, exist, err := db.GetByID[repo_model.Repository](ctx, row.RepoID)
if err != nil {
return fmt.Errorf("get repo %d: %w", row.RepoID, err)
} else if !exist {
return nil
}
row.Schedule, row.Repo = schedule, repo
// only archived repos are skipped; mirrors keep their schedules because a mirror is a normal repo
// for Actions, and nightly builds or scans of the mirrored code are a common reason to run one
if row.Repo.IsArchived {
return nil
}
cfg, err := row.Repo.GetUnit(ctx, unit.TypeActions)
if err != nil {
if repo_model.IsErrUnitTypeNotExist(err) {
return nil
}
return fmt.Errorf("GetUnit: %w", err)
}
if cfg.ActionsConfig().IsWorkflowDisabled(row.Schedule.WorkflowID) {
return nil
}
if err := CreateScheduleTaskBySpec(ctx, row); err != nil {
return fmt.Errorf("create run for %s workflow %q: %w", row.Repo.FullName(), row.Schedule.WorkflowID, err)
}
return nil
}
// CreateScheduleTaskBySpec 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 CreateScheduleTaskBySpec(ctx context.Context, spec *actions_model.ActionScheduleSpec) error {
cron := spec.Schedule
// Scheduled runs carry no webhook payload; synthesize what github.event.* expects.
if err := spec.Repo.LoadOwner(ctx); err != nil {
return fmt.Errorf("LoadOwner: %w", err)
}
fields := map[string]any{
"repository": convert.ToRepo(ctx, spec.Repo, access_model.Permission{AccessMode: perm_model.AccessModeRead}),
"sender": convert.ToUser(ctx, user_model.NewActionsUser(), nil),
}
if spec.Repo.Owner.IsOrganization() {
fields["organization"] = convert.ToOrganization(ctx, organization.OrgFromUser(spec.Repo.Owner))
}
eventPayload := withScheduleInEventPayload(cron.EventPayload, spec.Spec, fields)
// Create a new action run based on the schedule
run := &actions_model.ActionRun{
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,
// schedule runs the repo's own workflow at the recorded commit
WorkflowRepoID: cron.RepoID,
WorkflowCommitSHA: cron.CommitSHA,
}
// FIXME cron.Content might be outdated if the workflow file has been changed.
// 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, fields map[string]any) 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{}
}
maps.Copy(event, fields)
event["schedule"] = schedule
updatedPayload, err := json.Marshal(event)
if err != nil {
log.Error("withScheduleInEventPayload: marshal: %v", err)
return eventPayload
}
return string(updatedPayload)
}

View File

@@ -0,0 +1,125 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package actions
import (
"testing"
"time"
actions_model "gitea.dev/models/actions"
"gitea.dev/models/db"
"gitea.dev/models/unittest"
"gitea.dev/modules/json"
api "gitea.dev/modules/structs"
"gitea.dev/modules/timeutil"
webhook_module "gitea.dev/modules/webhook"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
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 * * * *", nil)
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", nil)
event := map[string]any{}
assert.NoError(t, json.Unmarshal([]byte(updated), &event))
assert.Equal(t, "37 12 5 1 2", event["schedule"])
})
t.Run("adds schedule to empty payload", func(t *testing.T) {
updated := withScheduleInEventPayload("", "37 12 5 1 2", nil)
event := map[string]any{}
assert.NoError(t, json.Unmarshal([]byte(updated), &event))
assert.Equal(t, "37 12 5 1 2", event["schedule"])
})
t.Run("adds schedule with repository, sender, organization", func(t *testing.T) {
updated := withScheduleInEventPayload("null", "@weekly", map[string]any{
"repository": &api.Repository{Name: "test-repo"},
"sender": &api.User{UserName: "test-user"},
"organization": &api.Organization{Name: "test-org"},
})
event := map[string]any{}
assert.NoError(t, json.Unmarshal([]byte(updated), &event))
assert.Equal(t, "@weekly", event["schedule"])
repository, ok := event["repository"].(map[string]any)
require.True(t, ok)
assert.Equal(t, "test-repo", repository["name"])
sender, ok := event["sender"].(map[string]any)
require.True(t, ok)
assert.Equal(t, "test-user", sender["login"])
organization, ok := event["organization"].(map[string]any)
require.True(t, ok)
assert.Equal(t, "test-org", organization["name"])
})
t.Run("keeps payload when schedule empty", func(t *testing.T) {
payload := `{"ref":"refs/heads/main"}`
updated := withScheduleInEventPayload(payload, "", nil)
assert.Equal(t, payload, updated)
})
t.Run("keeps payload when malformed JSON", func(t *testing.T) {
payload := `not a json object`
updated := withScheduleInEventPayload(payload, "*/5 * * * *", nil)
assert.Equal(t, payload, updated)
})
}
func TestStartTasks(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
insertSchedule := func(repoID, ownerID int64, workflowID, cronSpec, content string, next timeutil.TimeStamp) *actions_model.ActionScheduleSpec {
schedule := &actions_model.ActionSchedule{
Title: workflowID,
RepoID: repoID,
OwnerID: ownerID,
WorkflowID: workflowID,
TriggerUserID: 1,
Ref: "refs/heads/master",
CommitSHA: "c2d72f548424103f01ee1dc02889c1e2bff816b0",
Event: webhook_module.HookEventSchedule,
EventPayload: "{}",
Content: []byte(content),
}
require.NoError(t, db.Insert(t.Context(), schedule))
spec := &actions_model.ActionScheduleSpec{RepoID: repoID, ScheduleID: schedule.ID, Spec: cronSpec, Next: next}
require.NoError(t, db.Insert(t.Context(), spec))
return spec
}
due := timeutil.TimeStamp(time.Now().Add(-time.Minute).Unix())
validWorkflow := "jobs:\n job:\n runs-on: ubuntu-latest\n steps:\n - run: true\n"
// specs are processed by ascending id, so the broken one runs first and used to abort the whole pass
broken := insertSchedule(1, 2, "broken.yml", "@every 1m", "this: [is: not: a: workflow", due)
valid := insertSchedule(4, 5, "valid.yml", "@every 1m", validWorkflow, due)
never := insertSchedule(4, 5, "never.yml", "0 0 30 2 *", validWorkflow, timeutil.TimeStamp(time.Time{}.Unix()))
require.ErrorContains(t, startTasks(t.Context()), "1 schedule(s) could not be started")
assert.Equal(t, 1, unittest.GetCount(t, &actions_model.ActionRun{RepoID: 4, WorkflowID: "valid.yml"}))
assert.Equal(t, 0, unittest.GetCount(t, &actions_model.ActionRun{RepoID: 1, WorkflowID: "broken.yml"}))
assert.Equal(t, 0, unittest.GetCount(t, &actions_model.ActionRun{RepoID: 4, WorkflowID: "never.yml"}))
// the broken spec moves on too, so it does not fail again on every pass
for _, spec := range []*actions_model.ActionScheduleSpec{broken, valid} {
updated := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionScheduleSpec{ID: spec.ID})
assert.Greater(t, updated.Next, spec.Next)
}
assert.Equal(t, never.Next, unittest.AssertExistsAndLoadBean(t, &actions_model.ActionScheduleSpec{ID: never.ID}).Next)
}

View File

@@ -0,0 +1,84 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package actions
import (
"context"
"fmt"
git_model "gitea.dev/models/git"
repo_model "gitea.dev/models/repo"
actions_module "gitea.dev/modules/actions"
"gitea.dev/modules/git"
"gitea.dev/modules/log"
lru "github.com/hashicorp/golang-lru/v2"
)
// cachedScopedWorkflows is one source repo's parsed scoped workflows together with the default-branch SHA they were parsed at.
type cachedScopedWorkflows struct {
sha string
parsed []*actions_module.ParsedScopedWorkflow
}
// scopedWorkflowCache caches each scoped-workflow source repo's parsed workflows, keyed by source repo ID.
// There is exactly one entry per source: a default-branch update is detected by SHA mismatch and overwrites the entry, so stale parses never accumulate.
var scopedWorkflowCache *lru.Cache[int64, *cachedScopedWorkflows]
const defaultScopedWorkflowCacheSize = 1024
func init() {
c, err := lru.New[int64, *cachedScopedWorkflows](defaultScopedWorkflowCacheSize)
if err != nil {
log.Fatal("failed to new scopedWorkflowCache, err: %v", err)
}
scopedWorkflowCache = c
}
// LoadParsedScopedWorkflows returns the source repo's parsed scoped workflows at its current default-branch HEAD.
func LoadParsedScopedWorkflows(ctx context.Context, sourceRepo *repo_model.Repository) (sha string, parsed []*actions_module.ParsedScopedWorkflow, err error) {
branch, err := git_model.GetBranchExisting(ctx, sourceRepo.ID, sourceRepo.DefaultBranch)
if err != nil {
return "", nil, fmt.Errorf("get source default branch: %w", err)
}
sha = branch.CommitID
if v, ok := scopedWorkflowCache.Get(sourceRepo.ID); ok && v.sha == sha {
// cache hit at the current default-branch HEAD
return sha, v.parsed, nil
}
// cache miss: open the source repo at the exact SHA we keyed on
sourceGitRepo, err := git.OpenRepository(ctx, sourceRepo)
if err != nil {
return "", nil, fmt.Errorf("open source repo: %w", err)
}
defer sourceGitRepo.Close()
sourceCommit, err := sourceGitRepo.GetCommit(ctx, sha)
if err != nil {
return "", nil, fmt.Errorf("get source commit %s: %w", sha, err)
}
parsed, err = actions_module.ParseScopedWorkflows(ctx, sourceGitRepo, sourceCommit)
if err != nil {
return "", nil, err
}
// overwrite this source's single entry (a stale entry from a previous HEAD is replaced, not accumulated)
scopedWorkflowCache.Add(sourceRepo.ID, &cachedScopedWorkflows{sha: sha, parsed: parsed})
return sha, parsed, nil
}
// ScopedWorkflowContent returns one scoped workflow's raw content (by entry name) at the source repo's current default-branch HEAD, or nil if no such workflow exists there.
func ScopedWorkflowContent(ctx context.Context, sourceRepo *repo_model.Repository, entryName string) ([]byte, error) {
_, parsed, err := LoadParsedScopedWorkflows(ctx, sourceRepo)
if err != nil {
return nil, err
}
for _, p := range parsed {
if p.EntryName == entryName {
return p.Content, nil
}
}
return nil, nil
}

195
services/actions/task.go Normal file
View File

@@ -0,0 +1,195 @@
// Copyright 2022 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package actions
import (
"context"
"errors"
"fmt"
"sync"
"time"
runnerv1 "gitea.dev/actionslib/runner/v1"
actions_model "gitea.dev/models/actions"
"gitea.dev/models/db"
secret_model "gitea.dev/models/secret"
"gitea.dev/modules/graceful"
"gitea.dev/modules/log"
"gitea.dev/modules/setting"
"google.golang.org/protobuf/types/known/structpb"
)
var (
taskPickSem chan struct{}
taskPickSemOnce sync.Once
)
func taskPickLimiter() chan struct{} {
taskPickSemOnce.Do(func() {
taskPickSem = make(chan struct{}, max(1, setting.Actions.MaxConcurrentTaskPicks))
})
return taskPickSem
}
// TryPickTask attempts to assign a task to the runner, bounding the number of
// concurrent assignment transactions to avoid a thundering herd when many
// runners poll at once. When the concurrency limit is reached it returns
// throttled=true without touching the DB, so the caller can let the runner
// retry on its next poll instead of advancing its tasks version.
func TryPickTask(ctx context.Context, runner *actions_model.ActionRunner) (task *runnerv1.Task, ok, throttled bool, err error) {
sem := taskPickLimiter()
select {
case sem <- struct{}{}:
defer func() { <-sem }()
default:
return nil, false, true, nil
}
task, ok, err = PickTask(ctx, runner)
return task, ok, false, err
}
// releaseTaskForRunnerCleanup releases a claimed task using a fresh, bounded context. The request context
// is typically already canceled when we reach the release paths below, and a DB transaction on a canceled
// context fails immediately, which would strand the claimed job in running state.
func releaseTaskForRunnerCleanup(t *actions_model.ActionTask) {
ctx, cancel := context.WithTimeout(graceful.GetManager().ShutdownContext(), 10*time.Second)
defer cancel()
if relErr := actions_model.ReleaseTaskForRunner(ctx, t); relErr != nil {
log.Error("ReleaseTaskForRunner [task_id: %d]: %v", t.ID, relErr)
}
}
func PickTask(ctx context.Context, runner *actions_model.ActionRunner) (*runnerv1.Task, bool, error) {
var (
task *runnerv1.Task
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")
}
}
t, ok, err := actions_model.CreateTaskForRunner(ctx, runner)
if err != nil {
return nil, false, fmt.Errorf("CreateTaskForRunner: %w", err)
}
if !ok {
return nil, false, nil
}
task, job, err = buildRunnerTask(ctx, t)
if err != nil {
// The job was already claimed but assembling its payload failed; release the
// claim so the job returns to the waiting queue instead of being stranded in
// running state with no runner ever executing it.
releaseTaskForRunnerCleanup(t)
return nil, false, err
}
actionTask = t
CreateCommitStatusForRunJobs(ctx, job.Run, job)
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)
}
// The job is claimed and its payload assembled, but if the request context was cancelled meanwhile, response can no longer reach the runner.
// Release the claim so another runner can pick the job up.
if err := ctx.Err(); err != nil {
releaseTaskForRunnerCleanup(t)
return nil, false, err
}
return task, true, nil
}
// buildRunnerTask assembles the runner-facing task payload for an already-claimed
// task. All operations are read-only; on error the caller releases the claim.
func buildRunnerTask(ctx context.Context, t *actions_model.ActionTask) (*runnerv1.Task, *actions_model.ActionRunJob, error) {
if err := t.LoadAttributes(ctx); err != nil {
return nil, nil, fmt.Errorf("task LoadAttributes: %w", err)
}
job := t.Job
secrets, err := secret_model.GetSecretsOfTask(ctx, t)
if err != nil {
return nil, nil, fmt.Errorf("GetSecretsOfTask: %w", err)
}
vars, err := actions_model.GetVariablesOfRun(ctx, t.Job.Run)
if err != nil {
return nil, nil, fmt.Errorf("GetVariablesOfRun: %w", err)
}
needs, err := findTaskNeeds(ctx, job)
if err != nil {
return nil, nil, fmt.Errorf("findTaskNeeds: %w", err)
}
taskContext, err := generateTaskContext(ctx, t)
if err != nil {
return nil, nil, fmt.Errorf("generateTaskContext: %w", err)
}
return &runnerv1.Task{
Id: t.ID,
WorkflowPayload: t.Job.WorkflowPayload,
Context: taskContext,
Secrets: secrets,
Vars: vars,
Needs: needs,
}, job, nil
}
func generateTaskContext(ctx context.Context, t *actions_model.ActionTask) (*structpb.Struct, error) {
giteaRuntimeToken, err := CreateAuthorizationToken(t.ID, t.Job.RunID, t.JobID)
if err != nil {
return nil, err
}
gitCtx := GenerateGiteaContext(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
}

View File

@@ -0,0 +1,74 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package actions
import (
"testing"
actions_model "gitea.dev/models/actions"
"gitea.dev/models/db"
"gitea.dev/models/unittest"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestTryPickTaskThrottled(t *testing.T) {
sem := taskPickLimiter()
// Saturate every assignment slot so the next attempt must be throttled.
for range cap(sem) {
sem <- struct{}{}
}
defer func() {
for range cap(sem) {
<-sem
}
}()
// No DB access happens on the throttled path, so this is safe without fixtures.
task, ok, throttled, err := TryPickTask(t.Context(), &actions_model.ActionRunner{})
require.NoError(t, err)
assert.Nil(t, task)
assert.False(t, ok)
assert.True(t, throttled)
}
// TestReleaseTaskForRunnerCleanup verifies the cleanup used by PickTask releases a claimed task through a
// fresh context. PickTask reaches this path when the request context is already canceled, and on
// PostgreSQL/MySQL a DB transaction on a canceled context fails immediately; reusing it would strand the
// claimed job in running state, so the cleanup must not use the request context.
func TestReleaseTaskForRunnerCleanup(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
run := &actions_model.ActionRun{
Title: "cleanup-run", RepoID: 1, OwnerID: 2, WorkflowID: "test.yaml",
TriggerUserID: 2, Ref: "refs/heads/main",
CommitSHA: "c2d72f548424103f01ee1dc02889c1e2bff816b0", Event: "push", TriggerEvent: "push",
Status: actions_model.StatusWaiting,
}
require.NoError(t, db.Insert(t.Context(), run))
job := &actions_model.ActionRunJob{
RunID: run.ID, RepoID: run.RepoID, OwnerID: run.OwnerID, CommitSHA: run.CommitSHA,
Name: "cleanup-job", Attempt: 1, JobID: "cleanup-job", Status: actions_model.StatusWaiting,
RunsOn: []string{"ubuntu-latest"},
WorkflowPayload: []byte("on: push\njobs:\n cleanup-job:\n runs-on: ubuntu-latest\n steps:\n - run: echo hi\n"),
}
require.NoError(t, db.Insert(t.Context(), job))
runner := &actions_model.ActionRunner{Name: "cleanup-runner", AgentLabels: []string{"ubuntu-latest"}}
runner.GenerateAndFillToken()
require.NoError(t, db.Insert(t.Context(), runner))
task, ok, err := actions_model.CreateTaskForRunner(t.Context(), runner)
require.NoError(t, err)
require.True(t, ok)
require.Equal(t, actions_model.StatusRunning, unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: job.ID}).Status)
// the cleanup helper uses its own context, so the claimed job is returned to the waiting queue
releaseTaskForRunnerCleanup(task)
released := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: job.ID})
assert.Equal(t, actions_model.StatusWaiting, released.Status)
assert.Zero(t, released.TaskID)
unittest.AssertNotExistsBean(t, &actions_model.ActionTask{ID: task.ID})
}

View File

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

View File

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

View File

@@ -0,0 +1,266 @@
// Copyright 2024 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package actions
import (
"context"
"fmt"
"gitea.dev/actionslib/pkg/exprparser"
"gitea.dev/actionslib/pkg/model"
actions_model "gitea.dev/models/actions"
audit_model "gitea.dev/models/audit"
"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/audit"
gitea_context "gitea.dev/services/context"
"gitea.dev/services/convert"
)
func EnableOrDisableWorkflow(ctx *gitea_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)
}
if err := repo_model.UpdateRepoUnitConfig(ctx, cfgUnit); err != nil {
return err
}
RecordWorkflowToggle(ctx, ctx.Repo.Repository, workflow.ID, isEnable)
return nil
}
// RecordWorkflowToggle writes the enable/disable audit event for a workflow.
func RecordWorkflowToggle(ctx context.Context, repo *repo_model.Repository, workflowID string, isEnable bool) {
action := audit_model.ActionsWorkflowDisable
if isEnable {
action = audit_model.ActionsWorkflowEnable
}
audit.Record(ctx, action, repo, "workflow", workflowID)
}
// DispatchActionWorkflow manually triggers a workflow_dispatch run.
// scopedWorkflowSourceRepoID selects the workflow source: 0 means a repo-level workflow in this repo; a non-zero value is the source repo of a scoped workflow.
func DispatchActionWorkflow(ctx reqctx.RequestContext, doer *user_model.User, repo *repo_model.Repository, gitRepo *git.Repository, workflowID, ref string, scopedWorkflowSourceRepoID int64, processInputs func(model *model.WorkflowDispatch, inputs map[string]any) error) (runID int64, _ error) {
if workflowID == "" {
return 0, util.ErrorWrapTranslatable(
util.NewNotExistErrorf("workflowID is empty"),
"actions.workflow.not_found", workflowID,
)
}
if ref == "" {
return 0, util.ErrorWrapTranslatable(
util.NewNotExistErrorf("ref is empty"),
"form.target_ref_not_exist", ref,
)
}
isScoped := scopedWorkflowSourceRepoID > 0
cfgUnit := repo.MustGetUnit(ctx, unit.TypeActions)
cfg := cfgUnit.ActionsConfig()
var workflowDisabled bool
if isScoped {
var err error
if workflowDisabled, err = actions_model.IsScopedWorkflowOptedOut(ctx, cfg, repo.OwnerID, scopedWorkflowSourceRepoID, workflowID); err != nil {
return 0, err
}
} else {
workflowDisabled = cfg.IsWorkflowDisabled(workflowID)
}
if workflowDisabled {
return 0, util.ErrorWrapTranslatable(
util.NewPermissionDeniedErrorf("workflow is disabled"),
"actions.workflow.disabled",
)
}
// 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(ctx, refName.TagName())
} else if refName.IsBranch() {
runTargetCommit, err = gitRepo.GetBranchCommit(ctx, refName.BranchName())
} else {
refName = git.RefNameFromBranch(ref)
runTargetCommit, err = gitRepo.GetBranchCommit(ctx, ref)
}
if err != nil {
return 0, util.ErrorWrapTranslatable(
util.NewNotExistErrorf("ref %q doesn't exist", ref),
"form.target_ref_not_exist", ref,
)
}
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,
// local dispatch: own repo at the target commit; the scoped path overrides these below
WorkflowRepoID: repo.ID,
WorkflowCommitSHA: runTargetCommit.ID.String(),
}
// resolve the workflow content and record its source on the run (scoped runs read from the source repo)
content, err := resolveDispatchWorkflowContent(ctx, repo, gitRepo, runTargetCommit, workflowID, scopedWorkflowSourceRepoID, isScoped, run)
if err != nil {
return 0, err
}
workflow, err := jobparser.ReadWorkflow(content)
if err != nil {
return 0, fmt.Errorf("failed to unmarshal workflow content: %w", err)
}
// get inputs from post
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
}
coerceDispatchInputTypes(workflowDispatch, inputsWithDefaults)
// ctx.Req.PostForm -> WorkflowDispatchPayload.Inputs -> ActionRun.EventPayload -> runner: ghc.Event
// https://docs.github.com/en/actions/learn-github-actions/contexts#github-context
// 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: dispatchEventInputs(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)
}
audit.RecordAs(ctx, doer, audit_model.ActionsWorkflowDispatch, repo,
"workflow", workflowID, "ref", ref, "run_id", run.ID)
return run.ID, nil
}
// coerceDispatchInputTypes types `inputs`, where boolean is the only non-string dispatch input type.
func coerceDispatchInputTypes(dispatch *model.WorkflowDispatch, inputs map[string]any) {
for name, cfg := range dispatch.Inputs {
if cfg.Type != "boolean" {
continue
}
if s, ok := inputs[name].(string); ok {
inputs[name] = util.ParseYamlBool(s)
}
}
}
// dispatchEventInputs stringifies the typed inputs for `github.event.inputs`.
func dispatchEventInputs(inputs map[string]any) map[string]any {
eventInputs := make(map[string]any, len(inputs))
for name, value := range inputs {
eventInputs[name] = exprparser.CoerceToString(value)
}
return eventInputs
}
// resolveDispatchWorkflowContent returns the YAML for a dispatched workflow and records its source on the run.
// - Repo-level: from the consumer's runTargetCommit.
// - Scoped: from the source repo's default branch.
func resolveDispatchWorkflowContent(ctx reqctx.RequestContext, repo *repo_model.Repository, gitRepo *git.Repository, runTargetCommit *git.Commit, workflowID string, sourceRepoID int64, isScoped bool, run *actions_model.ActionRun) ([]byte, error) {
if isScoped {
return resolveScopedDispatchContent(ctx, repo, sourceRepoID, workflowID, run)
}
_, entries, err := actions.ListWorkflows(ctx, gitRepo, runTargetCommit)
if err != nil {
return nil, err
}
for _, e := range entries {
if e.Name() == workflowID {
return actions.GetContentFromEntry(ctx, gitRepo, e)
}
}
return nil, util.ErrorWrapTranslatable(
util.NewNotExistErrorf("workflow %q doesn't exist", workflowID),
"actions.workflow.not_found", workflowID,
)
}
func resolveScopedDispatchContent(ctx reqctx.RequestContext, repo *repo_model.Repository, sourceRepoID int64, workflowID string, run *actions_model.ActionRun) ([]byte, error) {
// the source must be an effective scoped source for this consumer repo
effective, err := actions_model.IsScopedWorkflowSourceEffective(ctx, repo.OwnerID, sourceRepoID)
if err != nil {
return nil, err
}
if !effective {
return nil, util.ErrorWrapTranslatable(
util.NewNotExistErrorf("scoped workflow source %d is not effective for this repository", sourceRepoID),
"actions.workflow.not_found", workflowID,
)
}
sourceRepo, err := repo_model.GetRepositoryByID(ctx, sourceRepoID)
if err != nil {
return nil, err
}
sha, parsed, err := LoadParsedScopedWorkflows(ctx, sourceRepo)
if err != nil {
return nil, err
}
for _, p := range parsed {
if p.EntryName == workflowID {
run.WorkflowRepoID = sourceRepo.ID
run.WorkflowCommitSHA = sha
run.IsScopedRun = true
return p.Content, nil
}
}
return nil, util.ErrorWrapTranslatable(
util.NewNotExistErrorf("scoped workflow %q doesn't exist", workflowID),
"actions.workflow.not_found", workflowID,
)
}

View File

@@ -0,0 +1,59 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package actions
import (
"testing"
"gitea.dev/actionslib/pkg/model"
"github.com/stretchr/testify/assert"
)
func TestCoerceDispatchInputTypes(t *testing.T) {
dispatch := &model.WorkflowDispatch{
Inputs: map[string]model.WorkflowDispatchInput{
"build_server": {Type: "boolean"},
"dry_run": {Type: "boolean"},
"already_bool": {Type: "boolean"},
"yaml_true": {Type: "boolean"},
"yaml_truthy": {Type: "boolean"},
"version": {Type: "string"},
},
}
inputs := map[string]any{
// dispatch callbacks fill booleans as strconv.FormatBool(...) strings
"build_server": "true",
"dry_run": "false",
// already-native booleans are passed through unchanged (coercion is idempotent)
"already_bool": true,
// source text of `default: True` and `default: yes`, only the former is a YAML 1.2 boolean
"yaml_true": "True",
"yaml_truthy": "yes",
// non-boolean inputs must be left untouched
"version": "1.2.3",
}
coerceDispatchInputTypes(dispatch, inputs)
// Regression: without coercion these stay strings, and a server-side needs-gated
// job `if: inputs.build_server == true` never matches, leaving the job blocked.
assert.Equal(t, true, inputs["build_server"])
assert.Equal(t, false, inputs["dry_run"])
assert.Equal(t, true, inputs["already_bool"])
assert.Equal(t, true, inputs["yaml_true"])
assert.Equal(t, false, inputs["yaml_truthy"])
assert.Equal(t, "1.2.3", inputs["version"])
// `github.event.inputs` mirrors them as normalized strings
assert.Equal(t, map[string]any{
"build_server": "true",
"dry_run": "false",
"already_bool": "true",
"yaml_true": "true",
"yaml_truthy": "false",
"version": "1.2.3",
}, dispatchEventInputs(inputs))
}

330
services/agit/agit.go Normal file
View File

@@ -0,0 +1,330 @@
// Copyright 2021 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package agit
import (
"context"
"encoding/base64"
"errors"
"fmt"
"strings"
git_model "gitea.dev/models/git"
issues_model "gitea.dev/models/issues"
repo_model "gitea.dev/models/repo"
user_model "gitea.dev/models/user"
"gitea.dev/modules/git"
"gitea.dev/modules/git/gitcmd"
"gitea.dev/modules/log"
"gitea.dev/modules/private"
"gitea.dev/modules/setting"
"gitea.dev/modules/util"
notify_service "gitea.dev/services/notify"
pull_service "gitea.dev/services/pull"
)
const SshInfoJson = `{"type":"agit","version":1}`
func parseAgitPushOptionValue(s string) string {
if base64Value, ok := strings.CutPrefix(s, "{base64}"); ok {
decoded, err := base64.StdEncoding.DecodeString(base64Value)
return util.Iif(err == nil, string(decoded), s)
}
return s
}
func GetAgitBranchInfo(ctx context.Context, repoID int64, baseBranchName string) (string, string, error) {
baseBranchExist, err := git_model.IsBranchExist(ctx, repoID, baseBranchName)
if err != nil {
return "", "", err
}
if baseBranchExist {
return baseBranchName, "", nil
}
// try match <target-branch>/<topic-branch>
// refs/for have been trimmed to get baseBranchName
for p, v := range baseBranchName {
if v != '/' {
continue
}
baseBranchExist, err := git_model.IsBranchExist(ctx, repoID, baseBranchName[:p])
if err != nil {
return "", "", err
}
if baseBranchExist {
return baseBranchName[:p], baseBranchName[p+1:], nil
}
}
return "", "", util.NewNotExistErrorf("base branch does not exist")
}
type ProcReceiveOptions struct {
OldCommitIDs []string
NewCommitIDs []string
RefFullNames []git.RefName
GitPushOptions private.GitPushOptions
Doer *user_model.User
}
// ProcReceive handle proc receive work
func ProcReceive(ctx context.Context, repo *repo_model.Repository, gitRepo *git.Repository, opts *ProcReceiveOptions) ([]private.HookProcReceiveRefResult, error) {
results := make([]private.HookProcReceiveRefResult, 0, len(opts.OldCommitIDs))
forcePush := opts.GitPushOptions.Bool(private.GitPushOptionForcePush)
topicBranch := opts.GitPushOptions["topic"]
// some options are base64-encoded with "{base64}" prefix if they contain new lines
// other agit push options like "issue", "reviewer" and "cc" are not supported
title := parseAgitPushOptionValue(opts.GitPushOptions["title"])
description := parseAgitPushOptionValue(opts.GitPushOptions["description"])
objectFormat := git.ObjectFormatFromName(repo.ObjectFormatName)
pusher := opts.Doer
userName := strings.ToLower(pusher.Name)
for i := range opts.OldCommitIDs {
if opts.NewCommitIDs[i] == objectFormat.EmptyObjectID().String() {
results = append(results, private.HookProcReceiveRefResult{
OriginalRef: opts.RefFullNames[i],
OldOID: opts.OldCommitIDs[i],
NewOID: opts.NewCommitIDs[i],
Err: "Can't delete not exist branch",
})
continue
}
if !opts.RefFullNames[i].IsFor() {
results = append(results, private.HookProcReceiveRefResult{
IsNotMatched: true,
OriginalRef: opts.RefFullNames[i],
})
continue
}
baseBranchName, currentTopicBranch, err := GetAgitBranchInfo(ctx, repo.ID, opts.RefFullNames[i].ForBranchName())
if err != nil {
if !errors.Is(err, util.ErrNotExist) {
return nil, fmt.Errorf("failed to get branch information. Error: %w", err)
}
// If branch does not exist, we can continue
results = append(results, private.HookProcReceiveRefResult{
OriginalRef: opts.RefFullNames[i],
OldOID: opts.OldCommitIDs[i],
NewOID: opts.NewCommitIDs[i],
Err: "base-branch does not exist",
})
continue
}
if len(topicBranch) == 0 && len(currentTopicBranch) == 0 {
results = append(results, private.HookProcReceiveRefResult{
OriginalRef: opts.RefFullNames[i],
OldOID: opts.OldCommitIDs[i],
NewOID: opts.NewCommitIDs[i],
Err: "topic-branch is not set",
})
continue
}
if len(currentTopicBranch) == 0 {
currentTopicBranch = topicBranch
}
// because different user maybe want to use same topic,
// So it's better to make sure the topic branch name
// has username prefix
var headBranch string
if !strings.HasPrefix(currentTopicBranch, userName+"/") {
headBranch = userName + "/" + currentTopicBranch
} else {
headBranch = currentTopicBranch
}
pr, err := issues_model.GetUnmergedPullRequest(ctx, repo.ID, repo.ID, headBranch, baseBranchName, issues_model.PullRequestFlowAGit)
if err != nil {
if !issues_model.IsErrPullRequestNotExist(err) {
return nil, fmt.Errorf("failed to get unmerged agit flow pull request in repository: %s Error: %w", repo.FullName(), err)
}
var commit *git.Commit
if title == "" || description == "" {
commit, err = gitRepo.GetCommit(ctx, opts.NewCommitIDs[i])
if err != nil {
return nil, fmt.Errorf("failed to get commit %s in repository: %s Error: %w", opts.NewCommitIDs[i], repo.FullName(), err)
}
// create a new pull request
if title == "" {
title = commit.MessageTitle()
}
if description == "" {
description = commit.MessageBody()
}
}
prIssue := &issues_model.Issue{
RepoID: repo.ID,
Title: title,
PosterID: pusher.ID,
Poster: pusher,
IsPull: true,
Content: description,
}
pr := &issues_model.PullRequest{
HeadRepoID: repo.ID,
BaseRepoID: repo.ID,
HeadBranch: headBranch,
HeadCommitID: opts.NewCommitIDs[i],
BaseBranch: baseBranchName,
HeadRepo: repo,
BaseRepo: repo,
MergeBase: "",
Type: issues_model.PullRequestGitea,
Flow: issues_model.PullRequestFlowAGit,
}
prOpts := &pull_service.NewPullRequestOptions{
Repo: repo,
Issue: prIssue,
PullRequest: pr,
}
if err := pull_service.NewPullRequest(ctx, prOpts); err != nil {
return nil, err
}
log.Trace("Pull request created: %d/%d", repo.ID, prIssue.ID)
results = append(results, private.HookProcReceiveRefResult{
Ref: pr.GetGitHeadRefName(),
OriginalRef: opts.RefFullNames[i],
OldOID: objectFormat.EmptyObjectID().String(),
NewOID: opts.NewCommitIDs[i],
IsCreatePR: false, // AGit always creates a pull request so there is no point in prompting user to create one
URL: fmt.Sprintf("%s/pulls/%d", repo.HTMLURL(), pr.Index),
ShouldShowMessage: setting.Git.PullRequestPushMessage && repo.AllowsPulls(ctx),
HeadBranch: headBranch,
})
continue
}
// update exist pull request
if err := pr.LoadBaseRepo(ctx); err != nil {
return nil, fmt.Errorf("unable to load base repository for PR[%d] Error: %w", pr.ID, err)
}
oldCommitID, err := gitRepo.GetRefCommitID(ctx, pr.GetGitHeadRefName())
if err != nil {
return nil, fmt.Errorf("unable to get ref commit id in base repository for PR[%d] Error: %w", pr.ID, err)
}
if oldCommitID == opts.NewCommitIDs[i] {
results = append(results, private.HookProcReceiveRefResult{
OriginalRef: opts.RefFullNames[i],
OldOID: opts.OldCommitIDs[i],
NewOID: opts.NewCommitIDs[i],
Err: "new commit is same with old commit",
})
continue
}
if !forcePush.Value() {
output, _, err := gitcmd.NewCommand("rev-list", "--max-count=1").
AddDynamicArguments(oldCommitID, "^"+opts.NewCommitIDs[i]).WithRepo(repo).RunStdString(ctx)
if err != nil {
return nil, fmt.Errorf("failed to detect force push: %w", err)
} else if len(output) > 0 {
results = append(results, private.HookProcReceiveRefResult{
OriginalRef: opts.RefFullNames[i],
OldOID: opts.OldCommitIDs[i],
NewOID: opts.NewCommitIDs[i],
Err: "request `force-push` push option",
})
continue
}
}
// Store old commit ID for review staleness checking
oldHeadCommitID := pr.HeadCommitID
pr.HeadCommitID = opts.NewCommitIDs[i]
if err = pull_service.UpdateRef(ctx, pr); err != nil {
return nil, fmt.Errorf("failed to update pull ref. Error: %w", err)
}
// Mark existing reviews as stale when PR content changes (same as regular GitHub flow)
if oldHeadCommitID != opts.NewCommitIDs[i] {
if err := issues_model.MarkReviewsAsStale(ctx, pr.IssueID); err != nil {
log.Error("MarkReviewsAsStale: %v", err)
}
// Dismiss all approval reviews if protected branch rule item enabled
pb, err := git_model.GetFirstMatchProtectedBranchRule(ctx, pr.BaseRepoID, pr.BaseBranch)
if err != nil {
log.Error("GetFirstMatchProtectedBranchRule: %v", err)
}
if pb != nil && pb.DismissStaleApprovals {
if err := pull_service.DismissApprovalReviews(ctx, pusher, pr); err != nil {
log.Error("DismissApprovalReviews: %v", err)
}
}
// Mark reviews for the new commit as not stale
if err := issues_model.MarkReviewsAsNotStale(ctx, pr.IssueID, opts.NewCommitIDs[i]); err != nil {
log.Error("MarkReviewsAsNotStale: %v", err)
}
}
pull_service.StartPullRequestCheckImmediately(ctx, pr)
err = pr.LoadIssue(ctx)
if err != nil {
return nil, fmt.Errorf("failed to load pull issue. Error: %w", err)
}
isForcePush := forcePush.Value()
comment, commentCreated, err := pull_service.CreatePushPullComment(ctx, pusher, pr, oldCommitID, opts.NewCommitIDs[i], isForcePush)
if err != nil {
log.Error("CreatePushPullComment: %v", err)
} else if commentCreated {
notify_service.PullRequestPushCommits(ctx, pusher, pr, comment)
}
notify_service.PullRequestSynchronized(ctx, pusher, pr, oldCommitID, opts.NewCommitIDs[i])
results = append(results, private.HookProcReceiveRefResult{
OldOID: oldCommitID,
NewOID: opts.NewCommitIDs[i],
Ref: pr.GetGitHeadRefName(),
OriginalRef: opts.RefFullNames[i],
IsForcePush: isForcePush,
IsCreatePR: false,
URL: fmt.Sprintf("%s/pulls/%d", repo.HTMLURL(), pr.Index),
ShouldShowMessage: setting.Git.PullRequestPushMessage && repo.AllowsPulls(ctx),
})
}
return results, nil
}
// UserNameChanged handle user name change for agit flow pull
func UserNameChanged(ctx context.Context, user *user_model.User, newName string) error {
pulls, err := issues_model.GetAllUnmergedAgitPullRequestByPoster(ctx, user.ID)
if err != nil {
return err
}
newName = strings.ToLower(newName)
for _, pull := range pulls {
pull.HeadBranch = strings.TrimPrefix(pull.HeadBranch, user.LowerName+"/")
pull.HeadBranch = newName + "/" + pull.HeadBranch
if err = pull.UpdateCols(ctx, "head_branch"); err != nil {
return err
}
}
return nil
}

View File

@@ -0,0 +1,61 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package agit
import (
"testing"
"gitea.dev/models/unittest"
"gitea.dev/modules/util"
"github.com/stretchr/testify/assert"
)
func TestMain(m *testing.M) {
unittest.MainTest(m)
}
func TestParseAgitPushOptionValue(t *testing.T) {
assert.Equal(t, "a", parseAgitPushOptionValue("a"))
assert.Equal(t, "a", parseAgitPushOptionValue("{base64}YQ=="))
assert.Equal(t, "{base64}invalid value", parseAgitPushOptionValue("{base64}invalid value"))
}
func TestGetAgitBranchInfo(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
_, _, err := GetAgitBranchInfo(t.Context(), 1, "non-exist-basebranch")
assert.ErrorIs(t, err, util.ErrNotExist)
baseBranch, currentTopicBranch, err := GetAgitBranchInfo(t.Context(), 1, "master")
assert.NoError(t, err)
assert.Equal(t, "master", baseBranch)
assert.Empty(t, currentTopicBranch)
baseBranch, currentTopicBranch, err = GetAgitBranchInfo(t.Context(), 1, "master/topicbranch")
assert.NoError(t, err)
assert.Equal(t, "master", baseBranch)
assert.Equal(t, "topicbranch", currentTopicBranch)
baseBranch, currentTopicBranch, err = GetAgitBranchInfo(t.Context(), 1, "master/")
assert.NoError(t, err)
assert.Equal(t, "master", baseBranch)
assert.Empty(t, currentTopicBranch)
_, _, err = GetAgitBranchInfo(t.Context(), 1, "/")
assert.ErrorIs(t, err, util.ErrNotExist)
_, _, err = GetAgitBranchInfo(t.Context(), 1, "//")
assert.ErrorIs(t, err, util.ErrNotExist)
baseBranch, currentTopicBranch, err = GetAgitBranchInfo(t.Context(), 1, "master/topicbranch/")
assert.NoError(t, err)
assert.Equal(t, "master", baseBranch)
assert.Equal(t, "topicbranch/", currentTopicBranch)
baseBranch, currentTopicBranch, err = GetAgitBranchInfo(t.Context(), 1, "master/topicbranch/1")
assert.NoError(t, err)
assert.Equal(t, "master", baseBranch)
assert.Equal(t, "topicbranch/1", currentTopicBranch)
}

455
services/asymkey/commit.go Normal file
View File

@@ -0,0 +1,455 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package asymkey
import (
"context"
"fmt"
"slices"
"strings"
asymkey_model "gitea.dev/models/asymkey"
"gitea.dev/models/db"
user_model "gitea.dev/models/user"
"gitea.dev/modules/cache"
"gitea.dev/modules/cachegroup"
"gitea.dev/modules/git"
"gitea.dev/modules/log"
"gitea.dev/modules/setting"
"gitea.dev/modules/util"
"github.com/42wim/sshsig"
"github.com/ProtonMail/go-crypto/openpgp/packet"
)
// ParseCommitWithSignature check if signature is good against keystore.
func ParseCommitWithSignature(ctx context.Context, c *git.Commit) *asymkey_model.CommitVerification {
committer, err := user_model.GetUserByEmail(ctx, c.Committer.Email)
if err != nil && !user_model.IsErrUserNotExist(err) {
log.Error("GetUserByEmail: %v", err)
return &asymkey_model.CommitVerification{
Verified: false,
Reason: "gpg.error.no_committer_account", // this error is not right, but such error should seldom happen
}
}
return ParseCommitWithSignatureCommitter(ctx, c, committer)
}
// ParseCommitWithSignatureCommitter parses a commit's GPG or SSH signature.
// The caller guarantees that the committer user is related to the commit by checking its activated email addresses or no-reply address.
// If the commit is singed by an instance key, then committer can be nil.
// If the signature exists, even if committer is nil, the returned CommittingUser will be a non-nil fake user (e.g.: instance key)
func ParseCommitWithSignatureCommitter(ctx context.Context, c *git.Commit, committer *user_model.User) *asymkey_model.CommitVerification {
// If no signature, just report the committer
if c.Signature == nil {
return &asymkey_model.CommitVerification{
CommittingUser: committer,
Verified: false,
Reason: asymkey_model.VerificationReasonCommitNotSigned,
}
}
// to support instance key, we need a fake committer user (not really needed, but legacy code accesses the committer without nil-check)
if committer == nil {
committer = &user_model.User{
Name: c.Committer.Name,
Email: c.Committer.Email,
}
}
if strings.HasPrefix(c.Signature.Signature, "-----BEGIN SSH SIGNATURE-----") {
return parseCommitWithSSHSignature(ctx, c, committer)
}
return parseCommitWithGPGSignature(ctx, c, committer)
}
func getInstanceCommitSignSettings(fmt string) *git.CommitSignSettings {
settingFmt := util.IfZero(setting.Repository.Signing.SigningFormat, git.SigningKeyFormatOpenPGP)
if settingFmt != fmt {
return nil
}
if slices.Contains([]string{"", "default", "none"}, setting.Repository.Signing.SigningKey) {
return nil
}
return &git.CommitSignSettings{
Sign: true,
Name: setting.Repository.Signing.SigningName,
Email: setting.Repository.Signing.SigningEmail,
Format: fmt,
KeyID: setting.Repository.Signing.SigningKey,
}
}
func getGitGlobalCommitSignSettings(fmt string) *git.CommitSignSettings {
css := git.GlobalCommitSignSettings.Value()
if css.Format != fmt || !css.Sign {
return nil
}
return css
}
func parseCommitWithGPGSignature(ctx context.Context, c *git.Commit, committer *user_model.User) *asymkey_model.CommitVerification {
// Parsing signature
sig, err := asymkey_model.ExtractSignature(c.Signature.Signature)
if err != nil { // Skipping failed to extract sign
log.Error("SignatureRead err: %v", err)
return &asymkey_model.CommitVerification{
CommittingUser: committer,
Verified: false,
Reason: "gpg.error.extract_sign",
}
}
keyID := asymkey_model.TryGetKeyIDFromSignature(sig)
defaultReason := asymkey_model.NoKeyFound
// First check if the sig has a keyID and if so just look at that
if commitVerification := HashAndVerifyForKeyID(
ctx,
sig,
c.Signature.Payload,
committer,
keyID,
setting.AppName,
""); commitVerification != nil {
if commitVerification.Reason == asymkey_model.BadSignature {
defaultReason = asymkey_model.BadSignature
} else {
return commitVerification
}
}
// Now try to associate the signature with the committer, if present
if committer.ID != 0 {
keys, err := db.Find[asymkey_model.GPGKey](ctx, asymkey_model.FindGPGKeyOptions{
OwnerID: committer.ID,
})
if err != nil { // Skipping failed to get gpg keys of user
log.Error("ListGPGKeys: %v", err)
return &asymkey_model.CommitVerification{
CommittingUser: committer,
Verified: false,
Reason: "gpg.error.failed_retrieval_gpg_keys",
}
}
if err := asymkey_model.GPGKeyList(keys).LoadSubKeys(ctx); err != nil {
log.Error("LoadSubKeys: %v", err)
return &asymkey_model.CommitVerification{
CommittingUser: committer,
Verified: false,
Reason: "gpg.error.failed_retrieval_gpg_keys",
}
}
for _, k := range keys {
// Pre-check (& optimization) that emails attached to key can be attached to the committer email and can validate
canValidate := false
email := ""
if k.Verified {
canValidate = true
email = c.Committer.Email
}
if !canValidate {
for _, e := range k.Emails {
if e.IsActivated && strings.EqualFold(e.Email, c.Committer.Email) {
canValidate = true
email = e.Email
break
}
}
}
if !canValidate {
continue // Skip this key
}
commitVerification := asymkey_model.HashAndVerifyWithSubKeysCommitVerification(sig, c.Signature.Payload, k, committer, committer, email)
if commitVerification != nil {
return commitVerification
}
}
}
if instanceSettings := getInstanceCommitSignSettings(git.SigningKeyFormatOpenPGP); instanceSettings != nil {
if commitVerification := verifyCommitSignByGPGSettings(ctx, instanceSettings, sig, c.Signature.Payload, committer, keyID); commitVerification != nil {
if commitVerification.Reason != asymkey_model.BadSignature {
return commitVerification
}
defaultReason = commitVerification.Reason
}
}
if globalSettings := getGitGlobalCommitSignSettings(git.SigningKeyFormatOpenPGP); globalSettings != nil {
if commitVerification := verifyCommitSignByGPGSettings(ctx, globalSettings, sig, c.Signature.Payload, committer, keyID); commitVerification != nil {
if commitVerification.Reason != asymkey_model.BadSignature {
return commitVerification
}
defaultReason = commitVerification.Reason
}
}
return &asymkey_model.CommitVerification{ // Default at this stage
CommittingUser: committer,
Verified: false,
Warning: defaultReason != asymkey_model.NoKeyFound,
Reason: defaultReason,
SigningKey: &asymkey_model.GPGKey{KeyID: keyID},
}
}
func checkKeyEmails(ctx context.Context, email string, keys ...*asymkey_model.GPGKey) (bool, string) {
uid := int64(0)
var userEmails []*user_model.EmailAddress
var user *user_model.User
for _, key := range keys {
for _, e := range key.Emails {
if e.IsActivated && (email == "" || strings.EqualFold(e.Email, email)) {
return true, e.Email
}
}
if key.Verified && key.OwnerID != 0 {
if uid != key.OwnerID {
userEmails, _ = cache.GetWithContextCache(ctx, cachegroup.UserEmailAddresses, key.OwnerID, user_model.GetEmailAddresses)
uid = key.OwnerID
user, _ = cache.GetWithContextCache(ctx, cachegroup.User, uid, user_model.GetUserByID)
}
for _, e := range userEmails {
if e.IsActivated && (email == "" || strings.EqualFold(e.Email, email)) {
return true, e.Email
}
}
if user != nil && strings.EqualFold(email, user.GetPlaceholderEmail()) {
return true, user.GetPlaceholderEmail()
}
}
}
return false, email
}
func HashAndVerifyForKeyID(ctx context.Context, sig *packet.Signature, payload string, committer *user_model.User, keyID, name, email string) *asymkey_model.CommitVerification {
if keyID == "" {
return nil
}
keys, err := cache.GetWithContextCache(ctx, cachegroup.GPGKeyWithSubKeys, keyID, asymkey_model.FindGPGKeyWithSubKeys)
if err != nil {
log.Error("GetGPGKeysByKeyID: %v", err)
return &asymkey_model.CommitVerification{
CommittingUser: committer,
Verified: false,
Reason: "gpg.error.failed_retrieval_gpg_keys",
}
}
if len(keys) == 0 {
return nil
}
for _, key := range keys {
var primaryKeys []*asymkey_model.GPGKey
if key.PrimaryKeyID != "" {
primaryKeys, err = cache.GetWithContextCache(ctx, cachegroup.GPGKeyWithSubKeys, key.PrimaryKeyID, asymkey_model.FindGPGKeyWithSubKeys)
if err != nil {
log.Error("GetGPGKeysByKeyID: %v", err)
return &asymkey_model.CommitVerification{
CommittingUser: committer,
Verified: false,
Reason: "gpg.error.failed_retrieval_gpg_keys",
}
}
}
activated, email := checkKeyEmails(ctx, email, append([]*asymkey_model.GPGKey{key}, primaryKeys...)...)
if !activated {
continue
}
signer := &user_model.User{
Name: name,
Email: email,
}
if key.OwnerID > 0 {
owner, err := cache.GetWithContextCache(ctx, cachegroup.User, key.OwnerID, user_model.GetUserByID)
if err == nil {
signer = owner
} else if !user_model.IsErrUserNotExist(err) {
log.Error("Failed to user_model.GetUserByID: %d for key ID: %d (%s) %v", key.OwnerID, key.ID, key.KeyID, err)
return &asymkey_model.CommitVerification{
CommittingUser: committer,
Verified: false,
Reason: "gpg.error.no_committer_account",
}
}
}
commitVerification := asymkey_model.HashAndVerifyWithSubKeysCommitVerification(sig, payload, key, committer, signer, email)
if commitVerification != nil {
return commitVerification
}
}
// This is a bad situation ... We have a key id that is in our database but the signature doesn't match.
return &asymkey_model.CommitVerification{
CommittingUser: committer,
Verified: false,
Warning: true,
Reason: asymkey_model.BadSignature,
SigningKey: &asymkey_model.GPGKey{KeyID: keyID},
}
}
func verifyCommitSignByGPGSettings(ctx context.Context, gpgSettings *git.CommitSignSettings, sig *packet.Signature, payload string, committer *user_model.User, keyID string) *asymkey_model.CommitVerification {
// First try to find the key in the db
if commitVerification := HashAndVerifyForKeyID(ctx, sig, payload, committer, gpgSettings.KeyID, gpgSettings.Name, gpgSettings.Email); commitVerification != nil {
return commitVerification
}
// Otherwise we have to parse the key
pubKeyContent, err := gpgSettings.PublicKeyContent(ctx)
if err != nil {
log.Error("gpgSettings.PublicKeyContent: %v", err)
return nil
}
ekeys, err := asymkey_model.CheckArmoredGPGKeyString(pubKeyContent)
if err != nil {
log.Error("Unable to get default signing key: %v", err)
return &asymkey_model.CommitVerification{
CommittingUser: committer,
Verified: false,
Reason: "gpg.error.generate_hash",
}
}
for _, ekey := range ekeys {
pubkey := ekey.PrimaryKey
content, err := asymkey_model.Base64EncPubKey(pubkey)
if err != nil {
return &asymkey_model.CommitVerification{
CommittingUser: committer,
Verified: false,
Reason: "gpg.error.generate_hash",
}
}
k := &asymkey_model.GPGKey{
Content: content,
CanSign: pubkey.CanSign(),
KeyID: pubkey.KeyIdString(),
}
for _, subKey := range ekey.Subkeys {
content, err := asymkey_model.Base64EncPubKey(subKey.PublicKey)
if err != nil {
return &asymkey_model.CommitVerification{
CommittingUser: committer,
Verified: false,
Reason: "gpg.error.generate_hash",
}
}
k.SubsKey = append(k.SubsKey, &asymkey_model.GPGKey{
Content: content,
CanSign: subKey.PublicKey.CanSign(),
KeyID: subKey.PublicKey.KeyIdString(),
})
}
if commitVerification := asymkey_model.HashAndVerifyWithSubKeysCommitVerification(sig, payload, k, committer, &user_model.User{
Name: gpgSettings.Name,
Email: gpgSettings.Email,
}, gpgSettings.Email); commitVerification != nil {
return commitVerification
}
if keyID == k.KeyID {
// This is a bad situation ... We have a key id that matches our default key but the signature doesn't match.
return &asymkey_model.CommitVerification{
CommittingUser: committer,
Verified: false,
Warning: true,
Reason: asymkey_model.BadSignature,
SigningKey: &asymkey_model.GPGKey{KeyID: keyID},
}
}
}
return nil
}
func verifyCommitSignBySSHSettings(c *git.Commit, committerUser, signerUser *user_model.User, committerGitEmail, publicKeyContent string) *asymkey_model.CommitVerification {
fingerprint, err := asymkey_model.CalcFingerprint(publicKeyContent)
if err != nil {
log.Error("Error calculating the fingerprint public key %q, err: %v", publicKeyContent, err)
return nil
}
sshPubKey := &asymkey_model.PublicKey{
Verified: true,
Content: publicKeyContent,
Fingerprint: fingerprint,
HasUsed: true,
}
return verifyCommitSignBySSHPublicKey(c.Signature.Signature, c.Signature.Payload, sshPubKey, committerUser, signerUser, committerGitEmail)
}
// parseCommitWithSSHSignature check if signature is good against keystore.
func parseCommitWithSSHSignature(ctx context.Context, c *git.Commit, committerUser *user_model.User) *asymkey_model.CommitVerification {
// Now try to associate the signature with the committer, if present
if committerUser.ID != 0 {
keys, err := db.Find[asymkey_model.PublicKey](ctx, asymkey_model.FindPublicKeyOptions{
OwnerID: committerUser.ID,
NotKeytype: asymkey_model.KeyTypePrincipal,
})
if err != nil { // Skipping failed to get ssh keys of user
log.Error("ListPublicKeys: %v", err)
return &asymkey_model.CommitVerification{
CommittingUser: committerUser,
Verified: false,
Reason: "gpg.error.failed_retrieval_gpg_keys",
}
}
for _, k := range keys {
if k.Verified {
commitVerification := verifyCommitSignBySSHPublicKey(c.Signature.Signature, c.Signature.Payload, k, committerUser, committerUser, c.Committer.Email)
if commitVerification != nil {
return commitVerification
}
}
}
}
// Try the pre-set trusted keys (for key-rotation purpose)
// At the moment, we still use the SigningName&SigningEmail for the rotated keys.
// Maybe in the future we can extend the key format to "ssh-xxx .... old-user@example.com" to support different signer emails.
for _, k := range setting.Repository.Signing.TrustedSSHKeys {
signerUser := &user_model.User{
Name: setting.Repository.Signing.SigningName,
Email: setting.Repository.Signing.SigningEmail,
}
commitVerification := verifyCommitSignBySSHSettings(c, committerUser, signerUser, c.Committer.Email, k)
if commitVerification != nil && commitVerification.Verified {
return commitVerification
}
}
// Try the configured instance-wide SSH public key
if instanceSettings := getInstanceCommitSignSettings(git.SigningKeyFormatSSH); instanceSettings != nil {
pubKeyContent, err := instanceSettings.PublicKeyContent(ctx)
if err != nil {
log.Error("commitSignSettings.PublicKeyContent: %v", err)
} else {
signerUser := &user_model.User{Name: instanceSettings.Name, Email: instanceSettings.Email}
commitVerification := verifyCommitSignBySSHSettings(c, committerUser, signerUser, instanceSettings.Email, pubKeyContent)
if commitVerification != nil && commitVerification.Verified {
return commitVerification
}
}
}
return &asymkey_model.CommitVerification{
CommittingUser: committerUser,
Verified: false,
Reason: asymkey_model.NoKeyFound,
}
}
func verifyCommitSignBySSHPublicKey(sig, payload string, k *asymkey_model.PublicKey, committer, signer *user_model.User, email string) *asymkey_model.CommitVerification {
if err := sshsig.Verify(strings.NewReader(payload), []byte(sig), []byte(k.Content), "git"); err != nil {
return nil
}
return &asymkey_model.CommitVerification{ // Everything is ok
CommittingUser: committer,
Verified: true,
Reason: fmt.Sprintf("%s / %s", signer.Name, k.Fingerprint),
SigningUser: signer,
SigningSSHKey: k,
SigningEmail: email,
}
}

View File

@@ -0,0 +1,133 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package asymkey
import (
"strings"
"testing"
asymkey_model "gitea.dev/models/asymkey"
"gitea.dev/models/db"
"gitea.dev/models/unittest"
user_model "gitea.dev/models/user"
"gitea.dev/modules/git"
"gitea.dev/modules/setting"
"gitea.dev/modules/test"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestCommitSignSettings(t *testing.T) {
defer test.MockVariableValue(&setting.Repository.Signing.SigningFormat)()
defer test.MockVariableValue(&setting.Repository.Signing.SigningKey, "any-content")()
t.Run("InstanceSettings", func(t *testing.T) {
setting.Repository.Signing.SigningFormat = ""
css := getInstanceCommitSignSettings(git.SigningKeyFormatOpenPGP)
assert.NotNil(t, css)
assert.Equal(t, git.SigningKeyFormatOpenPGP, css.Format)
css = getInstanceCommitSignSettings(git.SigningKeyFormatSSH)
assert.Nil(t, css)
setting.Repository.Signing.SigningFormat = git.SigningKeyFormatOpenPGP
css = getInstanceCommitSignSettings(git.SigningKeyFormatOpenPGP)
assert.NotNil(t, css)
assert.Equal(t, git.SigningKeyFormatOpenPGP, css.Format)
setting.Repository.Signing.SigningFormat = git.SigningKeyFormatSSH
css = getInstanceCommitSignSettings(git.SigningKeyFormatSSH)
assert.NotNil(t, css)
assert.Equal(t, git.SigningKeyFormatSSH, css.Format)
css = getInstanceCommitSignSettings(git.SigningKeyFormatOpenPGP)
assert.Nil(t, css)
})
t.Run("GitGlobalSettings", func(t *testing.T) {
css := git.GlobalCommitSignSettings.Value()
assert.False(t, css.Sign)
assert.Equal(t, git.SigningKeyFormatOpenPGP, css.Format)
css = getGitGlobalCommitSignSettings(git.SigningKeyFormatOpenPGP)
assert.Nil(t, css)
})
}
func TestParseCommitWithSSHSignature(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
// Here we only need to do some tests that "tests/integration/gpg_ssh_git_test.go" doesn't cover
// -----BEGIN OPENSSH PRIVATE KEY-----
// b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
// QyNTUxOQAAACC6T6zF0oPak8dOIzzT1kXB7LrcsVo04SKc3GjuvMllZwAAAJgy08upMtPL
// qQAAAAtzc2gtZWQyNTUxOQAAACC6T6zF0oPak8dOIzzT1kXB7LrcsVo04SKc3GjuvMllZw
// AAAEDWqPHTH51xb4hy1y1f1VeWL/2A9Q0b6atOyv5fx8x5prpPrMXSg9qTx04jPNPWRcHs
// utyxWjThIpzcaO68yWVnAAAAEXVzZXIyQGV4YW1wbGUuY29tAQIDBA==
// -----END OPENSSH PRIVATE KEY-----
sshPubKey, err := asymkey_model.AddPublicKey(t.Context(), 999, "user-ssh-key-any-name", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILpPrMXSg9qTx04jPNPWRcHsutyxWjThIpzcaO68yWVn", 0, false)
require.NoError(t, err)
_, err = db.GetEngine(t.Context()).ID(sshPubKey.ID).Cols("verified").Update(&asymkey_model.PublicKey{Verified: true})
require.NoError(t, err)
t.Run("UserSSHKey", func(t *testing.T) {
commit, err := git.CommitFromReader(git.Sha1ObjectFormat.EmptyObjectID(), strings.NewReader(`tree a3b1fad553e0f9a2b4a58327bebde36c7da75aa2
author user2 <user2@example.com> 1752194028 -0700
committer user2 <user2@example.com> 1752194028 -0700
gpgsig -----BEGIN SSH SIGNATURE-----
U1NIU0lHAAAAAQAAADMAAAALc3NoLWVkMjU1MTkAAAAguk+sxdKD2pPHTiM809ZFwey63L
FaNOEinNxo7rzJZWcAAAADZ2l0AAAAAAAAAAZzaGE1MTIAAABTAAAAC3NzaC1lZDI1NTE5
AAAAQBfX+6mcKZBnXckwHcBFqRuXMD3vTKi1yv5wgrqIxTyr2LWB97xxmO92cvjsr0POQ2
2YA7mQS510Cg2s1uU1XAk=
-----END SSH SIGNATURE-----
init project
`))
require.NoError(t, err)
// the committingUser is guaranteed by the caller, parseCommitWithSSHSignature doesn't do any more checks
committingUser := &user_model.User{ID: 999, Name: "user-x"}
ret := parseCommitWithSSHSignature(t.Context(), commit, committingUser)
require.NotNil(t, ret)
assert.True(t, ret.Verified)
assert.Equal(t, committingUser.Name+" / "+sshPubKey.Fingerprint, ret.Reason)
assert.False(t, ret.Warning)
assert.Equal(t, committingUser, ret.SigningUser)
assert.Equal(t, committingUser, ret.CommittingUser)
assert.Equal(t, sshPubKey.ID, ret.SigningSSHKey.ID)
})
t.Run("TrustedSSHKey", func(t *testing.T) {
defer test.MockVariableValue(&setting.Repository.Signing.SigningName, "gitea")()
defer test.MockVariableValue(&setting.Repository.Signing.SigningEmail, "gitea@fake.local")()
defer test.MockVariableValue(&setting.Repository.Signing.TrustedSSHKeys, []string{"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIH6Y4idVaW3E+bLw1uqoAfJD7o5Siu+HqS51E9oQLPE9"})()
commit, err := git.CommitFromReader(git.Sha1ObjectFormat.EmptyObjectID(), strings.NewReader(`tree 9a93ffa76e8b72bdb6431910b3a506fa2b39f42e
author User Two <user2@example.com> 1749230009 +0200
committer User Two <user2@example.com> 1749230009 +0200
gpgsig -----BEGIN SSH SIGNATURE-----
U1NIU0lHAAAAAQAAADMAAAALc3NoLWVkMjU1MTkAAAAgfpjiJ1VpbcT5svDW6qgB8kPujl
KK74epLnUT2hAs8T0AAAADZ2l0AAAAAAAAAAZzaGE1MTIAAABTAAAAC3NzaC1lZDI1NTE5
AAAAQDX2t2iHuuLxEWHLJetYXKsgayv3c43r0pJNfAzdLN55Q65pC5M7rG6++gT2bxcpOu
Y6EXbpLqia9sunEF3+LQY=
-----END SSH SIGNATURE-----
Initial commit with signed file
`))
require.NoError(t, err)
committingUser := &user_model.User{
ID: 2,
Name: "User Two",
Email: "user2@example.com",
}
ret := parseCommitWithSSHSignature(t.Context(), commit, committingUser)
require.NotNil(t, ret)
assert.True(t, ret.Verified)
assert.False(t, ret.Warning)
assert.Equal(t, committingUser, ret.CommittingUser)
if assert.NotNil(t, ret.SigningUser) {
assert.Equal(t, "gitea", ret.SigningUser.Name)
assert.Equal(t, "gitea@fake.local", ret.SigningUser.Email)
}
})
}

View File

@@ -0,0 +1,77 @@
// Copyright 2021 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package asymkey
import (
"context"
"fmt"
asymkey_model "gitea.dev/models/asymkey"
audit_model "gitea.dev/models/audit"
"gitea.dev/models/db"
deploykey_model "gitea.dev/models/deploykey"
repo_model "gitea.dev/models/repo"
"gitea.dev/services/audit"
)
// DeleteRepoDeployKeys deletes all deploy keys of a repository. permissions check should be done outside
func DeleteRepoDeployKeys(ctx context.Context, repoID int64) (int, error) {
deployKeys, err := db.Find[deploykey_model.DeployKey](ctx, deploykey_model.ListDeployKeysOptions{RepoID: repoID})
if err != nil {
return 0, fmt.Errorf("listDeployKeys: %w", err)
}
for _, dKey := range deployKeys {
if err := deleteDeployKeyFromDB(ctx, dKey); err != nil {
return 0, fmt.Errorf("deleteDeployKeys: %w", err)
}
}
return len(deployKeys), nil
}
// deleteDeployKeyFromDB delete deploy keys from database
func deleteDeployKeyFromDB(ctx context.Context, key *deploykey_model.DeployKey) error {
if _, err := db.DeleteByID[deploykey_model.DeployKey](ctx, key.ID); err != nil {
return fmt.Errorf("delete deploy key [%d]: %w", key.ID, err)
}
if key.KeyType == deploykey_model.KeyTypeToken { // a token has no public key to clean up
return nil
}
// Check if this is the last reference to same key content.
has, err := deploykey_model.IsDeployKeyExistByPublicKeyID(ctx, key.KeyID)
if err != nil {
return err
} else if !has {
if _, err = db.DeleteByID[asymkey_model.PublicKey](ctx, key.KeyID); err != nil {
return err
}
}
return nil
}
// DeleteDeployKey deletes deploy key from its repository authorized_keys file if needed,
// and returns the key it deleted. Permissions check should be done outside.
func DeleteDeployKey(ctx context.Context, repo *repo_model.Repository, id int64) (*deploykey_model.DeployKey, error) {
deleted, err := db.WithTx2(ctx, func(ctx context.Context) (*deploykey_model.DeployKey, error) {
key, err := deploykey_model.GetDeployKeyByID(ctx, repo.ID, id)
if err != nil {
return nil, err
}
return key, deleteDeployKeyFromDB(ctx, key)
})
if err != nil {
return nil, err
}
audit.Record(ctx, audit_model.RepositoryDeployKeyRemove, repo, "deploy_key", deleted.Name)
if deleted.KeyType == deploykey_model.KeyTypeToken {
return deleted, nil // a token never appears in the authorized_keys file
}
return deleted, RewriteAllPublicKeys(ctx)
}

View File

@@ -0,0 +1,18 @@
// Copyright 2019 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package asymkey
import (
"testing"
"gitea.dev/models/unittest"
_ "gitea.dev/models"
_ "gitea.dev/models/actions"
_ "gitea.dev/models/activities"
)
func TestMain(m *testing.M) {
unittest.MainTest(m)
}

380
services/asymkey/sign.go Normal file
View File

@@ -0,0 +1,380 @@
// Copyright 2021 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package asymkey
import (
"context"
"fmt"
"os"
"strings"
asymkey_model "gitea.dev/models/asymkey"
"gitea.dev/models/auth"
"gitea.dev/models/db"
git_model "gitea.dev/models/git"
issues_model "gitea.dev/models/issues"
repo_model "gitea.dev/models/repo"
user_model "gitea.dev/models/user"
"gitea.dev/modules/git"
"gitea.dev/modules/log"
"gitea.dev/modules/process"
"gitea.dev/modules/setting"
)
type signingMode string
const (
never signingMode = "never"
always signingMode = "always"
pubkey signingMode = "pubkey"
twofa signingMode = "twofa"
parentSigned signingMode = "parentsigned"
baseSigned signingMode = "basesigned"
headSigned signingMode = "headsigned"
commitsSigned signingMode = "commitssigned"
approved signingMode = "approved"
noKey signingMode = "nokey"
)
func signingModeFromStrings(modeStrings []string) []signingMode {
returnable := make([]signingMode, 0, len(modeStrings))
for _, mode := range modeStrings {
signMode := signingMode(strings.ToLower(strings.TrimSpace(mode)))
switch signMode {
case never:
return []signingMode{never}
case always:
return []signingMode{always}
case pubkey:
fallthrough
case twofa:
fallthrough
case parentSigned:
fallthrough
case baseSigned:
fallthrough
case headSigned:
fallthrough
case approved:
fallthrough
case commitsSigned:
returnable = append(returnable, signMode)
}
}
if len(returnable) == 0 {
return []signingMode{never}
}
return returnable
}
func userHasPubkeysGPG(ctx context.Context, userID int64) (bool, error) {
return db.Exist[asymkey_model.GPGKey](ctx, asymkey_model.FindGPGKeyOptions{
OwnerID: userID,
IncludeSubKeys: true,
}.ToConds())
}
func userHasPubkeysSSH(ctx context.Context, userID int64) (bool, error) {
return db.Exist[asymkey_model.PublicKey](ctx, asymkey_model.FindPublicKeyOptions{
OwnerID: userID,
NotKeytype: asymkey_model.KeyTypePrincipal,
}.ToConds())
}
// userHasPubkeys checks if a user has any public keys (GPG or SSH)
func userHasPubkeys(ctx context.Context, userID int64) (bool, error) {
has, err := userHasPubkeysGPG(ctx, userID)
if has || err != nil {
return has, err
}
return userHasPubkeysSSH(ctx, userID)
}
// ErrWontSign explains the first reason why a commit would not be signed
// There may be other reasons - this is just the first reason found
type ErrWontSign struct {
Reason signingMode
}
func (e *ErrWontSign) Error() string {
return fmt.Sprintf("wont sign: %s", e.Reason)
}
// IsErrWontSign checks if an error is a ErrWontSign
func IsErrWontSign(err error) bool {
_, ok := err.(*ErrWontSign)
return ok
}
// PublicSigningKey gets the public signing key of the entire instance
func PublicSigningKey(ctx context.Context) (content, format string, err error) {
signingKey, _ := git.GetSigningKey(ctx)
if signingKey == nil {
return "", "", nil
}
if signingKey.Format == git.SigningKeyFormatSSH {
content, err := os.ReadFile(signingKey.KeyID)
if err != nil {
log.Error("Unable to read SSH public key file: %s, %v", signingKey, err)
return "", signingKey.Format, err
}
return string(content), signingKey.Format, nil
}
content, stderr, err := process.CommandContext(ctx, "gpg", "--export", "-a", signingKey.KeyID).WithDir(setting.Git.HomePath).OutputString()
if err != nil {
log.Error("Unable to get default signing key: %s, %s, %v", signingKey, stderr, err)
return "", signingKey.Format, err
}
return content, signingKey.Format, nil
}
// SignInitialCommit determines if we should sign the initial commit to this repository
func SignInitialCommit(ctx context.Context, u *user_model.User) (bool, *git.SigningKey, *git.Signature, error) {
rules := signingModeFromStrings(setting.Repository.Signing.InitialCommit)
signingKey, sig := git.GetSigningKey(ctx)
if signingKey == nil {
return false, nil, nil, &ErrWontSign{noKey}
}
Loop:
for _, rule := range rules {
switch rule {
case never:
return false, nil, nil, &ErrWontSign{never}
case always:
break Loop
case pubkey:
hasKeys, err := userHasPubkeys(ctx, u.ID)
if err != nil {
return false, nil, nil, err
}
if !hasKeys {
return false, nil, nil, &ErrWontSign{pubkey}
}
case twofa:
twofaModel, err := auth.GetTwoFactorByUID(ctx, u.ID)
if err != nil && !auth.IsErrTwoFactorNotEnrolled(err) {
return false, nil, nil, err
}
if twofaModel == nil {
return false, nil, nil, &ErrWontSign{twofa}
}
}
}
return true, signingKey, sig, nil
}
// SignWikiCommit determines if we should sign the commits to this repository wiki
func SignWikiCommit(ctx context.Context, repo *repo_model.Repository, gitRepo *git.Repository, u *user_model.User) (bool, *git.SigningKey, *git.Signature, error) {
rules := signingModeFromStrings(setting.Repository.Signing.Wiki)
signingKey, sig := git.GetSigningKey(ctx)
if signingKey == nil {
return false, nil, nil, &ErrWontSign{noKey}
}
Loop:
for _, rule := range rules {
switch rule {
case never:
return false, nil, nil, &ErrWontSign{never}
case always:
break Loop
case pubkey:
hasKeys, err := userHasPubkeys(ctx, u.ID)
if err != nil {
return false, nil, nil, err
}
if !hasKeys {
return false, nil, nil, &ErrWontSign{pubkey}
}
case twofa:
twofaModel, err := auth.GetTwoFactorByUID(ctx, u.ID)
if err != nil && !auth.IsErrTwoFactorNotEnrolled(err) {
return false, nil, nil, err
}
if twofaModel == nil {
return false, nil, nil, &ErrWontSign{twofa}
}
case parentSigned:
commit, err := gitRepo.GetCommit(ctx, "HEAD")
if err != nil {
return false, nil, nil, err
}
if commit.Signature == nil {
return false, nil, nil, &ErrWontSign{parentSigned}
}
verification := ParseCommitWithSignature(ctx, commit)
if !verification.Verified {
return false, nil, nil, &ErrWontSign{parentSigned}
}
}
}
return true, signingKey, sig, nil
}
// SignCRUDAction determines if we should sign a CRUD commit to this repository
func SignCRUDAction(ctx context.Context, u *user_model.User, gitRepo *git.Repository, parentCommit string) (bool, *git.SigningKey, *git.Signature, error) {
rules := signingModeFromStrings(setting.Repository.Signing.CRUDActions)
signingKey, sig := git.GetSigningKey(ctx)
if signingKey == nil {
return false, nil, nil, &ErrWontSign{noKey}
}
Loop:
for _, rule := range rules {
switch rule {
case never:
return false, nil, nil, &ErrWontSign{never}
case always:
break Loop
case pubkey:
hasKeys, err := userHasPubkeys(ctx, u.ID)
if err != nil {
return false, nil, nil, err
}
if !hasKeys {
return false, nil, nil, &ErrWontSign{pubkey}
}
case twofa:
twofaModel, err := auth.GetTwoFactorByUID(ctx, u.ID)
if err != nil && !auth.IsErrTwoFactorNotEnrolled(err) {
return false, nil, nil, err
}
if twofaModel == nil {
return false, nil, nil, &ErrWontSign{twofa}
}
case parentSigned:
isEmpty, err := gitRepo.IsEmpty(ctx)
if err != nil {
return false, nil, nil, err
}
if !isEmpty {
commit, err := gitRepo.GetCommit(ctx, parentCommit)
if err != nil {
return false, nil, nil, err
}
if commit.Signature == nil {
return false, nil, nil, &ErrWontSign{parentSigned}
}
verification := ParseCommitWithSignature(ctx, commit)
if !verification.Verified {
return false, nil, nil, &ErrWontSign{parentSigned}
}
}
}
}
return true, signingKey, sig, nil
}
// SignMerge determines if we should sign a PR merge commit to the base repository.
// baseRef and headRef must resolve in gitRepo. Callers pass the temporary merge repo's own
// refs for an update by merge, whose fake reverse PR has no head ref in the base repository.
func SignMerge(ctx context.Context, pr *issues_model.PullRequest, u *user_model.User, gitRepo *git.Repository, baseRef, headRef string) (bool, *git.SigningKey, *git.Signature, error) {
if err := pr.LoadBaseRepo(ctx); err != nil {
log.Error("Unable to get Base Repo for pull request")
return false, nil, nil, err
}
repo := pr.BaseRepo
baseCommit, err := gitRepo.GetCommit(ctx, baseRef)
if err != nil {
return false, nil, nil, err
}
headCommit, err := gitRepo.GetCommit(ctx, headRef)
if err != nil {
return false, nil, nil, err
}
signingKey, signer := git.GetSigningKey(ctx)
if signingKey == nil {
return false, nil, nil, &ErrWontSign{noKey}
}
rules := signingModeFromStrings(setting.Repository.Signing.Merges)
Loop:
for _, rule := range rules {
switch rule {
case never:
return false, nil, nil, &ErrWontSign{never}
case always:
break Loop
case pubkey:
hasKeys, err := userHasPubkeys(ctx, u.ID)
if err != nil {
return false, nil, nil, err
}
if !hasKeys {
return false, nil, nil, &ErrWontSign{pubkey}
}
case twofa:
twofaModel, err := auth.GetTwoFactorByUID(ctx, u.ID)
if err != nil && !auth.IsErrTwoFactorNotEnrolled(err) {
return false, nil, nil, err
}
if twofaModel == nil {
return false, nil, nil, &ErrWontSign{twofa}
}
case approved:
protectedBranch, err := git_model.GetFirstMatchProtectedBranchRule(ctx, repo.ID, pr.BaseBranch)
if err != nil {
return false, nil, nil, err
}
if protectedBranch == nil {
return false, nil, nil, &ErrWontSign{approved}
}
if issues_model.GetGrantedApprovalsCount(ctx, protectedBranch, pr) < 1 {
return false, nil, nil, &ErrWontSign{approved}
}
case baseSigned:
verification := ParseCommitWithSignature(ctx, baseCommit)
if !verification.Verified {
return false, nil, nil, &ErrWontSign{baseSigned}
}
case headSigned:
verification := ParseCommitWithSignature(ctx, headCommit)
if !verification.Verified {
return false, nil, nil, &ErrWontSign{headSigned}
}
case commitsSigned:
verified, err := allCommitsVerified(ctx, gitRepo, baseCommit, headCommit)
if err != nil {
return false, nil, nil, err
}
if !verified {
return false, nil, nil, &ErrWontSign{commitsSigned}
}
}
}
return true, signingKey, signer, nil
}
// AllHeadCommitsVerified checks that every new commit in the PR head has a
// verified signature.
func AllHeadCommitsVerified(ctx context.Context, pr *issues_model.PullRequest, gitRepo *git.Repository) (bool, error) {
baseCommit, err := gitRepo.GetCommit(ctx, pr.BaseBranch)
if err != nil {
return false, err
}
headCommit, err := gitRepo.GetCommit(ctx, pr.GetGitHeadRefName())
if err != nil {
return false, err
}
return allCommitsVerified(ctx, gitRepo, baseCommit, headCommit)
}
// allCommitsVerified checks the commits a merge would introduce, those reachable from
// headCommit but not from baseCommit. Both commits must come from gitRepo.
func allCommitsVerified(ctx context.Context, gitRepo *git.Repository, baseCommit, headCommit *git.Commit) (bool, error) {
commitList, err := headCommit.CommitsBeforeUntil(ctx, gitRepo, baseCommit.ID.RefName())
if err != nil {
return false, err
}
for _, commit := range commitList {
if !ParseCommitWithSignature(ctx, commit).Verified {
return false, nil
}
}
return true, nil
}

View File

@@ -0,0 +1,39 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package asymkey
import (
"testing"
"gitea.dev/models/unittest"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestUserHasPubkeys(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
test := func(t *testing.T, userID int64, expectedHasGPG, expectedHasSSH bool) {
ctx := t.Context()
hasGPG, err := userHasPubkeysGPG(ctx, userID)
require.NoError(t, err)
hasSSH, err := userHasPubkeysSSH(ctx, userID)
require.NoError(t, err)
hasPubkeys, err := userHasPubkeys(ctx, userID)
require.NoError(t, err)
assert.Equal(t, expectedHasGPG, hasGPG)
assert.Equal(t, expectedHasSSH, hasSSH)
assert.Equal(t, expectedHasGPG || expectedHasSSH, hasPubkeys)
}
t.Run("AllowUserWithGPGKey", func(t *testing.T) {
test(t, 36, true, false) // has gpg
})
t.Run("AllowUserWithSSHKey", func(t *testing.T) {
test(t, 2, false, true) // has ssh
})
t.Run("DenyUserWithNoKeys", func(t *testing.T) {
test(t, 1, false, false) // no pubkey
})
}

View File

@@ -0,0 +1,47 @@
// Copyright 2021 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package asymkey
import (
"context"
asymkey_model "gitea.dev/models/asymkey"
audit_model "gitea.dev/models/audit"
"gitea.dev/models/db"
user_model "gitea.dev/models/user"
"gitea.dev/services/audit"
)
// DeletePublicKey deletes SSH key information both in database and authorized_keys file.
func DeletePublicKey(ctx context.Context, doer *user_model.User, id int64) (err error) {
key, err := asymkey_model.GetPublicKeyByID(ctx, id)
if err != nil {
return err
}
// Check if user has access to delete this key.
if !doer.IsAdmin && doer.ID != key.OwnerID {
return asymkey_model.ErrKeyAccessDenied{
UserID: doer.ID,
KeyID: key.ID,
Note: "public",
}
}
if _, err = db.DeleteByID[asymkey_model.PublicKey](ctx, id); err != nil {
return err
}
owner := audit.ScopeFromUserID(ctx, key.OwnerID)
if key.Type == asymkey_model.KeyTypePrincipal {
audit.Record(ctx, audit_model.UserKeyPrincipalRemove, owner, "key", key.Name)
return RewriteAllPrincipalKeys(ctx)
}
audit.Record(ctx, audit_model.UserKeySSHRemove, owner, "fingerprint", key.Fingerprint)
return RewriteAllPublicKeys(ctx)
}

View File

@@ -0,0 +1,79 @@
// Copyright 2024 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package asymkey
import (
"context"
"fmt"
"os"
"path/filepath"
"time"
asymkey_model "gitea.dev/models/asymkey"
"gitea.dev/modules/log"
"gitea.dev/modules/setting"
"gitea.dev/modules/util"
)
// RewriteAllPublicKeys removes any authorized key and rewrite all keys from database again.
// Note: db.GetEngine(ctx).Iterate does not get latest data after insert/delete, so we have to call this function
// outside any session scope independently.
func RewriteAllPublicKeys(ctx context.Context) error {
// Don't rewrite key if internal server
if setting.SSH.StartBuiltinServer || !setting.SSH.CreateAuthorizedKeysFile {
return nil
}
return asymkey_model.WithSSHOpLocker(func() error {
return rewriteAllPublicKeys(ctx)
})
}
func rewriteAllPublicKeys(ctx context.Context) error {
if setting.SSH.RootPath != "" {
// First of ensure that the RootPath is present, and if not make it with 0700 permissions
// This of course doesn't guarantee that this is the right directory for authorized_keys
// but at least if it's supposed to be this directory and it doesn't exist and we're the
// right user it will at least be created properly.
err := os.MkdirAll(setting.SSH.RootPath, 0o700)
if err != nil {
log.Error("Unable to MkdirAll(%s): %v", setting.SSH.RootPath, err)
return err
}
}
fPath := filepath.Join(setting.SSH.RootPath, "authorized_keys")
tmpPath := fPath + ".tmp"
t, err := os.OpenFile(tmpPath, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0o600)
if err != nil {
return err
}
defer func() {
_ = t.Close()
if err := util.RemoveWithRetry(tmpPath); err != nil {
log.Warn("Unable to remove temporary authorized keys file: %s: Error: %v", tmpPath, err)
}
}()
if setting.SSH.AuthorizedKeysBackup {
isExist, err := util.IsExist(fPath)
if err != nil {
log.Error("Unable to check if %s exists. Error: %v", fPath, err)
return err
}
if isExist {
bakPath := fmt.Sprintf("%s_%d.gitea_bak", fPath, time.Now().Unix())
if err = util.CopyFile(fPath, bakPath); err != nil {
return err
}
}
}
if err := asymkey_model.RegeneratePublicKeys(ctx, t); err != nil {
return err
}
_ = t.Close()
return util.RenameWithRetry(tmpPath, fPath)
}

View File

@@ -0,0 +1,129 @@
// Copyright 2021 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package asymkey
import (
"bufio"
"context"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"time"
asymkey_model "gitea.dev/models/asymkey"
"gitea.dev/models/db"
"gitea.dev/modules/log"
"gitea.dev/modules/setting"
"gitea.dev/modules/util"
"xorm.io/builder"
)
// This file contains functions for creating authorized_principals files
//
// There is a dependence on the database within RewriteAllPrincipalKeys & RegeneratePrincipalKeys
// The sshOpLocker is used from ssh_key_authorized_keys.go
const authorizedPrincipalsFile = "authorized_principals"
// RewriteAllPrincipalKeys removes any authorized principal and rewrite all keys from database again.
// Note: db.GetEngine(ctx).Iterate does not get latest data after insert/delete, so we have to call this function
// outside any session scope independently.
func RewriteAllPrincipalKeys(ctx context.Context) error {
// Don't rewrite key if internal server
if setting.SSH.StartBuiltinServer || !setting.SSH.CreateAuthorizedPrincipalsFile {
return nil
}
return asymkey_model.WithSSHOpLocker(func() error {
return rewriteAllPrincipalKeys(ctx)
})
}
func rewriteAllPrincipalKeys(ctx context.Context) error {
if setting.SSH.RootPath != "" {
// First of ensure that the RootPath is present, and if not make it with 0700 permissions
// This of course doesn't guarantee that this is the right directory for authorized_keys
// but at least if it's supposed to be this directory and it doesn't exist and we're the
// right user it will at least be created properly.
err := os.MkdirAll(setting.SSH.RootPath, 0o700)
if err != nil {
log.Error("Unable to MkdirAll(%s): %v", setting.SSH.RootPath, err)
return err
}
}
fPath := filepath.Join(setting.SSH.RootPath, authorizedPrincipalsFile)
tmpPath := fPath + ".tmp"
t, err := os.OpenFile(tmpPath, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0o600)
if err != nil {
return err
}
defer func() {
_ = t.Close()
_ = util.RemoveWithRetry(tmpPath)
}()
if setting.SSH.AuthorizedPrincipalsBackup {
isExist, err := util.IsExist(fPath)
if err != nil {
log.Error("Unable to check if %s exists. Error: %v", fPath, err)
return err
}
if isExist {
bakPath := fmt.Sprintf("%s_%d.gitea_bak", fPath, time.Now().Unix())
if err = util.CopyFile(fPath, bakPath); err != nil {
return err
}
}
}
if err := regeneratePrincipalKeys(ctx, t); err != nil {
return err
}
_ = t.Close()
return util.RenameWithRetry(tmpPath, fPath)
}
func regeneratePrincipalKeys(ctx context.Context, t io.Writer) error {
if err := db.Iterate(ctx, builder.Eq{"type": asymkey_model.KeyTypePrincipal}, func(ctx context.Context, key *asymkey_model.PublicKey) error {
return asymkey_model.WriteAuthorizedStringForValidKey(key, t)
}); err != nil {
return err
}
fPath := filepath.Join(setting.SSH.RootPath, authorizedPrincipalsFile)
isExist, err := util.IsExist(fPath)
if err != nil {
log.Error("Unable to check if %s exists. Error: %v", fPath, err)
return err
}
if isExist {
f, err := os.Open(fPath)
if err != nil {
return err
}
defer f.Close()
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := scanner.Text()
if strings.HasPrefix(line, asymkey_model.AuthorizedStringCommentPrefix) {
scanner.Scan()
continue
}
_, err = io.WriteString(t, line+"\n")
if err != nil {
return err
}
}
if err = scanner.Err(); err != nil {
return fmt.Errorf("regeneratePrincipalKeys scan: %w", err)
}
}
return nil
}

View File

@@ -0,0 +1,48 @@
// Copyright 2024 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package asymkey
import (
"context"
"fmt"
asymkey_model "gitea.dev/models/asymkey"
"gitea.dev/models/db"
"gitea.dev/models/perm"
)
// AddPrincipalKey adds new principal to database and authorized_principals file.
func AddPrincipalKey(ctx context.Context, ownerID int64, content string, authSourceID int64) (*asymkey_model.PublicKey, error) {
key := &asymkey_model.PublicKey{
OwnerID: ownerID,
Name: content,
Content: content,
Mode: perm.AccessModeWrite,
Type: asymkey_model.KeyTypePrincipal,
LoginSourceID: authSourceID,
}
if err := db.WithTx(ctx, func(ctx context.Context) error {
// Principals cannot be duplicated.
has, err := db.GetEngine(ctx).
Where("content = ? AND type = ?", content, asymkey_model.KeyTypePrincipal).
Get(new(asymkey_model.PublicKey))
if err != nil {
return err
} else if has {
return asymkey_model.ErrKeyAlreadyExist{
Content: content,
}
}
if err = db.Insert(ctx, key); err != nil {
return fmt.Errorf("addKey: %w", err)
}
return nil
}); err != nil {
return nil, err
}
return key, RewriteAllPrincipalKeys(ctx)
}

View File

@@ -0,0 +1,87 @@
// Copyright 2021 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package asymkey
import (
"testing"
asymkey_model "gitea.dev/models/asymkey"
"gitea.dev/models/auth"
"gitea.dev/models/db"
"gitea.dev/models/unittest"
user_model "gitea.dev/models/user"
"github.com/stretchr/testify/assert"
)
func TestAddLdapSSHPublicKeys(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
s := &auth.Source{ID: 1}
testCases := []struct {
keyString string
number int
keyContents []string
}{
{
keyString: "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQC4cn+iXnA4KvcQYSV88vGn0Yi91vG47t1P7okprVmhNTkipNRIHWr6WdCO4VDr/cvsRkuVJAsLO2enwjGWWueOO6BodiBgyAOZ/5t5nJNMCNuLGT5UIo/RI1b0WRQwxEZTRjt6mFNw6lH14wRd8ulsr9toSWBPMOGWoYs1PDeDL0JuTjL+tr1SZi/EyxCngpYszKdXllJEHyI79KQgeD0Vt3pTrkbNVTOEcCNqZePSVmUH8X8Vhugz3bnE0/iE9Pb5fkWO9c4AnM1FgI/8Bvp27Fw2ShryIXuR6kKvUqhVMTuOSDHwu6A8jLE5Owt3GAYugDpDYuwTVNGrHLXKpPzrGGPE/jPmaLCMZcsdkec95dYeU3zKODEm8UQZFhmJmDeWVJ36nGrGZHL4J5aTTaeFUJmmXDaJYiJ+K2/ioKgXqnXvltu0A9R8/LGy4nrTJRr4JMLuJFoUXvGm1gXQ70w2LSpk6yl71RNC0hCtsBe8BP8IhYCM0EP5jh7eCMQZNvM= nocomment\n",
number: 1,
keyContents: []string{
"ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQC4cn+iXnA4KvcQYSV88vGn0Yi91vG47t1P7okprVmhNTkipNRIHWr6WdCO4VDr/cvsRkuVJAsLO2enwjGWWueOO6BodiBgyAOZ/5t5nJNMCNuLGT5UIo/RI1b0WRQwxEZTRjt6mFNw6lH14wRd8ulsr9toSWBPMOGWoYs1PDeDL0JuTjL+tr1SZi/EyxCngpYszKdXllJEHyI79KQgeD0Vt3pTrkbNVTOEcCNqZePSVmUH8X8Vhugz3bnE0/iE9Pb5fkWO9c4AnM1FgI/8Bvp27Fw2ShryIXuR6kKvUqhVMTuOSDHwu6A8jLE5Owt3GAYugDpDYuwTVNGrHLXKpPzrGGPE/jPmaLCMZcsdkec95dYeU3zKODEm8UQZFhmJmDeWVJ36nGrGZHL4J5aTTaeFUJmmXDaJYiJ+K2/ioKgXqnXvltu0A9R8/LGy4nrTJRr4JMLuJFoUXvGm1gXQ70w2LSpk6yl71RNC0hCtsBe8BP8IhYCM0EP5jh7eCMQZNvM=",
},
},
{
keyString: `ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQC4cn+iXnA4KvcQYSV88vGn0Yi91vG47t1P7okprVmhNTkipNRIHWr6WdCO4VDr/cvsRkuVJAsLO2enwjGWWueOO6BodiBgyAOZ/5t5nJNMCNuLGT5UIo/RI1b0WRQwxEZTRjt6mFNw6lH14wRd8ulsr9toSWBPMOGWoYs1PDeDL0JuTjL+tr1SZi/EyxCngpYszKdXllJEHyI79KQgeD0Vt3pTrkbNVTOEcCNqZePSVmUH8X8Vhugz3bnE0/iE9Pb5fkWO9c4AnM1FgI/8Bvp27Fw2ShryIXuR6kKvUqhVMTuOSDHwu6A8jLE5Owt3GAYugDpDYuwTVNGrHLXKpPzrGGPE/jPmaLCMZcsdkec95dYeU3zKODEm8UQZFhmJmDeWVJ36nGrGZHL4J5aTTaeFUJmmXDaJYiJ+K2/ioKgXqnXvltu0A9R8/LGy4nrTJRr4JMLuJFoUXvGm1gXQ70w2LSpk6yl71RNC0hCtsBe8BP8IhYCM0EP5jh7eCMQZNvM= nocomment
ssh-dss AAAAB3NzaC1kc3MAAACBAOChCC7lf6Uo9n7BmZ6M8St19PZf4Tn59NriyboW2x/DZuYAz3ibZ2OkQ3S0SqDIa0HXSEJ1zaExQdmbO+Ux/wsytWZmCczWOVsaszBZSl90q8UnWlSH6P+/YA+RWJm5SFtuV9PtGIhyZgoNuz5kBQ7K139wuQsecdKktISwTakzAAAAFQCzKsO2JhNKlL+wwwLGOcLffoAmkwAAAIBpK7/3xvduajLBD/9vASqBQIHrgK2J+wiQnIb/Wzy0UsVmvfn8A+udRbBo+csM8xrSnlnlJnjkJS3qiM5g+eTwsLIV1IdKPEwmwB+VcP53Cw6lSyWyJcvhFb0N6s08NZysLzvj0N+ZC/FnhKTLzIyMtkHf/IrPCwlM+pV/M/96YgAAAIEAqQcGn9CKgzgPaguIZooTAOQdvBLMI5y0bQjOW6734XOpqQGf/Kra90wpoasLKZjSYKNPjE+FRUOrStLrxcNs4BeVKhy2PYTRnybfYVk1/dmKgH6P1YSRONsGKvTsH6c5IyCRG0ncCgYeF8tXppyd642982daopE7zQ/NPAnJfag= nocomment`,
number: 2,
keyContents: []string{
"ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQC4cn+iXnA4KvcQYSV88vGn0Yi91vG47t1P7okprVmhNTkipNRIHWr6WdCO4VDr/cvsRkuVJAsLO2enwjGWWueOO6BodiBgyAOZ/5t5nJNMCNuLGT5UIo/RI1b0WRQwxEZTRjt6mFNw6lH14wRd8ulsr9toSWBPMOGWoYs1PDeDL0JuTjL+tr1SZi/EyxCngpYszKdXllJEHyI79KQgeD0Vt3pTrkbNVTOEcCNqZePSVmUH8X8Vhugz3bnE0/iE9Pb5fkWO9c4AnM1FgI/8Bvp27Fw2ShryIXuR6kKvUqhVMTuOSDHwu6A8jLE5Owt3GAYugDpDYuwTVNGrHLXKpPzrGGPE/jPmaLCMZcsdkec95dYeU3zKODEm8UQZFhmJmDeWVJ36nGrGZHL4J5aTTaeFUJmmXDaJYiJ+K2/ioKgXqnXvltu0A9R8/LGy4nrTJRr4JMLuJFoUXvGm1gXQ70w2LSpk6yl71RNC0hCtsBe8BP8IhYCM0EP5jh7eCMQZNvM=",
"ssh-dss AAAAB3NzaC1kc3MAAACBAOChCC7lf6Uo9n7BmZ6M8St19PZf4Tn59NriyboW2x/DZuYAz3ibZ2OkQ3S0SqDIa0HXSEJ1zaExQdmbO+Ux/wsytWZmCczWOVsaszBZSl90q8UnWlSH6P+/YA+RWJm5SFtuV9PtGIhyZgoNuz5kBQ7K139wuQsecdKktISwTakzAAAAFQCzKsO2JhNKlL+wwwLGOcLffoAmkwAAAIBpK7/3xvduajLBD/9vASqBQIHrgK2J+wiQnIb/Wzy0UsVmvfn8A+udRbBo+csM8xrSnlnlJnjkJS3qiM5g+eTwsLIV1IdKPEwmwB+VcP53Cw6lSyWyJcvhFb0N6s08NZysLzvj0N+ZC/FnhKTLzIyMtkHf/IrPCwlM+pV/M/96YgAAAIEAqQcGn9CKgzgPaguIZooTAOQdvBLMI5y0bQjOW6734XOpqQGf/Kra90wpoasLKZjSYKNPjE+FRUOrStLrxcNs4BeVKhy2PYTRnybfYVk1/dmKgH6P1YSRONsGKvTsH6c5IyCRG0ncCgYeF8tXppyd642982daopE7zQ/NPAnJfag=",
},
},
{
keyString: `ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQC4cn+iXnA4KvcQYSV88vGn0Yi91vG47t1P7okprVmhNTkipNRIHWr6WdCO4VDr/cvsRkuVJAsLO2enwjGWWueOO6BodiBgyAOZ/5t5nJNMCNuLGT5UIo/RI1b0WRQwxEZTRjt6mFNw6lH14wRd8ulsr9toSWBPMOGWoYs1PDeDL0JuTjL+tr1SZi/EyxCngpYszKdXllJEHyI79KQgeD0Vt3pTrkbNVTOEcCNqZePSVmUH8X8Vhugz3bnE0/iE9Pb5fkWO9c4AnM1FgI/8Bvp27Fw2ShryIXuR6kKvUqhVMTuOSDHwu6A8jLE5Owt3GAYugDpDYuwTVNGrHLXKpPzrGGPE/jPmaLCMZcsdkec95dYeU3zKODEm8UQZFhmJmDeWVJ36nGrGZHL4J5aTTaeFUJmmXDaJYiJ+K2/ioKgXqnXvltu0A9R8/LGy4nrTJRr4JMLuJFoUXvGm1gXQ70w2LSpk6yl71RNC0hCtsBe8BP8IhYCM0EP5jh7eCMQZNvM= nocomment
# comment asmdna,ndp
ssh-dss AAAAB3NzaC1kc3MAAACBAOChCC7lf6Uo9n7BmZ6M8St19PZf4Tn59NriyboW2x/DZuYAz3ibZ2OkQ3S0SqDIa0HXSEJ1zaExQdmbO+Ux/wsytWZmCczWOVsaszBZSl90q8UnWlSH6P+/YA+RWJm5SFtuV9PtGIhyZgoNuz5kBQ7K139wuQsecdKktISwTakzAAAAFQCzKsO2JhNKlL+wwwLGOcLffoAmkwAAAIBpK7/3xvduajLBD/9vASqBQIHrgK2J+wiQnIb/Wzy0UsVmvfn8A+udRbBo+csM8xrSnlnlJnjkJS3qiM5g+eTwsLIV1IdKPEwmwB+VcP53Cw6lSyWyJcvhFb0N6s08NZysLzvj0N+ZC/FnhKTLzIyMtkHf/IrPCwlM+pV/M/96YgAAAIEAqQcGn9CKgzgPaguIZooTAOQdvBLMI5y0bQjOW6734XOpqQGf/Kra90wpoasLKZjSYKNPjE+FRUOrStLrxcNs4BeVKhy2PYTRnybfYVk1/dmKgH6P1YSRONsGKvTsH6c5IyCRG0ncCgYeF8tXppyd642982daopE7zQ/NPAnJfag= nocomment`,
number: 2,
keyContents: []string{
"ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQC4cn+iXnA4KvcQYSV88vGn0Yi91vG47t1P7okprVmhNTkipNRIHWr6WdCO4VDr/cvsRkuVJAsLO2enwjGWWueOO6BodiBgyAOZ/5t5nJNMCNuLGT5UIo/RI1b0WRQwxEZTRjt6mFNw6lH14wRd8ulsr9toSWBPMOGWoYs1PDeDL0JuTjL+tr1SZi/EyxCngpYszKdXllJEHyI79KQgeD0Vt3pTrkbNVTOEcCNqZePSVmUH8X8Vhugz3bnE0/iE9Pb5fkWO9c4AnM1FgI/8Bvp27Fw2ShryIXuR6kKvUqhVMTuOSDHwu6A8jLE5Owt3GAYugDpDYuwTVNGrHLXKpPzrGGPE/jPmaLCMZcsdkec95dYeU3zKODEm8UQZFhmJmDeWVJ36nGrGZHL4J5aTTaeFUJmmXDaJYiJ+K2/ioKgXqnXvltu0A9R8/LGy4nrTJRr4JMLuJFoUXvGm1gXQ70w2LSpk6yl71RNC0hCtsBe8BP8IhYCM0EP5jh7eCMQZNvM=",
"ssh-dss AAAAB3NzaC1kc3MAAACBAOChCC7lf6Uo9n7BmZ6M8St19PZf4Tn59NriyboW2x/DZuYAz3ibZ2OkQ3S0SqDIa0HXSEJ1zaExQdmbO+Ux/wsytWZmCczWOVsaszBZSl90q8UnWlSH6P+/YA+RWJm5SFtuV9PtGIhyZgoNuz5kBQ7K139wuQsecdKktISwTakzAAAAFQCzKsO2JhNKlL+wwwLGOcLffoAmkwAAAIBpK7/3xvduajLBD/9vASqBQIHrgK2J+wiQnIb/Wzy0UsVmvfn8A+udRbBo+csM8xrSnlnlJnjkJS3qiM5g+eTwsLIV1IdKPEwmwB+VcP53Cw6lSyWyJcvhFb0N6s08NZysLzvj0N+ZC/FnhKTLzIyMtkHf/IrPCwlM+pV/M/96YgAAAIEAqQcGn9CKgzgPaguIZooTAOQdvBLMI5y0bQjOW6734XOpqQGf/Kra90wpoasLKZjSYKNPjE+FRUOrStLrxcNs4BeVKhy2PYTRnybfYVk1/dmKgH6P1YSRONsGKvTsH6c5IyCRG0ncCgYeF8tXppyd642982daopE7zQ/NPAnJfag=",
},
},
{
keyString: `ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQC4cn+iXnA4KvcQYSV88vGn0Yi91vG47t1P7okprVmhNTkipNRIHWr6WdCO4VDr/cvsRkuVJAsLO2enwjGWWueOO6BodiBgyAOZ/5t5nJNMCNuLGT5UIo/RI1b0WRQwxEZTRjt6mFNw6lH14wRd8ulsr9toSWBPMOGWoYs1PDeDL0JuTjL+tr1SZi/EyxCngpYszKdXllJEHyI79KQgeD0Vt3pTrkbNVTOEcCNqZePSVmUH8X8Vhugz3bnE0/iE9Pb5fkWO9c4AnM1FgI/8Bvp27Fw2ShryIXuR6kKvUqhVMTuOSDHwu6A8jLE5Owt3GAYugDpDYuwTVNGrHLXKpPzrGGPE/jPmaLCMZcsdkec95dYeU3zKODEm8UQZFhmJmDeWVJ36nGrGZHL4J5aTTaeFUJmmXDaJYiJ+K2/ioKgXqnXvltu0A9R8/LGy4nrTJRr4JMLuJFoUXvGm1gXQ70w2LSpk6yl71RNC0hCtsBe8BP8IhYCM0EP5jh7eCMQZNvM= nocomment
382488320jasdj1lasmva/vasodifipi4193-fksma.cm
ssh-dss AAAAB3NzaC1kc3MAAACBAOChCC7lf6Uo9n7BmZ6M8St19PZf4Tn59NriyboW2x/DZuYAz3ibZ2OkQ3S0SqDIa0HXSEJ1zaExQdmbO+Ux/wsytWZmCczWOVsaszBZSl90q8UnWlSH6P+/YA+RWJm5SFtuV9PtGIhyZgoNuz5kBQ7K139wuQsecdKktISwTakzAAAAFQCzKsO2JhNKlL+wwwLGOcLffoAmkwAAAIBpK7/3xvduajLBD/9vASqBQIHrgK2J+wiQnIb/Wzy0UsVmvfn8A+udRbBo+csM8xrSnlnlJnjkJS3qiM5g+eTwsLIV1IdKPEwmwB+VcP53Cw6lSyWyJcvhFb0N6s08NZysLzvj0N+ZC/FnhKTLzIyMtkHf/IrPCwlM+pV/M/96YgAAAIEAqQcGn9CKgzgPaguIZooTAOQdvBLMI5y0bQjOW6734XOpqQGf/Kra90wpoasLKZjSYKNPjE+FRUOrStLrxcNs4BeVKhy2PYTRnybfYVk1/dmKgH6P1YSRONsGKvTsH6c5IyCRG0ncCgYeF8tXppyd642982daopE7zQ/NPAnJfag= nocomment`,
number: 2,
keyContents: []string{
"ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQC4cn+iXnA4KvcQYSV88vGn0Yi91vG47t1P7okprVmhNTkipNRIHWr6WdCO4VDr/cvsRkuVJAsLO2enwjGWWueOO6BodiBgyAOZ/5t5nJNMCNuLGT5UIo/RI1b0WRQwxEZTRjt6mFNw6lH14wRd8ulsr9toSWBPMOGWoYs1PDeDL0JuTjL+tr1SZi/EyxCngpYszKdXllJEHyI79KQgeD0Vt3pTrkbNVTOEcCNqZePSVmUH8X8Vhugz3bnE0/iE9Pb5fkWO9c4AnM1FgI/8Bvp27Fw2ShryIXuR6kKvUqhVMTuOSDHwu6A8jLE5Owt3GAYugDpDYuwTVNGrHLXKpPzrGGPE/jPmaLCMZcsdkec95dYeU3zKODEm8UQZFhmJmDeWVJ36nGrGZHL4J5aTTaeFUJmmXDaJYiJ+K2/ioKgXqnXvltu0A9R8/LGy4nrTJRr4JMLuJFoUXvGm1gXQ70w2LSpk6yl71RNC0hCtsBe8BP8IhYCM0EP5jh7eCMQZNvM=",
"ssh-dss AAAAB3NzaC1kc3MAAACBAOChCC7lf6Uo9n7BmZ6M8St19PZf4Tn59NriyboW2x/DZuYAz3ibZ2OkQ3S0SqDIa0HXSEJ1zaExQdmbO+Ux/wsytWZmCczWOVsaszBZSl90q8UnWlSH6P+/YA+RWJm5SFtuV9PtGIhyZgoNuz5kBQ7K139wuQsecdKktISwTakzAAAAFQCzKsO2JhNKlL+wwwLGOcLffoAmkwAAAIBpK7/3xvduajLBD/9vASqBQIHrgK2J+wiQnIb/Wzy0UsVmvfn8A+udRbBo+csM8xrSnlnlJnjkJS3qiM5g+eTwsLIV1IdKPEwmwB+VcP53Cw6lSyWyJcvhFb0N6s08NZysLzvj0N+ZC/FnhKTLzIyMtkHf/IrPCwlM+pV/M/96YgAAAIEAqQcGn9CKgzgPaguIZooTAOQdvBLMI5y0bQjOW6734XOpqQGf/Kra90wpoasLKZjSYKNPjE+FRUOrStLrxcNs4BeVKhy2PYTRnybfYVk1/dmKgH6P1YSRONsGKvTsH6c5IyCRG0ncCgYeF8tXppyd642982daopE7zQ/NPAnJfag=",
},
},
}
for i, kase := range testCases {
s.ID = int64(i) + 20
asymkey_model.AddPublicKeysBySource(t.Context(), user, s, []string{kase.keyString}, false)
keys, err := db.Find[asymkey_model.PublicKey](t.Context(), asymkey_model.FindPublicKeyOptions{
OwnerID: user.ID,
LoginSourceID: s.ID,
})
assert.NoError(t, err)
if err != nil {
continue
}
assert.Len(t, keys, kase.number)
for _, key := range keys {
assert.Contains(t, kase.keyContents, key.Content)
}
for _, key := range keys {
DeletePublicKey(t.Context(), user, key.ID)
}
}
}

View File

@@ -0,0 +1,100 @@
// Copyright 2021 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package attachment
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"net/http"
"uuid"
"gitea.dev/models/db"
repo_model "gitea.dev/models/repo"
"gitea.dev/modules/setting"
"gitea.dev/modules/storage"
"gitea.dev/modules/util"
"gitea.dev/services/context/upload"
)
// NewAttachment creates a new attachment object, but do not verify.
func NewAttachment(ctx context.Context, attach *repo_model.Attachment, file io.Reader, size int64) (*repo_model.Attachment, error) {
if attach.RepoID == 0 {
return nil, fmt.Errorf("attachment %s should belong to a repository", attach.Name)
}
err := db.WithTx(ctx, func(ctx context.Context) error {
attach.UUID = uuid.New().String()
size, err := storage.Attachments.Save(attach.RelativePath(), file, size)
if err != nil {
return fmt.Errorf("Attachments.Save: %w", err)
}
attach.Size = size
return db.Insert(ctx, attach)
})
return attach, err
}
type UploaderFile struct {
rd io.ReadCloser
size int64
respWriter http.ResponseWriter
}
func NewLimitedUploaderKnownSize(r io.Reader, size int64) *UploaderFile {
return &UploaderFile{rd: io.NopCloser(r), size: size}
}
func NewLimitedUploaderMaxBytesReader(r io.ReadCloser, w http.ResponseWriter) *UploaderFile {
return &UploaderFile{rd: r, size: -1, respWriter: w}
}
type UploadAttachmentFunc func(ctx context.Context, file *UploaderFile, attach *repo_model.Attachment) (*repo_model.Attachment, error)
func UploadAttachmentForIssue(ctx context.Context, file *UploaderFile, attach *repo_model.Attachment) (*repo_model.Attachment, error) {
return uploadAttachment(ctx, file, setting.Attachment.AllowedTypes, setting.Attachment.MaxSize<<20, attach)
}
func UploadAttachmentForRelease(ctx context.Context, file *UploaderFile, attach *repo_model.Attachment) (*repo_model.Attachment, error) {
// FIXME: although the release attachment has different settings from the issue attachment,
// it still uses the same attachment table, the same storage and the same upload logic
// So if the "issue attachment [attachment]" is not enabled, it will also affect the release attachment, which is not expected.
return uploadAttachment(ctx, file, setting.Repository.Release.AllowedTypes, setting.Repository.Release.FileMaxSize<<20, attach)
}
func uploadAttachment(ctx context.Context, file *UploaderFile, allowedTypes string, maxFileSize int64, attach *repo_model.Attachment) (*repo_model.Attachment, error) {
src := file.rd
if file.size < 0 {
src = http.MaxBytesReader(file.respWriter, src, maxFileSize)
}
buf := make([]byte, 1024)
n, _ := util.ReadAtMost(src, buf)
buf = buf[:n]
if err := upload.Verify(buf, attach.Name, allowedTypes); err != nil {
return nil, err
}
if maxFileSize >= 0 && file.size > maxFileSize {
return nil, util.ErrorWrap(util.ErrContentTooLarge, "attachment exceeds limit %d", maxFileSize)
}
attach, err := NewAttachment(ctx, attach, io.MultiReader(bytes.NewReader(buf), src), file.size)
if _, ok := errors.AsType[*http.MaxBytesError](err); ok {
return nil, util.ErrorWrap(util.ErrContentTooLarge, "attachment exceeds limit %d", maxFileSize)
}
return attach, err
}
// UpdateAttachment updates an attachment, verifying that its name is among the allowed types.
func UpdateAttachment(ctx context.Context, allowedTypes string, attach *repo_model.Attachment) error {
if err := upload.Verify(nil, attach.Name, allowedTypes); err != nil {
return err
}
return repo_model.UpdateAttachment(ctx, attach)
}

View File

@@ -0,0 +1,45 @@
// Copyright 2021 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package attachment
import (
"os"
"path/filepath"
"testing"
repo_model "gitea.dev/models/repo"
"gitea.dev/models/unittest"
user_model "gitea.dev/models/user"
_ "gitea.dev/models/actions"
"github.com/stretchr/testify/assert"
)
func TestMain(m *testing.M) {
unittest.MainTest(m)
}
func TestUploadAttachment(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
fPath := "./attachment_test.go"
f, err := os.Open(fPath)
assert.NoError(t, err)
defer f.Close()
attach, err := NewAttachment(t.Context(), &repo_model.Attachment{
RepoID: 1,
UploaderID: user.ID,
Name: filepath.Base(fPath),
}, f, -1)
assert.NoError(t, err)
attachment, err := repo_model.GetAttachmentByUUID(t.Context(), attach.UUID)
assert.NoError(t, err)
assert.Equal(t, user.ID, attachment.UploaderID)
assert.Equal(t, int64(0), attachment.DownloadCount)
}

83
services/audit/actor.go Normal file
View File

@@ -0,0 +1,83 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package audit
import (
"context"
user_model "gitea.dev/models/user"
"gitea.dev/modules/web/middleware"
)
type doerContextKeyType struct{}
var doerContextKey doerContextKeyType
type impersonatorContextKeyType struct{}
var impersonatorContextKey impersonatorContextKeyType
// WithImpersonator returns a context that records audit events as performed by
// the doer on behalf of the given admin.
func WithImpersonator(ctx context.Context, impersonator *user_model.User) context.Context {
return context.WithValue(ctx, impersonatorContextKey, impersonator)
}
// WithDoer returns a context that records audit events as the given user.
//
// Web and API requests need this only in unusual cases: the signed-in user is
// already published to the request data store by routers/common.AuthShared and
// is picked up automatically. Use it at entry points that have no signed-in
// user - the CLI, cron tasks, authentication source syncs and git hooks - before
// calling into services that record audit events themselves.
func WithDoer(ctx context.Context, doer *user_model.User) context.Context {
return context.WithValue(ctx, doerContextKey, doer)
}
// doerFromContext resolves the actor of an audit event: an explicit WithDoer
// value wins over the signed-in user of the surrounding request. Returns nil
// when neither is available, which Record turns into an unknown actor.
func doerFromContext(ctx context.Context) *user_model.User {
if doer, ok := ctx.Value(doerContextKey).(*user_model.User); ok && doer != nil {
return doer
}
if data := middleware.GetContextData(ctx); data != nil {
if doer, ok := data[middleware.ContextDataKeySignedUser].(*user_model.User); ok {
return doer
}
}
return nil
}
// credentialFromContext returns the credential the surrounding request
// authenticated with. It is dropped when the event is recorded for someone
// other than the signed-in user, so an explicit actor is never tied to a
// credential that is not theirs.
func credentialFromContext(ctx context.Context, doer *user_model.User) string {
data := middleware.GetContextData(ctx)
if data == nil {
return ""
}
signedUser, _ := data[middleware.ContextDataKeySignedUser].(*user_model.User)
if signedUser == nil || signedUser.ID != doer.ID {
return ""
}
credential, _ := data[middleware.ContextDataKeyAuthCredential].(string)
return credential
}
// ImpersonatorFromContext resolves the admin acting as the doer, so an event
// recorded during an impersonated session cannot be pinned on the impersonated
// user alone. Returns nil for ordinary sessions.
func ImpersonatorFromContext(ctx context.Context) *user_model.User {
if impersonator, ok := ctx.Value(impersonatorContextKey).(*user_model.User); ok && impersonator != nil {
return impersonator
}
if data := middleware.GetContextData(ctx); data != nil {
if impersonator, ok := data[middleware.ContextDataKeyImpersonator].(*user_model.User); ok {
return impersonator
}
}
return nil
}

View File

@@ -0,0 +1,279 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package audit
import (
"context"
"testing"
audit_model "gitea.dev/models/audit"
repository_model "gitea.dev/models/repo"
user_model "gitea.dev/models/user"
"gitea.dev/modules/reqctx"
"gitea.dev/modules/setting"
"gitea.dev/modules/test"
"gitea.dev/modules/web/middleware"
"github.com/stretchr/testify/assert"
)
func BenchmarkRecordDisabled(b *testing.B) {
defer test.MockVariableValue(&setting.Audit.RecordOutput, setting.AuditRecordOutputDisabled)()
ctx := context.Background()
u := &user_model.User{ID: 1, Name: "user"}
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
RecordAs(ctx, u, audit_model.UserPassword, u)
}
}
func BenchmarkBuildEvent(b *testing.B) {
params := RecordParams{
Action: audit_model.RepositoryMirrorPushAdd,
Actor: audit_model.EntityRef{Type: audit_model.ScopeUser, ID: 1, Name: "actor"},
Scope: audit_model.EntityRef{Type: audit_model.ScopeRepository, ID: 2, Name: "owner/repo"},
Metadata: map[string]any{
"remote_address": "https://example.com/repo.git",
},
}
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = buildEvent(context.Background(), params)
}
}
// newRequestContext mimics what routers/common.AuthShared publishes for a
// signed-in request.
func newRequestContext(t *testing.T, signedIn *user_model.User) context.Context {
t.Helper()
rc := reqctx.NewRequestContextForTest(t)
rc.GetData()[middleware.ContextDataKeySignedUser] = signedIn
return rc
}
func TestBuildEvent(t *testing.T) {
doer := &user_model.User{ID: 2, Name: "Doer"}
u := &user_model.User{ID: 1, Name: "TestUser"}
t.Run("MessageFromTemplate", func(t *testing.T) {
e := buildEvent(context.Background(), RecordParams{
Action: audit_model.UserCreate,
Actor: actorRef(doer),
Scope: ScopeFromUser(u),
})
assert.Equal(t, audit_model.UserCreate, e.Action)
assert.Equal(t, audit_model.EntityRef{Type: audit_model.ScopeUser, ID: 2, Name: "Doer"}, e.Actor())
assert.Equal(t, audit_model.EntityRef{Type: audit_model.ScopeUser, ID: 1, Name: "TestUser"}, e.Scope())
assert.Equal(t, "Created user TestUser.", e.Message)
})
t.Run("MetadataFillsPlaceholders", func(t *testing.T) {
r := &repository_model.Repository{ID: 3, Name: "TestRepo", OwnerName: "TestUser"}
m := &repository_model.PushMirror{ID: 4, RemoteAddress: "git@example.com:repo.git"}
e := buildEvent(context.Background(), RecordParams{
Action: audit_model.RepositoryMirrorPushAdd,
Actor: actorRef(doer),
Scope: ScopeFromRepository(r),
Metadata: metaPairs(
"mirror_id", m.ID,
"remote_address", m.RemoteAddress,
),
})
assert.Equal(t, "TestUser/TestRepo", e.ScopeName)
assert.Equal(t, "Added push mirror to git@example.com:repo.git for repository TestUser/TestRepo.", e.Message)
assert.InDelta(t, float64(m.ID), audit_model.DecodeMetadata(e.Metadata)["mirror_id"], 0)
})
t.Run("StatusChangesIncludeTheirNewValue", func(t *testing.T) {
e := buildEvent(context.Background(), RecordParams{
Action: audit_model.UserRestricted,
Actor: actorRef(doer),
Scope: ScopeFromUser(u),
Metadata: metaPairs("restricted", true),
})
assert.Equal(t, "Changed restricted status of user TestUser to true.", e.Message)
})
t.Run("SystemActorNamesTaskOrKey", func(t *testing.T) {
actions := user_model.NewActionsUserWithTaskID(42)
e := buildEvent(context.Background(), RecordParams{
Action: audit_model.UserCreate,
Actor: actorRef(actions),
ActorCredential: actorCredential(context.Background(), actions),
Scope: ScopeFromUser(u),
})
assert.Equal(t, user_model.ActionsUserID, e.ActorID)
assert.Equal(t, "gitea-actions:42", e.ActorCredential)
key := user_model.NewDeployKeyUserWithKeyID(7)
assert.Equal(t, "deploy-key:7", actorCredential(context.Background(), key))
assert.Empty(t, actorCredential(context.Background(), doer))
})
t.Run("CredentialFromRequest", func(t *testing.T) {
ctx := newRequestContext(t, doer)
middleware.GetContextData(ctx)[middleware.ContextDataKeyAuthCredential] = "access-token:9"
assert.Equal(t, "access-token:9", actorCredential(ctx, doer))
// an event recorded for someone other than the signed-in user is not
// tied to the credential of that request
assert.Empty(t, actorCredential(ctx, u))
})
t.Run("RequestInfo", func(t *testing.T) {
params := RecordParams{Action: audit_model.UserCreate, Actor: actorRef(doer), Scope: ScopeFromUser(u)}
e := buildEvent(context.Background(), params)
assert.Empty(t, e.IPAddress)
assert.Equal(t, audit_model.OriginSystem, e.Origin)
cliCtx := WithOrigin(context.Background(), audit_model.OriginCLI)
assert.Equal(t, audit_model.OriginCLI, buildEvent(cliCtx, params).Origin)
apiCtx := reqctx.NewRequestContextForTest(t)
SetRequestInfo(apiCtx, audit_model.OriginAPI, "127.0.0.1")
e = buildEvent(apiCtx, params)
assert.Equal(t, "127.0.0.1", e.IPAddress)
assert.Equal(t, audit_model.OriginAPI, e.Origin)
// an explicit origin wins over the one of the surrounding request
systemAPIContext := WithOrigin(apiCtx, audit_model.OriginSystem)
assert.Equal(t, audit_model.OriginSystem, buildEvent(systemAPIContext, params).Origin)
})
}
func TestEntityRefDisplay(t *testing.T) {
ref := audit_model.EntityRef{Type: audit_model.ScopeUser, ID: 1, Name: "TestUser"}
assert.Equal(t, "TestUser", ref.DisplayName())
assert.Equal(t, "/TestUser", ref.HomeLink())
assert.True(t, ref.HasLink())
sys := ScopeSystem()
assert.Equal(t, "System", sys.DisplayName())
assert.Empty(t, sys.HomeLink())
assert.False(t, sys.HasLink())
// a scope whose entity was deleted keeps its ID but has no name to link to
deleted := audit_model.EntityRef{Type: audit_model.ScopeRepository, ID: 3}
assert.Empty(t, deleted.DisplayName())
assert.False(t, deleted.HasLink())
repo := audit_model.EntityRef{Type: audit_model.ScopeRepository, ID: 3, Name: "Test User/Test Repo"}
assert.Equal(t, "/Test%20User/Test%20Repo", repo.HomeLink())
assert.True(t, repo.HasLink())
}
func TestEncodeDecodeMetadata(t *testing.T) {
raw := audit_model.EncodeMetadata(metaPairs("repo_id", int64(42), "repo", "o/r"))
decoded := audit_model.DecodeMetadata(raw)
assert.InDelta(t, 42.0, decoded["repo_id"], 0) // json numbers decode as float64
assert.Equal(t, "o/r", decoded["repo"])
}
func TestDoerFromContext(t *testing.T) {
doer := &user_model.User{ID: 2, Name: "Doer"}
signedIn := &user_model.User{ID: 3, Name: "SignedIn"}
t.Run("NoActor", func(t *testing.T) {
assert.Nil(t, doerFromContext(context.Background()))
})
t.Run("WithDoer", func(t *testing.T) {
assert.Equal(t, doer, doerFromContext(WithDoer(context.Background(), doer)))
})
t.Run("SignedInUserOfRequest", func(t *testing.T) {
ctx := newRequestContext(t, signedIn)
assert.Equal(t, signedIn, doerFromContext(ctx))
})
t.Run("WithDoerWinsOverSignedInUser", func(t *testing.T) {
ctx := WithDoer(newRequestContext(t, signedIn), doer)
assert.Equal(t, doer, doerFromContext(ctx))
})
}
// An impersonated session must never pin an event on the impersonated user
// alone, otherwise an admin could act in someone else's name untraceably.
func TestImpersonatorRef(t *testing.T) {
admin := &user_model.User{ID: 1, Name: "Admin"}
impersonated := &user_model.User{ID: 2, Name: "Impersonated"}
rc := reqctx.NewRequestContextForTest(t)
rc.GetData()[middleware.ContextDataKeySignedUser] = impersonated
rc.GetData()[middleware.ContextDataKeyImpersonator] = admin
assert.Equal(t, admin, ImpersonatorFromContext(rc))
e := buildEvent(rc, RecordParams{
Action: audit_model.UserPassword,
Actor: actorRef(doerFromContext(rc)),
Impersonator: impersonatorRef(ImpersonatorFromContext(rc), doerFromContext(rc)),
Scope: ScopeFromUser(impersonated),
})
assert.Equal(t, int64(2), e.ActorID)
assert.Equal(t, &audit_model.EntityRef{Type: audit_model.ScopeUser, ID: 1, Name: "Admin"}, e.Impersonator())
// an admin acting as themselves is not an impersonation
assert.Nil(t, impersonatorRef(admin, admin))
assert.Nil(t, impersonatorRef(nil, impersonated))
}
// An unresolvable actor must still produce an event, so a missing entry point
// never silently drops security relevant records.
func TestActorRefWithoutDoer(t *testing.T) {
ref := actorRef(nil)
assert.Equal(t, "Unknown", ref.DisplayName())
assert.False(t, ref.HasLink())
}
func TestRenderMessage(t *testing.T) {
actor := audit_model.EntityRef{Type: audit_model.ScopeUser, ID: 1, Name: "Actor"}
scope := audit_model.EntityRef{Type: audit_model.ScopeRepository, ID: 2, Name: "owner/repo"}
t.Run("EveryActionHasATemplate", func(t *testing.T) {
for _, action := range audit_model.AllActions() {
tmpl, ok := audit_model.MessageTemplate(action)
assert.True(t, ok, "action %q has no message template", action)
assert.NotEmpty(t, tmpl, "action %q has empty message template", action)
}
})
t.Run("ReservedPlaceholders", func(t *testing.T) {
assert.Equal(t,
"User Actor started impersonating user owner/repo.",
renderMessage(audit_model.UserImpersonation, actor, scope, nil),
)
})
t.Run("NonStringMetadata", func(t *testing.T) {
assert.Equal(t,
"Removed external login from authentication source 7 for user owner/repo.",
renderMessage(audit_model.UserExternalLoginRemove, actor, scope, map[string]any{"auth_source_id": int64(7)}),
)
})
t.Run("MissingMetadataKeepsTheKey", func(t *testing.T) {
assert.Equal(t,
"Added deploy key deploy_key for repository owner/repo.",
renderMessage(audit_model.RepositoryDeployKeyAdd, actor, scope, nil),
)
})
t.Run("UnknownActionFallsBackToItsName", func(t *testing.T) {
assert.Equal(t, "not:an:action", renderMessage(audit_model.Action("not:an:action"), actor, scope, nil))
})
}
func TestActionFilters(t *testing.T) {
assert.True(t, audit_model.IsActionFilter("user:impersonation"))
assert.True(t, audit_model.IsActionFilter(audit_model.UserImpersonation))
assert.False(t, audit_model.IsActionFilter("user:unknown"))
}

165
services/audit/event.go Normal file
View File

@@ -0,0 +1,165 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package audit
import (
"context"
"time"
audit_model "gitea.dev/models/audit"
user_model "gitea.dev/models/user"
"gitea.dev/modules/log"
"gitea.dev/modules/reqctx"
"gitea.dev/modules/setting"
"gitea.dev/modules/timeutil"
)
// RecordParams describes an audit event. Callers (or domain-specific helpers)
// supply metadata; the message is rendered from the action's template.
type RecordParams struct {
Action audit_model.Action
Actor audit_model.EntityRef
ActorCredential string
Impersonator *audit_model.EntityRef
Scope audit_model.EntityRef
Metadata map[string]any
}
type originContextKeyType struct{}
var originContextKey originContextKeyType
type requestInfoContextKeyType struct{}
var requestInfoContextKey requestInfoContextKeyType
// requestInfo is what an audit event needs to know about the request it was
// recorded for. The routers publish it, so services don't have to reach for the
// request themselves.
type requestInfo struct {
origin audit_model.Origin
ipAddress string
}
// WithOrigin returns a context that records audit events with the given origin.
// It is for entry points that serve no request, eg: the CLI or a cron task.
func WithOrigin(ctx context.Context, origin audit_model.Origin) context.Context {
return context.WithValue(ctx, originContextKey, origin)
}
// SetRequestInfo attributes the audit events recorded while serving a request
// to the given origin and client address.
func SetRequestInfo(store reqctx.RequestDataStore, origin audit_model.Origin, ipAddress string) {
store.SetContextValue(requestInfoContextKey, &requestInfo{origin: origin, ipAddress: ipAddress})
}
func requestInfoFromContext(ctx context.Context) *requestInfo {
info, _ := ctx.Value(requestInfoContextKey).(*requestInfo)
return info
}
func buildEvent(ctx context.Context, params RecordParams) *audit_model.Event {
e := &audit_model.Event{
Action: params.Action,
ActorID: params.Actor.ID,
ActorName: params.Actor.DisplayName(),
ActorCredential: params.ActorCredential,
ScopeType: params.Scope.Type,
ScopeID: params.Scope.ID,
ScopeName: params.Scope.DisplayName(),
Message: renderMessage(params.Action, params.Actor, params.Scope, params.Metadata),
Metadata: audit_model.EncodeMetadata(params.Metadata),
IPAddress: getIPAddress(ctx),
Origin: getOrigin(ctx),
TimestampUnix: timeutil.TimeStamp(time.Now().Unix()),
}
if params.Impersonator != nil {
e.ImpersonatorID = params.Impersonator.ID
e.ImpersonatorName = params.Impersonator.DisplayName()
}
return e
}
func getIPAddress(ctx context.Context) string {
if info := requestInfoFromContext(ctx); info != nil {
return info.ipAddress
}
return ""
}
func getOrigin(ctx context.Context) audit_model.Origin {
if origin, ok := ctx.Value(originContextKey).(audit_model.Origin); ok && origin != "" {
return origin
}
if info := requestInfoFromContext(ctx); info != nil && info.origin != "" {
return info.origin
}
return audit_model.OriginSystem
}
// Record writes an audit event for an action against a scope entity. The actor
// is the signed-in user of the surrounding request, or whoever audit.WithDoer
// named for a background context.
//
// The scope is the affected entity and may be a *user.User,
// *organization.Organization, *repo.Repository, an EntityRef, or nil for an
// instance-wide/system event. Metadata is supplied as alternating
// string-key/value pairs and fills the placeholders of the action's message
// template, so every key a template names must be passed here.
//
// audit.Record(ctx, audit_model.RepositoryArchive, repo)
// audit.Record(ctx, audit_model.RepositoryDeployKeyAdd, repo, "deploy_key", key.Name)
func Record(ctx context.Context, action audit_model.Action, scope any, metadata ...any) {
RecordAs(ctx, doerFromContext(ctx), action, scope, metadata...)
}
// RecordAs is Record with an explicit actor, for the few call sites where the
// acting user is not the one the context resolves to.
func RecordAs(ctx context.Context, doer *user_model.User, action audit_model.Action, scope any, metadata ...any) {
writeEvent(ctx, RecordParams{
Action: action,
Actor: actorRef(doer),
ActorCredential: actorCredential(ctx, doer),
Impersonator: impersonatorRef(ImpersonatorFromContext(ctx), doer),
Scope: scopeRef(scope),
Metadata: metaPairs(metadata...),
})
}
// writeEvent persists an audit event when audit logging is enabled.
func writeEvent(ctx context.Context, params RecordParams) {
if !setting.AuditRecordEnabled() {
return
}
e := buildEvent(ctx, params)
if err := audit_model.InsertEvent(ctx, e); err != nil {
log.Error("Error writing audit event action=%s actor=%s scope=%s/%d to database: %v", e.Action, e.ActorName, e.ScopeType, e.ScopeID, err)
}
}
func FindEvents(ctx context.Context, opts *audit_model.EventSearchOptions) ([]*audit_model.Event, int64, error) {
return audit_model.FindEvents(ctx, opts)
}
// metaPairs builds caller-defined metadata from alternating string-key/value
// pairs. Keys should be stable for log parsers. A non-string key is skipped and
// logged rather than panicking: audit recording must never crash the request
// that triggered it.
func metaPairs(pairs ...any) map[string]any {
if len(pairs) == 0 {
return nil
}
m := make(map[string]any, len(pairs)/2)
for i := 0; i+1 < len(pairs); i += 2 {
key, ok := pairs[i].(string)
if !ok {
log.Error("audit: metadata key must be string, got %T; skipping pair", pairs[i])
continue
}
m[key] = pairs[i+1]
}
return m
}

25
services/audit/export.go Normal file
View File

@@ -0,0 +1,25 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package audit
import (
"io"
audit_model "gitea.dev/models/audit"
"gitea.dev/modules/json"
)
// WriteEventsAsJSON writes one JSON object per line.
func WriteEventsAsJSON(w io.Writer, events []*audit_model.Event) error {
for _, event := range events {
b, err := json.Marshal(event)
if err != nil {
return err
}
if _, err := w.Write(append(b, '\n')); err != nil {
return err
}
}
return nil
}

View File

@@ -0,0 +1,48 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package audit
import (
"strings"
"testing"
"time"
audit_model "gitea.dev/models/audit"
repository_model "gitea.dev/models/repo"
user_model "gitea.dev/models/user"
"gitea.dev/modules/reqctx"
"gitea.dev/modules/timeutil"
"github.com/stretchr/testify/assert"
)
func TestWriteEventsAsJSON(t *testing.T) {
r := &repository_model.Repository{ID: 3, Name: "TestRepo", OwnerName: "TestUser"}
m := &repository_model.PushMirror{ID: 4, RemoteAddress: "git@example.com:repo.git"}
doer := &user_model.User{ID: 2, Name: "Doer"}
ctx := reqctx.NewRequestContextForTest(t)
SetRequestInfo(ctx, audit_model.OriginUI, "127.0.0.1")
e := buildEvent(ctx, RecordParams{
Action: audit_model.RepositoryMirrorPushAdd,
Actor: actorRef(doer),
Scope: ScopeFromRepository(r),
Metadata: metaPairs(
"mirror_id", m.ID,
"remote_address", m.RemoteAddress,
),
})
e.TimestampUnix = timeutil.TimeStamp(time.Time{}.Unix())
sb := strings.Builder{}
assert.NoError(t, WriteEventsAsJSON(&sb, []*audit_model.Event{e, e}))
out := sb.String()
assert.Equal(t, 2, strings.Count(out, "\n"))
assert.Contains(t, out, `"action":"repository:mirror:push:add"`)
assert.Contains(t, out, `"name":"Doer"`)
assert.Contains(t, out, `"metadata"`)
assert.Contains(t, out, `"remote_address":"git@example.com:repo.git"`)
assert.Contains(t, out, `"ip_address":"127.0.0.1"`)
}

View File

@@ -0,0 +1,14 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package audit
import (
"testing"
"gitea.dev/models/unittest"
)
func TestMain(m *testing.M) {
unittest.MainTest(m)
}

66
services/audit/message.go Normal file
View File

@@ -0,0 +1,66 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package audit
import (
"fmt"
"strings"
audit_model "gitea.dev/models/audit"
"gitea.dev/modules/log"
)
// Reserved placeholders, filled from the event itself rather than from metadata.
const (
placeholderScope = "scope"
placeholderActor = "actor"
)
// renderMessage fills the action's template from the event's scope, actor and
// metadata. A missing template or an unresolved placeholder is logged and
// rendered as the bare key: audit recording must never fail the request that
// triggered it, and a partial message is more useful than none.
func renderMessage(action audit_model.Action, actor, scope audit_model.EntityRef, metadata map[string]any) string {
tmpl, ok := audit_model.MessageTemplate(action)
if !ok {
log.Error("audit: no message template for action %q", action)
return string(action)
}
var sb strings.Builder
rest := tmpl
for {
start := strings.IndexByte(rest, '{')
if start < 0 {
break
}
end := strings.IndexByte(rest[start:], '}')
if end < 0 {
break
}
end += start
key := rest[start+1 : end]
sb.WriteString(rest[:start])
sb.WriteString(resolvePlaceholder(action, key, actor, scope, metadata))
rest = rest[end+1:]
}
sb.WriteString(rest)
return sb.String()
}
func resolvePlaceholder(action audit_model.Action, key string, actor, scope audit_model.EntityRef, metadata map[string]any) string {
switch key {
case placeholderScope:
return scope.DisplayName()
case placeholderActor:
return actor.DisplayName()
}
if v, ok := metadata[key]; ok {
return fmt.Sprint(v)
}
log.Error("audit: action %q has no metadata for placeholder %q", action, key)
return key
}

154
services/audit/notifier.go Normal file
View File

@@ -0,0 +1,154 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package audit
import (
"context"
"fmt"
audit_model "gitea.dev/models/audit"
issues_model "gitea.dev/models/issues"
repo_model "gitea.dev/models/repo"
user_model "gitea.dev/models/user"
"gitea.dev/modules/log"
notify_service "gitea.dev/services/notify"
)
func init() {
notify_service.RegisterNotifier(new(auditNotifier))
}
type auditNotifier struct {
notify_service.NullNotifier
}
var _ notify_service.Notifier = new(auditNotifier)
func (n *auditNotifier) CreateRepository(ctx context.Context, doer, _ *user_model.User, repo *repo_model.Repository) {
RecordAs(ctx, doer, audit_model.RepositoryCreate, repo)
}
func (n *auditNotifier) ForkRepository(ctx context.Context, doer *user_model.User, oldRepo, repo *repo_model.Repository) {
RecordAs(ctx, doer, audit_model.RepositoryCreateFork, repo, "base_repo", oldRepo.FullName())
}
func (n *auditNotifier) RenameRepository(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, oldRepoName string) {
RecordAs(ctx, doer, audit_model.RepositoryName, repo, "previous_name", oldRepoName)
}
func (n *auditNotifier) TransferRepository(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, oldOwnerName string) {
RecordAs(ctx, doer, audit_model.RepositoryTransferFinish, repo, "old_owner", oldOwnerName, "new_owner", repo.OwnerName)
}
func (n *auditNotifier) RepoPendingTransfer(ctx context.Context, doer, newOwner *user_model.User, repo *repo_model.Repository) {
RecordAs(ctx, doer, audit_model.RepositoryTransferStart, repo, "new_owner", newOwner.Name)
}
func (n *auditNotifier) ChangeDefaultBranch(ctx context.Context, repo *repo_model.Repository) {
Record(ctx, audit_model.RepositoryBranchDefault, repo, "default_branch", repo.DefaultBranch)
}
func issueLabel(issue *issues_model.Issue) string {
return fmt.Sprintf("#%d", issue.Index)
}
func loadIssueRepo(ctx context.Context, issue *issues_model.Issue) *repo_model.Repository {
if issue.Repo == nil {
if err := issue.LoadRepo(ctx); err != nil {
log.Error("audit: LoadRepo for issue %d: %v", issue.ID, err)
return nil
}
}
return issue.Repo
}
func issueOrPR(issue *issues_model.Issue, issueAction, prAction audit_model.Action) (audit_model.Action, string) {
if issue.IsPull {
return prAction, "pull_request"
}
return issueAction, "issue"
}
// issueMeta keeps the label, ID and title keys of every issue and pull request
// event consistent; the ID is always the issue row ID the label refers to.
func issueMeta(issue *issues_model.Issue, key string) []any {
return []any{key, issueLabel(issue), key + "_id", issue.ID, "title", issue.Title}
}
func (n *auditNotifier) NewIssue(ctx context.Context, issue *issues_model.Issue, _ []*user_model.User) {
repo := loadIssueRepo(ctx, issue)
if repo == nil {
return
}
RecordAs(ctx, issue.Poster, audit_model.IssueCreate, repo, issueMeta(issue, "issue")...)
}
func (n *auditNotifier) DeleteIssue(ctx context.Context, doer *user_model.User, issue *issues_model.Issue) {
repo := loadIssueRepo(ctx, issue)
if repo == nil {
return
}
action, key := issueOrPR(issue, audit_model.IssueDelete, audit_model.PullRequestDelete)
RecordAs(ctx, doer, action, repo, issueMeta(issue, key)...)
}
func (n *auditNotifier) NewPullRequest(ctx context.Context, pr *issues_model.PullRequest, _ []*user_model.User) {
if pr.Issue == nil {
if err := pr.LoadIssue(ctx); err != nil {
log.Error("audit: LoadIssue for pull request %d: %v", pr.ID, err)
return
}
}
repo := loadIssueRepo(ctx, pr.Issue)
if repo == nil {
return
}
RecordAs(ctx, pr.Issue.Poster, audit_model.PullRequestCreate, repo, issueMeta(pr.Issue, "pull_request")...)
}
func (n *auditNotifier) MergePullRequest(ctx context.Context, doer *user_model.User, pr *issues_model.PullRequest) {
if pr.Issue == nil {
if err := pr.LoadIssue(ctx); err != nil {
log.Error("audit: LoadIssue for pull request %d: %v", pr.ID, err)
return
}
}
repo := loadIssueRepo(ctx, pr.Issue)
if repo == nil {
return
}
RecordAs(ctx, doer, audit_model.PullRequestMerge, repo, issueMeta(pr.Issue, "pull_request")...)
}
func (n *auditNotifier) CreateIssueComment(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, issue *issues_model.Issue, comment *issues_model.Comment, _ []*user_model.User) {
action, key := issueOrPR(issue, audit_model.IssueCommentCreate, audit_model.PullRequestCommentCreate)
RecordAs(ctx, doer, action, repo, key, issueLabel(issue), "comment_id", comment.ID)
}
func (n *auditNotifier) DeleteComment(ctx context.Context, doer *user_model.User, comment *issues_model.Comment) {
if comment.Issue == nil {
if err := comment.LoadIssue(ctx); err != nil {
log.Error("audit: LoadIssue for comment %d: %v", comment.ID, err)
return
}
}
repo := loadIssueRepo(ctx, comment.Issue)
if repo == nil {
return
}
action, key := issueOrPR(comment.Issue, audit_model.IssueCommentDelete, audit_model.PullRequestCommentDelete)
RecordAs(ctx, doer, action, repo, key, issueLabel(comment.Issue), "comment_id", comment.ID)
}
func (n *auditNotifier) NewWikiPage(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, page, _ string) {
RecordAs(ctx, doer, audit_model.WikiPageCreate, repo, "page", page)
}
func (n *auditNotifier) EditWikiPage(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, page, _ string) {
RecordAs(ctx, doer, audit_model.WikiPageUpdate, repo, "page", page)
}
func (n *auditNotifier) DeleteWikiPage(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, page string) {
RecordAs(ctx, doer, audit_model.WikiPageDelete, repo, "page", page)
}

View File

@@ -0,0 +1,81 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package audit
import (
"testing"
audit_model "gitea.dev/models/audit"
issues_model "gitea.dev/models/issues"
repo_model "gitea.dev/models/repo"
"gitea.dev/models/unittest"
user_model "gitea.dev/models/user"
"gitea.dev/modules/reqctx"
"gitea.dev/modules/setting"
"gitea.dev/modules/test"
"github.com/stretchr/testify/assert"
)
func TestAuditNotifier(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
defer test.MockVariableValue(&setting.Audit.RecordOutput, setting.AuditRecordOutputDatabase)()
doer := &user_model.User{ID: 2, Name: "doer"}
repo := &repo_model.Repository{ID: 1, OwnerName: "owner", Name: "repo"}
ctx := reqctx.NewRequestContextForTest(t)
SetRequestInfo(ctx, audit_model.OriginUI, "127.0.0.1")
notifier := new(auditNotifier)
issue := &issues_model.Issue{ID: 10, Index: 5, Title: "Issue title", Poster: doer, Repo: repo}
notifier.NewIssue(ctx, issue, nil)
unittest.AssertExistsAndLoadBean(t, &audit_model.Event{
Action: audit_model.IssueCreate,
ScopeType: audit_model.ScopeRepository,
ScopeID: repo.ID,
Origin: audit_model.OriginUI,
})
pr := &issues_model.PullRequest{ID: 11, Issue: &issues_model.Issue{ID: 12, Index: 6, Title: "PR title", Poster: doer, Repo: repo, IsPull: true}}
notifier.NewPullRequest(ctx, pr, nil)
unittest.AssertExistsAndLoadBean(t, &audit_model.Event{
Action: audit_model.PullRequestCreate,
ScopeType: audit_model.ScopeRepository,
ScopeID: repo.ID,
Origin: audit_model.OriginUI,
})
notifier.NewWikiPage(ctx, doer, repo, "Home", "")
unittest.AssertExistsAndLoadBean(t, &audit_model.Event{
Action: audit_model.WikiPageCreate,
ScopeType: audit_model.ScopeRepository,
ScopeID: repo.ID,
Origin: audit_model.OriginUI,
})
notifier.CreateRepository(ctx, doer, doer, repo)
unittest.AssertExistsAndLoadBean(t, &audit_model.Event{
Action: audit_model.RepositoryCreate,
ActorID: doer.ID,
ScopeType: audit_model.ScopeRepository,
ScopeID: repo.ID,
Origin: audit_model.OriginUI,
})
notifier.TransferRepository(ctx, doer, repo, "previous_owner")
unittest.AssertExistsAndLoadBean(t, &audit_model.Event{
Action: audit_model.RepositoryTransferFinish,
ActorID: doer.ID,
ScopeType: audit_model.ScopeRepository,
ScopeID: repo.ID,
Message: "Transferred repository owner/repo from previous_owner to owner.",
})
notifier.ChangeDefaultBranch(ctx, repo)
unittest.AssertExistsAndLoadBean(t, &audit_model.Event{
Action: audit_model.RepositoryBranchDefault,
ScopeType: audit_model.ScopeRepository,
ScopeID: repo.ID,
})
}

109
services/audit/refs.go Normal file
View File

@@ -0,0 +1,109 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package audit
import (
"context"
audit_model "gitea.dev/models/audit"
repository_model "gitea.dev/models/repo"
user_model "gitea.dev/models/user"
"gitea.dev/modules/log"
"gitea.dev/modules/setting"
)
// actorRef builds the actor reference of an event. An unresolvable actor means
// an entry point neither runs inside an authenticated request nor called
// WithDoer; record the event with an "Unknown" actor rather than dropping it,
// and log so the missing entry point is visible.
func actorRef(doer *user_model.User) audit_model.EntityRef {
if doer == nil {
log.Error("audit: no actor in context, recording event as unknown actor")
return audit_model.EntityRef{Type: audit_model.ScopeUser, Name: "Unknown"}
}
return audit_model.EntityRef{Type: audit_model.ScopeUser, ID: doer.ID, Name: doer.Name}
}
// actorCredential names what the actor acted with: the task or key behind a
// system user, otherwise the token the surrounding request authenticated with.
// An incident then traces one credential across every event it produced,
// instead of stopping at the account that owns it.
func actorCredential(ctx context.Context, doer *user_model.User) string {
if doer == nil {
return ""
}
if doer.ExtDoerData != nil {
return doer.ExtDoerData.EncodeToString()
}
return credentialFromContext(ctx, doer)
}
// impersonatorRef names the admin behind an impersonated session. It is dropped
// when the actor is the admin themselves, so events an admin performs before
// entering or after leaving an impersonation are not marked as impersonated.
func impersonatorRef(impersonator, doer *user_model.User) *audit_model.EntityRef {
if impersonator == nil || (doer != nil && impersonator.ID == doer.ID) {
return nil
}
return &audit_model.EntityRef{Type: audit_model.ScopeUser, ID: impersonator.ID, Name: impersonator.Name}
}
func ScopeFromUser(u *user_model.User) audit_model.EntityRef {
if u == nil {
return audit_model.EntityRef{}
}
if u.IsOrganization() {
return audit_model.EntityRef{Type: audit_model.ScopeOrganization, ID: u.ID, Name: u.Name}
}
return audit_model.EntityRef{Type: audit_model.ScopeUser, ID: u.ID, Name: u.Name}
}
// ScopeFromUserID resolves the scope of a user known only by ID, for call sites
// that would otherwise load the user solely to name it. It costs nothing while
// audit logging is off, and a failed lookup still yields a usable scope so the
// event is never dropped.
func ScopeFromUserID(ctx context.Context, id int64) audit_model.EntityRef {
ref := audit_model.EntityRef{Type: audit_model.ScopeUser, ID: id}
if !setting.AuditRecordEnabled() {
return ref
}
u, err := user_model.GetUserByID(ctx, id)
if err != nil {
log.Error("audit: GetUserByID(%d): %v", id, err)
return ref
}
return ScopeFromUser(u)
}
func ScopeFromRepository(repo *repository_model.Repository) audit_model.EntityRef {
if repo == nil {
return audit_model.EntityRef{}
}
return audit_model.EntityRef{Type: audit_model.ScopeRepository, ID: repo.ID, Name: repo.FullName()}
}
func ScopeSystem() audit_model.EntityRef {
return audit_model.EntityRef{Type: audit_model.ScopeSystem}
}
// scopeRef derives an EntityRef from the affected entity passed to Record.
// Supported types: *user_model.User, *repository_model.Repository, EntityRef,
// or nil for an instance-wide event.
func scopeRef(scope any) audit_model.EntityRef {
switch s := scope.(type) {
case nil:
return ScopeSystem()
case audit_model.EntityRef:
return s
case *user_model.User:
return ScopeFromUser(s)
case *repository_model.Repository:
return ScopeFromRepository(s)
default:
// Audit recording must never crash the request that triggered it; record
// a system-scoped event instead of panicking on an unexpected type.
log.Error("audit: unsupported scope type %T; recording as system scope", scope)
return ScopeSystem()
}
}

122
services/audit/scoped.go Normal file
View File

@@ -0,0 +1,122 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package audit
import (
"context"
audit_model "gitea.dev/models/audit"
repository_model "gitea.dev/models/repo"
user_model "gitea.dev/models/user"
"gitea.dev/modules/log"
)
// ScopedActions holds the action variants for a resource that can be owned by a
// repository, organization, user or the instance itself. RecordScoped selects
// the matching one based on the owner/repo passed at the call site.
type ScopedActions struct {
Repo audit_model.Action
Org audit_model.Action
User audit_model.Action
System audit_model.Action
}
var (
SecretAdd = ScopedActions{
Repo: audit_model.RepositorySecretAdd,
Org: audit_model.OrganizationSecretAdd,
User: audit_model.UserSecretAdd,
}
SecretUpdate = ScopedActions{
Repo: audit_model.RepositorySecretUpdate,
Org: audit_model.OrganizationSecretUpdate,
User: audit_model.UserSecretUpdate,
}
SecretRemove = ScopedActions{
Repo: audit_model.RepositorySecretRemove,
Org: audit_model.OrganizationSecretRemove,
User: audit_model.UserSecretRemove,
}
OAuth2ApplicationAdd = ScopedActions{
User: audit_model.UserOAuth2ApplicationAdd,
Org: audit_model.OrganizationOAuth2ApplicationAdd,
System: audit_model.SystemOAuth2ApplicationAdd,
}
OAuth2ApplicationUpdate = ScopedActions{
User: audit_model.UserOAuth2ApplicationUpdate,
Org: audit_model.OrganizationOAuth2ApplicationUpdate,
System: audit_model.SystemOAuth2ApplicationUpdate,
}
OAuth2ApplicationSecret = ScopedActions{
User: audit_model.UserOAuth2ApplicationSecret,
Org: audit_model.OrganizationOAuth2ApplicationSecret,
System: audit_model.SystemOAuth2ApplicationSecret,
}
OAuth2ApplicationRemove = ScopedActions{
User: audit_model.UserOAuth2ApplicationRemove,
Org: audit_model.OrganizationOAuth2ApplicationRemove,
System: audit_model.SystemOAuth2ApplicationRemove,
}
OAuth2ApplicationRevoke = ScopedActions{
User: audit_model.UserOAuth2ApplicationRevoke,
}
WebhookAdd = ScopedActions{
Repo: audit_model.RepositoryWebhookAdd,
Org: audit_model.OrganizationWebhookAdd,
User: audit_model.UserWebhookAdd,
System: audit_model.SystemWebhookAdd,
}
WebhookUpdate = ScopedActions{
Repo: audit_model.RepositoryWebhookUpdate,
Org: audit_model.OrganizationWebhookUpdate,
User: audit_model.UserWebhookUpdate,
System: audit_model.SystemWebhookUpdate,
}
WebhookRemove = ScopedActions{
Repo: audit_model.RepositoryWebhookRemove,
Org: audit_model.OrganizationWebhookRemove,
User: audit_model.UserWebhookRemove,
System: audit_model.SystemWebhookRemove,
}
)
// resolveScope maps an (owner, repo) pair to the scoped action and audit scope.
// The rules cover every multi-scope resource (secrets, OAuth2 apps, webhooks):
// a repo wins when set, a nil owner means the instance, otherwise the owner's
// kind decides.
func resolveScope(actions ScopedActions, owner *user_model.User, repo *repository_model.Repository) (audit_model.Action, audit_model.EntityRef) {
switch {
case repo != nil:
return actions.Repo, ScopeFromRepository(repo)
case owner == nil:
return actions.System, ScopeSystem()
case owner.IsOrganization():
return actions.Org, ScopeFromUser(owner)
default:
return actions.User, ScopeFromUser(owner)
}
}
// RecordScoped records an audit event for a resource owned by a repository (repo
// set), organization, user, or the instance (owner nil, repo nil). It picks the
// scoped action and scope; each variant carries its own message template, so the
// wording follows automatically. Metadata is supplied as alternating
// string-key/value pairs, like Record.
func RecordScoped(ctx context.Context, owner *user_model.User, repo *repository_model.Repository, actions ScopedActions, metadata ...any) {
action, scope := resolveScope(actions, owner, repo)
if action == "" {
log.Error("audit: no action configured for scope type %s", scope.Type)
return
}
doer := doerFromContext(ctx)
writeEvent(ctx, RecordParams{
Action: action,
Actor: actorRef(doer),
ActorCredential: actorCredential(ctx, doer),
Scope: scope,
Metadata: metaPairs(metadata...),
})
}

View File

@@ -0,0 +1,70 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package audit
import (
"testing"
audit_model "gitea.dev/models/audit"
repository_model "gitea.dev/models/repo"
user_model "gitea.dev/models/user"
"github.com/stretchr/testify/assert"
)
func TestResolveScope(t *testing.T) {
actions := WebhookAdd
org := &user_model.User{ID: 10, Name: "MyOrg", Type: user_model.UserTypeOrganization}
usr := &user_model.User{ID: 11, Name: "MyUser", Type: user_model.UserTypeIndividual}
repo := &repository_model.Repository{ID: 12, Name: "repo", OwnerName: "MyOrg"}
t.Run("repo wins over owner", func(t *testing.T) {
action, scope := resolveScope(actions, org, repo)
assert.Equal(t, audit_model.RepositoryWebhookAdd, action)
assert.Equal(t, audit_model.ScopeRepository, scope.Type)
assert.Equal(t, "MyOrg/repo", scope.Name)
})
t.Run("organization owner", func(t *testing.T) {
action, scope := resolveScope(actions, org, nil)
assert.Equal(t, audit_model.OrganizationWebhookAdd, action)
assert.Equal(t, audit_model.ScopeOrganization, scope.Type)
assert.Equal(t, "MyOrg", scope.Name)
})
t.Run("user owner", func(t *testing.T) {
action, scope := resolveScope(actions, usr, nil)
assert.Equal(t, audit_model.UserWebhookAdd, action)
assert.Equal(t, audit_model.ScopeUser, scope.Type)
assert.Equal(t, "MyUser", scope.Name)
})
t.Run("system when no owner and no repo", func(t *testing.T) {
action, scope := resolveScope(actions, nil, nil)
assert.Equal(t, audit_model.SystemWebhookAdd, action)
assert.Equal(t, audit_model.ScopeSystem, scope.Type)
})
}
// Audit recording must never crash the request that triggered it.
func TestRecordHelpersNeverPanic(t *testing.T) {
t.Run("metaPairs skips non-string keys", func(t *testing.T) {
var m map[string]any
assert.NotPanics(t, func() {
m = metaPairs("ok", 1, 42 /* bad key */, "value", "second", 2)
})
assert.Equal(t, 1, m["ok"])
assert.Equal(t, 2, m["second"])
assert.Len(t, m, 2) // the pair with the non-string key is dropped
})
t.Run("scopeRef falls back to system on unsupported type", func(t *testing.T) {
var ref audit_model.EntityRef
assert.NotPanics(t, func() {
ref = scopeRef(struct{ Foo string }{Foo: "bar"})
})
assert.Equal(t, audit_model.ScopeSystem, ref.Type)
})
}

68
services/auth/auth.go Normal file
View File

@@ -0,0 +1,68 @@
// Copyright 2014 The Gogs Authors. All rights reserved.
// Copyright 2019 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package auth
import (
"errors"
"fmt"
"net/http"
user_model "gitea.dev/models/user"
"gitea.dev/modules/auth/webauthn"
"gitea.dev/modules/log"
"gitea.dev/modules/optional"
"gitea.dev/modules/session"
"gitea.dev/modules/web/middleware"
user_service "gitea.dev/services/user"
)
type ErrUserAuthMessage string
func (e ErrUserAuthMessage) Error() string {
return string(e)
}
func ErrAsUserAuthMessage(err error) (string, bool) {
if msg, ok := errors.AsType[ErrUserAuthMessage](err); ok {
return msg.Error(), true
}
return "", false
}
// Init should be called exactly once when the application starts to allow plugins
// to allocate necessary resources
func Init() {
webauthn.Init()
}
// handleSignInNonInteractive clears existing session variables and stores new ones for the specified user object
// it is mainly for middleware sign-in which doesn't need user's interaction.
func handleSignInNonInteractive(resp http.ResponseWriter, req *http.Request, sess SessionStore, user *user_model.User) {
// We need to regenerate the session...
newSess, err := session.RegenerateSession(resp, req)
if err != nil {
log.Error(fmt.Sprintf("Error regenerating session: %v", err))
} else {
sess = newSess
}
ClearSessionKeysForSignIn(sess)
err = sess.Set(session.KeyUID, user.ID)
if err != nil {
log.Error(fmt.Sprintf("Error setting session: %v", err))
}
opts := &user_service.UpdateOptions{SetLastLogin: true}
// Language setting of the user overwrites the one previously set
// If the user does not have a locale set, we save the current one.
if len(user.Language) == 0 {
opts.Language = optional.Some(middleware.Locale(resp, req).Language())
}
if err := user_service.UpdateUser(req.Context(), user, opts); err != nil {
log.Error("Error updating user on sign-in [user: %d]: %v", user.ID, err)
}
middleware.SetLocaleCookie(resp, user.Language, 0)
}

113
services/auth/auth_token.go Normal file
View File

@@ -0,0 +1,113 @@
// Copyright 2023 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package auth
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"strings"
"time"
auth_model "gitea.dev/models/auth"
"gitea.dev/modules/setting"
"gitea.dev/modules/timeutil"
"gitea.dev/modules/util"
)
// Based on https://paragonie.com/blog/2015/04/secure-authentication-php-with-long-term-persistence#secure-remember-me-cookies
// The auth token consists of two parts: ID and token hash
// Every device login creates a new auth token with an individual id and hash.
// If a device uses the token to login into the instance, a fresh token gets generated which has the same id but a new hash.
var (
ErrAuthTokenInvalidFormat = util.NewInvalidArgumentErrorf("auth token has an invalid format")
ErrAuthTokenExpired = util.NewInvalidArgumentErrorf("auth token has expired")
ErrAuthTokenInvalidHash = util.NewInvalidArgumentErrorf("auth token is invalid")
)
func CheckAuthToken(ctx context.Context, value string) (*auth_model.AuthToken, error) {
if len(value) == 0 {
return nil, nil //nolint:nilnil // the auth method is not applicable
}
parts := strings.SplitN(value, ":", 2)
if len(parts) != 2 {
return nil, ErrAuthTokenInvalidFormat
}
t, err := auth_model.GetAuthTokenByID(ctx, parts[0])
if err != nil {
if errors.Is(err, util.ErrNotExist) {
return nil, ErrAuthTokenExpired
}
return nil, err
}
if t.ExpiresUnix < timeutil.TimeStampNow() {
return nil, ErrAuthTokenExpired
}
hashedToken := sha256.Sum256([]byte(parts[1]))
if !util.CryptoConstTimeEqual(t.TokenHash, hex.EncodeToString(hashedToken[:])) {
// If an attacker steals a token and uses the token to create a new session the hash gets updated.
// When the victim uses the old token the hashes don't match anymore and the victim should be notified about the compromised token.
// Revoke the token so the attacker's rotated token (which shares this ID) can no longer be used.
if err := auth_model.DeleteAuthTokenByID(ctx, t.ID); err != nil {
return nil, err
}
return nil, ErrAuthTokenInvalidHash
}
return t, nil
}
func RegenerateAuthToken(ctx context.Context, t *auth_model.AuthToken) (*auth_model.AuthToken, string, error) {
token, hash := generateTokenAndHash()
newToken := &auth_model.AuthToken{
ID: t.ID,
TokenHash: hash,
UserID: t.UserID,
ExpiresUnix: timeutil.TimeStampNow().AddDuration(time.Duration(setting.LogInRememberDays*24) * time.Hour),
}
if err := auth_model.UpdateAuthTokenByID(ctx, newToken); err != nil {
return nil, "", err
}
return newToken, token, nil
}
func CreateAuthTokenForUserID(ctx context.Context, userID int64) (*auth_model.AuthToken, string, error) {
t := &auth_model.AuthToken{
UserID: userID,
ExpiresUnix: timeutil.TimeStampNow().AddDuration(time.Duration(setting.LogInRememberDays*24) * time.Hour),
}
t.ID = util.CryptoRandomString(10)
token, hash := generateTokenAndHash()
t.TokenHash = hash
if err := auth_model.InsertAuthToken(ctx, t); err != nil {
return nil, "", err
}
return t, token, nil
}
func generateTokenAndHash() (string, string) {
buf := util.CryptoRandomBytes(32)
token := hex.EncodeToString(buf)
hashedToken := sha256.Sum256([]byte(token))
return token, hex.EncodeToString(hashedToken[:])
}

View File

@@ -0,0 +1,109 @@
// Copyright 2023 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package auth
import (
"testing"
"time"
auth_model "gitea.dev/models/auth"
"gitea.dev/models/unittest"
"gitea.dev/modules/timeutil"
"gitea.dev/modules/util"
"github.com/stretchr/testify/assert"
)
func TestCheckAuthToken(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
t.Run("Empty", func(t *testing.T) {
token, err := CheckAuthToken(t.Context(), "")
assert.NoError(t, err)
assert.Nil(t, token)
})
t.Run("InvalidFormat", func(t *testing.T) {
token, err := CheckAuthToken(t.Context(), "dummy")
assert.ErrorIs(t, err, ErrAuthTokenInvalidFormat)
assert.Nil(t, token)
})
t.Run("NotFound", func(t *testing.T) {
token, err := CheckAuthToken(t.Context(), "notexists:dummy")
assert.ErrorIs(t, err, ErrAuthTokenExpired)
assert.Nil(t, token)
})
t.Run("Expired", func(t *testing.T) {
timeutil.MockSet(time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC))
at, token, err := CreateAuthTokenForUserID(t.Context(), 2)
assert.NoError(t, err)
assert.NotNil(t, at)
assert.NotEmpty(t, token)
timeutil.MockUnset()
at2, err := CheckAuthToken(t.Context(), at.ID+":"+token)
assert.ErrorIs(t, err, ErrAuthTokenExpired)
assert.Nil(t, at2)
assert.NoError(t, auth_model.DeleteAuthTokenByID(t.Context(), at.ID))
})
t.Run("InvalidHash", func(t *testing.T) {
at, token, err := CreateAuthTokenForUserID(t.Context(), 2)
assert.NoError(t, err)
assert.NotNil(t, at)
assert.NotEmpty(t, token)
at2, err := CheckAuthToken(t.Context(), at.ID+":"+token+"dummy")
assert.ErrorIs(t, err, ErrAuthTokenInvalidHash)
assert.Nil(t, at2)
// a hash mismatch signals a compromised token, which must be revoked
_, err = auth_model.GetAuthTokenByID(t.Context(), at.ID)
assert.ErrorIs(t, err, util.ErrNotExist)
})
t.Run("Valid", func(t *testing.T) {
at, token, err := CreateAuthTokenForUserID(t.Context(), 2)
assert.NoError(t, err)
assert.NotNil(t, at)
assert.NotEmpty(t, token)
at2, err := CheckAuthToken(t.Context(), at.ID+":"+token)
assert.NoError(t, err)
assert.NotNil(t, at2)
assert.NoError(t, auth_model.DeleteAuthTokenByID(t.Context(), at.ID))
})
}
func TestRegenerateAuthToken(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
timeutil.MockSet(time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC))
defer timeutil.MockUnset()
at, token, err := CreateAuthTokenForUserID(t.Context(), 2)
assert.NoError(t, err)
assert.NotNil(t, at)
assert.NotEmpty(t, token)
timeutil.MockSet(time.Date(2023, 1, 1, 0, 0, 1, 0, time.UTC))
at2, token2, err := RegenerateAuthToken(t.Context(), at)
assert.NoError(t, err)
assert.NotNil(t, at2)
assert.NotEmpty(t, token2)
assert.Equal(t, at.ID, at2.ID)
assert.Equal(t, at.UserID, at2.UserID)
assert.NotEqual(t, token, token2)
assert.NotEqual(t, at.ExpiresUnix, at2.ExpiresUnix)
assert.NoError(t, auth_model.DeleteAuthTokenByID(t.Context(), at.ID))
}

208
services/auth/basic.go Normal file
View File

@@ -0,0 +1,208 @@
// Copyright 2014 The Gogs Authors. All rights reserved.
// Copyright 2019 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package auth
import (
"errors"
"net/http"
actions_model "gitea.dev/models/actions"
audit_model "gitea.dev/models/audit"
auth_model "gitea.dev/models/auth"
user_model "gitea.dev/models/user"
"gitea.dev/modules/auth/httpauth"
"gitea.dev/modules/log"
"gitea.dev/modules/setting"
"gitea.dev/modules/timeutil"
"gitea.dev/modules/util"
"gitea.dev/services/audit"
)
// Ensure the struct implements the interface.
var (
_ Method = &Basic{}
)
// BasicMethodName is the constant name of the basic authentication method
const (
BasicMethodName = "basic"
AccessTokenMethodName = "access_token"
OAuth2TokenMethodName = "oauth2_token"
ActionTokenMethodName = "action_token"
DeployTokenMethodName = "deploy_token"
)
// Basic implements the Auth interface and authenticates requests (API requests
// only) by looking for Basic authentication data or "x-oauth-basic" token in the "Authorization"
// header.
type Basic struct{}
// Name represents the name of auth method
func (b *Basic) Name() string {
return BasicMethodName
}
func parseAuthBasic(req *http.Request) (ret struct{ authToken, uname, passwd string }) {
authHeader := req.Header.Get("Authorization")
if authHeader == "" {
return ret
}
parsed, ok := httpauth.ParseAuthorizationHeader(authHeader)
if !ok || parsed.BasicAuth == nil {
return ret
}
uname, passwd := parsed.BasicAuth.Username, parsed.BasicAuth.Password
// Check if username or password is a token
isUsernameToken := passwd == "" || passwd == "x-oauth-basic"
// Assume username is token
authToken := uname
if !isUsernameToken {
log.Trace("Basic Authorization: Attempting login for: %s", uname)
// Assume password is token
authToken = passwd
} else {
log.Trace("Basic Authorization: Attempting login with username as token")
}
ret.authToken, ret.uname, ret.passwd = authToken, uname, passwd
return ret
}
// VerifyAuthToken only the access token provided as parameter, used by other auth methods that want to reuse access token verification logic
func (b *Basic) VerifyAuthToken(req *http.Request, w http.ResponseWriter, store DataStore, sess SessionStore, authToken string) (*user_model.User, error) {
// get oauth2 token's user's ID
accessTokenScope, uid, grantID := GetOAuthAccessTokenScopeAndUserID(req.Context(), authToken)
if uid != 0 {
log.Trace("Basic Authorization: Valid OAuthAccessToken for user[%d]", uid)
u, err := user_model.GetUserByID(req.Context(), uid)
if err != nil {
log.Error("GetUserByID: %v", err)
return nil, err
}
store.GetData()["LoginMethod"] = OAuth2TokenMethodName
store.GetData()["ApiTokenScope"] = accessTokenScope
setAuthCredential(store, credentialOAuth2Grant, grantID)
return u, nil
}
// check personal access token
token, err := auth_model.GetAccessTokenBySHA(req.Context(), authToken)
if err == nil {
log.Trace("Basic Authorization: Valid AccessToken for user[%d]", uid)
u, err := user_model.GetUserByID(req.Context(), token.UID)
if err != nil {
log.Error("GetUserByID: %v", err)
return nil, err
}
token.UpdatedUnix = timeutil.TimeStampNow()
if err = auth_model.UpdateAccessToken(req.Context(), token); err != nil {
log.Error("UpdateAccessToken: %v", err)
}
store.GetData()["LoginMethod"] = AccessTokenMethodName
store.GetData()["ApiTokenScope"] = token.Scope
setAuthCredential(store, credentialAccessToken, token.ID)
return u, nil
} else if !errors.Is(err, util.ErrNotExist) {
log.Error("GetAccessTokenBySHA: %v", err)
}
// check task token
task, err := actions_model.GetRunningTaskByToken(req.Context(), authToken)
if err == nil && task != nil {
log.Trace("Basic Authorization: Valid AccessToken for task[%d]", task.ID)
store.GetData()["LoginMethod"] = ActionTokenMethodName
return user_model.NewActionsUserWithTaskID(task.ID), nil
}
return nil, nil //nolint:nilnil // the auth method is not applicable
}
// Verify extracts and validates Basic data (username and password/token) from the
// "Authorization" header of the request and returns the corresponding user object for that
// name/token on successful validation.
// Returns nil if header is empty or validation fails.
func (b *Basic) Verify(req *http.Request, w http.ResponseWriter, store DataStore, sess SessionStore) (*user_model.User, error) {
parseBasicRet := parseAuthBasic(req)
authToken, uname, passwd := parseBasicRet.authToken, parseBasicRet.uname, parseBasicRet.passwd
if authToken == "" && uname == "" {
return nil, nil //nolint:nilnil // the auth method is not applicable
}
u, err := b.VerifyAuthToken(req, w, store, sess, authToken)
if u != nil || err != nil {
return u, err
}
if !setting.Service.EnableBasicAuth {
return nil, nil //nolint:nilnil // the auth method is not applicable
}
log.Trace("Basic Authorization: Attempting SignIn for %s", uname)
u, source, err := UserSignIn(req.Context(), uname, passwd)
if err != nil {
if !user_model.IsErrUserNotExist(err) {
log.Error("UserSignIn: %v", err)
}
return nil, err
}
if !source.TwoFactorShouldSkip() {
// Check if the user has WebAuthn registration
hasWebAuthn, err := auth_model.HasWebAuthnRegistrationsByUID(req.Context(), u.ID)
if err != nil {
return nil, err
}
if hasWebAuthn {
return nil, ErrUserAuthMessage("basic authorization is not allowed while WebAuthn enrolled")
}
if err := validateTOTP(req, u); err != nil {
return nil, err
}
}
store.GetData()["LoginMethod"] = BasicMethodName
log.Trace("Basic Authorization: Logged in user %-v", u)
return u, nil
}
func validateTOTP(req *http.Request, u *user_model.User) error {
twofa, err := auth_model.GetTwoFactorByUID(req.Context(), u.ID)
if err != nil {
if auth_model.IsErrTwoFactorNotEnrolled(err) {
// No 2FA enrollment for this user
return nil
}
return err
}
// Consume the passcode atomically so a captured OTP cannot be replayed within its validity window.
if ok, err := twofa.ValidateAndConsumeTOTP(req.Context(), req.Header.Get("X-Gitea-OTP")); err != nil {
return err
} else if !ok {
audit.RecordAs(req.Context(), u, audit_model.UserAuthenticationFailTwoFactor, u)
return util.NewInvalidArgumentErrorf("invalid provided OTP")
}
return nil
}
func GetAccessScope(store DataStore) auth_model.AccessTokenScope {
if scope, hasApiTokenScope := store.GetData()["ApiTokenScope"].(auth_model.AccessTokenScope); hasApiTokenScope {
return scope
}
switch store.GetData()["LoginMethod"] {
case OAuth2TokenMethodName:
fallthrough
case BasicMethodName, AccessTokenMethodName:
return auth_model.AccessTokenScopeAll
case ActionTokenMethodName:
fallthrough
default:
return ""
}
}

View File

@@ -0,0 +1,21 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package auth
import (
"strconv"
"gitea.dev/modules/web/middleware"
)
// Credential kinds naming how a request authenticated, recorded so an audit
// event can point at the token rather than only at its owner.
const (
credentialAccessToken = "access-token"
credentialOAuth2Grant = "oauth2-grant"
)
func setAuthCredential(store DataStore, kind string, id int64) {
store.GetData()[middleware.ContextDataKeyAuthCredential] = kind + ":" + strconv.FormatInt(id, 10)
}

View File

@@ -0,0 +1,47 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package auth
import (
"net/http"
deploykey_model "gitea.dev/models/deploykey"
user_model "gitea.dev/models/user"
"gitea.dev/modules/log"
)
var _ Method = &DeployToken{}
// DeployToken authenticates a deploy key token given as HTTP basic auth credential.
// Only add it to an auth group where a repo scoped credential makes sense.
type DeployToken struct{}
func (d *DeployToken) Name() string {
return DeployTokenMethodName
}
// Verify returns a user that stands for the deploy key alone. Its permissions come from the key,
// see access_model.getDeployKeyRepoPermission, so the request can never reach another repository
// or exceed the access mode of the key.
func (d *DeployToken) Verify(req *http.Request, _ http.ResponseWriter, store DataStore, _ SessionStore) (*user_model.User, error) {
authToken := parseAuthBasic(req).authToken
if authToken == "" {
return nil, nil //nolint:nilnil // the auth method is not applicable
}
key, err := deploykey_model.VerifyDeployKeyToken(req.Context(), authToken)
if err != nil {
if deploykey_model.IsErrDeployKeyNotExist(err) {
return nil, nil //nolint:nilnil // not a deploy token, let the other methods try
}
return nil, err
}
if err := deploykey_model.UpdateDeployKeyLastUsed(req.Context(), key.ID); err != nil {
log.Error("UpdateDeployKeyUpdated: %v", err)
}
store.GetData()["LoginMethod"] = DeployTokenMethodName
return user_model.NewDeployKeyUserWithKeyID(key.ID), nil
}

72
services/auth/group.go Normal file
View File

@@ -0,0 +1,72 @@
// Copyright 2021 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package auth
import (
"net/http"
"strings"
user_model "gitea.dev/models/user"
)
// Ensure the struct implements the interface.
var (
_ Method = &Group{}
)
// Group implements the Auth interface with serval Auth.
type Group struct {
methods []Method
}
// NewGroup creates a new auth group
func NewGroup(methods ...Method) *Group {
return &Group{
methods: methods,
}
}
// Add adds a new method to group
func (b *Group) Add(method Method) {
b.methods = append(b.methods, method)
}
// Name returns group's methods name
func (b *Group) Name() string {
names := make([]string, 0, len(b.methods))
for _, m := range b.methods {
names = append(names, m.Name())
}
return strings.Join(names, ",")
}
func (b *Group) Verify(req *http.Request, w http.ResponseWriter, store DataStore, sess SessionStore) (*user_model.User, error) {
// Try to sign in with each of the enabled plugins
var retErr error
for _, m := range b.methods {
user, err := m.Verify(req, w, store, sess)
if err != nil {
if retErr == nil {
retErr = err
}
// Try other methods if this one failed.
// Some methods may share the same protocol to detect if they are matched.
// For example, OAuth2 and conan.Auth both read token from "Authorization: Bearer <token>" header,
// If OAuth2 returns error, we should give conan.Auth a chance to try.
continue
}
// If any method returns a user, we can stop trying.
// Return the user and ignore any error returned by previous methods.
if user != nil {
if store.GetData()["AuthedMethod"] == nil {
store.GetData()["AuthedMethod"] = m.Name()
}
return user, nil
}
}
// If no method returns a user, return the error returned by the first method.
return nil, retErr
}

219
services/auth/httpsign.go Normal file
View File

@@ -0,0 +1,219 @@
// Copyright 2021 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package auth
import (
"bytes"
"encoding/base64"
"errors"
"fmt"
"net/http"
"strings"
asymkey_model "gitea.dev/models/asymkey"
"gitea.dev/models/db"
user_model "gitea.dev/models/user"
"gitea.dev/modules/log"
"gitea.dev/modules/setting"
"github.com/42wim/httpsig"
"golang.org/x/crypto/ssh"
)
// Ensure the struct implements the interface.
var (
_ Method = &HTTPSign{}
)
// HTTPSign implements the Auth interface and authenticates requests (API requests
// only) by looking for http signature data in the "Signature" header.
// more information can be found on https://github.com/go-fed/httpsig
type HTTPSign struct{}
// Name represents the name of auth method
func (h *HTTPSign) Name() string {
return "httpsign"
}
// Verify extracts and validates HTTPsign from the Signature header of the request and returns
// the corresponding user object on successful validation.
// Returns nil if header is empty or validation fails.
func (h *HTTPSign) Verify(req *http.Request, w http.ResponseWriter, store DataStore, sess SessionStore) (*user_model.User, error) {
sigHead := req.Header.Get("Signature")
if len(sigHead) == 0 {
return nil, nil //nolint:nilnil // the auth method is not applicable
}
var (
publicKey *asymkey_model.PublicKey
err error
)
if len(req.Header.Get("X-Ssh-Certificate")) != 0 {
// Handle Signature signed by SSH certificates
if len(setting.SSH.TrustedUserCAKeys) == 0 {
return nil, nil //nolint:nilnil // the auth method is not applicable
}
publicKey, err = VerifyCert(req)
if err != nil {
log.Debug("VerifyCert on request from %s: failed: %v", req.RemoteAddr, err)
log.Warn("Failed authentication attempt from %s", req.RemoteAddr)
return nil, nil //nolint:nilnil // the auth method is not applicable
}
} else {
// Handle Signature signed by Public Key
publicKey, err = VerifyPubKey(req)
if err != nil {
log.Debug("VerifyPubKey on request from %s: failed: %v", req.RemoteAddr, err)
log.Warn("Failed authentication attempt from %s", req.RemoteAddr)
return nil, nil //nolint:nilnil // the auth method is not applicable
}
}
u, err := user_model.GetUserByID(req.Context(), publicKey.OwnerID)
if err != nil {
log.Error("GetUserByID: %v", err)
return nil, err
}
log.Trace("HTTP Sign: Logged in user %-v", u)
return u, nil
}
func VerifyPubKey(r *http.Request) (*asymkey_model.PublicKey, error) {
verifier, err := httpsig.NewVerifier(r)
if err != nil {
return nil, fmt.Errorf("httpsig.NewVerifier failed: %s", err)
}
keyID := verifier.KeyId()
publicKeys, err := db.Find[asymkey_model.PublicKey](r.Context(), asymkey_model.FindPublicKeyOptions{
Fingerprint: keyID,
})
if err != nil {
return nil, err
}
if len(publicKeys) == 0 {
return nil, fmt.Errorf("no public key found for keyid %s", keyID)
}
sshPublicKey, _, _, _, err := ssh.ParseAuthorizedKey([]byte(publicKeys[0].Content))
if err != nil {
return nil, err
}
if err := doVerify(verifier, []ssh.PublicKey{sshPublicKey}); err != nil {
return nil, err
}
return publicKeys[0], nil
}
// VerifyCert verifies the validity of the ssh certificate and returns the publickey of the signer
// We verify that the certificate is signed with the correct CA
// We verify that the http request is signed with the private key (of the public key mentioned in the certificate)
func VerifyCert(r *http.Request) (*asymkey_model.PublicKey, error) {
// Get our certificate from the header
bcert, err := base64.RawStdEncoding.DecodeString(r.Header.Get("x-ssh-certificate"))
if err != nil {
return nil, err
}
pk, err := ssh.ParsePublicKey(bcert)
if err != nil {
return nil, err
}
// Check if it's really a ssh certificate
cert, ok := pk.(*ssh.Certificate)
if !ok {
return nil, errors.New("no certificate found")
}
c := &ssh.CertChecker{
IsUserAuthority: func(auth ssh.PublicKey) bool {
marshaled := auth.Marshal()
for _, k := range setting.SSH.TrustedUserCAKeysParsed {
if bytes.Equal(marshaled, k.Marshal()) {
return true
}
}
return false
},
}
// check the CA of the cert
if !c.IsUserAuthority(cert.SignatureKey) {
return nil, errors.New("CA check failed")
}
// Create a verifier
verifier, err := httpsig.NewVerifier(r)
if err != nil {
return nil, fmt.Errorf("httpsig.NewVerifier failed: %s", err)
}
// now verify that this request was signed with the private key that matches the certificate public key
if err := doVerify(verifier, []ssh.PublicKey{cert.Key}); err != nil {
return nil, err
}
// Now for each of the certificate valid principals
for _, principal := range cert.ValidPrincipals {
// Look in the db for the public key
publicKey, err := asymkey_model.SearchPublicKeyByContentExact(r.Context(), principal)
if asymkey_model.IsErrKeyNotExist(err) {
// No public key matches this principal - try the next principal
continue
} else if err != nil {
// this error will be a db error therefore we can't solve this and we should abort
log.Error("SearchPublicKeyByContentExact: %v", err)
return nil, err
}
// Validate the cert for this principal
if err := c.CheckCert(principal, cert); err != nil {
// however, because principal is a member of ValidPrincipals - if this fails then the certificate itself is invalid
return nil, err
}
// OK we have a public key for a principal matching a valid certificate whose key has signed this request.
return publicKey, nil
}
// No public key matching a principal in the certificate is registered in gitea
return nil, errors.New("no valid principal found")
}
// doVerify iterates across the provided public keys attempting the verify the current request against each key in turn
func doVerify(verifier httpsig.Verifier, sshPublicKeys []ssh.PublicKey) error {
for _, publicKey := range sshPublicKeys {
cryptoPublicKey, ok := publicKey.(ssh.CryptoPublicKey)
if !ok {
continue
}
cryptoPubkey := cryptoPublicKey.CryptoPublicKey()
var algos []httpsig.Algorithm
switch {
case strings.HasPrefix(publicKey.Type(), "ssh-ed25519"):
algos = []httpsig.Algorithm{httpsig.ED25519}
case strings.HasPrefix(publicKey.Type(), "ssh-rsa"):
algos = []httpsig.Algorithm{httpsig.RSA_SHA256, httpsig.RSA_SHA512}
}
for _, algo := range algos {
if err := verifier.Verify(cryptoPubkey, algo); err == nil {
return nil
}
}
}
return errors.New("verification failed")
}

View File

@@ -0,0 +1,73 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package auth
import (
"errors"
"fmt"
user_model "gitea.dev/models/user"
"gitea.dev/modules/session"
)
func ImpersonateUser(sess SessionStore, u *user_model.User) error {
if sess.Get(session.KeyImpersonatorData) != nil {
return errors.New("already impersonating a user")
}
// TODO: in the future, we need to process all sessions keys, but the session store doesn't have the ability to list keys
// So we need to refactor all "Session.Get" to use consts, then we can enumerate the pre-defined keys.
backupKeys := []string{session.KeyUID, session.KeyUserHasTwoFactorAuth}
backup := map[string]any{}
for _, key := range backupKeys {
v := sess.Get(key)
if v != nil {
backup[key] = v
}
}
err := sess.Set(session.KeyImpersonatorData, backup)
if err != nil {
return fmt.Errorf("set impersonator data: %w", err)
}
ClearSessionKeysForSignIn(sess)
data := map[string]any{}
data[session.KeyUID] = u.ID
data[session.KeyUserHasTwoFactorAuth] = true // since we are impersonating, we don't want to require 2FA for the impersonated user
for k, v := range data {
if err = sess.Set(k, v); err != nil {
return fmt.Errorf("set session data: %w", err)
}
}
return sess.Release()
}
// ImpersonatorUserID returns the ID of the admin behind an impersonated
// session, or zero when the session is not impersonating anyone.
func ImpersonatorUserID(sess SessionStore) int64 {
data, ok := sess.Get(session.KeyImpersonatorData).(map[string]any)
if !ok {
return 0
}
uid, _ := data[session.KeyUID].(int64)
return uid
}
func ExitImpersonatedUser(sess SessionStore) (bool, error) {
impersonatorData, ok := sess.Get(session.KeyImpersonatorData).(map[string]any)
if !ok {
return false, nil
}
err := sess.Delete(session.KeyImpersonatorData)
if err != nil {
return false, fmt.Errorf("delete impersonator data: %w", err)
}
ClearSessionKeysForSignIn(sess)
for k, v := range impersonatorData {
if err = sess.Set(k, v); err != nil {
return false, fmt.Errorf("set impersonator data: %w", err)
}
}
return true, sess.Release()
}

View File

@@ -0,0 +1,41 @@
// Copyright 2019 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package auth
import (
"context"
"net/http"
user_model "gitea.dev/models/user"
"gitea.dev/modules/reqctx"
"gitea.dev/modules/session"
)
type DataStore = reqctx.ContextDataProvider
// SessionStore represents a session store
type SessionStore session.Store
// Method represents an authentication method (plugin) for HTTP requests.
type Method interface {
// Verify tries to verify the authentication data contained in the request.
// If verification succeeds, it returns either an existing user object (with id > 0)
// or a new user object (with id = 0) populated with the information that was found
// in the authentication data (username or email).
// Second argument returns err if verification fails, otherwise
// First return argument returns nil if no matched verification condition
Verify(http *http.Request, w http.ResponseWriter, store DataStore, sess SessionStore) (*user_model.User, error)
Name() string
}
// PasswordAuthenticator represents a source of authentication
type PasswordAuthenticator interface {
Authenticate(ctx context.Context, user *user_model.User, login, password string) (*user_model.User, error)
}
// SynchronizableSource represents a source that can synchronize users
type SynchronizableSource interface {
Sync(ctx context.Context, updateExisting bool) error
}

View File

@@ -0,0 +1,14 @@
// Copyright 2023 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package auth
import (
"testing"
"gitea.dev/models/unittest"
)
func TestMain(m *testing.M) {
unittest.MainTest(m)
}

165
services/auth/oauth2.go Normal file
View File

@@ -0,0 +1,165 @@
// Copyright 2014 The Gogs Authors. All rights reserved.
// Copyright 2019 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package auth
import (
"context"
"errors"
"net/http"
"strings"
"time"
actions_model "gitea.dev/models/actions"
auth_model "gitea.dev/models/auth"
user_model "gitea.dev/models/user"
"gitea.dev/modules/auth/httpauth"
"gitea.dev/modules/log"
"gitea.dev/modules/setting"
"gitea.dev/modules/timeutil"
"gitea.dev/modules/util"
"gitea.dev/services/actions"
"gitea.dev/services/oauth2_provider"
)
var _ Method = &OAuth2{}
// GetOAuthAccessTokenScopeAndUserID returns access token scope, user id and the
// grant the token was issued for.
func GetOAuthAccessTokenScopeAndUserID(ctx context.Context, accessToken string) (_ auth_model.AccessTokenScope, userID, grantID int64) {
var accessTokenScope auth_model.AccessTokenScope
if !setting.OAuth2.Enabled {
return accessTokenScope, 0, 0
}
// JWT tokens require a ".", if the token isn't like that, return early
if !strings.Contains(accessToken, ".") {
return accessTokenScope, 0, 0
}
token, err := oauth2_provider.ParseToken(accessToken, oauth2_provider.DefaultSigningKey)
if err != nil {
log.Trace("oauth2.ParseToken: %v", err)
return accessTokenScope, 0, 0
}
var grant *auth_model.OAuth2Grant
if grant, err = auth_model.GetOAuth2GrantByID(ctx, token.GrantID); err != nil || grant == nil {
return accessTokenScope, 0, 0
}
if token.Kind != oauth2_provider.KindAccessToken {
return accessTokenScope, 0, 0
}
if token.ExpiresAt.Before(time.Now()) || token.IssuedAt.After(time.Now()) {
return accessTokenScope, 0, 0
}
accessTokenScope = oauth2_provider.GrantAdditionalScopes(grant.Scope)
return accessTokenScope, grant.UserID, grant.ID
}
// CheckTaskIsRunning verifies that the TaskID corresponds to a running task
func CheckTaskIsRunning(ctx context.Context, taskID int64) bool {
// Verify the task exists
task, err := actions_model.GetTaskByID(ctx, taskID)
if err != nil {
return false
}
// Verify that it's running
return task.Status == actions_model.StatusRunning
}
// OAuth2 implements the Auth interface and authenticates requests
// (API requests only) by looking for an OAuth token in query parameters or the
// "Authorization" header.
type OAuth2 struct{}
// Name represents the name of auth method
func (o *OAuth2) Name() string {
return "oauth2"
}
// parseToken returns the token from request, and a boolean value
// representing whether the token exists or not
func parseToken(req *http.Request) (string, bool) {
_ = req.ParseForm()
if !setting.DisableQueryAuthToken {
// Check token.
if token := req.Form.Get("token"); token != "" {
return token, true
}
// Check access token.
if token := req.Form.Get("access_token"); token != "" {
return token, true
}
} else if req.Form.Get("token") != "" || req.Form.Get("access_token") != "" {
log.Warn("API token sent in query string but DISABLE_QUERY_AUTH_TOKEN=true")
}
// check header token
if auHead := req.Header.Get("Authorization"); auHead != "" {
parsed, ok := httpauth.ParseAuthorizationHeader(auHead)
if ok && parsed.BearerToken != nil {
return parsed.BearerToken.Token, true
}
}
return "", false
}
// userFromToken returns the user corresponding to the OAuth token.
// It will set 'ApiTokenScope' to the scope of the access token (TODO: this behavior should be fixed, don't set ctx.Data)
func (o *OAuth2) userFromToken(ctx context.Context, tokenSHA string, store DataStore) (*user_model.User, error) {
// Let's see if token is valid.
if strings.Contains(tokenSHA, ".") {
// First attempt to decode an actions JWT, returning the actions user
if taskID, err := actions.TokenToTaskID(tokenSHA); err == nil {
if CheckTaskIsRunning(ctx, taskID) {
return user_model.NewActionsUserWithTaskID(taskID), nil
}
}
// Otherwise, check if this is an OAuth access token
accessTokenScope, uid, grantID := GetOAuthAccessTokenScopeAndUserID(ctx, tokenSHA)
if uid != 0 {
store.GetData()["ApiTokenScope"] = accessTokenScope
setAuthCredential(store, credentialOAuth2Grant, grantID)
}
return user_model.GetUserByID(ctx, uid)
}
t, err := auth_model.GetAccessTokenBySHA(ctx, tokenSHA)
if err != nil {
if errors.Is(err, util.ErrNotExist) {
// check task token
if task, err := actions_model.GetRunningTaskByToken(ctx, tokenSHA); err == nil {
log.Trace("Basic Authorization: Valid AccessToken for task[%d]", task.ID)
return user_model.NewActionsUserWithTaskID(task.ID), nil
}
}
return nil, err
}
t.UpdatedUnix = timeutil.TimeStampNow()
if err = auth_model.UpdateAccessToken(ctx, t); err != nil {
log.Error("UpdateAccessToken: %v", err)
}
store.GetData()["ApiTokenScope"] = t.Scope
setAuthCredential(store, credentialAccessToken, t.ID)
return user_model.GetUserByID(ctx, t.UID)
}
// Verify extracts the user ID from the OAuth token in the query parameters
// or the "Authorization" header and returns the corresponding user object for that ID.
// If verification is successful returns an existing user object.
// Returns nil if verification fails.
func (o *OAuth2) Verify(req *http.Request, w http.ResponseWriter, store DataStore, sess SessionStore) (*user_model.User, error) {
token, ok := parseToken(req)
if !ok {
return nil, nil //nolint:nilnil // the auth method is not applicable
}
user, err := o.userFromToken(req.Context(), token, store)
if err != nil && !errors.Is(err, util.ErrNotExist) {
log.Error("userFromToken: %v", err) // the callers might ignore the error, so log it here
}
return user, err
}

View File

@@ -0,0 +1,57 @@
// Copyright 2024 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package auth
import (
"testing"
"gitea.dev/models/unittest"
user_model "gitea.dev/models/user"
"gitea.dev/modules/reqctx"
"gitea.dev/services/actions"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestUserIDFromToken(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
t.Run("Actions JWT", func(t *testing.T) {
const RunningTaskID int64 = 47
token, err := actions.CreateAuthorizationToken(RunningTaskID, 1, 2)
assert.NoError(t, err)
ds := make(reqctx.ContextData)
o := OAuth2{}
u, err := o.userFromToken(t.Context(), token, ds)
require.NoError(t, err)
assert.Equal(t, user_model.ActionsUserID, u.ID)
taskID, ok := user_model.GetActionsUserTaskID(u)
assert.True(t, ok)
assert.Equal(t, RunningTaskID, taskID)
})
}
func TestCheckTaskIsRunning(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
cases := map[string]struct {
TaskID int64
Expected bool
}{
"Running": {TaskID: 47, Expected: true},
"Missing": {TaskID: 1, Expected: false},
"Cancelled": {TaskID: 46, Expected: false},
}
for name := range cases {
c := cases[name]
t.Run(name, func(t *testing.T) {
actual := CheckTaskIsRunning(t.Context(), c.TaskID)
assert.Equal(t, c.Expected, actual)
})
}
}

View File

@@ -0,0 +1,179 @@
// Copyright 2014 The Gogs Authors. All rights reserved.
// Copyright 2019 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package auth
import (
"net/http"
"strings"
"uuid"
audit_model "gitea.dev/models/audit"
user_model "gitea.dev/models/user"
"gitea.dev/modules/log"
"gitea.dev/modules/optional"
"gitea.dev/modules/session"
"gitea.dev/modules/setting"
"gitea.dev/services/audit"
)
// Ensure the struct implements the interface.
var (
_ Method = &ReverseProxy{}
)
// ReverseProxyMethodName is the constant name of the ReverseProxy authentication method
const ReverseProxyMethodName = "reverse_proxy"
// ReverseProxy implements the Auth interface, but actually relies on
// a reverse proxy for authentication of users.
// On successful authentication the proxy is expected to populate the username in the
// "setting.ReverseProxyAuthUser" header. Optionally it can also populate the email of the
// user in the "setting.ReverseProxyAuthEmail" header.
type ReverseProxy struct {
CreateSession bool
}
// getUserName extracts the username from the "setting.ReverseProxyAuthUser" header
func (r *ReverseProxy) getUserName(req *http.Request) string {
return strings.TrimSpace(req.Header.Get(setting.ReverseProxyAuthUser))
}
// Name represents the name of auth method
func (r *ReverseProxy) Name() string {
return ReverseProxyMethodName
}
// getUserFromAuthUser extracts the username from the "setting.ReverseProxyAuthUser" header
// of the request and returns the corresponding user object for that name.
// Verification of header data is not performed as it should have already been done by
// the reverse proxy.
// If a username is available in the "setting.ReverseProxyAuthUser" header an existing
// user object is returned (populated with username or email found in header).
// Returns nil if header is empty.
func (r *ReverseProxy) getUserFromAuthUser(req *http.Request) (*user_model.User, error) {
username := r.getUserName(req)
if len(username) == 0 {
return nil, nil //nolint:nilnil // the auth method is not applicable
}
log.Trace("ReverseProxy Authorization: Found username: %s", username)
user, err := user_model.GetUserByName(req.Context(), username)
if err != nil {
if !user_model.IsErrUserNotExist(err) || !r.isAutoRegisterAllowed() {
log.Error("GetUserByName: %v", err)
return nil, err
}
user = r.newUser(req)
}
return user, nil
}
// getEmail extracts the email from the "setting.ReverseProxyAuthEmail" header
func (r *ReverseProxy) getEmail(req *http.Request) string {
return strings.TrimSpace(req.Header.Get(setting.ReverseProxyAuthEmail))
}
// getUserFromAuthEmail extracts the username from the "setting.ReverseProxyAuthEmail" header
// of the request and returns the corresponding user object for that email.
// Verification of header data is not performed as it should have already been done by
// the reverse proxy.
// If an email is available in the "setting.ReverseProxyAuthEmail" header an existing
// user object is returned (populated with the email found in header).
// Returns nil if header is empty or if "setting.EnableReverseProxyEmail" is disabled.
func (r *ReverseProxy) getUserFromAuthEmail(req *http.Request) *user_model.User {
if !setting.Service.EnableReverseProxyEmail {
return nil
}
email := r.getEmail(req)
if len(email) == 0 {
return nil
}
log.Trace("ReverseProxy Authorization: Found email: %s", email)
user, err := user_model.GetUserByEmail(req.Context(), email)
if err != nil {
// Do not allow auto-registration, we don't have a username here
if !user_model.IsErrUserNotExist(err) {
log.Error("GetUserByEmail: %v", err)
}
return nil
}
return user
}
// Verify attempts to load a user object based on headers sent by the reverse proxy.
// First it will attempt to load it based on the username (see docs for getUserFromAuthUser),
// and failing that it will attempt to load it based on the email (see docs for getUserFromAuthEmail).
// Returns nil if the headers are empty or the user is not found.
func (r *ReverseProxy) Verify(req *http.Request, w http.ResponseWriter, store DataStore, sess SessionStore) (*user_model.User, error) {
user, err := r.getUserFromAuthUser(req)
if err != nil {
return nil, err
}
if user == nil {
user = r.getUserFromAuthEmail(req)
if user == nil {
return nil, nil //nolint:nilnil // the auth method is not applicable
}
}
if r.CreateSession && sess != nil {
sessionUID, ok := sess.Get(session.KeyUID).(int64)
if !ok || sessionUID != user.ID {
handleSignInNonInteractive(w, req, sess, user)
}
}
store.GetData()["IsReverseProxy"] = true
log.Trace("ReverseProxy Authorization: Logged in user %-v", user)
return user, nil
}
// isAutoRegisterAllowed checks if EnableReverseProxyAutoRegister setting is true
func (r *ReverseProxy) isAutoRegisterAllowed() bool {
return setting.Service.EnableReverseProxyAutoRegister
}
// newUser creates a new user object for the purpose of automatic registration
// and populates its name and email with the information present in request headers.
func (r *ReverseProxy) newUser(req *http.Request) *user_model.User {
username := r.getUserName(req)
if len(username) == 0 {
return nil
}
email := uuid.New().String() + "@localhost"
if setting.Service.EnableReverseProxyEmail {
webAuthEmail := req.Header.Get(setting.ReverseProxyAuthEmail)
if len(webAuthEmail) > 0 {
email = webAuthEmail
}
}
var fullname string
if setting.Service.EnableReverseProxyFullName {
fullname = req.Header.Get(setting.ReverseProxyAuthFullName)
}
user := &user_model.User{
Name: username,
Email: email,
FullName: fullname,
}
overwriteDefault := user_model.CreateUserOverwriteOptions{
IsActive: optional.Some(true),
}
if err := user_model.CreateUser(req.Context(), user, &user_model.Meta{}, &overwriteDefault); err != nil {
// FIXME: should I create a system notice?
log.Error("CreateUser: %v", err)
return nil
}
audit.RecordAs(req.Context(), user_model.NewAuthSourceUser(), audit_model.UserCreate, user)
return user
}

View File

@@ -0,0 +1,41 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package auth
import (
"testing"
"gitea.dev/models/unittest"
user_model "gitea.dev/models/user"
"gitea.dev/modules/session"
"gitea.dev/modules/setting"
"gitea.dev/modules/test"
"gitea.dev/services/contexttest"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestReverseProxyLastLogin(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
defer test.MockVariableValue(&setting.ReverseProxyAuthUser, "X-WEBAUTH-USER")()
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
require.Zero(t, user.LastLoginUnix)
ctx, resp := contexttest.MockContext(t, "/", contexttest.MockContextOption{SessionStore: session.NewMockMemStore("reverse-proxy-last-login")})
ctx.Req.Header.Set(setting.ReverseProxyAuthUser, user.Name)
rp := &ReverseProxy{CreateSession: true}
_, err := rp.Verify(ctx.Req, resp, ctx, ctx.Session)
require.NoError(t, err)
assert.NotZero(t, unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: user.ID}).LastLoginUnix)
user.LastLoginUnix = 1
require.NoError(t, user_model.UpdateUserCols(t.Context(), user, "last_login_unix"))
_, err = rp.Verify(ctx.Req, resp, ctx, ctx.Session)
require.NoError(t, err)
assert.EqualValues(t, 1, unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: user.ID}).LastLoginUnix) // no write without a new session
}

68
services/auth/session.go Normal file
View File

@@ -0,0 +1,68 @@
// Copyright 2019 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package auth
import (
"net/http"
user_model "gitea.dev/models/user"
"gitea.dev/modules/log"
"gitea.dev/modules/session"
)
// Ensure the struct implements the interface.
var (
_ Method = &Session{}
)
// Session checks if there is a user uid stored in the session and returns the user
// object for that uid.
type Session struct{}
// Name represents the name of auth method
func (s *Session) Name() string {
return "session"
}
// Verify checks if there is a user uid stored in the session and returns the user
// object for that uid.
// Returns nil if there is no user uid stored in the session.
func (s *Session) Verify(req *http.Request, w http.ResponseWriter, store DataStore, sess SessionStore) (*user_model.User, error) {
if sess == nil {
return nil, nil //nolint:nilnil // the auth method is not applicable
}
// Get session user ID
uid, ok := sess.Get(session.KeyUID).(int64)
if !ok {
return nil, nil //nolint:nilnil // the auth method is not applicable
}
// Get user object
user, err := user_model.GetUserByID(req.Context(), uid)
if err != nil {
if !user_model.IsErrUserNotExist(err) {
log.Error("GetUserByID: %v", err)
// Return the err as-is to keep current signed-in session, in case the err is something like context.Canceled. Otherwise non-existing user (nil, nil) will make the caller clear the signed-in session.
return nil, err
}
return nil, nil //nolint:nilnil // the auth method is not applicable
}
log.Trace("Session Authorization: Logged in user %-v", user)
return user, nil
}
func ClearSessionKeysForSignIn(sess SessionStore) {
_ = sess.Delete("openid_verified_uri")
_ = sess.Delete("openid_signin_remember")
_ = sess.Delete("openid_determined_email")
_ = sess.Delete("openid_determined_username")
_ = sess.Delete("twofaUid")
_ = sess.Delete("twofaRemember")
_ = sess.Delete("webauthnAssertion")
_ = sess.Delete("linkAccount")
_ = sess.Delete("linkAccountData")
_ = sess.Delete("openidPendingURI")
}

143
services/auth/signin.go Normal file
View File

@@ -0,0 +1,143 @@
// Copyright 2021 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package auth
import (
"context"
"strings"
"gitea.dev/models/auth"
"gitea.dev/models/db"
user_model "gitea.dev/models/user"
"gitea.dev/modules/log"
"gitea.dev/modules/optional"
"gitea.dev/services/auth/source/oauth2"
"gitea.dev/services/auth/source/smtp"
_ "gitea.dev/services/auth/source/db" // register the sources (and below)
_ "gitea.dev/services/auth/source/ldap" // register the ldap source
_ "gitea.dev/services/auth/source/pam" // register the pam source
_ "gitea.dev/services/auth/source/sspi" // register the sspi source
"xorm.io/builder"
)
// UserSignIn validates user name and password.
func UserSignIn(ctx context.Context, username, password string) (*user_model.User, *auth.Source, error) {
var user *user_model.User
isEmail := false
if strings.Contains(username, "@") {
isEmail = true
// check same email
emailAddress, has, err := db.Get[user_model.EmailAddress](ctx, builder.Eq{"lower_email": strings.ToLower(strings.TrimSpace(username))})
if err != nil {
return nil, nil, err
}
if has {
if !emailAddress.IsActivated {
return nil, nil, user_model.ErrEmailAddressNotExist{
Email: username,
}
}
user = &user_model.User{ID: emailAddress.UID}
}
} else {
trimmedUsername := strings.TrimSpace(username)
if len(trimmedUsername) == 0 {
return nil, nil, user_model.ErrUserNotExist{Name: username}
}
user = &user_model.User{LowerName: strings.ToLower(trimmedUsername)}
}
if user != nil {
var hasUser bool
var err error
if user.ID > 0 {
user, err = user_model.GetUserByID(ctx, user.ID)
if err != nil && !user_model.IsErrUserNotExist(err) {
return nil, nil, err
}
if user != nil && user.Type != user_model.UserTypeIndividual {
return nil, nil, user_model.ErrUserNotExist{Name: username}
}
hasUser = user != nil
} else if user.LowerName != "" {
user, err = user_model.GetIndividualUserByName(ctx, user.LowerName)
if err != nil && !user_model.IsErrUserNotExist(err) {
return nil, nil, err
}
hasUser = user != nil
}
if hasUser {
source, err := auth.GetSourceByID(ctx, user.LoginSource)
if err != nil {
return nil, nil, err
}
if !source.IsActive {
return nil, nil, oauth2.ErrAuthSourceNotActivated
}
authenticator, ok := source.Cfg.(PasswordAuthenticator)
if !ok {
return nil, nil, smtp.ErrUnsupportedLoginType
}
user, err := authenticator.Authenticate(ctx, user, user.LoginName, password)
if err != nil {
return nil, nil, err
}
// WARN: DON'T check user.IsActive, that will be checked on reqSign so that
// user could be hint to resend confirm email.
if user.ProhibitLogin {
return nil, nil, user_model.ErrUserProhibitLogin{UID: user.ID, Name: user.Name}
}
return user, source, nil
}
}
sources, err := db.Find[auth.Source](ctx, auth.FindSourcesOptions{
IsActive: optional.Some(true),
})
if err != nil {
return nil, nil, err
}
for _, source := range sources {
if !source.IsActive {
// don't try to authenticate non-active sources
continue
}
authenticator, ok := source.Cfg.(PasswordAuthenticator)
if !ok {
continue
}
authUser, err := authenticator.Authenticate(ctx, nil, username, password)
if err == nil {
if !authUser.ProhibitLogin {
return authUser, source, nil
}
err = user_model.ErrUserProhibitLogin{UID: authUser.ID, Name: authUser.Name}
}
if user_model.IsErrUserNotExist(err) {
log.Debug("Failed to login '%s' via '%s': %v", username, source.Name, err)
} else {
log.Warn("Failed to login '%s' via '%s': %v", username, source.Name, err)
}
}
if isEmail {
return nil, nil, user_model.ErrEmailAddressNotExist{Email: username}
}
return nil, nil, user_model.ErrUserNotExist{Name: username}
}

73
services/auth/source.go Normal file
View File

@@ -0,0 +1,73 @@
// Copyright 2021 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package auth
import (
"context"
audit_model "gitea.dev/models/audit"
"gitea.dev/models/auth"
"gitea.dev/models/db"
user_model "gitea.dev/models/user"
"gitea.dev/services/audit"
)
// CreateSource creates a AuthSource record in DB.
func CreateSource(ctx context.Context, source *auth.Source) error {
if err := auth.CreateSource(ctx, source); err != nil {
return err
}
audit.Record(ctx, audit_model.SystemAuthenticationSourceAdd, nil,
"auth_source", source.Name, "auth_source_type", source.Type.String(), "is_active", source.IsActive)
return nil
}
// UpdateSource updates a AuthSource record in DB.
func UpdateSource(ctx context.Context, source *auth.Source) error {
if err := auth.UpdateSource(ctx, source); err != nil {
return err
}
audit.Record(ctx, audit_model.SystemAuthenticationSourceUpdate, nil,
"auth_source", source.Name, "auth_source_type", source.Type.String(), "is_active", source.IsActive)
return nil
}
// DeleteSource deletes a AuthSource record in DB.
func DeleteSource(ctx context.Context, source *auth.Source) error {
count, err := db.GetEngine(ctx).Count(&user_model.User{LoginSource: source.ID})
if err != nil {
return err
} else if count > 0 {
return auth.ErrSourceInUse{
ID: source.ID,
}
}
count, err = db.GetEngine(ctx).Count(&user_model.ExternalLoginUser{LoginSourceID: source.ID})
if err != nil {
return err
} else if count > 0 {
return auth.ErrSourceInUse{
ID: source.ID,
}
}
if registerableSource, ok := source.Cfg.(auth.RegisterableSource); ok {
if err := registerableSource.UnregisterSource(); err != nil {
return err
}
}
if _, err = db.GetEngine(ctx).ID(source.ID).Delete(new(auth.Source)); err != nil {
return err
}
audit.Record(ctx, audit_model.SystemAuthenticationSourceRemove, nil, "auth_source", source.Name)
return nil
}

View File

@@ -0,0 +1,20 @@
// Copyright 2021 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package db_test
import (
auth_model "gitea.dev/models/auth"
"gitea.dev/services/auth"
"gitea.dev/services/auth/source/db"
)
// This test file exists to assert that our Source exposes the interfaces that we expect
// It tightly binds the interfaces and implementation without breaking go import cycles
type sourceInterface interface {
auth.PasswordAuthenticator
auth_model.Config
}
var _ sourceInterface = &db.Source{}

View File

@@ -0,0 +1,87 @@
// Copyright 2021 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package db
import (
"context"
"fmt"
user_model "gitea.dev/models/user"
"gitea.dev/modules/setting"
"gitea.dev/modules/util"
)
// ErrUserPasswordNotSet represents a "ErrUserPasswordNotSet" kind of error.
type ErrUserPasswordNotSet struct {
UID int64
Name string
}
func (err ErrUserPasswordNotSet) Error() string {
return fmt.Sprintf("user's password isn't set [uid: %d, name: %s]", err.UID, err.Name)
}
// Unwrap unwraps this error as a ErrInvalidArgument error
func (err ErrUserPasswordNotSet) Unwrap() error {
return util.ErrInvalidArgument
}
// ErrUserPasswordInvalid represents a "ErrUserPasswordInvalid" kind of error.
type ErrUserPasswordInvalid struct {
UID int64
Name string
}
func (err ErrUserPasswordInvalid) Error() string {
return fmt.Sprintf("user's password is invalid [uid: %d, name: %s]", err.UID, err.Name)
}
// Unwrap unwraps this error as a ErrInvalidArgument error
func (err ErrUserPasswordInvalid) Unwrap() error {
return util.ErrInvalidArgument
}
// Authenticate authenticates the provided user against the DB
func Authenticate(ctx context.Context, user *user_model.User, login, password string) (*user_model.User, error) {
if user == nil {
return nil, user_model.ErrUserNotExist{Name: login}
}
if !user.IsPasswordSet() {
return nil, ErrUserPasswordNotSet{UID: user.ID, Name: user.Name}
} else if !user.ValidatePassword(password) {
return nil, ErrUserPasswordInvalid{UID: user.ID, Name: user.Name}
}
// Update password hash if server password hash algorithm have changed
// Or update the password when the salt length doesn't match the current
// recommended salt length, this in order to migrate user's salts to a more secure salt.
if user.PasswdHashAlgo != setting.PasswordHashAlgo || len(user.Salt) != user_model.SaltByteLength*2 {
if err := user.SetPassword(password); err != nil {
return nil, err
}
if err := user_model.UpdateUserCols(ctx, user, "passwd", "passwd_hash_algo", "salt"); err != nil {
return nil, err
}
}
// WARN: DON'T check user.IsActive, that will be checked on reqSign so that
// user could be hinted to resend confirm email.
if user.ProhibitLogin {
return nil, user_model.ErrUserProhibitLogin{
UID: user.ID,
Name: user.Name,
}
}
// attempting to login as a non-user account
if user.Type != user_model.UserTypeIndividual {
return nil, user_model.ErrUserProhibitLogin{
UID: user.ID,
Name: user.Name,
}
}
return user, nil
}

View File

@@ -0,0 +1,37 @@
// Copyright 2021 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package db
import (
"context"
"gitea.dev/models/auth"
user_model "gitea.dev/models/user"
)
// Source is a password authentication service
type Source struct {
auth.ConfigBase `json:"-"`
}
// FromDB fills up an OAuth2Config from serialized format.
func (source *Source) FromDB(bs []byte) error {
return nil
}
// ToDB exports the config to a byte slice to be saved into database (this method is just dummy and does nothing for DB source)
func (source *Source) ToDB() ([]byte, error) {
return nil, nil
}
// Authenticate queries if login/password is valid against the PAM,
// and create a local user if success when enabled.
func (source *Source) Authenticate(ctx context.Context, user *user_model.User, login, password string) (*user_model.User, error) {
return Authenticate(ctx, user, login, password)
}
func init() {
auth.RegisterTypeConfig(auth.NoType, &Source{})
auth.RegisterTypeConfig(auth.Plain, &Source{})
}

View File

@@ -0,0 +1,131 @@
# Gitea LDAP Authentication Module
## About
This authentication module attempts to authorize and authenticate a user
against an LDAP server. It provides two methods of authentication: LDAP via
BindDN, and LDAP simple authentication.
LDAP via BindDN functions like most LDAP authentication systems. First, it
queries the LDAP server using a Bind DN and searches for the user that is
attempting to sign in. If the user is found, the module attempts to bind to the
server using the user's supplied credentials. If this succeeds, the user has
been authenticated, and his account information is retrieved and passed to the
Gogs login infrastructure.
LDAP simple authentication does not utilize a Bind DN. Instead, it binds
directly with the LDAP server using the user's supplied credentials. If the bind
succeeds and no filter rules out the user, the user is authenticated.
LDAP via BindDN is recommended for most users. By using a Bind DN, the server
can perform authorization by restricting which entries the Bind DN account can
read. Further, using a Bind DN with reduced permissions can reduce security risk
in the face of application bugs.
## Usage
To use this module, add an LDAP authentication source via the Authentications
section in the admin panel. Both the LDAP via BindDN and the simple auth LDAP
share the following fields:
* Authorization Name **(required)**
* A name to assign to the new method of authorization.
* Host **(required)**
* The address where the LDAP server can be reached.
* Example: mydomain.com
* Port **(required)**
* The port to use when connecting to the server.
* Example: 636
* Enable TLS Encryption (optional)
* Whether to use TLS when connecting to the LDAP server.
* Admin Filter (optional)
* An LDAP filter specifying if a user should be given administrator
privileges. If a user accounts passes the filter, the user will be
privileged as an administrator.
* Example: (objectClass=adminAccount)
* First name attribute (optional)
* The attribute of the user's LDAP record containing the user's first name.
This will be used to populate their account information.
* Example: givenName
* Surname attribute (optional)
* The attribute of the user's LDAP record containing the user's surname This
will be used to populate their account information.
* Example: sn
* E-mail attribute **(required)**
* The attribute of the user's LDAP record containing the user's email
address. This will be used to populate their account information.
* Example: mail
**LDAP via BindDN** adds the following fields:
* Bind DN (optional)
* The DN to bind to the LDAP server with when searching for the user. This
may be left blank to perform an anonymous search.
* Example: cn=Search,dc=mydomain,dc=com
* Bind Password (optional)
* The password for the Bind DN specified above, if any. _Note: The password
is stored in plaintext at the server. As such, ensure that your Bind DN
has as few privileges as possible._
* User Search Base **(required)**
* The LDAP base at which user accounts will be searched for.
* Example: ou=Users,dc=mydomain,dc=com
* User Filter **(required)**
* An LDAP filter declaring how to find the user record that is attempting to
authenticate. The '%[1]s' matching parameter will be substituted with the
user's username.
* Example: (&(objectClass=posixAccount)(|(uid=%[1]s)(mail=%[1]s)))
**LDAP using simple auth** adds the following fields:
* User DN **(required)**
* A template to use as the user's DN. The `%s` matching parameter will be
substituted with the user's username.
* Example: cn=%s,ou=Users,dc=mydomain,dc=com
* Example: uid=%s,ou=Users,dc=mydomain,dc=com
* User Search Base (optional)
* The LDAP base at which user accounts will be searched for.
* Example: ou=Users,dc=mydomain,dc=com
* User Filter **(required)**
* An LDAP filter declaring when a user should be allowed to log in. The `%[1]s`
matching parameter will be substituted with the user's username.
* Example: (&(objectClass=posixAccount)(|(cn=%[1]s)(mail=%[1]s)))
* Example: (&(objectClass=posixAccount)(|(uid=%[1]s)(mail=%[1]s)))
**Verify group membership in LDAP** uses the following fields:
* Group Search Base (optional)
* The LDAP DN used for groups.
* Example: ou=group,dc=mydomain,dc=com
* Group Name Filter (optional)
* An LDAP filter declaring how to find valid groups in the above DN.
* Example: (|(cn=gitea_users)(cn=admins))
* User Attribute in Group (optional)
* The user attribute that is used to reference a user in the group object.
* Example: uid if the group objects contains a member: bender and the user object contains a uid: bender.
* Example: dn if the group object contains a member: uid=bender,ou=users,dc=planetexpress,dc=com.
* Group Attribute for User (optional)
* The attribute of the group object that lists/contains the group members.
* Example: memberUid or member
* Team group map (optional)
* Automatically add users to Organization teams, depending on LDAP group memberships.
* Note: this function only adds users to teams, it never removes users.
* Example: {"cn=MyGroup,cn=groups,dc=example,dc=org": {"MyGiteaOrganization": ["MyGiteaTeam1", "MyGiteaTeam2", ...], ...}, ...}
* Team group map removal (optional)
* If set to true, users will be removed from teams if they are not members of the corresponding group.

View File

@@ -0,0 +1,25 @@
// Copyright 2021 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package ldap_test
import (
auth_model "gitea.dev/models/auth"
"gitea.dev/services/auth"
"gitea.dev/services/auth/source/ldap"
)
// This test file exists to assert that our Source exposes the interfaces that we expect
// It tightly binds the interfaces and implementation without breaking go import cycles
type sourceInterface interface {
auth.PasswordAuthenticator
auth.SynchronizableSource
auth_model.SSHKeyProvider
auth_model.Config
auth_model.SkipVerifiable
auth_model.HasTLSer
auth_model.UseTLSer
}
var _ sourceInterface = &ldap.Source{}

View File

@@ -0,0 +1,31 @@
// Copyright 2021 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package ldap
// SecurityProtocol protocol type
type SecurityProtocol int
// Note: new type must be added at the end of list to maintain compatibility.
const (
SecurityProtocolUnencrypted SecurityProtocol = iota
SecurityProtocolLDAPS
SecurityProtocolStartTLS
)
// String returns the name of the SecurityProtocol
func (s SecurityProtocol) String() string {
return SecurityProtocolNames[s]
}
// Int returns the int value of the SecurityProtocol
func (s SecurityProtocol) Int() int {
return int(s)
}
// SecurityProtocolNames contains the name of SecurityProtocol values.
var SecurityProtocolNames = map[SecurityProtocol]string{
SecurityProtocolUnencrypted: "Unencrypted",
SecurityProtocolLDAPS: "LDAPS",
SecurityProtocolStartTLS: "StartTLS",
}

View File

@@ -0,0 +1,119 @@
// Copyright 2021 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package ldap
import (
"strings"
"gitea.dev/models/auth"
"gitea.dev/modules/json"
"gitea.dev/modules/log"
"gitea.dev/modules/secret"
"gitea.dev/modules/setting"
)
// .____ ________ _____ __________
// | | \______ \ / _ \\______ \
// | | | | \ / /_\ \| ___/
// | |___ | ` \/ | \ |
// |_______ \/_______ /\____|__ /____|
// \/ \/ \/
// Package ldap provide functions & structure to query a LDAP ldap directory
// For now, it's mainly tested again an MS Active Directory service, see README.md for more information
// Source Basic LDAP authentication service
type Source struct {
auth.ConfigBase `json:"-"`
Name string // canonical name (ie. corporate.ad)
Host string // LDAP host
Port int // port number
SecurityProtocol SecurityProtocol
SkipVerify bool
BindDN string // DN to bind with
BindPasswordEncrypt string // Encrypted Bind BN password
BindPassword string // Bind DN password
UserBase string // Base search path for users
UserDN string // Template for the DN of the user for simple auth
AttributeUsername string // Username attribute
AttributeName string // First name attribute
AttributeSurname string // Surname attribute
AttributeMail string // E-mail attribute
AttributesInBind bool // fetch attributes in bind context (not user)
AttributeSSHPublicKey string // LDAP SSH Public Key attribute
AttributeAvatar string
SSHKeysAreVerified bool // true if SSH keys in LDAP are verified
SearchPageSize uint32 // Search with paging page size
Filter string // Query filter to validate entry
AdminFilter string // Query filter to check if user is admin
RestrictedFilter string // Query filter to check if user is restricted
Enabled bool // if this source is disabled
AllowDeactivateAll bool // Allow an empty search response to deactivate all users from this source
GroupsEnabled bool // if the group checking is enabled
GroupDN string // Group Search Base
GroupFilter string // Group Name Filter
GroupMemberUID string // Group Attribute containing array of UserUID
GroupTeamMap string // Map LDAP groups to teams
GroupTeamMapRemoval bool // Remove user from teams which are synchronized and user is not a member of the corresponding LDAP group
UserUID string // User Attribute listed in Group
}
// FromDB fills up a LDAPConfig from serialized format.
func (source *Source) FromDB(bs []byte) error {
err := json.UnmarshalHandleDoubleEncode(bs, &source)
if err != nil {
return err
}
if source.BindPasswordEncrypt != "" {
source.BindPassword, err = secret.DecryptSecret(setting.SecretKey, source.BindPasswordEncrypt)
if err != nil {
log.Error("Unable to decrypt bind password for LDAP source, maybe SECRET_KEY is wrong: %v", err)
}
source.BindPasswordEncrypt = ""
}
return nil
}
// ToDB exports a LDAPConfig to a serialized format.
func (source *Source) ToDB() ([]byte, error) {
var err error
source.BindPasswordEncrypt, err = secret.EncryptSecret(setting.SecretKey, source.BindPassword)
if err != nil {
return nil, err
}
source.BindPassword = ""
return json.Marshal(source)
}
// SecurityProtocolName returns the name of configured security
// protocol.
func (source *Source) SecurityProtocolName() string {
return SecurityProtocolNames[source.SecurityProtocol]
}
// IsSkipVerify returns if SkipVerify is set
func (source *Source) IsSkipVerify() bool {
return source.SkipVerify
}
// HasTLS returns if HasTLS
func (source *Source) HasTLS() bool {
return source.SecurityProtocol > SecurityProtocolUnencrypted
}
// UseTLS returns if UseTLS
func (source *Source) UseTLS() bool {
return source.SecurityProtocol != SecurityProtocolUnencrypted
}
// ProvidesSSHKeys returns if this source provides SSH Keys
func (source *Source) ProvidesSSHKeys() bool {
return strings.TrimSpace(source.AttributeSSHPublicKey) != ""
}
func init() {
auth.RegisterTypeConfig(auth.LDAP, &Source{})
auth.RegisterTypeConfig(auth.DLDAP, &Source{})
}

View File

@@ -0,0 +1,129 @@
// Copyright 2021 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package ldap
import (
"context"
"strings"
asymkey_model "gitea.dev/models/asymkey"
audit_model "gitea.dev/models/audit"
"gitea.dev/models/auth"
user_model "gitea.dev/models/user"
auth_module "gitea.dev/modules/auth"
"gitea.dev/modules/log"
"gitea.dev/modules/optional"
asymkey_service "gitea.dev/services/asymkey"
"gitea.dev/services/audit"
source_service "gitea.dev/services/auth/source"
user_service "gitea.dev/services/user"
)
// Authenticate queries if login/password is valid against the LDAP directory pool,
// and create a local user if success when enabled.
func (source *Source) Authenticate(ctx context.Context, user *user_model.User, userName, password string) (*user_model.User, error) {
ctx = audit.WithDoer(ctx, user_model.NewAuthSourceUser())
loginName := userName
if user != nil {
loginName = user.LoginName
}
sr := source.SearchEntry(loginName, password, source.AuthSource.Type == auth.DLDAP)
if sr == nil {
// User not in LDAP, do nothing
return nil, user_model.ErrUserNotExist{Name: loginName}
}
// Fallback.
// FIXME: this fallback would cause problems when the "Username" attribute is not set and a user inputs their email.
// In this case, the email would be used as the username, and will cause the "CreateUser" failure for the first login.
if sr.Username == "" {
if strings.Contains(userName, "@") {
log.Error("No username in search result (Username Attribute is not set properly?), using email as username might cause problems")
}
sr.Username = userName
}
if sr.Mail == "" {
sr.Mail = sr.Username + "@localhost.local"
}
isAttributeSSHPublicKeySet := strings.TrimSpace(source.AttributeSSHPublicKey) != ""
// Update User admin flag if exist
if isExist, err := user_model.IsUserExist(ctx, 0, sr.Username); err != nil {
return nil, err
} else if isExist {
if user == nil {
user, err = user_model.GetUserByName(ctx, sr.Username)
if err != nil {
return nil, err
}
}
if user != nil && !user.ProhibitLogin {
opts := &user_service.UpdateOptions{}
if source.AdminFilter != "" && user.IsAdmin != sr.IsAdmin {
// Change existing admin flag only if AdminFilter option is set
opts.IsAdmin = user_service.UpdateOptionFieldFromSync(sr.IsAdmin)
}
if !sr.IsAdmin && source.RestrictedFilter != "" && user.IsRestricted != sr.IsRestricted {
// Change existing restricted flag only if RestrictedFilter option is set
opts.IsRestricted = optional.Some(sr.IsRestricted)
}
if opts.IsAdmin.Has() || opts.IsRestricted.Has() {
if err := user_service.UpdateUser(ctx, user, opts); err != nil {
return nil, err
}
}
}
}
if user != nil {
if isAttributeSSHPublicKeySet && asymkey_model.SynchronizePublicKeys(ctx, user, source.AuthSource, sr.SSHPublicKey, source.SSHKeysAreVerified) {
if err := asymkey_service.RewriteAllPublicKeys(ctx); err != nil {
return user, err
}
}
} else {
user = &user_model.User{
LowerName: strings.ToLower(sr.Username),
Name: sr.Username,
FullName: composeFullName(sr.Name, sr.Surname, sr.Username),
Email: sr.Mail,
LoginType: source.AuthSource.Type,
LoginSource: source.AuthSource.ID,
LoginName: userName,
IsAdmin: sr.IsAdmin,
}
overwriteDefault := &user_model.CreateUserOverwriteOptions{
IsRestricted: optional.Some(sr.IsRestricted),
IsActive: optional.Some(true),
}
err := user_model.CreateUser(ctx, user, &user_model.Meta{}, overwriteDefault)
if err != nil {
return user, err
}
audit.Record(ctx, audit_model.UserCreate, user)
if isAttributeSSHPublicKeySet && asymkey_model.AddPublicKeysBySource(ctx, user, source.AuthSource, sr.SSHPublicKey, source.SSHKeysAreVerified) {
if err := asymkey_service.RewriteAllPublicKeys(ctx); err != nil {
return user, err
}
}
if source.AttributeAvatar != "" {
_ = user_service.UploadAvatar(ctx, user, sr.Avatar)
}
}
if source.GroupsEnabled && (source.GroupTeamMap != "" || source.GroupTeamMapRemoval) {
groupTeamMapping, err := auth_module.UnmarshalGroupTeamMapping(source.GroupTeamMap)
if err != nil {
return user, err
}
if err := source_service.SyncGroupsToTeams(ctx, user, sr.Groups, groupTeamMapping, source.GroupTeamMapRemoval); err != nil {
return user, err
}
}
return user, nil
}

View File

@@ -0,0 +1,526 @@
// Copyright 2014 The Gogs Authors. All rights reserved.
// Copyright 2020 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package ldap
import (
"crypto/tls"
"fmt"
"net"
"strconv"
"strings"
"gitea.dev/modules/container"
"gitea.dev/modules/log"
"github.com/go-ldap/ldap/v3"
)
// SearchResult : user data
type SearchResult struct {
Username string // Username
Name string // Name
Surname string // Surname
Mail string // E-mail address
SSHPublicKey []string // SSH Public Key
IsAdmin bool // if user is administrator
IsRestricted bool // if user is restricted
LowerName string // LowerName
Avatar []byte
Groups container.Set[string]
}
func (source *Source) sanitizedUserQuery(username string) (string, bool) {
// See http://tools.ietf.org/search/rfc4515
badCharacters := "\x00()*\\"
if strings.ContainsAny(username, badCharacters) {
log.Debug("'%s' contains invalid query characters. Aborting.", username)
return "", false
}
return fmt.Sprintf(source.Filter, username), true
}
func (source *Source) sanitizedUserDN(username string) (string, bool) {
// See http://tools.ietf.org/search/rfc4514: "special characters"
badCharacters := "\x00()*\\,='\"#+;<>"
if strings.ContainsAny(username, badCharacters) {
log.Debug("'%s' contains invalid DN characters. Aborting.", username)
return "", false
}
return fmt.Sprintf(source.UserDN, username), true
}
func (source *Source) sanitizedGroupFilter(group string) (string, bool) {
// See http://tools.ietf.org/search/rfc4515
badCharacters := "\x00*\\"
if strings.ContainsAny(group, badCharacters) {
log.Trace("Group filter invalid query characters: %s", group)
return "", false
}
return group, true
}
func (source *Source) sanitizedGroupDN(groupDn string) (string, bool) {
// See http://tools.ietf.org/search/rfc4514: "special characters"
badCharacters := "\x00()*\\'\"#+;<>"
if strings.ContainsAny(groupDn, badCharacters) || strings.HasPrefix(groupDn, " ") || strings.HasSuffix(groupDn, " ") {
log.Trace("Group DN contains invalid query characters: %s", groupDn)
return "", false
}
return groupDn, true
}
func (source *Source) findUserDN(l *ldap.Conn, name string) (string, bool) {
log.Trace("Search for LDAP user: %s", name)
// A search for the user.
userFilter, ok := source.sanitizedUserQuery(name)
if !ok {
return "", false
}
log.Trace("Searching for DN using filter %s and base %s", userFilter, source.UserBase)
search := ldap.NewSearchRequest(
source.UserBase, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0,
false, userFilter, []string{}, nil)
// Ensure we found a user
sr, err := l.Search(search)
if err != nil || len(sr.Entries) < 1 {
log.Debug("Failed search using filter[%s]: %v", userFilter, err)
return "", false
} else if len(sr.Entries) > 1 {
log.Debug("Filter '%s' returned more than one user.", userFilter)
return "", false
}
userDN := sr.Entries[0].DN
if userDN == "" {
log.Error("LDAP search was successful, but found no DN!")
return "", false
}
return userDN, true
}
func dial(source *Source) (*ldap.Conn, error) {
log.Trace("Dialing LDAP with security protocol (%v) without verifying: %v", source.SecurityProtocol, source.SkipVerify)
tlsConfig := &tls.Config{
ServerName: source.Host,
InsecureSkipVerify: source.SkipVerify,
}
hostPort := net.JoinHostPort(source.Host, strconv.Itoa(source.Port))
if source.SecurityProtocol == SecurityProtocolLDAPS {
return ldap.DialURL("ldaps://"+hostPort, ldap.DialWithTLSConfig(tlsConfig))
}
conn, err := ldap.DialURL("ldap://" + hostPort)
if err != nil {
return nil, fmt.Errorf("error during Dial: %w", err)
}
if source.SecurityProtocol == SecurityProtocolStartTLS {
if err = conn.StartTLS(tlsConfig); err != nil {
conn.Close()
return nil, fmt.Errorf("error during StartTLS: %w", err)
}
}
return conn, nil
}
func bindUser(l *ldap.Conn, userDN, passwd string) error {
log.Trace("Binding with userDN: %s", userDN)
err := l.Bind(userDN, passwd)
if err != nil {
log.Debug("LDAP auth. failed for %s, reason: %v", userDN, err)
return err
}
log.Trace("Bound successfully with userDN: %s", userDN)
return err
}
func checkAdmin(l *ldap.Conn, ls *Source, userDN string) bool {
if ls.AdminFilter == "" {
return false
}
log.Trace("Checking admin with filter %s and base %s", ls.AdminFilter, userDN)
search := ldap.NewSearchRequest(
userDN, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false, ls.AdminFilter,
[]string{ls.AttributeName},
nil)
sr, err := l.Search(search)
if err != nil {
log.Error("LDAP Admin Search with filter %s for %s failed unexpectedly! (%v)", ls.AdminFilter, userDN, err)
} else if len(sr.Entries) < 1 {
log.Trace("LDAP Admin Search found no matching entries.")
} else {
return true
}
return false
}
func checkRestricted(l *ldap.Conn, ls *Source, userDN string) bool {
if ls.RestrictedFilter == "" {
return false
}
if ls.RestrictedFilter == "*" {
return true
}
log.Trace("Checking restricted with filter %s and base %s", ls.RestrictedFilter, userDN)
search := ldap.NewSearchRequest(
userDN, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false, ls.RestrictedFilter,
[]string{ls.AttributeName},
nil)
sr, err := l.Search(search)
if err != nil {
log.Error("LDAP Restrictred Search with filter %s for %s failed unexpectedly! (%v)", ls.RestrictedFilter, userDN, err)
} else if len(sr.Entries) < 1 {
log.Trace("LDAP Restricted Search found no matching entries.")
} else {
return true
}
return false
}
// List all group memberships of a user
func (source *Source) listLdapGroupMemberships(l *ldap.Conn, uid string, applyGroupFilter bool) container.Set[string] {
ldapGroups := make(container.Set[string])
groupFilter, ok := source.sanitizedGroupFilter(source.GroupFilter)
if !ok {
return ldapGroups
}
groupDN, ok := source.sanitizedGroupDN(source.GroupDN)
if !ok {
return ldapGroups
}
var searchFilter string
if applyGroupFilter && groupFilter != "" {
searchFilter = fmt.Sprintf("(&(%s)(%s=%s))", groupFilter, source.GroupMemberUID, ldap.EscapeFilter(uid))
} else {
searchFilter = fmt.Sprintf("(%s=%s)", source.GroupMemberUID, ldap.EscapeFilter(uid))
}
result, err := l.Search(ldap.NewSearchRequest(
groupDN,
ldap.ScopeWholeSubtree,
ldap.NeverDerefAliases,
0,
0,
false,
searchFilter,
[]string{},
nil,
))
if err != nil {
log.Error("Failed group search in LDAP with filter [%s]: %v", searchFilter, err)
return ldapGroups
}
for _, entry := range result.Entries {
if entry.DN == "" {
log.Error("LDAP search was successful, but found no DN!")
continue
}
ldapGroups.Add(entry.DN)
}
return ldapGroups
}
func (source *Source) getUserAttributeListedInGroup(entry *ldap.Entry) string {
if strings.EqualFold(source.UserUID, "dn") {
return entry.DN
}
return entry.GetAttributeValue(source.UserUID)
}
// SearchEntry : search an LDAP source if an entry (name, passwd) is valid and in the specific filter
func (source *Source) SearchEntry(name, passwd string, directBind bool) *SearchResult {
if MockedSearchEntry != nil {
return MockedSearchEntry(source, name, passwd, directBind)
}
return realSearchEntry(source, name, passwd, directBind)
}
var MockedSearchEntry func(source *Source, name, passwd string, directBind bool) *SearchResult
func realSearchEntry(source *Source, name, passwd string, directBind bool) *SearchResult {
// See https://tools.ietf.org/search/rfc4513#section-5.1.2
if passwd == "" {
log.Debug("Auth. failed for %s, password cannot be empty", name)
return nil
}
l, err := dial(source)
if err != nil {
log.Error("LDAP Connect error, %s:%v", source.Host, err)
source.Enabled = false
return nil
}
defer l.Close()
var userDN string
if directBind {
log.Trace("LDAP will bind directly via UserDN template: %s", source.UserDN)
var ok bool
userDN, ok = source.sanitizedUserDN(name)
if !ok {
return nil
}
err = bindUser(l, userDN, passwd)
if err != nil {
return nil
}
if source.UserBase != "" {
// not everyone has a CN compatible with input name so we need to find
// the real userDN in that case
userDN, ok = source.findUserDN(l, name)
if !ok {
return nil
}
}
} else {
log.Trace("LDAP will use BindDN.")
var found bool
if source.BindDN != "" && source.BindPassword != "" {
err := l.Bind(source.BindDN, source.BindPassword)
if err != nil {
log.Debug("Failed to bind as BindDN[%s]: %v", source.BindDN, err)
return nil
}
log.Trace("Bound as BindDN %s", source.BindDN)
} else {
log.Trace("Proceeding with anonymous LDAP search.")
}
userDN, found = source.findUserDN(l, name)
if !found {
return nil
}
}
if !source.AttributesInBind {
// binds user (checking password) before looking-up attributes in user context
err = bindUser(l, userDN, passwd)
if err != nil {
return nil
}
}
userFilter, ok := source.sanitizedUserQuery(name)
if !ok {
return nil
}
isAttributeSSHPublicKeySet := strings.TrimSpace(source.AttributeSSHPublicKey) != ""
isAttributeAvatarSet := strings.TrimSpace(source.AttributeAvatar) != ""
attribs := []string{source.AttributeUsername, source.AttributeName, source.AttributeSurname, source.AttributeMail}
if strings.TrimSpace(source.UserUID) != "" {
attribs = append(attribs, source.UserUID)
}
if isAttributeSSHPublicKeySet {
attribs = append(attribs, source.AttributeSSHPublicKey)
}
if isAttributeAvatarSet {
attribs = append(attribs, source.AttributeAvatar)
}
log.Trace("Fetching attributes '%v', '%v', '%v', '%v', '%v', '%v', '%v' with filter '%s' and base '%s'", source.AttributeUsername, source.AttributeName, source.AttributeSurname, source.AttributeMail, source.AttributeSSHPublicKey, source.AttributeAvatar, source.UserUID, userFilter, userDN)
search := ldap.NewSearchRequest(
userDN, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false, userFilter,
attribs, nil)
sr, err := l.Search(search)
if err != nil {
log.Error("LDAP Search failed unexpectedly! (%v)", err)
return nil
} else if len(sr.Entries) < 1 {
if directBind {
log.Trace("User filter inhibited user login.")
} else {
log.Trace("LDAP Search found no matching entries.")
}
return nil
}
var sshPublicKey []string
var Avatar []byte
username := sr.Entries[0].GetAttributeValue(source.AttributeUsername)
firstname := sr.Entries[0].GetAttributeValue(source.AttributeName)
surname := sr.Entries[0].GetAttributeValue(source.AttributeSurname)
mail := sr.Entries[0].GetAttributeValue(source.AttributeMail)
if isAttributeSSHPublicKeySet {
sshPublicKey = sr.Entries[0].GetAttributeValues(source.AttributeSSHPublicKey)
}
isAdmin := checkAdmin(l, source, userDN)
var isRestricted bool
if !isAdmin {
isRestricted = checkRestricted(l, source, userDN)
}
if isAttributeAvatarSet {
Avatar = sr.Entries[0].GetRawAttributeValue(source.AttributeAvatar)
}
// Check group membership
var usersLdapGroups container.Set[string]
if source.GroupsEnabled {
userAttributeListedInGroup := source.getUserAttributeListedInGroup(sr.Entries[0])
usersLdapGroups = source.listLdapGroupMemberships(l, userAttributeListedInGroup, true)
if source.GroupFilter != "" && len(usersLdapGroups) == 0 {
return nil
}
}
if !directBind && source.AttributesInBind {
// binds user (checking password) after looking-up attributes in BindDN context
err = bindUser(l, userDN, passwd)
if err != nil {
return nil
}
}
return &SearchResult{
LowerName: strings.ToLower(username),
Username: username,
Name: firstname,
Surname: surname,
Mail: mail,
SSHPublicKey: sshPublicKey,
IsAdmin: isAdmin,
IsRestricted: isRestricted,
Avatar: Avatar,
Groups: usersLdapGroups,
}
}
// UsePagedSearch returns if need to use paged search
func (source *Source) UsePagedSearch() bool {
return source.SearchPageSize > 0
}
// SearchEntries : search an LDAP source for all users matching userFilter
func (source *Source) SearchEntries() ([]*SearchResult, error) {
l, err := dial(source)
if err != nil {
log.Error("LDAP Connect error, %s:%v", source.Host, err)
source.Enabled = false
return nil, err
}
defer l.Close()
if source.BindDN != "" && source.BindPassword != "" {
err := l.Bind(source.BindDN, source.BindPassword)
if err != nil {
log.Debug("Failed to bind as BindDN[%s]: %v", source.BindDN, err)
return nil, err
}
log.Trace("Bound as BindDN %s", source.BindDN)
} else {
log.Trace("Proceeding with anonymous LDAP search.")
}
userFilter := fmt.Sprintf(source.Filter, "*")
isAttributeSSHPublicKeySet := strings.TrimSpace(source.AttributeSSHPublicKey) != ""
isAttributeAvatarSet := strings.TrimSpace(source.AttributeAvatar) != ""
attribs := []string{source.AttributeUsername, source.AttributeName, source.AttributeSurname, source.AttributeMail, source.UserUID}
if isAttributeSSHPublicKeySet {
attribs = append(attribs, source.AttributeSSHPublicKey)
}
if isAttributeAvatarSet {
attribs = append(attribs, source.AttributeAvatar)
}
log.Trace("Fetching attributes '%v', '%v', '%v', '%v', '%v', '%v' with filter %s and base %s", source.AttributeUsername, source.AttributeName, source.AttributeSurname, source.AttributeMail, source.AttributeSSHPublicKey, source.AttributeAvatar, userFilter, source.UserBase)
search := ldap.NewSearchRequest(
source.UserBase, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false, userFilter,
attribs, nil)
var sr *ldap.SearchResult
if source.UsePagedSearch() {
sr, err = l.SearchWithPaging(search, source.SearchPageSize)
} else {
sr, err = l.Search(search)
}
if err != nil {
log.Error("LDAP Search failed unexpectedly! (%v)", err)
return nil, err
}
result := make([]*SearchResult, 0, len(sr.Entries))
for _, v := range sr.Entries {
var usersLdapGroups container.Set[string]
if source.GroupsEnabled {
userAttributeListedInGroup := source.getUserAttributeListedInGroup(v)
if source.GroupFilter != "" {
usersLdapGroups = source.listLdapGroupMemberships(l, userAttributeListedInGroup, true)
if len(usersLdapGroups) == 0 {
continue
}
}
if source.GroupTeamMap != "" || source.GroupTeamMapRemoval {
usersLdapGroups = source.listLdapGroupMemberships(l, userAttributeListedInGroup, false)
}
}
user := &SearchResult{
Username: v.GetAttributeValue(source.AttributeUsername),
Name: v.GetAttributeValue(source.AttributeName),
Surname: v.GetAttributeValue(source.AttributeSurname),
Mail: v.GetAttributeValue(source.AttributeMail),
IsAdmin: checkAdmin(l, source, v.DN),
Groups: usersLdapGroups,
}
if !user.IsAdmin {
user.IsRestricted = checkRestricted(l, source, v.DN)
}
if isAttributeSSHPublicKeySet {
user.SSHPublicKey = v.GetAttributeValues(source.AttributeSSHPublicKey)
}
if isAttributeAvatarSet {
user.Avatar = v.GetRawAttributeValue(source.AttributeAvatar)
}
user.LowerName = strings.ToLower(user.Username)
result = append(result, user)
}
return result, nil
}

Some files were not shown because too many files have changed in this diff Show More