diff --git a/cmd/hook.go b/cmd/hook.go
index 4f6492b0f05..a0280e283f7 100644
--- a/cmd/hook.go
+++ b/cmd/hook.go
@@ -194,7 +194,7 @@ Gitea or set your environment appropriately.`, "")
userID, _ := strconv.ParseInt(os.Getenv(repo_module.EnvPusherID), 10, 64)
prID, _ := strconv.ParseInt(os.Getenv(repo_module.EnvPRID), 10, 64)
deployKeyID, _ := strconv.ParseInt(os.Getenv(repo_module.EnvDeployKeyID), 10, 64)
- actionPerm, _ := strconv.Atoi(os.Getenv(repo_module.EnvActionPerm))
+ actionsTaskID, _ := strconv.ParseInt(os.Getenv(repo_module.EnvActionsTaskID), 10, 64)
hookOptions := private.HookOptions{
UserID: userID,
@@ -204,7 +204,7 @@ Gitea or set your environment appropriately.`, "")
GitPushOptions: pushOptions(),
PullRequestID: prID,
DeployKeyID: deployKeyID,
- ActionPerm: actionPerm,
+ ActionsTaskID: actionsTaskID,
IsWiki: isWiki,
}
diff --git a/models/actions/artifact.go b/models/actions/artifact.go
index 757bd13acd7..ec5cc0e32f1 100644
--- a/models/actions/artifact.go
+++ b/models/actions/artifact.go
@@ -170,10 +170,10 @@ type ActionArtifactMeta struct {
}
// ListUploadedArtifactsMeta returns all uploaded artifacts meta of a run
-func ListUploadedArtifactsMeta(ctx context.Context, runID int64) ([]*ActionArtifactMeta, error) {
+func ListUploadedArtifactsMeta(ctx context.Context, repoID, runID int64) ([]*ActionArtifactMeta, error) {
arts := make([]*ActionArtifactMeta, 0, 10)
return arts, db.GetEngine(ctx).Table("action_artifact").
- Where("run_id=? AND (status=? OR status=?)", runID, ArtifactStatusUploadConfirmed, ArtifactStatusExpired).
+ Where("repo_id=? AND run_id=? AND (status=? OR status=?)", repoID, runID, ArtifactStatusUploadConfirmed, ArtifactStatusExpired).
GroupBy("artifact_name").
Select("artifact_name, sum(file_size) as file_size, max(status) as status").
Find(&arts)
diff --git a/models/actions/config.go b/models/actions/config.go
new file mode 100644
index 00000000000..4f5357c5605
--- /dev/null
+++ b/models/actions/config.go
@@ -0,0 +1,74 @@
+// Copyright 2026 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package actions
+
+import (
+ "context"
+
+ "code.gitea.io/gitea/models/perm"
+ repo_model "code.gitea.io/gitea/models/repo"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/json"
+ "code.gitea.io/gitea/modules/util"
+
+ "xorm.io/xorm/convert"
+)
+
+// OwnerActionsConfig defines the Actions configuration for a user or organization
+type OwnerActionsConfig struct {
+ // TokenPermissionMode defines the default permission mode (permissive, restricted)
+ TokenPermissionMode repo_model.ActionsTokenPermissionMode `json:"token_permission_mode,omitempty"`
+
+ // MaxTokenPermissions defines the absolute maximum permissions any token can have in this context.
+ MaxTokenPermissions *repo_model.ActionsTokenPermissions `json:"max_token_permissions,omitempty"`
+
+ // AllowedCrossRepoIDs is a list of specific repo IDs that can be accessed cross-repo
+ AllowedCrossRepoIDs []int64 `json:"allowed_cross_repo_ids,omitempty"`
+}
+
+var _ convert.ConversionFrom = (*OwnerActionsConfig)(nil)
+
+func (cfg *OwnerActionsConfig) FromDB(bytes []byte) error {
+ _ = json.Unmarshal(bytes, cfg)
+ cfg.TokenPermissionMode, _ = util.EnumValue(cfg.TokenPermissionMode)
+ return nil
+}
+
+// GetOwnerActionsConfig loads the OwnerActionsConfig for a user or organization from user settings
+// It returns a default config if no setting is found
+func GetOwnerActionsConfig(ctx context.Context, userID int64) (ret OwnerActionsConfig, err error) {
+ return user_model.GetUserSettingJSON(ctx, userID, user_model.SettingsKeyActionsConfig, ret)
+}
+
+// SetOwnerActionsConfig saves the OwnerActionsConfig for a user or organization to user settings
+func SetOwnerActionsConfig(ctx context.Context, userID int64, cfg OwnerActionsConfig) error {
+ return user_model.SetUserSettingJSON(ctx, userID, user_model.SettingsKeyActionsConfig, cfg)
+}
+
+// GetDefaultTokenPermissions returns the default token permissions by its TokenPermissionMode.
+func (cfg *OwnerActionsConfig) GetDefaultTokenPermissions() repo_model.ActionsTokenPermissions {
+ switch cfg.TokenPermissionMode {
+ case repo_model.ActionsTokenPermissionModeRestricted:
+ return repo_model.MakeRestrictedPermissions()
+ case repo_model.ActionsTokenPermissionModePermissive:
+ return repo_model.MakeActionsTokenPermissions(perm.AccessModeWrite)
+ default:
+ return repo_model.MakeActionsTokenPermissions(perm.AccessModeNone)
+ }
+}
+
+// GetMaxTokenPermissions returns the maximum allowed permissions
+func (cfg *OwnerActionsConfig) GetMaxTokenPermissions() repo_model.ActionsTokenPermissions {
+ if cfg.MaxTokenPermissions != nil {
+ return *cfg.MaxTokenPermissions
+ }
+ // Default max is write for everything
+ return repo_model.MakeActionsTokenPermissions(perm.AccessModeWrite)
+}
+
+// ClampPermissions ensures that the given permissions don't exceed the maximum
+func (cfg *OwnerActionsConfig) ClampPermissions(perms repo_model.ActionsTokenPermissions) repo_model.ActionsTokenPermissions {
+ maxPerms := cfg.GetMaxTokenPermissions()
+ return repo_model.ClampActionsTokenPermissions(perms, maxPerms)
+}
diff --git a/models/actions/run_job.go b/models/actions/run_job.go
index c752e61b7d7..616e298dc97 100644
--- a/models/actions/run_job.go
+++ b/models/actions/run_job.go
@@ -51,6 +51,11 @@ type ActionRunJob struct {
ConcurrencyGroup string `xorm:"index(repo_concurrency) NOT NULL DEFAULT ''"` // evaluated concurrency.group
ConcurrencyCancel bool `xorm:"NOT NULL DEFAULT FALSE"` // evaluated concurrency.cancel-in-progress
+ // TokenPermissions stores the explicit permissions from workflow/job YAML (no org/repo clamps applied).
+ // Org/repo clamps are enforced when the token is used at runtime.
+ // It is JSON-encoded repo_model.ActionsTokenPermissions and may be empty if not specified.
+ TokenPermissions *repo_model.ActionsTokenPermissions `xorm:"JSON TEXT"`
+
Started timeutil.TimeStamp
Stopped timeutil.TimeStamp
Created timeutil.TimeStamp `xorm:"created"`
diff --git a/models/actions/token_permissions.go b/models/actions/token_permissions.go
new file mode 100644
index 00000000000..985f6cc97be
--- /dev/null
+++ b/models/actions/token_permissions.go
@@ -0,0 +1,60 @@
+// Copyright 2026 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package actions
+
+import (
+ "context"
+
+ repo_model "code.gitea.io/gitea/models/repo"
+ "code.gitea.io/gitea/models/unit"
+)
+
+// ComputeTaskTokenPermissions computes the effective permissions for a job token against the target repository.
+// It uses the job's stored permissions (if any), then applies org/repo clamps and fork/cross-repo restrictions.
+// Note: target repository access policy checks are enforced in GetActionsUserRepoPermission; this function only computes the job token's effective permission ceiling.
+func ComputeTaskTokenPermissions(ctx context.Context, task *ActionTask, targetRepo *repo_model.Repository) (ret repo_model.ActionsTokenPermissions, err error) {
+ if err := task.LoadJob(ctx); err != nil {
+ return ret, err
+ }
+ if err := task.Job.LoadRepo(ctx); err != nil {
+ return ret, err
+ }
+ runRepo := task.Job.Repo
+
+ if err := runRepo.LoadOwner(ctx); err != nil {
+ return ret, err
+ }
+
+ repoActionsCfg := runRepo.MustGetUnit(ctx, unit.TypeActions).ActionsConfig()
+ ownerActionsCfg, err := GetOwnerActionsConfig(ctx, runRepo.OwnerID)
+ if err != nil {
+ return ret, err
+ }
+
+ var jobDeclaredPerms repo_model.ActionsTokenPermissions
+ if task.Job.TokenPermissions != nil {
+ jobDeclaredPerms = *task.Job.TokenPermissions
+ } else if repoActionsCfg.OverrideOwnerConfig {
+ jobDeclaredPerms = repoActionsCfg.GetDefaultTokenPermissions()
+ } else {
+ jobDeclaredPerms = ownerActionsCfg.GetDefaultTokenPermissions()
+ }
+
+ var effectivePerms repo_model.ActionsTokenPermissions
+ if repoActionsCfg.OverrideOwnerConfig {
+ effectivePerms = repoActionsCfg.ClampPermissions(jobDeclaredPerms)
+ } else {
+ effectivePerms = ownerActionsCfg.ClampPermissions(jobDeclaredPerms)
+ }
+
+ // Cross-repository access and fork pull requests are strictly read-only for security.
+ // This ensures a "task repo" cannot gain write access to other repositories via CrossRepoAccess settings.
+ isSameRepo := task.Job.RepoID == targetRepo.ID
+ restrictCrossRepoAccess := task.IsForkPullRequest || !isSameRepo
+ if restrictCrossRepoAccess {
+ effectivePerms = repo_model.ClampActionsTokenPermissions(effectivePerms, repo_model.MakeRestrictedPermissions())
+ }
+
+ return effectivePerms, nil
+}
diff --git a/models/migrations/migrations.go b/models/migrations/migrations.go
index c1d448577c6..dc5dc9f3308 100644
--- a/models/migrations/migrations.go
+++ b/models/migrations/migrations.go
@@ -402,6 +402,7 @@ func prepareMigrationTasks() []*migration {
newMigration(325, "Fix missed repo_id when migrate attachments", v1_26.FixMissedRepoIDWhenMigrateAttachments),
newMigration(326, "Migrate commit status target URL to use run ID and job ID", v1_26.FixCommitStatusTargetURLToUseRunAndJobID),
newMigration(327, "Add disabled state to action runners", v1_26.AddDisabledToActionRunner),
+ newMigration(328, "Add TokenPermissions column to ActionRunJob", v1_26.AddTokenPermissionsToActionRunJob),
}
return preparedMigrations
}
diff --git a/models/migrations/v1_26/v328.go b/models/migrations/v1_26/v328.go
new file mode 100644
index 00000000000..81047305289
--- /dev/null
+++ b/models/migrations/v1_26/v328.go
@@ -0,0 +1,16 @@
+// Copyright 2026 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package v1_26
+
+import (
+ "xorm.io/xorm"
+)
+
+func AddTokenPermissionsToActionRunJob(x *xorm.Engine) error {
+ type ActionRunJob struct {
+ TokenPermissions string `xorm:"JSON TEXT"`
+ }
+ _, err := x.SyncWithOptions(xorm.SyncOptions{IgnoreDropIndices: true}, new(ActionRunJob))
+ return err
+}
diff --git a/models/perm/access/actions_repo_permission_test.go b/models/perm/access/actions_repo_permission_test.go
new file mode 100644
index 00000000000..442f6cf2fc2
--- /dev/null
+++ b/models/perm/access/actions_repo_permission_test.go
@@ -0,0 +1,155 @@
+// Copyright 2026 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package access
+
+import (
+ "testing"
+
+ actions_model "code.gitea.io/gitea/models/actions"
+ "code.gitea.io/gitea/models/db"
+ perm_model "code.gitea.io/gitea/models/perm"
+ repo_model "code.gitea.io/gitea/models/repo"
+ "code.gitea.io/gitea/models/unit"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestGetActionsUserRepoPermission(t *testing.T) {
+ require.NoError(t, unittest.PrepareTestDatabase())
+ ctx := t.Context()
+
+ // Use fixtures for repos and users
+ repo4 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 4}) // Public, Owner 5, has Actions unit
+ repo2 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2}) // Private, Owner 2, no Actions unit in fixtures
+ repo15 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 15}) // Private, Owner 2, no Actions unit in fixtures
+ owner2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+ actionsUser := user_model.NewActionsUser()
+
+ // Ensure repo2 and repo15 have Actions units for testing configuration
+ for _, r := range []*repo_model.Repository{repo2, repo15} {
+ require.NoError(t, db.Insert(ctx, &repo_model.RepoUnit{
+ RepoID: r.ID,
+ Type: unit.TypeActions,
+ Config: &repo_model.ActionsConfig{},
+ }))
+ }
+
+ t.Run("SameRepo_Public", func(t *testing.T) {
+ task47 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionTask{ID: 47})
+ require.Equal(t, repo4.ID, task47.RepoID)
+
+ perm, err := GetActionsUserRepoPermission(ctx, repo4, actionsUser, task47.ID)
+ require.NoError(t, err)
+
+ // Public repo, bot should have Read access even if not collaborator
+ assert.Equal(t, perm_model.AccessModeNone, perm.AccessMode)
+ assert.True(t, perm.CanRead(unit.TypeCode))
+ })
+
+ t.Run("SameRepo_Private", func(t *testing.T) {
+ // Use Task 53 which is already in Repo 2 (Private)
+ task53 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionTask{ID: 53})
+ require.Equal(t, repo2.ID, task53.RepoID)
+
+ perm, err := GetActionsUserRepoPermission(ctx, repo2, actionsUser, task53.ID)
+ require.NoError(t, err)
+
+ // Private repo, bot has no base access, but gets Write from effective tokens perms (Permissive by default)
+ assert.Equal(t, perm_model.AccessModeNone, perm.AccessMode)
+ assert.True(t, perm.CanWrite(unit.TypeCode))
+ })
+
+ t.Run("CrossRepo_Denied_None", func(t *testing.T) {
+ task53 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionTask{ID: 53})
+
+ // Set owner policy to nil allowed repos (None)
+ cfg := actions_model.OwnerActionsConfig{}
+ require.NoError(t, actions_model.SetOwnerActionsConfig(ctx, owner2.ID, cfg))
+
+ perm, err := GetActionsUserRepoPermission(ctx, repo15, actionsUser, task53.ID)
+ require.NoError(t, err)
+
+ // Should NOT have access to the private repo.
+ assert.False(t, perm.CanRead(unit.TypeCode))
+ })
+
+ t.Run("ForkPR_NoCrossRepo", func(t *testing.T) {
+ task53 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionTask{ID: 53})
+ task53.IsForkPullRequest = true
+ require.NoError(t, actions_model.UpdateTask(ctx, task53, "is_fork_pull_request"))
+
+ // Policy contains repo15
+ cfg := actions_model.OwnerActionsConfig{
+ AllowedCrossRepoIDs: []int64{repo15.ID},
+ }
+ require.NoError(t, actions_model.SetOwnerActionsConfig(ctx, owner2.ID, cfg))
+
+ perm, err := GetActionsUserRepoPermission(ctx, repo15, actionsUser, task53.ID)
+ require.NoError(t, err)
+
+ // Fork PR never gets cross-repo access to other private repos
+ assert.False(t, perm.CanRead(unit.TypeCode))
+ })
+
+ t.Run("Inheritance_And_Clamping", func(t *testing.T) {
+ task53 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionTask{ID: 53})
+ task53.IsForkPullRequest = false
+ require.NoError(t, actions_model.UpdateTask(ctx, task53, "is_fork_pull_request"))
+
+ // Owner policy: Restricted mode (Read-only Code)
+ ownerCfg := actions_model.OwnerActionsConfig{
+ TokenPermissionMode: repo_model.ActionsTokenPermissionModeRestricted,
+ MaxTokenPermissions: &repo_model.ActionsTokenPermissions{
+ UnitAccessModes: map[unit.Type]perm_model.AccessMode{
+ unit.TypeCode: perm_model.AccessModeRead,
+ },
+ },
+ }
+ require.NoError(t, actions_model.SetOwnerActionsConfig(ctx, owner2.ID, ownerCfg))
+
+ // Repo policy: OverrideOwnerConfig = false (should inherit owner's restricted mode)
+ repo2ActionsUnit := repo2.MustGetUnit(ctx, unit.TypeActions)
+ repo2ActionsCfg := repo2ActionsUnit.ActionsConfig()
+ repo2ActionsCfg.OverrideOwnerConfig = false
+ require.NoError(t, repo_model.UpdateRepoUnitConfig(ctx, repo2ActionsUnit))
+
+ perm, err := GetActionsUserRepoPermission(ctx, repo2, actionsUser, task53.ID)
+ require.NoError(t, err)
+
+ // Should be clamped to Read-only
+ assert.Equal(t, perm_model.AccessModeRead, perm.UnitAccessMode(unit.TypeCode))
+ assert.False(t, perm.CanWrite(unit.TypeCode))
+ })
+
+ t.Run("RepoOverride_Clamping", func(t *testing.T) {
+ task53 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionTask{ID: 53})
+
+ // Owner policy: Permissive (Write access)
+ ownerCfg := actions_model.OwnerActionsConfig{
+ TokenPermissionMode: repo_model.ActionsTokenPermissionModePermissive,
+ }
+ require.NoError(t, actions_model.SetOwnerActionsConfig(ctx, owner2.ID, ownerCfg))
+
+ // Repo policy: OverrideOwnerConfig = true, MaxTokenPermissions = Read
+ repo2ActionsUnit := repo2.MustGetUnit(ctx, unit.TypeActions)
+ repo2ActionsCfg := repo2ActionsUnit.ActionsConfig()
+ repo2ActionsCfg.OverrideOwnerConfig = true
+ repo2ActionsCfg.TokenPermissionMode = repo_model.ActionsTokenPermissionModeRestricted
+ repo2ActionsCfg.MaxTokenPermissions = &repo_model.ActionsTokenPermissions{
+ UnitAccessModes: map[unit.Type]perm_model.AccessMode{
+ unit.TypeCode: perm_model.AccessModeRead,
+ },
+ }
+ require.NoError(t, repo_model.UpdateRepoUnitConfig(ctx, repo2ActionsUnit))
+
+ perm, err := GetActionsUserRepoPermission(ctx, repo2, actionsUser, task53.ID)
+ require.NoError(t, err)
+
+ // Should be clamped to Read-only
+ assert.Equal(t, perm_model.AccessModeRead, perm.UnitAccessMode(unit.TypeCode))
+ })
+}
diff --git a/models/perm/access/repo_permission.go b/models/perm/access/repo_permission.go
index 3235d83203c..622fa5d99ab 100644
--- a/models/perm/access/repo_permission.go
+++ b/models/perm/access/repo_permission.go
@@ -7,6 +7,7 @@ import (
"context"
"errors"
"fmt"
+ "maps"
"slices"
"strings"
@@ -258,6 +259,23 @@ func finalProcessRepoUnitPermission(user *user_model.User, perm *Permission) {
}
}
+func checkSameOwnerCrossRepoAccess(ctx context.Context, taskRepo, targetRepo *repo_model.Repository, isForkPR bool) bool {
+ if isForkPR {
+ // Fork PRs are never allowed cross-repo access to other private repositories of the owner.
+ return false
+ }
+ if taskRepo.OwnerID != targetRepo.OwnerID {
+ return false
+ }
+ ownerCfg, err := actions_model.GetOwnerActionsConfig(ctx, targetRepo.OwnerID)
+ if err != nil {
+ log.Error("GetOwnerActionsConfig: %v", err)
+ return false
+ }
+
+ return slices.Contains(ownerCfg.AllowedCrossRepoIDs, targetRepo.ID)
+}
+
// GetActionsUserRepoPermission returns the actions user permissions to the repository
func GetActionsUserRepoPermission(ctx context.Context, repo *repo_model.Repository, actionsUser *user_model.User, taskID int64) (perm Permission, err error) {
if actionsUser.ID != user_model.ActionsUserID {
@@ -268,37 +286,96 @@ func GetActionsUserRepoPermission(ctx context.Context, repo *repo_model.Reposito
return perm, err
}
- var accessMode perm_model.AccessMode
+ if err := task.LoadJob(ctx); err != nil {
+ return perm, err
+ }
+
+ var taskRepo *repo_model.Repository
if task.RepoID != repo.ID {
- taskRepo, exist, err := db.GetByID[repo_model.Repository](ctx, task.RepoID)
- if err != nil || !exist {
+ if err := task.Job.LoadRepo(ctx); err != nil {
return perm, err
}
- actionsCfg := repo.MustGetUnit(ctx, unit.TypeActions).ActionsConfig()
- if !actionsCfg.IsCollaborativeOwner(taskRepo.OwnerID) || !taskRepo.IsPrivate {
- // The task repo can access the current repo only if the task repo is private and
- // the owner of the task repo is a collaborative owner of the current repo.
- // FIXME should owner's visibility also be considered here?
+ taskRepo = task.Job.Repo
+ } else {
+ taskRepo = repo
+ }
- // check permission like simple user but limit to read-only
- perm, err = GetUserRepoPermission(ctx, repo, user_model.NewActionsUser())
+ // Compute effective permissions for this task against the target repo
+ effectivePerms, err := actions_model.ComputeTaskTokenPermissions(ctx, task, repo)
+ if err != nil {
+ return perm, err
+ }
+ if task.RepoID != repo.ID {
+ // Cross-repo access must also respect the target repo's permission ceiling.
+ targetRepoActionsCfg := repo.MustGetUnit(ctx, unit.TypeActions).ActionsConfig()
+ if targetRepoActionsCfg.OverrideOwnerConfig {
+ effectivePerms = targetRepoActionsCfg.ClampPermissions(effectivePerms)
+ } else {
+ targetRepoOwnerActionsCfg, err := actions_model.GetOwnerActionsConfig(ctx, repo.OwnerID)
if err != nil {
return perm, err
}
- perm.AccessMode = min(perm.AccessMode, perm_model.AccessModeRead)
- return perm, nil
+ effectivePerms = targetRepoOwnerActionsCfg.ClampPermissions(effectivePerms)
}
- accessMode = perm_model.AccessModeRead
- } else if task.IsForkPullRequest {
- accessMode = perm_model.AccessModeRead
- } else {
- accessMode = perm_model.AccessModeWrite
}
if err := repo.LoadUnits(ctx); err != nil {
return perm, err
}
- perm.SetUnitsWithDefaultAccessMode(repo.Units, accessMode)
+
+ var maxPerm Permission
+
+ // Set up per-unit access modes based on configured permissions
+ maxPerm.units = repo.Units
+ maxPerm.unitsMode = maps.Clone(effectivePerms.UnitAccessModes)
+
+ // Check permission like simple user but limit to read-only (PR #36095)
+ // Enhanced to also grant read-only access if isSameRepo is true and target repository is public
+ botPerm, err := GetUserRepoPermission(ctx, repo, user_model.NewActionsUser())
+ if err != nil {
+ return perm, err
+ }
+ if botPerm.AccessMode >= perm_model.AccessModeRead {
+ // Public repo allows read access, increase permissions to at least read
+ // Otherwise you cannot access your own repository if your permissions are set to none but the repository is public
+ for _, u := range repo.Units {
+ if botPerm.CanRead(u.Type) {
+ maxPerm.unitsMode[u.Type] = max(maxPerm.unitsMode[u.Type], perm_model.AccessModeRead)
+ }
+ }
+ }
+
+ if task.RepoID == repo.ID {
+ return maxPerm, nil
+ }
+
+ if checkSameOwnerCrossRepoAccess(ctx, taskRepo, repo, task.IsForkPullRequest) {
+ // Access allowed by owner policy (grants access to private repos).
+ // Note: maxPerm has already been restricted to Read-Only in ComputeTaskTokenPermissions
+ // because isSameRepo is false.
+ return maxPerm, nil
+ }
+
+ // Fall through to allow public repository read access via botPerm check below
+
+ // Check if the repo is public or the Bot has explicit access
+ if botPerm.AccessMode >= perm_model.AccessModeRead {
+ return maxPerm, nil
+ }
+
+ // Check Collaborative Owner and explicit Bot permissions
+ // We allow access if:
+ // 1. It's a collaborative owner relationship
+ // 2. The Actions Bot user has been explicitly granted access and repository is private
+ // 3. The repository is public (handled by botPerm above)
+
+ if taskRepo.IsPrivate {
+ actionsUnit := repo.MustGetUnit(ctx, unit.TypeActions)
+ if actionsUnit.ActionsConfig().IsCollaborativeOwner(taskRepo.OwnerID) {
+ return maxPerm, nil
+ }
+ }
+
return perm, nil
}
diff --git a/models/repo/pull_request_default_test.go b/models/repo/pull_request_default_test.go
index 1c4f585ed90..b1653f2f1ae 100644
--- a/models/repo/pull_request_default_test.go
+++ b/models/repo/pull_request_default_test.go
@@ -26,7 +26,7 @@ func TestDefaultTargetBranchSelection(t *testing.T) {
prConfig := prUnit.PullRequestsConfig()
prConfig.DefaultTargetBranch = "branch2"
prUnit.Config = prConfig
- assert.NoError(t, UpdateRepoUnit(ctx, prUnit))
+ assert.NoError(t, UpdateRepoUnitConfig(ctx, prUnit))
repo.Units = nil
assert.Equal(t, "branch2", repo.GetPullRequestTargetBranch(ctx))
}
diff --git a/models/repo/repo_list.go b/models/repo/repo_list.go
index 811f83c9997..e927174a55f 100644
--- a/models/repo/repo_list.go
+++ b/models/repo/repo_list.go
@@ -778,3 +778,11 @@ func GetUserRepositories(ctx context.Context, opts SearchRepoOptions) (Repositor
repos := make(RepositoryList, 0, opts.PageSize)
return repos, count, db.SetSessionPagination(sess, &opts).Find(&repos)
}
+
+func GetOwnerRepositoriesByIDs(ctx context.Context, ownerID int64, repoIDs []int64) (RepositoryList, error) {
+ if len(repoIDs) == 0 {
+ return RepositoryList{}, nil
+ }
+ repos := make(RepositoryList, 0, len(repoIDs))
+ return repos, db.GetEngine(ctx).Where(builder.Eq{"owner_id": ownerID}).In("id", repoIDs).Find(&repos)
+}
diff --git a/models/repo/repo_unit.go b/models/repo/repo_unit.go
index 491e96770c4..797b34de696 100644
--- a/models/repo/repo_unit.go
+++ b/models/repo/repo_unit.go
@@ -5,8 +5,6 @@ package repo
import (
"context"
- "slices"
- "strings"
"code.gitea.io/gitea/models/db"
"code.gitea.io/gitea/models/perm"
@@ -175,57 +173,6 @@ func DefaultPullRequestsUnit(repoID int64) RepoUnit {
return RepoUnit{RepoID: repoID, Type: unit.TypePullRequests, Config: DefaultPullRequestsConfig()}
}
-type ActionsConfig struct {
- DisabledWorkflows []string
- // CollaborativeOwnerIDs is a list of owner IDs used to share actions from private repos.
- // Only workflows from the private repos whose owners are in CollaborativeOwnerIDs can access the current repo's actions.
- CollaborativeOwnerIDs []int64
-}
-
-func (cfg *ActionsConfig) EnableWorkflow(file string) {
- cfg.DisabledWorkflows = util.SliceRemoveAll(cfg.DisabledWorkflows, file)
-}
-
-func (cfg *ActionsConfig) ToString() string {
- return strings.Join(cfg.DisabledWorkflows, ",")
-}
-
-func (cfg *ActionsConfig) IsWorkflowDisabled(file string) bool {
- return slices.Contains(cfg.DisabledWorkflows, file)
-}
-
-func (cfg *ActionsConfig) DisableWorkflow(file string) {
- if slices.Contains(cfg.DisabledWorkflows, file) {
- return
- }
-
- cfg.DisabledWorkflows = append(cfg.DisabledWorkflows, file)
-}
-
-func (cfg *ActionsConfig) AddCollaborativeOwner(ownerID int64) {
- if !slices.Contains(cfg.CollaborativeOwnerIDs, ownerID) {
- cfg.CollaborativeOwnerIDs = append(cfg.CollaborativeOwnerIDs, ownerID)
- }
-}
-
-func (cfg *ActionsConfig) RemoveCollaborativeOwner(ownerID int64) {
- cfg.CollaborativeOwnerIDs = util.SliceRemoveAll(cfg.CollaborativeOwnerIDs, ownerID)
-}
-
-func (cfg *ActionsConfig) IsCollaborativeOwner(ownerID int64) bool {
- return slices.Contains(cfg.CollaborativeOwnerIDs, ownerID)
-}
-
-// FromDB fills up a ActionsConfig from serialized format.
-func (cfg *ActionsConfig) FromDB(bs []byte) error {
- return json.UnmarshalHandleDoubleEncode(bs, &cfg)
-}
-
-// ToDB exports a ActionsConfig to a serialized format.
-func (cfg *ActionsConfig) ToDB() ([]byte, error) {
- return json.Marshal(cfg)
-}
-
// ProjectsMode represents the projects enabled for a repository
type ProjectsMode string
@@ -279,7 +226,8 @@ func (cfg *ProjectsConfig) IsProjectsAllowed(m ProjectsMode) bool {
func (r *RepoUnit) BeforeSet(colName string, val xorm.Cell) {
switch colName {
case "type":
- switch unit.Type(db.Cell2Int64(val)) {
+ r.Type = unit.Type(db.Cell2Int64(val))
+ switch r.Type {
case unit.TypeExternalWiki:
r.Config = new(ExternalWikiConfig)
case unit.TypeExternalTracker:
@@ -297,6 +245,11 @@ func (r *RepoUnit) BeforeSet(colName string, val xorm.Cell) {
default:
r.Config = new(UnitConfig)
}
+ case "config":
+ if *val == nil {
+ // XROM doesn't call FromDB if the value is nil, but we need to set default values for the config fields
+ _ = r.Config.FromDB(nil)
+ }
}
}
@@ -360,9 +313,9 @@ func getUnitsByRepoID(ctx context.Context, repoID int64) (units []*RepoUnit, err
return units, nil
}
-// UpdateRepoUnit updates the provided repo unit
-func UpdateRepoUnit(ctx context.Context, unit *RepoUnit) error {
- _, err := db.GetEngine(ctx).ID(unit.ID).Update(unit)
+// UpdateRepoUnitConfig updates the config of the provided repo unit
+func UpdateRepoUnitConfig(ctx context.Context, unit *RepoUnit) error {
+ _, err := db.GetEngine(ctx).ID(unit.ID).Cols("config").Update(unit)
return err
}
diff --git a/models/repo/repo_unit_actions.go b/models/repo/repo_unit_actions.go
new file mode 100644
index 00000000000..50e2925792a
--- /dev/null
+++ b/models/repo/repo_unit_actions.go
@@ -0,0 +1,153 @@
+// Copyright 2026 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package repo
+
+import (
+ "slices"
+
+ "code.gitea.io/gitea/models/perm"
+ "code.gitea.io/gitea/models/unit"
+ "code.gitea.io/gitea/modules/json"
+ "code.gitea.io/gitea/modules/util"
+)
+
+// ActionsTokenPermissionMode defines the default permission mode for Actions tokens
+type ActionsTokenPermissionMode string
+
+const (
+ // ActionsTokenPermissionModePermissive - write access by default (current behavior, backwards compatible)
+ ActionsTokenPermissionModePermissive ActionsTokenPermissionMode = "permissive"
+ // ActionsTokenPermissionModeRestricted - read access by default
+ ActionsTokenPermissionModeRestricted ActionsTokenPermissionMode = "restricted"
+)
+
+func (ActionsTokenPermissionMode) EnumValues() []ActionsTokenPermissionMode {
+ return []ActionsTokenPermissionMode{ActionsTokenPermissionModePermissive /* default */, ActionsTokenPermissionModeRestricted}
+}
+
+// ActionsTokenPermissions defines the permissions for different repository units
+type ActionsTokenPermissions struct {
+ UnitAccessModes map[unit.Type]perm.AccessMode `json:"unit_access_modes,omitempty"`
+}
+
+var ActionsTokenUnitTypes = []unit.Type{
+ unit.TypeCode,
+ unit.TypeIssues,
+ unit.TypePullRequests,
+ unit.TypePackages,
+ unit.TypeActions,
+ unit.TypeWiki,
+ unit.TypeReleases,
+ unit.TypeProjects,
+}
+
+func MakeActionsTokenPermissions(unitAccessMode perm.AccessMode) (ret ActionsTokenPermissions) {
+ ret.UnitAccessModes = make(map[unit.Type]perm.AccessMode)
+ for _, u := range ActionsTokenUnitTypes {
+ ret.UnitAccessModes[u] = unitAccessMode
+ }
+ return ret
+}
+
+// ClampActionsTokenPermissions ensures that the given permissions don't exceed the maximum
+func ClampActionsTokenPermissions(p1, p2 ActionsTokenPermissions) (ret ActionsTokenPermissions) {
+ ret.UnitAccessModes = make(map[unit.Type]perm.AccessMode)
+ for _, ut := range ActionsTokenUnitTypes {
+ ret.UnitAccessModes[ut] = min(p1.UnitAccessModes[ut], p2.UnitAccessModes[ut])
+ }
+ return ret
+}
+
+// MakeRestrictedPermissions returns the restricted permissions
+func MakeRestrictedPermissions() ActionsTokenPermissions {
+ ret := MakeActionsTokenPermissions(perm.AccessModeNone)
+ ret.UnitAccessModes[unit.TypeCode] = perm.AccessModeRead
+ ret.UnitAccessModes[unit.TypePackages] = perm.AccessModeRead
+ ret.UnitAccessModes[unit.TypeReleases] = perm.AccessModeRead
+ return ret
+}
+
+type ActionsConfig struct {
+ DisabledWorkflows []string
+ // CollaborativeOwnerIDs is a list of owner IDs used to share actions from private repos.
+ // Only workflows from the private repos whose owners are in CollaborativeOwnerIDs can access the current repo's actions.
+ CollaborativeOwnerIDs []int64
+ // TokenPermissionMode defines the default permission mode (permissive, restricted, or custom)
+ TokenPermissionMode ActionsTokenPermissionMode `json:"token_permission_mode,omitempty"`
+ // MaxTokenPermissions defines the absolute maximum permissions any token can have in this context.
+ // Workflow YAML "permissions" keywords can reduce permissions but never exceed this ceiling.
+ MaxTokenPermissions *ActionsTokenPermissions `json:"max_token_permissions,omitempty"`
+ // OverrideOwnerConfig indicates if this repository should override the owner-level configuration (User or Org)
+ OverrideOwnerConfig bool `json:"override_owner_config,omitempty"`
+}
+
+func (cfg *ActionsConfig) EnableWorkflow(file string) {
+ cfg.DisabledWorkflows = util.SliceRemoveAll(cfg.DisabledWorkflows, file)
+}
+
+func (cfg *ActionsConfig) IsWorkflowDisabled(file string) bool {
+ return slices.Contains(cfg.DisabledWorkflows, file)
+}
+
+func (cfg *ActionsConfig) DisableWorkflow(file string) {
+ if slices.Contains(cfg.DisabledWorkflows, file) {
+ return
+ }
+
+ cfg.DisabledWorkflows = append(cfg.DisabledWorkflows, file)
+}
+
+func (cfg *ActionsConfig) AddCollaborativeOwner(ownerID int64) {
+ if !slices.Contains(cfg.CollaborativeOwnerIDs, ownerID) {
+ cfg.CollaborativeOwnerIDs = append(cfg.CollaborativeOwnerIDs, ownerID)
+ }
+}
+
+func (cfg *ActionsConfig) RemoveCollaborativeOwner(ownerID int64) {
+ cfg.CollaborativeOwnerIDs = util.SliceRemoveAll(cfg.CollaborativeOwnerIDs, ownerID)
+}
+
+func (cfg *ActionsConfig) IsCollaborativeOwner(ownerID int64) bool {
+ return slices.Contains(cfg.CollaborativeOwnerIDs, ownerID)
+}
+
+// GetDefaultTokenPermissions returns the default token permissions by its TokenPermissionMode.
+// It does not apply MaxTokenPermissions; callers must clamp if needed.
+func (cfg *ActionsConfig) GetDefaultTokenPermissions() ActionsTokenPermissions {
+ switch cfg.TokenPermissionMode {
+ case ActionsTokenPermissionModeRestricted:
+ return MakeRestrictedPermissions()
+ case ActionsTokenPermissionModePermissive:
+ return MakeActionsTokenPermissions(perm.AccessModeWrite)
+ default:
+ return ActionsTokenPermissions{}
+ }
+}
+
+// GetMaxTokenPermissions returns the maximum allowed permissions
+func (cfg *ActionsConfig) GetMaxTokenPermissions() ActionsTokenPermissions {
+ if cfg.MaxTokenPermissions != nil {
+ return *cfg.MaxTokenPermissions
+ }
+ // Default max is write for everything
+ return MakeActionsTokenPermissions(perm.AccessModeWrite)
+}
+
+// ClampPermissions ensures that the given permissions don't exceed the maximum
+func (cfg *ActionsConfig) ClampPermissions(perms ActionsTokenPermissions) ActionsTokenPermissions {
+ maxPerms := cfg.GetMaxTokenPermissions()
+ return ClampActionsTokenPermissions(perms, maxPerms)
+}
+
+// FromDB fills up a ActionsConfig from serialized format.
+func (cfg *ActionsConfig) FromDB(bs []byte) error {
+ _ = json.UnmarshalHandleDoubleEncode(bs, &cfg)
+ cfg.TokenPermissionMode, _ = util.EnumValue(cfg.TokenPermissionMode)
+ return nil
+}
+
+// ToDB exports a ActionsConfig to a serialized format.
+func (cfg *ActionsConfig) ToDB() ([]byte, error) {
+ return json.Marshal(cfg)
+}
diff --git a/models/repo/repo_unit_test.go b/models/repo/repo_unit_test.go
index 56dda5672d4..08f9ac2cd4e 100644
--- a/models/repo/repo_unit_test.go
+++ b/models/repo/repo_unit_test.go
@@ -4,8 +4,12 @@
package repo
import (
+ "strings"
"testing"
+ "code.gitea.io/gitea/models/perm"
+ "code.gitea.io/gitea/models/unit"
+
"github.com/stretchr/testify/assert"
)
@@ -26,5 +30,75 @@ func TestActionsConfig(t *testing.T) {
cfg.DisableWorkflow("test1.yaml")
cfg.DisableWorkflow("test2.yaml")
cfg.DisableWorkflow("test3.yaml")
- assert.Equal(t, "test1.yaml,test2.yaml,test3.yaml", cfg.ToString())
+ assert.Equal(t, "test1.yaml,test2.yaml,test3.yaml", strings.Join(cfg.DisabledWorkflows, ","))
+}
+
+func TestActionsConfigTokenPermissions(t *testing.T) {
+ t.Run("Default Permission Mode", func(t *testing.T) {
+ cfg := &ActionsConfig{TokenPermissionMode: "invalid-value"}
+ _ = cfg.FromDB(nil)
+ assert.Equal(t, ActionsTokenPermissionModePermissive, cfg.TokenPermissionMode)
+ assert.Equal(t, perm.AccessModeWrite, cfg.GetDefaultTokenPermissions().UnitAccessModes[unit.TypeCode])
+ })
+
+ t.Run("Explicit Permission Mode", func(t *testing.T) {
+ cfg := &ActionsConfig{
+ TokenPermissionMode: ActionsTokenPermissionModeRestricted,
+ }
+ assert.Equal(t, ActionsTokenPermissionModeRestricted, cfg.TokenPermissionMode)
+ })
+
+ t.Run("Effective Permissions - Permissive Mode", func(t *testing.T) {
+ cfg := &ActionsConfig{
+ TokenPermissionMode: ActionsTokenPermissionModePermissive,
+ }
+ defaultPerms := cfg.GetDefaultTokenPermissions()
+ perms := cfg.ClampPermissions(defaultPerms)
+ assert.Equal(t, perm.AccessModeWrite, perms.UnitAccessModes[unit.TypeCode])
+ assert.Equal(t, perm.AccessModeWrite, perms.UnitAccessModes[unit.TypeIssues])
+ assert.Equal(t, perm.AccessModeWrite, perms.UnitAccessModes[unit.TypePackages])
+ })
+
+ t.Run("Effective Permissions - Restricted Mode", func(t *testing.T) {
+ cfg := &ActionsConfig{
+ TokenPermissionMode: ActionsTokenPermissionModeRestricted,
+ }
+ defaultPerms := cfg.GetDefaultTokenPermissions()
+ perms := cfg.ClampPermissions(defaultPerms)
+ assert.Equal(t, perm.AccessModeRead, perms.UnitAccessModes[unit.TypeCode])
+ assert.Equal(t, perm.AccessModeNone, perms.UnitAccessModes[unit.TypeIssues])
+ assert.Equal(t, perm.AccessModeRead, perms.UnitAccessModes[unit.TypePackages])
+ })
+
+ t.Run("Clamp Permissions", func(t *testing.T) {
+ cfg := &ActionsConfig{
+ MaxTokenPermissions: &ActionsTokenPermissions{
+ UnitAccessModes: map[unit.Type]perm.AccessMode{
+ unit.TypeCode: perm.AccessModeRead,
+ unit.TypeIssues: perm.AccessModeWrite,
+ unit.TypePullRequests: perm.AccessModeRead,
+ unit.TypePackages: perm.AccessModeRead,
+ unit.TypeActions: perm.AccessModeNone,
+ unit.TypeWiki: perm.AccessModeWrite,
+ },
+ },
+ }
+ input := ActionsTokenPermissions{
+ UnitAccessModes: map[unit.Type]perm.AccessMode{
+ unit.TypeCode: perm.AccessModeWrite, // Should be clamped to Read
+ unit.TypeIssues: perm.AccessModeWrite, // Should stay Write
+ unit.TypePullRequests: perm.AccessModeWrite, // Should be clamped to Read
+ unit.TypePackages: perm.AccessModeWrite, // Should be clamped to Read
+ unit.TypeActions: perm.AccessModeRead, // Should be clamped to None
+ unit.TypeWiki: perm.AccessModeRead, // Should stay Read
+ },
+ }
+ clamped := cfg.ClampPermissions(input)
+ assert.Equal(t, perm.AccessModeRead, clamped.UnitAccessModes[unit.TypeCode])
+ assert.Equal(t, perm.AccessModeWrite, clamped.UnitAccessModes[unit.TypeIssues])
+ assert.Equal(t, perm.AccessModeRead, clamped.UnitAccessModes[unit.TypePullRequests])
+ assert.Equal(t, perm.AccessModeRead, clamped.UnitAccessModes[unit.TypePackages])
+ assert.Equal(t, perm.AccessModeNone, clamped.UnitAccessModes[unit.TypeActions])
+ assert.Equal(t, perm.AccessModeRead, clamped.UnitAccessModes[unit.TypeWiki])
+ })
}
diff --git a/models/user/setting.go b/models/user/setting.go
index c65afae76c8..a16fc86e55e 100644
--- a/models/user/setting.go
+++ b/models/user/setting.go
@@ -11,10 +11,12 @@ import (
"code.gitea.io/gitea/models/db"
"code.gitea.io/gitea/modules/cache"
+ "code.gitea.io/gitea/modules/json"
setting_module "code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/util"
"xorm.io/builder"
+ "xorm.io/xorm/convert"
)
// Setting is a key value store of user settings
@@ -211,3 +213,44 @@ func upsertUserSettingValue(ctx context.Context, userID int64, key, value string
return err
})
}
+
+func GetUserSettingJSON[T any](ctx context.Context, userID int64, key string, def T) (ret T, _ error) {
+ ret = def
+ str, err := GetUserSetting(ctx, userID, key)
+ if err != nil {
+ return ret, err
+ }
+
+ conv, ok := any(&ret).(convert.ConversionFrom)
+ if !ok {
+ conv, ok = any(ret).(convert.ConversionFrom)
+ }
+ if ok {
+ if err := conv.FromDB(util.UnsafeStringToBytes(str)); err != nil {
+ return ret, err
+ }
+ } else {
+ if str == "" {
+ return ret, nil
+ }
+ err = json.Unmarshal(util.UnsafeStringToBytes(str), &ret)
+ }
+ return ret, err
+}
+
+func SetUserSettingJSON[T any](ctx context.Context, userID int64, key string, val T) (err error) {
+ conv, ok := any(&val).(convert.ConversionTo)
+ if !ok {
+ conv, ok = any(val).(convert.ConversionTo)
+ }
+ var bs []byte
+ if ok {
+ bs, err = conv.ToDB()
+ } else {
+ bs, err = json.Marshal(val)
+ }
+ if err != nil {
+ return err
+ }
+ return SetUserSetting(ctx, userID, key, util.UnsafeBytesToString(bs))
+}
diff --git a/models/user/setting_options.go b/models/user/setting_options.go
index 587a46e8dec..5867b908d1b 100644
--- a/models/user/setting_options.go
+++ b/models/user/setting_options.go
@@ -22,4 +22,6 @@ const (
SettingEmailNotificationGiteaActionsAll = "all"
SettingEmailNotificationGiteaActionsFailureOnly = "failure-only" // Default for actions email preference
SettingEmailNotificationGiteaActionsDisabled = "disabled"
+
+ SettingsKeyActionsConfig = "actions.config"
)
diff --git a/modules/actions/artifacts.go b/modules/actions/artifacts.go
index d28726e8993..e8bf70ec310 100644
--- a/modules/actions/artifacts.go
+++ b/modules/actions/artifacts.go
@@ -20,7 +20,7 @@ func IsArtifactV4(art *actions_model.ActionArtifact) bool {
func DownloadArtifactV4ServeDirectOnly(ctx *context.Base, art *actions_model.ActionArtifact) (bool, error) {
if setting.Actions.ArtifactStorage.ServeDirect() {
- u, err := storage.ActionsArtifacts.URL(art.StoragePath, art.ArtifactPath, ctx.Req.Method, nil)
+ u, err := storage.ActionsArtifacts.ServeDirectURL(art.StoragePath, art.ArtifactPath, ctx.Req.Method, nil)
if u != nil && err == nil {
ctx.Redirect(u.String(), http.StatusFound)
return true, nil
diff --git a/modules/httplib/serve.go b/modules/httplib/serve.go
index 2d66a86a8b0..fc7edc36c43 100644
--- a/modules/httplib/serve.go
+++ b/modules/httplib/serve.go
@@ -112,21 +112,20 @@ func setServeHeadersByFile(r *http.Request, w http.ResponseWriter, mineBuf []byt
opts.ContentTypeCharset = strings.ToLower(charset)
}
- isSVG := sniffedType.IsSvgImage()
-
// serve types that can present a security risk with CSP
- if isSVG {
- w.Header().Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; sandbox")
- } else if sniffedType.IsPDF() {
- // no sandbox attribute for pdf as it breaks rendering in at least safari. this
+ w.Header().Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; sandbox")
+
+ if sniffedType.IsPDF() {
+ // no sandbox attribute for PDF as it breaks rendering in at least safari. this
// should generally be safe as scripts inside PDF can not escape the PDF document
// see https://bugs.chromium.org/p/chromium/issues/detail?id=413851 for more discussion
// HINT: PDF-RENDER-SANDBOX: PDF won't render in sandboxed context
w.Header().Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'")
}
+ // TODO: UNIFY-CONTENT-DISPOSITION-FROM-STORAGE
opts.Disposition = "inline"
- if isSVG && !setting.UI.SVG.Enabled {
+ if sniffedType.IsSvgImage() && !setting.UI.SVG.Enabled {
opts.Disposition = "attachment"
}
diff --git a/modules/packages/content_store.go b/modules/packages/content_store.go
index 57974515e2c..4c61233b5e7 100644
--- a/modules/packages/content_store.go
+++ b/modules/packages/content_store.go
@@ -36,8 +36,8 @@ func (s *ContentStore) ShouldServeDirect() bool {
return setting.Packages.Storage.ServeDirect()
}
-func (s *ContentStore) GetServeDirectURL(key BlobHash256Key, filename, method string, reqParams url.Values) (*url.URL, error) {
- return s.store.URL(KeyToRelativePath(key), filename, method, reqParams)
+func (s *ContentStore) GetServeDirectURL(key BlobHash256Key, filename, method string, reqParams *storage.ServeDirectOptions) (*url.URL, error) {
+ return s.store.ServeDirectURL(KeyToRelativePath(key), filename, method, reqParams)
}
// FIXME: Workaround to be removed in v1.20
diff --git a/modules/private/hook.go b/modules/private/hook.go
index 215996b9b99..ce87ccd8019 100644
--- a/modules/private/hook.go
+++ b/modules/private/hook.go
@@ -37,7 +37,7 @@ type HookOptions struct {
PushTrigger repository.PushTrigger
DeployKeyID int64 // if the pusher is a DeployKey, then UserID is the repo's org user.
IsWiki bool
- ActionPerm int
+ ActionsTaskID int64 // if the pusher is an Actions user, the task ID
}
// SSHLogOption ssh log options
diff --git a/modules/public/mime_types.go b/modules/public/mime_types.go
index 32bdf3bfa25..fef85d77cbe 100644
--- a/modules/public/mime_types.go
+++ b/modules/public/mime_types.go
@@ -3,9 +3,11 @@
package public
-import "strings"
+import (
+ "strings"
+)
-// wellKnownMimeTypesLower comes from Golang's builtin mime package: `builtinTypesLower`, see the comment of detectWellKnownMimeType
+// wellKnownMimeTypesLower comes from Golang's builtin mime package: `builtinTypesLower`, see the comment of DetectWellKnownMimeType
var wellKnownMimeTypesLower = map[string]string{
".avif": "image/avif",
".css": "text/css; charset=utf-8",
@@ -28,13 +30,13 @@ var wellKnownMimeTypesLower = map[string]string{
".txt": "text/plain; charset=utf-8",
}
-// detectWellKnownMimeType will return the mime-type for a well-known file ext name
+// DetectWellKnownMimeType will return the mime-type for a well-known file ext name
// The purpose of this function is to bypass the unstable behavior of Golang's mime.TypeByExtension
// mime.TypeByExtension would use OS's mime-type config to overwrite the well-known types (see its document).
// If the user's OS has incorrect mime-type config, it would make Gitea can not respond a correct Content-Type to browsers.
// For example, if Gitea returns `text/plain` for a `.js` file, the browser couldn't run the JS due to security reasons.
-// detectWellKnownMimeType makes the Content-Type for well-known files stable.
-func detectWellKnownMimeType(ext string) string {
+// DetectWellKnownMimeType makes the Content-Type for well-known files stable.
+func DetectWellKnownMimeType(ext string) string {
ext = strings.ToLower(ext)
return wellKnownMimeTypesLower[ext]
}
diff --git a/modules/public/public.go b/modules/public/public.go
index 3a5a76637e7..004aad5f3b1 100644
--- a/modules/public/public.go
+++ b/modules/public/public.go
@@ -51,9 +51,9 @@ func parseAcceptEncoding(val string) container.Set[string] {
}
// setWellKnownContentType will set the Content-Type if the file is a well-known type.
-// See the comments of detectWellKnownMimeType
+// See the comments of DetectWellKnownMimeType
func setWellKnownContentType(w http.ResponseWriter, file string) {
- mimeType := detectWellKnownMimeType(path.Ext(file))
+ mimeType := DetectWellKnownMimeType(path.Ext(file))
if mimeType != "" {
w.Header().Set("Content-Type", mimeType)
}
diff --git a/modules/repository/env.go b/modules/repository/env.go
index 55a81f006e2..ed2c6fef81a 100644
--- a/modules/repository/env.go
+++ b/modules/repository/env.go
@@ -11,25 +11,26 @@ import (
repo_model "code.gitea.io/gitea/models/repo"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/util"
)
// env keys for git hooks need
const (
- EnvRepoName = "GITEA_REPO_NAME"
- EnvRepoUsername = "GITEA_REPO_USER_NAME"
- EnvRepoID = "GITEA_REPO_ID"
- EnvRepoIsWiki = "GITEA_REPO_IS_WIKI"
- EnvPusherName = "GITEA_PUSHER_NAME"
- EnvPusherEmail = "GITEA_PUSHER_EMAIL"
- EnvPusherID = "GITEA_PUSHER_ID"
- EnvKeyID = "GITEA_KEY_ID" // public key ID
- EnvDeployKeyID = "GITEA_DEPLOY_KEY_ID"
- EnvPRID = "GITEA_PR_ID"
- EnvPRIndex = "GITEA_PR_INDEX" // not used by Gitea at the moment, it is for custom git hooks
- EnvPushTrigger = "GITEA_PUSH_TRIGGER"
- EnvIsInternal = "GITEA_INTERNAL_PUSH"
- EnvAppURL = "GITEA_ROOT_URL"
- EnvActionPerm = "GITEA_ACTION_PERM"
+ EnvRepoName = "GITEA_REPO_NAME"
+ EnvRepoUsername = "GITEA_REPO_USER_NAME"
+ EnvRepoID = "GITEA_REPO_ID"
+ EnvRepoIsWiki = "GITEA_REPO_IS_WIKI"
+ EnvPusherName = "GITEA_PUSHER_NAME"
+ EnvPusherEmail = "GITEA_PUSHER_EMAIL"
+ EnvPusherID = "GITEA_PUSHER_ID"
+ EnvKeyID = "GITEA_KEY_ID" // public key ID
+ EnvDeployKeyID = "GITEA_DEPLOY_KEY_ID"
+ EnvPRID = "GITEA_PR_ID"
+ EnvPRIndex = "GITEA_PR_INDEX" // not used by Gitea at the moment, it is for custom git hooks
+ EnvPushTrigger = "GITEA_PUSH_TRIGGER"
+ EnvIsInternal = "GITEA_INTERNAL_PUSH"
+ EnvAppURL = "GITEA_ROOT_URL"
+ EnvActionsTaskID = "GITEA_ACTIONS_TASK_ID"
)
type PushTrigger string
@@ -54,36 +55,39 @@ func PushingEnvironment(doer *user_model.User, repo *repo_model.Repository) []st
return FullPushingEnvironment(doer, doer, repo, repo.Name, 0, 0)
}
+func DoerPushingEnvironment(doer *user_model.User, repo *repo_model.Repository, isWiki bool) []string {
+ env := []string{
+ EnvAppURL + "=" + setting.AppURL,
+ EnvRepoName + "=" + repo.Name + util.Iif(isWiki, ".wiki", ""),
+ EnvRepoUsername + "=" + repo.OwnerName,
+ EnvRepoID + "=" + strconv.FormatInt(repo.ID, 10),
+ EnvRepoIsWiki + "=" + strconv.FormatBool(isWiki),
+ EnvPusherName + "=" + doer.Name,
+ EnvPusherID + "=" + strconv.FormatInt(doer.ID, 10),
+ }
+ if !doer.KeepEmailPrivate {
+ env = append(env, EnvPusherEmail+"="+doer.Email)
+ }
+ if taskID, isActionsUser := user_model.GetActionsUserTaskID(doer); isActionsUser {
+ env = append(env, EnvActionsTaskID+"="+strconv.FormatInt(taskID, 10))
+ }
+ return env
+}
+
// FullPushingEnvironment returns an os environment to allow hooks to work on push
func FullPushingEnvironment(author, committer *user_model.User, repo *repo_model.Repository, repoName string, prID, prIndex int64) []string {
- isWiki := "false"
- if strings.HasSuffix(repoName, ".wiki") {
- isWiki = "true"
- }
-
+ isWiki := strings.HasSuffix(repoName, ".wiki")
authorSig := author.NewGitSig()
committerSig := committer.NewGitSig()
-
environ := append(os.Environ(),
"GIT_AUTHOR_NAME="+authorSig.Name,
"GIT_AUTHOR_EMAIL="+authorSig.Email,
"GIT_COMMITTER_NAME="+committerSig.Name,
"GIT_COMMITTER_EMAIL="+committerSig.Email,
- EnvRepoName+"="+repoName,
- EnvRepoUsername+"="+repo.OwnerName,
- EnvRepoIsWiki+"="+isWiki,
- EnvPusherName+"="+committer.Name,
- EnvPusherID+"="+strconv.FormatInt(committer.ID, 10),
- EnvRepoID+"="+strconv.FormatInt(repo.ID, 10),
EnvPRID+"="+strconv.FormatInt(prID, 10),
EnvPRIndex+"="+strconv.FormatInt(prIndex, 10),
- EnvAppURL+"="+setting.AppURL,
"SSH_ORIGINAL_COMMAND=gitea-internal",
)
-
- if !committer.KeepEmailPrivate {
- environ = append(environ, EnvPusherEmail+"="+committer.Email)
- }
-
+ environ = append(environ, DoerPushingEnvironment(committer, repo, isWiki)...)
return environ
}
diff --git a/modules/storage/azureblob.go b/modules/storage/azureblob.go
index e7297cec77a..a273a7770d0 100644
--- a/modules/storage/azureblob.go
+++ b/modules/storage/azureblob.go
@@ -8,6 +8,7 @@ import (
"errors"
"fmt"
"io"
+ "net/http"
"net/url"
"os"
"path"
@@ -246,16 +247,53 @@ func (a *AzureBlobStorage) Delete(path string) error {
return convertAzureBlobErr(err)
}
-// URL gets the redirect URL to a file. The presigned link is valid for 5 minutes.
-func (a *AzureBlobStorage) URL(path, name, _ string, reqParams url.Values) (*url.URL, error) {
- blobClient := a.getBlobClient(path)
+func (a *AzureBlobStorage) getSasURL(b *blob.Client, template sas.BlobSignatureValues) (string, error) {
+ urlParts, err := blob.ParseURL(b.URL())
+ if err != nil {
+ return "", err
+ }
- // TODO: OBJECT-STORAGE-CONTENT-TYPE: "browser inline rendering images/PDF" needs proper Content-Type header from storage
- startTime := time.Now()
- u, err := blobClient.GetSASURL(sas.BlobPermissions{
- Read: true,
- }, time.Now().Add(5*time.Minute), &blob.GetSASURLOptions{
- StartTime: &startTime,
+ var t time.Time
+ if urlParts.Snapshot == "" {
+ t = time.Time{}
+ } else {
+ t, err = time.Parse(blob.SnapshotTimeFormat, urlParts.Snapshot)
+ if err != nil {
+ return "", err
+ }
+ }
+
+ template.ContainerName = urlParts.ContainerName
+ template.BlobName = urlParts.BlobName
+ template.SnapshotTime = t
+ template.Version = sas.Version
+
+ qps, err := template.SignWithSharedKey(a.credential)
+ if err != nil {
+ return "", err
+ }
+
+ endpoint := b.URL() + "?" + qps.Encode()
+
+ return endpoint, nil
+}
+
+func (a *AzureBlobStorage) ServeDirectURL(storePath, name, method string, reqParams *ServeDirectOptions) (*url.URL, error) {
+ blobClient := a.getBlobClient(storePath)
+
+ startTime := time.Now().UTC()
+
+ param := prepareServeDirectOptions(reqParams, name)
+
+ u, err := a.getSasURL(blobClient, sas.BlobSignatureValues{
+ Permissions: (&sas.BlobPermissions{
+ Read: method == http.MethodGet || method == http.MethodHead,
+ Write: method == http.MethodPut,
+ }).String(),
+ StartTime: startTime,
+ ExpiryTime: startTime.Add(5 * time.Minute),
+ ContentDisposition: param.ContentDisposition,
+ ContentType: param.ContentType,
})
if err != nil {
return nil, convertAzureBlobErr(err)
diff --git a/modules/storage/azureblob_test.go b/modules/storage/azureblob_test.go
index b3791b49164..b5d5d0fecc0 100644
--- a/modules/storage/azureblob_test.go
+++ b/modules/storage/azureblob_test.go
@@ -14,12 +14,13 @@ import (
"github.com/stretchr/testify/assert"
)
-func TestAzureBlobStorageIterator(t *testing.T) {
+func TestAzureBlobStorage(t *testing.T) {
if os.Getenv("CI") == "" {
t.Skip("azureBlobStorage not present outside of CI")
return
}
- testStorageIterator(t, setting.AzureBlobStorageType, &setting.Storage{
+ storageType := setting.AzureBlobStorageType
+ config := &setting.Storage{
AzureBlobConfig: setting.AzureBlobStorageConfig{
// https://learn.microsoft.com/azure/storage/common/storage-use-azurite?tabs=visual-studio-code#ip-style-url
Endpoint: "http://devstoreaccount1.azurite.local:10000",
@@ -28,7 +29,25 @@ func TestAzureBlobStorageIterator(t *testing.T) {
AccountKey: "Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==",
Container: "test",
},
- })
+ }
+ table := []struct {
+ name string
+ test func(t *testing.T, typStr Type, cfg *setting.Storage)
+ }{
+ {
+ name: "iterator",
+ test: testStorageIterator,
+ },
+ {
+ name: "testBlobStorageURLContentTypeAndDisposition",
+ test: testBlobStorageURLContentTypeAndDisposition,
+ },
+ }
+ for _, entry := range table {
+ t.Run(entry.name, func(t *testing.T) {
+ entry.test(t, storageType, config)
+ })
+ }
}
func TestAzureBlobStoragePath(t *testing.T) {
diff --git a/modules/storage/helper.go b/modules/storage/helper.go
index f6c3d5eebbc..30d96a33add 100644
--- a/modules/storage/helper.go
+++ b/modules/storage/helper.go
@@ -30,7 +30,7 @@ func (s discardStorage) Delete(_ string) error {
return fmt.Errorf("%s", s)
}
-func (s discardStorage) URL(_, _, _ string, _ url.Values) (*url.URL, error) {
+func (s discardStorage) ServeDirectURL(_, _, _ string, _ *ServeDirectOptions) (*url.URL, error) {
return nil, fmt.Errorf("%s", s)
}
diff --git a/modules/storage/helper_test.go b/modules/storage/helper_test.go
index 3cba1e13c01..ae64c8aca49 100644
--- a/modules/storage/helper_test.go
+++ b/modules/storage/helper_test.go
@@ -37,7 +37,7 @@ func Test_discardStorage(t *testing.T) {
assert.Error(t, err, string(tt))
}
{
- got, err := tt.URL("path", "name", "GET", nil)
+ got, err := tt.ServeDirectURL("path", "name", "GET", nil)
assert.Nil(t, got)
assert.Errorf(t, err, string(tt))
}
diff --git a/modules/storage/local.go b/modules/storage/local.go
index 5ea6f055ce7..04e3e05f950 100644
--- a/modules/storage/local.go
+++ b/modules/storage/local.go
@@ -133,8 +133,7 @@ func (l *LocalStorage) Delete(path string) error {
return err
}
-// URL gets the redirect URL to a file
-func (l *LocalStorage) URL(path, name, _ string, reqParams url.Values) (*url.URL, error) {
+func (l *LocalStorage) ServeDirectURL(path, name, _ string, reqParams *ServeDirectOptions) (*url.URL, error) {
return nil, ErrURLNotSupported
}
diff --git a/modules/storage/minio.go b/modules/storage/minio.go
index 6993ac2d922..1355280f367 100644
--- a/modules/storage/minio.go
+++ b/modules/storage/minio.go
@@ -278,37 +278,16 @@ func (m *MinioStorage) Delete(path string) error {
return convertMinioErr(err)
}
-// URL gets the redirect URL to a file. The presigned link is valid for 5 minutes.
-func (m *MinioStorage) URL(storePath, name, method string, serveDirectReqParams url.Values) (*url.URL, error) {
- // copy serveDirectReqParams
- reqParams, err := url.ParseQuery(serveDirectReqParams.Encode())
- if err != nil {
- return nil, err
- }
+func (m *MinioStorage) ServeDirectURL(storePath, name, method string, opt *ServeDirectOptions) (*url.URL, error) {
+ reqParams := url.Values{}
- // Here we might not know the real filename, and it's quite inefficient to detect the mine type by pre-fetching the object head.
- // So we just do a quick detection by extension name, at least if works for the "View Raw File" for an LFS file on the Web UI.
- // Detect content type by extension name, only support the well-known safe types for inline rendering.
- // TODO: OBJECT-STORAGE-CONTENT-TYPE: need a complete solution and refactor for Azure in the future
- ext := path.Ext(name)
- inlineExtMimeTypes := map[string]string{
- ".png": "image/png",
- ".jpg": "image/jpeg",
- ".jpeg": "image/jpeg",
- ".gif": "image/gif",
- ".webp": "image/webp",
- ".avif": "image/avif",
- // ATTENTION! Don't support unsafe types like HTML/SVG due to security concerns: they can contain JS code, and maybe they need proper Content-Security-Policy
- // HINT: PDF-RENDER-SANDBOX: PDF won't render in sandboxed context, it seems fine to render it inline
- ".pdf": "application/pdf",
-
- // TODO: refactor with "modules/public/mime_types.go", for example: "DetectWellKnownSafeInlineMimeType"
+ param := prepareServeDirectOptions(opt, name)
+ // minio does not ignore empty params
+ if param.ContentType != "" {
+ reqParams.Set("response-content-type", param.ContentType)
}
- if mimeType, ok := inlineExtMimeTypes[ext]; ok {
- reqParams.Set("response-content-type", mimeType)
- reqParams.Set("response-content-disposition", "inline")
- } else {
- reqParams.Set("response-content-disposition", fmt.Sprintf(`attachment; filename="%s"`, quoteEscaper.Replace(name)))
+ if param.ContentDisposition != "" {
+ reqParams.Set("response-content-disposition", param.ContentDisposition)
}
expires := 5 * time.Minute
@@ -323,6 +302,7 @@ func (m *MinioStorage) URL(storePath, name, method string, serveDirectReqParams
// IterateObjects iterates across the objects in the miniostorage
func (m *MinioStorage) IterateObjects(dirName string, fn func(path string, obj Object) error) error {
opts := minio.GetObjectOptions{}
+ // FIXME: this loop is not right and causes resource leaking, see the comment of ListObjects
for mObjInfo := range m.client.ListObjects(m.ctx, m.bucket, minio.ListObjectsOptions{
Prefix: m.buildMinioDirPrefix(dirName),
Recursive: true,
diff --git a/modules/storage/minio_test.go b/modules/storage/minio_test.go
index 2726d765ddf..5c15ee1ed6d 100644
--- a/modules/storage/minio_test.go
+++ b/modules/storage/minio_test.go
@@ -16,12 +16,13 @@ import (
"github.com/stretchr/testify/assert"
)
-func TestMinioStorageIterator(t *testing.T) {
+func TestMinioStorage(t *testing.T) {
if os.Getenv("CI") == "" {
t.Skip("minioStorage not present outside of CI")
return
}
- testStorageIterator(t, setting.MinioStorageType, &setting.Storage{
+ storageType := setting.MinioStorageType
+ config := &setting.Storage{
MinioConfig: setting.MinioStorageConfig{
Endpoint: "minio:9000",
AccessKeyID: "123456",
@@ -29,7 +30,25 @@ func TestMinioStorageIterator(t *testing.T) {
Bucket: "gitea",
Location: "us-east-1",
},
- })
+ }
+ table := []struct {
+ name string
+ test func(t *testing.T, typStr Type, cfg *setting.Storage)
+ }{
+ {
+ name: "iterator",
+ test: testStorageIterator,
+ },
+ {
+ name: "testBlobStorageURLContentTypeAndDisposition",
+ test: testBlobStorageURLContentTypeAndDisposition,
+ },
+ }
+ for _, entry := range table {
+ t.Run(entry.name, func(t *testing.T) {
+ entry.test(t, storageType, config)
+ })
+ }
}
func TestMinioStoragePath(t *testing.T) {
diff --git a/modules/storage/storage.go b/modules/storage/storage.go
index 74d0cd47c87..2491c77a3e0 100644
--- a/modules/storage/storage.go
+++ b/modules/storage/storage.go
@@ -10,8 +10,10 @@ import (
"io"
"net/url"
"os"
+ "path"
"code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/public"
"code.gitea.io/gitea/modules/setting"
)
@@ -56,6 +58,38 @@ type Object interface {
Stat() (os.FileInfo, error)
}
+// ServeDirectOptions customizes HTTP headers for a generated signed URL.
+type ServeDirectOptions struct {
+ // Overrides the automatically detected MIME type.
+ ContentType string
+ // Overrides the default Content-Disposition header, which is `inline; filename="name"`.
+ ContentDisposition string
+}
+
+// Safe defaults are applied only when not explicitly overridden by the caller.
+func prepareServeDirectOptions(optsOptional *ServeDirectOptions, name string) (ret ServeDirectOptions) {
+ // Here we might not know the real filename, and it's quite inefficient to detect the MIME type by pre-fetching the object head.
+ // So we just do a quick detection by extension name, at least it works for the "View Raw File" for an LFS file on the Web UI.
+ // TODO: OBJECT-STORAGE-CONTENT-TYPE: need a complete solution and refactor for Azure in the future
+
+ if optsOptional != nil {
+ ret = *optsOptional
+ }
+
+ // TODO: UNIFY-CONTENT-DISPOSITION-FROM-STORAGE
+ if ret.ContentType == "" {
+ ext := path.Ext(name)
+ ret.ContentType = public.DetectWellKnownMimeType(ext)
+ }
+ if ret.ContentDisposition == "" {
+ // When using ServeDirect, the URL is from the object storage's web server,
+ // it is not the same origin as Gitea server, so it should be safe enough to use "inline" to render the content directly.
+ // If a browser doesn't support the content type to be displayed inline, browser will download with the filename.
+ ret.ContentDisposition = fmt.Sprintf(`inline; filename="%s"`, quoteEscaper.Replace(name))
+ }
+ return ret
+}
+
// ObjectStorage represents an object storage to handle a bucket and files
type ObjectStorage interface {
Open(path string) (Object, error)
@@ -67,7 +101,15 @@ type ObjectStorage interface {
Stat(path string) (os.FileInfo, error)
Delete(path string) error
- URL(path, name, method string, reqParams url.Values) (*url.URL, error)
+
+ // ServeDirectURL generates a "serve-direct" URL for the specified blob storage file,
+ // end user (browser) will use this URL to access the file directly from the object storage, bypassing Gitea server.
+ // Usually the link is time-limited (a few minutes) and contains a signature to ensure security.
+ // The generated URL must NOT use the same origin as Gitea server, otherwise it will cause security issues.
+ // * method defines which HTTP method is permitted for certain storage providers (e.g., MinIO).
+ // * opt allows customizing the Content-Type and Content-Disposition headers.
+ // TODO: need to merge "ServeDirect()" check into this function, avoid duplicate code and potential inconsistency.
+ ServeDirectURL(path, name, method string, opt *ServeDirectOptions) (*url.URL, error)
// IterateObjects calls the iterator function for each object in the storage with the given path as prefix
// The "fullPath" argument in callback is the full path in this storage.
@@ -136,7 +178,7 @@ var (
// Actions represents actions storage
Actions ObjectStorage = uninitializedStorage
- // Actions Artifacts represents actions artifacts storage
+ // ActionsArtifacts Artifacts represents actions artifacts storage
ActionsArtifacts ObjectStorage = uninitializedStorage
)
diff --git a/modules/storage/storage_test.go b/modules/storage/storage_test.go
index 08f274e74b4..4156723c364 100644
--- a/modules/storage/storage_test.go
+++ b/modules/storage/storage_test.go
@@ -4,12 +4,14 @@
package storage
import (
+ "net/http"
"strings"
"testing"
"code.gitea.io/gitea/modules/setting"
"github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
)
func testStorageIterator(t *testing.T, typStr Type, cfg *setting.Storage) {
@@ -50,3 +52,55 @@ func testStorageIterator(t *testing.T, typStr Type, cfg *setting.Storage) {
assert.Len(t, expected, count)
}
}
+
+func testSingleBlobStorageURLContentTypeAndDisposition(t *testing.T, s ObjectStorage, path, name string, expected ServeDirectOptions, reqParams *ServeDirectOptions) {
+ u, err := s.ServeDirectURL(path, name, http.MethodGet, reqParams)
+ require.NoError(t, err)
+ resp, err := http.Get(u.String())
+ require.NoError(t, err)
+ defer resp.Body.Close()
+ if expected.ContentType != "" {
+ assert.Equal(t, expected.ContentType, resp.Header.Get("Content-Type"))
+ }
+ if expected.ContentDisposition != "" {
+ assert.Equal(t, expected.ContentDisposition, resp.Header.Get("Content-Disposition"))
+ }
+}
+
+func testBlobStorageURLContentTypeAndDisposition(t *testing.T, typStr Type, cfg *setting.Storage) {
+ s, err := NewStorage(typStr, cfg)
+ assert.NoError(t, err)
+
+ data := "Q2xTckt6Y1hDOWh0" // arbitrary test content; specific value is irrelevant to this test
+ testfilename := "test.txt" // arbitrary file name; specific value is irrelevant to this test
+ _, err = s.Save(testfilename, strings.NewReader(data), int64(len(data)))
+ assert.NoError(t, err)
+
+ testSingleBlobStorageURLContentTypeAndDisposition(t, s, testfilename, "test.txt", ServeDirectOptions{
+ ContentType: "text/plain; charset=utf-8",
+ ContentDisposition: `inline; filename="test.txt"`,
+ }, nil)
+
+ testSingleBlobStorageURLContentTypeAndDisposition(t, s, testfilename, "test.pdf", ServeDirectOptions{
+ ContentType: "application/pdf",
+ ContentDisposition: `inline; filename="test.pdf"`,
+ }, nil)
+
+ testSingleBlobStorageURLContentTypeAndDisposition(t, s, testfilename, "test.wasm", ServeDirectOptions{
+ ContentDisposition: `inline; filename="test.wasm"`,
+ }, nil)
+
+ testSingleBlobStorageURLContentTypeAndDisposition(t, s, testfilename, "test.wasm", ServeDirectOptions{
+ ContentDisposition: `inline; filename="test.wasm"`,
+ }, &ServeDirectOptions{})
+
+ testSingleBlobStorageURLContentTypeAndDisposition(t, s, testfilename, "test.txt", ServeDirectOptions{
+ ContentType: "application/octet-stream",
+ ContentDisposition: `inline; filename="test.xml"`,
+ }, &ServeDirectOptions{
+ ContentType: "application/octet-stream",
+ ContentDisposition: `inline; filename="test.xml"`,
+ })
+
+ assert.NoError(t, s.Delete(testfilename))
+}
diff --git a/modules/util/util.go b/modules/util/util.go
index d7702439d64..7d1343f20d6 100644
--- a/modules/util/util.go
+++ b/modules/util/util.go
@@ -8,6 +8,7 @@ import (
"crypto/rand"
"fmt"
"math/big"
+ "slices"
"strconv"
"strings"
@@ -240,6 +241,20 @@ func OptionalArg[T any](optArg []T, defaultValue ...T) (ret T) {
return ret
}
+type EnumConst[T comparable] interface {
+ EnumValues() []T
+}
+
+// EnumValue returns the value if it's in the enum const's values,
+// otherwise returns the first item of enums as default value.
+func EnumValue[T comparable](val EnumConst[T]) (ret T, valid bool) {
+ enums := val.EnumValues()
+ if slices.Contains(enums, val.(T)) {
+ return val.(T), true
+ }
+ return enums[0], false
+}
+
func ReserveLineBreakForTextarea(input string) string {
// Since the content is from a form which is a textarea, the line endings are \r\n.
// It's a standard behavior of HTML.
diff --git a/options/locale/locale_en-US.json b/options/locale/locale_en-US.json
index 5a5148a146e..80744253d8b 100644
--- a/options/locale/locale_en-US.json
+++ b/options/locale/locale_en-US.json
@@ -81,6 +81,7 @@
"retry": "Retry",
"rerun": "Re-run",
"rerun_all": "Re-run all jobs",
+ "rerun_failed": "Re-run failed jobs",
"save": "Save",
"add": "Add",
"add_all": "Add All",
@@ -645,6 +646,7 @@
"user.block.note.edit": "Edit note",
"user.block.list": "Blocked users",
"user.block.list.none": "You have not blocked any users.",
+ "settings.general": "General",
"settings.profile": "Profile",
"settings.account": "Account",
"settings.appearance": "Appearance",
@@ -3707,6 +3709,10 @@
"actions.runs.not_done": "This workflow run is not done.",
"actions.runs.view_workflow_file": "View workflow file",
"actions.runs.workflow_graph": "Workflow Graph",
+ "actions.runs.summary": "Summary",
+ "actions.runs.all_jobs": "All jobs",
+ "actions.runs.triggered_via": "Triggered via %s",
+ "actions.runs.total_duration": "Total duration:",
"actions.workflow.disable": "Disable Workflow",
"actions.workflow.disable_success": "Workflow '%s' disabled successfully.",
"actions.workflow.enable": "Enable Workflow",
@@ -3756,5 +3762,24 @@
"git.filemode.normal_file": "Regular",
"git.filemode.executable_file": "Executable",
"git.filemode.symbolic_link": "Symlink",
- "git.filemode.submodule": "Submodule"
+ "git.filemode.submodule": "Submodule",
+ "org.repos.none": "No repositories.",
+ "actions.general.permissions": "Actions Token Permissions",
+ "actions.general.token_permissions.mode": "Default Token Permissions",
+ "actions.general.token_permissions.mode.desc": "An Actions job will use the default permissions if it doesn't declare its permissions in the workflow file.",
+ "actions.general.token_permissions.mode.permissive": "Permissive",
+ "actions.general.token_permissions.mode.permissive.desc": "Read and write permissions for the job's repository.",
+ "actions.general.token_permissions.mode.restricted": "Restricted",
+ "actions.general.token_permissions.mode.restricted.desc": "Read-only permissions for contents units (code, releases) of the job's repository.",
+ "actions.general.token_permissions.override_owner": "Override owner-level configuration",
+ "actions.general.token_permissions.override_owner_desc": "If enabled, this repository will use its own Actions configuration instead of following the owner-level (user or organization) configuration.",
+ "actions.general.token_permissions.maximum": "Maximum Token Permissions",
+ "actions.general.token_permissions.maximum.description": "Actions job's effective permissions will be limited by the maximum permissions.",
+ "actions.general.token_permissions.fork_pr_note": "If a job is started by a pull request from a fork, its effective permissions won't exceed the read-only permissions.",
+ "actions.general.token_permissions.customize_max_permissions": "Customize maximum permissions",
+ "actions.general.cross_repo": "Cross-Repository Access",
+ "actions.general.cross_repo_desc": "Allow the selected repositories to be accessed (read-only) by all the repositories in this owner with GITEA_TOKEN when running Actions jobs.",
+ "actions.general.cross_repo_selected": "Selected repositories",
+ "actions.general.cross_repo_target_repos": "Target Repositories",
+ "actions.general.cross_repo_add": "Add Target Repository"
}
diff --git a/routers/api/actions/artifacts.go b/routers/api/actions/artifacts.go
index d7e7203a857..76facd769f2 100644
--- a/routers/api/actions/artifacts.go
+++ b/routers/api/actions/artifacts.go
@@ -428,7 +428,7 @@ func (ar artifactRoutes) getDownloadArtifactURL(ctx *ArtifactContext) {
for _, artifact := range artifacts {
var downloadURL string
if setting.Actions.ArtifactStorage.ServeDirect() {
- u, err := ar.fs.URL(artifact.StoragePath, artifact.ArtifactName, ctx.Req.Method, nil)
+ u, err := ar.fs.ServeDirectURL(artifact.StoragePath, artifact.ArtifactName, ctx.Req.Method, nil)
if err != nil && !errors.Is(err, storage.ErrURLNotSupported) {
log.Error("Error getting serve direct url: %v", err)
}
diff --git a/routers/api/actions/artifactsv4.go b/routers/api/actions/artifactsv4.go
index d208785f638..62605f27022 100644
--- a/routers/api/actions/artifactsv4.go
+++ b/routers/api/actions/artifactsv4.go
@@ -562,7 +562,8 @@ func (r *artifactV4Routes) getSignedArtifactURL(ctx *ArtifactContext) {
respData := GetSignedArtifactURLResponse{}
if setting.Actions.ArtifactStorage.ServeDirect() {
- u, err := storage.ActionsArtifacts.URL(artifact.StoragePath, artifact.ArtifactPath, ctx.Req.Method, nil)
+ // DO NOT USE the http POST method coming from the getSignedArtifactURL endpoint
+ u, err := storage.ActionsArtifacts.ServeDirectURL(artifact.StoragePath, artifact.ArtifactPath, http.MethodGet, nil)
if u != nil && err == nil {
respData.SignedUrl = u.String()
}
diff --git a/routers/api/packages/container/container.go b/routers/api/packages/container/container.go
index a6512181e06..0726302d41f 100644
--- a/routers/api/packages/container/container.go
+++ b/routers/api/packages/container/container.go
@@ -26,6 +26,7 @@ import (
packages_module "code.gitea.io/gitea/modules/packages"
container_module "code.gitea.io/gitea/modules/packages/container"
"code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/storage"
"code.gitea.io/gitea/modules/util"
"code.gitea.io/gitea/routers/api/packages/helper"
auth_service "code.gitea.io/gitea/services/auth"
@@ -706,9 +707,9 @@ func DeleteManifest(ctx *context.Context) {
}
func serveBlob(ctx *context.Context, pfd *packages_model.PackageFileDescriptor) {
- serveDirectReqParams := make(url.Values)
- serveDirectReqParams.Set("response-content-type", pfd.Properties.GetByName(container_module.PropertyMediaType))
- s, u, _, err := packages_service.OpenBlobForDownload(ctx, pfd.File, pfd.Blob, ctx.Req.Method, serveDirectReqParams)
+ s, u, _, err := packages_service.OpenBlobForDownload(ctx, pfd.File, pfd.Blob, ctx.Req.Method, &storage.ServeDirectOptions{
+ ContentType: pfd.Properties.GetByName(container_module.PropertyMediaType),
+ })
if err != nil {
apiError(ctx, http.StatusInternalServerError, err)
return
diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go
index f8f59debc3e..3dfbe6f430a 100644
--- a/routers/api/v1/api.go
+++ b/routers/api/v1/api.go
@@ -1259,6 +1259,7 @@ func Routes() *web.Router {
m.Get("", repo.GetWorkflowRun)
m.Delete("", reqToken(), reqRepoWriter(unit.TypeActions), repo.DeleteActionRun)
m.Post("/rerun", reqToken(), reqRepoWriter(unit.TypeActions), repo.RerunWorkflowRun)
+ m.Post("/rerun-failed-jobs", reqToken(), reqRepoWriter(unit.TypeActions), repo.RerunFailedWorkflowRun)
m.Get("/jobs", repo.ListWorkflowRunJobs)
m.Post("/jobs/{job_id}/rerun", reqToken(), reqRepoWriter(unit.TypeActions), repo.RerunWorkflowJob)
m.Get("/artifacts", repo.GetArtifactsOfRun)
diff --git a/routers/api/v1/repo/action.go b/routers/api/v1/repo/action.go
index d7e51b80469..d704092051b 100644
--- a/routers/api/v1/repo/action.go
+++ b/routers/api/v1/repo/action.go
@@ -1255,7 +1255,7 @@ func RerunWorkflowRun(ctx *context.APIContext) {
return
}
- if err := actions_service.RerunWorkflowRunJobs(ctx, ctx.Repo.Repository, run, jobs, nil); err != nil {
+ if err := actions_service.RerunWorkflowRunJobs(ctx, ctx.Repo.Repository, run, jobs); err != nil {
handleWorkflowRerunError(ctx, err)
return
}
@@ -1268,6 +1268,52 @@ func RerunWorkflowRun(ctx *context.APIContext) {
ctx.JSON(http.StatusCreated, convertedRun)
}
+// RerunFailedWorkflowRun Reruns all failed jobs in a workflow run.
+func RerunFailedWorkflowRun(ctx *context.APIContext) {
+ // swagger:operation POST /repos/{owner}/{repo}/actions/runs/{run}/rerun-failed-jobs repository rerunFailedWorkflowRun
+ // ---
+ // summary: Reruns all failed jobs in a workflow run
+ // parameters:
+ // - name: owner
+ // in: path
+ // description: owner of the repo
+ // type: string
+ // required: true
+ // - name: repo
+ // in: path
+ // description: name of the repository
+ // type: string
+ // required: true
+ // - name: run
+ // in: path
+ // description: id of the run
+ // type: integer
+ // required: true
+ // responses:
+ // "201":
+ // "$ref": "#/responses/empty"
+ // "400":
+ // "$ref": "#/responses/error"
+ // "403":
+ // "$ref": "#/responses/forbidden"
+ // "404":
+ // "$ref": "#/responses/notFound"
+ // "422":
+ // "$ref": "#/responses/validationError"
+
+ run, jobs := getCurrentRepoActionRunJobsByID(ctx)
+ if ctx.Written() {
+ return
+ }
+
+ if err := actions_service.RerunWorkflowRunJobs(ctx, ctx.Repo.Repository, run, actions_service.GetFailedRerunJobs(jobs)); err != nil {
+ handleWorkflowRerunError(ctx, err)
+ return
+ }
+
+ ctx.Status(http.StatusCreated)
+}
+
// RerunWorkflowJob Reruns a specific workflow job in a run.
func RerunWorkflowJob(ctx *context.APIContext) {
// swagger:operation POST /repos/{owner}/{repo}/actions/runs/{run}/jobs/{job_id}/rerun repository rerunWorkflowJob
@@ -1321,7 +1367,7 @@ func RerunWorkflowJob(ctx *context.APIContext) {
}
targetJob := jobs[jobIdx]
- if err := actions_service.RerunWorkflowRunJobs(ctx, ctx.Repo.Repository, run, jobs, targetJob); err != nil {
+ if err := actions_service.RerunWorkflowRunJobs(ctx, ctx.Repo.Repository, run, actions_service.GetAllRerunJobs(targetJob, jobs)); err != nil {
handleWorkflowRerunError(ctx, err)
return
}
diff --git a/routers/api/v1/repo/file.go b/routers/api/v1/repo/file.go
index e31bc24eef6..d0596d778b7 100644
--- a/routers/api/v1/repo/file.go
+++ b/routers/api/v1/repo/file.go
@@ -207,7 +207,7 @@ func GetRawFileOrLFS(ctx *context.APIContext) {
if setting.LFS.Storage.ServeDirect() {
// If we have a signed url (S3, object storage), redirect to this directly.
- u, err := storage.LFS.URL(pointer.RelativePath(), blob.Name(), ctx.Req.Method, nil)
+ u, err := storage.LFS.ServeDirectURL(pointer.RelativePath(), blob.Name(), ctx.Req.Method, nil)
if u != nil && err == nil {
ctx.Redirect(u.String())
return
diff --git a/routers/private/hook_pre_receive.go b/routers/private/hook_pre_receive.go
index 704b777dbf7..2dbf072f3a8 100644
--- a/routers/private/hook_pre_receive.go
+++ b/routers/private/hook_pre_receive.go
@@ -496,16 +496,25 @@ func (ctx *preReceiveContext) loadPusherAndPermission() bool {
}
if ctx.opts.UserID == user_model.ActionsUserID {
- ctx.user = user_model.NewActionsUser()
- ctx.userPerm.AccessMode = perm_model.AccessMode(ctx.opts.ActionPerm)
- if err := ctx.Repo.Repository.LoadUnits(ctx); err != nil {
- log.Error("Unable to get User id %d Error: %v", ctx.opts.UserID, err)
+ taskID := ctx.opts.ActionsTaskID
+ ctx.user = user_model.NewActionsUserWithTaskID(taskID)
+ if taskID == 0 {
+ log.Error("HookPreReceive: ActionsUser with task ID 0")
ctx.JSON(http.StatusInternalServerError, private.Response{
- Err: fmt.Sprintf("Unable to get User id %d Error: %v", ctx.opts.UserID, err),
+ Err: "ActionsUser with task ID 0",
})
return false
}
- ctx.userPerm.SetUnitsWithDefaultAccessMode(ctx.Repo.Repository.Units, ctx.userPerm.AccessMode)
+
+ userPerm, err := access_model.GetActionsUserRepoPermission(ctx, ctx.Repo.Repository, ctx.user, taskID)
+ if err != nil {
+ log.Error("Unable to get Actions user repo permission for task %d Error: %v", taskID, err)
+ ctx.JSON(http.StatusInternalServerError, private.Response{
+ Err: fmt.Sprintf("Unable to get Actions user repo permission for task %d Error: %v", taskID, err),
+ })
+ return false
+ }
+ ctx.userPerm = userPerm
} else {
user, err := user_model.GetUserByID(ctx, ctx.opts.UserID)
if err != nil {
diff --git a/routers/web/admin/runners.go b/routers/web/admin/runners.go
deleted file mode 100644
index 4b89237364e..00000000000
--- a/routers/web/admin/runners.go
+++ /dev/null
@@ -1,13 +0,0 @@
-// Copyright 2022 The Gitea Authors. All rights reserved.
-// SPDX-License-Identifier: MIT
-
-package admin
-
-import (
- "code.gitea.io/gitea/modules/setting"
- "code.gitea.io/gitea/services/context"
-)
-
-func RedirectToDefaultSetting(ctx *context.Context) {
- ctx.Redirect(setting.AppSubURL + "/-/admin/actions/runners")
-}
diff --git a/routers/web/base.go b/routers/web/base.go
index db96432bedc..6afcac62e23 100644
--- a/routers/web/base.go
+++ b/routers/web/base.go
@@ -69,7 +69,7 @@ func avatarStorageHandler(storageSetting *setting.Storage, prefix string, objSto
// So in theory, it doesn't work with the non-existing avatar fallback, it just gets the URL and redirects to it.
// Checking "stat" requires one more request to the storage, which is inefficient.
// Workaround: disable "SERVE_DIRECT". Leave the problem to the future.
- u, err := objStore.URL(avatarPath, path.Base(avatarPath), req.Method, nil)
+ u, err := objStore.ServeDirectURL(avatarPath, path.Base(avatarPath), req.Method, nil)
if handleError(w, req, avatarPath, err) {
return
}
diff --git a/routers/web/devtest/mock_actions.go b/routers/web/devtest/mock_actions.go
index 0dd33425dc2..00ca095e716 100644
--- a/routers/web/devtest/mock_actions.go
+++ b/routers/web/devtest/mock_actions.go
@@ -59,15 +59,14 @@ func generateMockStepsLog(logCur actions.LogCursor, opts generateMockStepsLogOpt
}
func MockActionsView(ctx *context.Context) {
- ctx.Data["RunID"] = ctx.PathParam("run")
- ctx.Data["JobID"] = ctx.PathParam("job")
+ ctx.Data["RunID"] = ctx.PathParamInt64("run")
+ ctx.Data["JobID"] = ctx.PathParamInt64("job")
ctx.HTML(http.StatusOK, "devtest/repo-action-view")
}
func MockActionsRunsJobs(ctx *context.Context) {
runID := ctx.PathParamInt64("run")
- req := web.GetForm(ctx).(*actions.ViewRequest)
resp := &actions.ViewResponse{}
resp.State.Run.TitleHTML = `mock run title link`
resp.State.Run.Link = setting.AppSubURL + "/devtest/repo-action-view/runs/" + strconv.FormatInt(runID, 10)
@@ -75,9 +74,13 @@ func MockActionsRunsJobs(ctx *context.Context) {
resp.State.Run.CanCancel = runID == 10
resp.State.Run.CanApprove = runID == 20
resp.State.Run.CanRerun = runID == 30
+ resp.State.Run.CanRerunFailed = runID == 30
resp.State.Run.CanDeleteArtifact = true
resp.State.Run.WorkflowID = "workflow-id"
resp.State.Run.WorkflowLink = "./workflow-link"
+ resp.State.Run.Duration = "1h 23m 45s"
+ resp.State.Run.TriggeredAt = time.Now().Add(-time.Hour).Unix()
+ resp.State.Run.TriggerEvent = "push"
resp.State.Run.Commit = actions.ViewCommit{
ShortSha: "ccccdddd",
Link: "./commit-link",
@@ -139,6 +142,17 @@ func MockActionsRunsJobs(ctx *context.Context) {
Needs: []string{"job-100", "job-101"},
})
+ fillViewRunResponseCurrentJob(ctx, resp)
+ ctx.JSON(http.StatusOK, resp)
+}
+
+func fillViewRunResponseCurrentJob(ctx *context.Context, resp *actions.ViewResponse) {
+ jobID := ctx.PathParamInt64("job")
+ if jobID == 0 {
+ return
+ }
+
+ req := web.GetForm(ctx).(*actions.ViewRequest)
var mockLogOptions []generateMockStepsLogOptions
resp.State.CurrentJob.Steps = append(resp.State.CurrentJob.Steps, &actions.ViewJobStep{
Summary: "step 0 (mock slow)",
@@ -162,7 +176,6 @@ func MockActionsRunsJobs(ctx *context.Context) {
mockLogOptions = append(mockLogOptions, generateMockStepsLogOptions{mockCountFirst: 30, mockCountGeneral: 3, groupRepeat: 3})
if len(req.LogCursors) == 0 {
- ctx.JSON(http.StatusOK, resp)
return
}
@@ -188,5 +201,4 @@ func MockActionsRunsJobs(ctx *context.Context) {
} else {
time.Sleep(time.Duration(100) * time.Millisecond) // actually, frontend reload every 1 second, any smaller delay is fine
}
- ctx.JSON(http.StatusOK, resp)
}
diff --git a/routers/web/misc/misc.go b/routers/web/misc/misc.go
index 3d2f624263b..a50d9130ac2 100644
--- a/routers/web/misc/misc.go
+++ b/routers/web/misc/misc.go
@@ -51,6 +51,12 @@ func StaticRedirect(target string) func(w http.ResponseWriter, req *http.Request
}
}
+func LocationRedirect(target string) func(w http.ResponseWriter, req *http.Request) {
+ return func(w http.ResponseWriter, req *http.Request) {
+ http.Redirect(w, req, target, http.StatusSeeOther)
+ }
+}
+
func WebBannerDismiss(ctx *context.Context) {
_, rev, _ := setting.Config().Instance.WebBanner.ValueRevision(ctx)
middleware.SetSiteCookie(ctx.Resp, middleware.CookieWebBannerDismissed, strconv.Itoa(rev), 48*3600)
diff --git a/routers/web/org/setting/runners.go b/routers/web/org/setting/runners.go
deleted file mode 100644
index fe05709237d..00000000000
--- a/routers/web/org/setting/runners.go
+++ /dev/null
@@ -1,12 +0,0 @@
-// Copyright 2022 The Gitea Authors. All rights reserved.
-// SPDX-License-Identifier: MIT
-
-package setting
-
-import (
- "code.gitea.io/gitea/services/context"
-)
-
-func RedirectToDefaultSetting(ctx *context.Context) {
- ctx.Redirect(ctx.Org.OrgLink + "/settings/actions/runners")
-}
diff --git a/routers/web/repo/actions/view.go b/routers/web/repo/actions/view.go
index 2685dd8857b..98d86f0bb34 100644
--- a/routers/web/repo/actions/view.go
+++ b/routers/web/repo/actions/view.go
@@ -38,41 +38,54 @@ import (
"github.com/nektos/act/pkg/model"
)
-func getRunID(ctx *context_module.Context) int64 {
- // if run param is "latest", get the latest run id
- if ctx.PathParam("run") == "latest" {
- if run, _ := actions_model.GetLatestRun(ctx, ctx.Repo.Repository.ID); run != nil {
- return run.ID
+func findCurrentJobByPathParam(ctx *context_module.Context, jobs []*actions_model.ActionRunJob) (job *actions_model.ActionRunJob, hasPathParam bool) {
+ selectedJobID := ctx.PathParamInt64("job")
+ if selectedJobID <= 0 {
+ return nil, false
+ }
+ for _, job = range jobs {
+ if job.ID == selectedJobID {
+ return job, true
}
}
- return ctx.PathParamInt64("run")
+ return nil, true
+}
+
+func getCurrentRunByPathParam(ctx *context_module.Context) (run *actions_model.ActionRun) {
+ var err error
+ // if run param is "latest", get the latest run id
+ if ctx.PathParam("run") == "latest" {
+ run, err = actions_model.GetLatestRun(ctx, ctx.Repo.Repository.ID)
+ } else {
+ run, err = actions_model.GetRunByRepoAndID(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("run"))
+ }
+ if errors.Is(err, util.ErrNotExist) {
+ ctx.NotFound(nil)
+ } else if err != nil {
+ ctx.ServerError("GetRun:"+ctx.PathParam("run"), err)
+ }
+ return run
}
func View(ctx *context_module.Context) {
ctx.Data["PageIsActions"] = true
- runID := getRunID(ctx)
-
- _, _, current := getRunJobsAndCurrentJob(ctx, runID)
+ run := getCurrentRunByPathParam(ctx)
if ctx.Written() {
return
}
-
- ctx.Data["RunID"] = runID
- ctx.Data["JobID"] = current.ID
+ ctx.Data["RunID"] = run.ID
+ ctx.Data["JobID"] = ctx.PathParamInt64("job") // it can be 0 when no job (e.g.: run summary view)
ctx.Data["ActionsURL"] = ctx.Repo.RepoLink + "/actions"
ctx.HTML(http.StatusOK, tplViewActions)
}
func ViewWorkflowFile(ctx *context_module.Context) {
- runID := getRunID(ctx)
- run, err := actions_model.GetRunByRepoAndID(ctx, ctx.Repo.Repository.ID, runID)
- if err != nil {
- ctx.NotFoundOrServerError("GetRunByRepoAndID", func(err error) bool {
- return errors.Is(err, util.ErrNotExist)
- }, err)
+ run := getCurrentRunByPathParam(ctx)
+ if ctx.Written() {
return
}
+
commit, err := ctx.Repo.GitRepo.GetCommit(run.CommitSHA)
if err != nil {
ctx.NotFoundOrServerError("GetCommit", func(err error) bool {
@@ -122,6 +135,7 @@ type ViewResponse struct {
CanCancel bool `json:"canCancel"`
CanApprove bool `json:"canApprove"` // the run needs an approval and the doer has permission to approve
CanRerun bool `json:"canRerun"`
+ CanRerunFailed bool `json:"canRerunFailed"`
CanDeleteArtifact bool `json:"canDeleteArtifact"`
Done bool `json:"done"`
WorkflowID string `json:"workflowID"`
@@ -129,6 +143,10 @@ type ViewResponse struct {
IsSchedule bool `json:"isSchedule"`
Jobs []*ViewJob `json:"jobs"`
Commit ViewCommit `json:"commit"`
+ // Summary view: run duration and trigger time/event
+ Duration string `json:"duration"`
+ TriggeredAt int64 `json:"triggeredAt"` // unix seconds for relative time
+ TriggerEvent string `json:"triggerEvent"` // e.g. pull_request, push, schedule
} `json:"run"`
CurrentJob struct {
Title string `json:"title"`
@@ -189,11 +207,7 @@ type ViewStepLogLine struct {
}
func getActionsViewArtifacts(ctx context.Context, repoID, runID int64) (artifactsViewItems []*ArtifactsViewItem, err error) {
- run, err := actions_model.GetRunByRepoAndID(ctx, repoID, runID)
- if err != nil {
- return nil, err
- }
- artifacts, err := actions_model.ListUploadedArtifactsMeta(ctx, run.ID)
+ artifacts, err := actions_model.ListUploadedArtifactsMeta(ctx, repoID, runID)
if err != nil {
return nil, err
}
@@ -208,10 +222,7 @@ func getActionsViewArtifacts(ctx context.Context, repoID, runID int64) (artifact
}
func ViewPost(ctx *context_module.Context) {
- req := web.GetForm(ctx).(*ViewRequest)
- runID := getRunID(ctx)
-
- run, jobs, current := getRunJobsAndCurrentJob(ctx, runID)
+ run, jobs := getCurrentRunJobsByPathParam(ctx)
if ctx.Written() {
return
}
@@ -220,14 +231,24 @@ func ViewPost(ctx *context_module.Context) {
return
}
- var err error
resp := &ViewResponse{}
- resp.Artifacts, err = getActionsViewArtifacts(ctx, ctx.Repo.Repository.ID, runID)
+ fillViewRunResponseSummary(ctx, resp, run, jobs)
+ if ctx.Written() {
+ return
+ }
+ fillViewRunResponseCurrentJob(ctx, resp, run, jobs)
+ if ctx.Written() {
+ return
+ }
+ ctx.JSON(http.StatusOK, resp)
+}
+
+func fillViewRunResponseSummary(ctx *context_module.Context, resp *ViewResponse, run *actions_model.ActionRun, jobs []*actions_model.ActionRunJob) {
+ var err error
+ resp.Artifacts, err = getActionsViewArtifacts(ctx, ctx.Repo.Repository.ID, run.ID)
if err != nil {
- if !errors.Is(err, util.ErrNotExist) {
- ctx.ServerError("getActionsViewArtifacts", err)
- return
- }
+ ctx.ServerError("getActionsViewArtifacts", err)
+ return
}
// the title for the "run" is from the commit message
@@ -238,6 +259,14 @@ func ViewPost(ctx *context_module.Context) {
resp.State.Run.CanApprove = run.NeedApproval && ctx.Repo.CanWrite(unit.TypeActions)
resp.State.Run.CanRerun = run.Status.IsDone() && ctx.Repo.CanWrite(unit.TypeActions)
resp.State.Run.CanDeleteArtifact = run.Status.IsDone() && ctx.Repo.CanWrite(unit.TypeActions)
+ if resp.State.Run.CanRerun {
+ for _, job := range jobs {
+ if job.Status == actions_model.StatusFailure || job.Status == actions_model.StatusCancelled {
+ resp.State.Run.CanRerunFailed = true
+ break
+ }
+ }
+ }
resp.State.Run.Done = run.Status.IsDone()
resp.State.Run.WorkflowID = run.WorkflowID
resp.State.Run.WorkflowLink = run.WorkflowLink()
@@ -280,6 +309,20 @@ func ViewPost(ctx *context_module.Context) {
Pusher: pusher,
Branch: branch,
}
+ resp.State.Run.Duration = run.Duration().String()
+ resp.State.Run.TriggeredAt = run.Created.AsTime().Unix()
+ resp.State.Run.TriggerEvent = run.TriggerEvent
+}
+
+func fillViewRunResponseCurrentJob(ctx *context_module.Context, resp *ViewResponse, run *actions_model.ActionRun, jobs []*actions_model.ActionRunJob) {
+ req := web.GetForm(ctx).(*ViewRequest)
+ current, hasPathParam := findCurrentJobByPathParam(ctx, jobs)
+ if current == nil {
+ if hasPathParam {
+ ctx.NotFound(nil)
+ }
+ return
+ }
var task *actions_model.ActionTask
if current.TaskID > 0 {
@@ -312,8 +355,6 @@ func ViewPost(ctx *context_module.Context) {
resp.State.CurrentJob.Steps = append(resp.State.CurrentJob.Steps, steps...)
resp.Logs.StepsLog = append(resp.Logs.StepsLog, logs...)
}
-
- ctx.JSON(http.StatusOK, resp)
}
func convertToViewModel(ctx context.Context, locale translation.Locale, cursors []LogCursor, task *actions_model.ActionTask) ([]*ViewJobStep, []*ViewStepLog, error) {
@@ -398,36 +439,65 @@ func convertToViewModel(ctx context.Context, locale translation.Locale, cursors
return viewJobs, logs, nil
}
-// Rerun will rerun jobs in the given run
-// If jobIDStr is a blank string, it means rerun all jobs
-func Rerun(ctx *context_module.Context) {
- runID := getRunID(ctx)
-
- run, jobs, currentJob := getRunJobsAndCurrentJob(ctx, runID)
- if ctx.Written() {
- return
- }
-
- // rerun is not allowed if the run is not done
+// checkRunRerunAllowed checks whether a rerun is permitted for the given run,
+// writing the appropriate JSON error to ctx and returning false when it is not.
+func checkRunRerunAllowed(ctx *context_module.Context, run *actions_model.ActionRun) bool {
if !run.Status.IsDone() {
ctx.JSONError(ctx.Locale.Tr("actions.runs.not_done"))
- return
+ return false
}
-
- // can not rerun job when workflow is disabled
cfgUnit := ctx.Repo.Repository.MustGetUnit(ctx, unit.TypeActions)
cfg := cfgUnit.ActionsConfig()
if cfg.IsWorkflowDisabled(run.WorkflowID) {
ctx.JSONError(ctx.Locale.Tr("actions.workflow.disabled"))
+ return false
+ }
+ return true
+}
+
+// Rerun will rerun jobs in the given run
+// If jobIDStr is a blank string, it means rerun all jobs
+func Rerun(ctx *context_module.Context) {
+ run, jobs := getCurrentRunJobsByPathParam(ctx)
+ if ctx.Written() {
+ return
+ }
+ if !checkRunRerunAllowed(ctx, run) {
return
}
- var targetJob *actions_model.ActionRunJob // nil means rerun all jobs
- if ctx.PathParam("job") != "" {
- targetJob = currentJob
+ currentJob, hasPathParam := findCurrentJobByPathParam(ctx, jobs)
+ if hasPathParam && currentJob == nil {
+ ctx.NotFound(nil)
+ return
}
- if err := actions_service.RerunWorkflowRunJobs(ctx, ctx.Repo.Repository, run, jobs, targetJob); err != nil {
+ var jobsToRerun []*actions_model.ActionRunJob
+ if currentJob != nil {
+ jobsToRerun = actions_service.GetAllRerunJobs(currentJob, jobs)
+ } else {
+ jobsToRerun = jobs
+ }
+
+ if err := actions_service.RerunWorkflowRunJobs(ctx, ctx.Repo.Repository, run, jobsToRerun); err != nil {
+ ctx.ServerError("RerunWorkflowRunJobs", err)
+ return
+ }
+
+ ctx.JSONOK()
+}
+
+// RerunFailed reruns all failed jobs in the given run
+func RerunFailed(ctx *context_module.Context) {
+ run, jobs := getCurrentRunJobsByPathParam(ctx)
+ if ctx.Written() {
+ return
+ }
+ if !checkRunRerunAllowed(ctx, run) {
+ return
+ }
+
+ if err := actions_service.RerunWorkflowRunJobs(ctx, ctx.Repo.Repository, run, actions_service.GetFailedRerunJobs(jobs)); err != nil {
ctx.ServerError("RerunWorkflowRunJobs", err)
return
}
@@ -436,18 +506,13 @@ func Rerun(ctx *context_module.Context) {
}
func Logs(ctx *context_module.Context) {
- runID := getRunID(ctx)
- jobID := ctx.PathParamInt64("job")
-
- run, err := actions_model.GetRunByRepoAndID(ctx, ctx.Repo.Repository.ID, runID)
- if err != nil {
- ctx.NotFoundOrServerError("GetRunByRepoAndID", func(err error) bool {
- return errors.Is(err, util.ErrNotExist)
- }, err)
+ run := getCurrentRunByPathParam(ctx)
+ if ctx.Written() {
return
}
+ jobID := ctx.PathParamInt64("job")
- if err = common.DownloadActionsRunJobLogsWithID(ctx.Base, ctx.Repo.Repository, run.ID, jobID); err != nil {
+ if err := common.DownloadActionsRunJobLogsWithID(ctx.Base, ctx.Repo.Repository, run.ID, jobID); err != nil {
ctx.NotFoundOrServerError("DownloadActionsRunJobLogsWithID", func(err error) bool {
return errors.Is(err, util.ErrNotExist)
}, err)
@@ -455,9 +520,7 @@ func Logs(ctx *context_module.Context) {
}
func Cancel(ctx *context_module.Context) {
- runID := getRunID(ctx)
-
- run, jobs, _ := getRunJobsAndCurrentJob(ctx, runID)
+ run, jobs := getCurrentRunJobsByPathParam(ctx)
if ctx.Written() {
return
}
@@ -491,9 +554,11 @@ func Cancel(ctx *context_module.Context) {
}
func Approve(ctx *context_module.Context) {
- runID := getRunID(ctx)
-
- approveRuns(ctx, []int64{runID})
+ run := getCurrentRunByPathParam(ctx)
+ if ctx.Written() {
+ return
+ }
+ approveRuns(ctx, []int64{run.ID})
if ctx.Written() {
return
}
@@ -568,16 +633,8 @@ func approveRuns(ctx *context_module.Context, runIDs []int64) {
}
func Delete(ctx *context_module.Context) {
- runID := getRunID(ctx)
- repoID := ctx.Repo.Repository.ID
-
- run, err := actions_model.GetRunByRepoAndID(ctx, repoID, runID)
- if err != nil {
- if errors.Is(err, util.ErrNotExist) {
- ctx.JSONErrorNotFound()
- return
- }
- ctx.ServerError("GetRunByRepoAndID", err)
+ run := getCurrentRunByPathParam(ctx)
+ if ctx.Written() {
return
}
@@ -594,59 +651,37 @@ func Delete(ctx *context_module.Context) {
ctx.JSONOK()
}
-// getRunJobsAndCurrentJob loads the run and its jobs for runID, and returns the selected job based on the optional "job" path param (or the first job by default).
-// Any error will be written to the ctx, and nils are returned in that case.
-func getRunJobsAndCurrentJob(ctx *context_module.Context, runID int64) (*actions_model.ActionRun, []*actions_model.ActionRunJob, *actions_model.ActionRunJob) {
- run, err := actions_model.GetRunByRepoAndID(ctx, ctx.Repo.Repository.ID, runID)
- if err != nil {
- ctx.NotFoundOrServerError("GetRunByRepoAndID", func(err error) bool {
- return errors.Is(err, util.ErrNotExist)
- }, err)
- return nil, nil, nil
+// getRunJobs loads the run and its jobs for runID
+// Any error will be written to the ctx, empty jobs will also result in 404 error, then the return values are all nil.
+func getCurrentRunJobsByPathParam(ctx *context_module.Context) (*actions_model.ActionRun, []*actions_model.ActionRunJob) {
+ run := getCurrentRunByPathParam(ctx)
+ if ctx.Written() {
+ return nil, nil
}
run.Repo = ctx.Repo.Repository
jobs, err := actions_model.GetRunJobsByRunID(ctx, run.ID)
if err != nil {
ctx.ServerError("GetRunJobsByRunID", err)
- return nil, nil, nil
+ return nil, nil
}
if len(jobs) == 0 {
ctx.NotFound(nil)
- return nil, nil, nil
+ return nil, nil
}
for _, job := range jobs {
job.Run = run
}
-
- current := jobs[0]
- if ctx.PathParam("job") != "" {
- jobID := ctx.PathParamInt64("job")
- current, err = actions_model.GetRunJobByRunAndID(ctx, run.ID, jobID)
- if err != nil {
- ctx.NotFoundOrServerError("GetRunJobByRunAndID", func(err error) bool {
- return errors.Is(err, util.ErrNotExist)
- }, err)
- return nil, nil, nil
- }
- current.Run = run
- }
-
- return run, jobs, current
+ return run, jobs
}
func ArtifactsDeleteView(ctx *context_module.Context) {
- runID := getRunID(ctx)
- artifactName := ctx.PathParam("artifact_name")
-
- run, err := actions_model.GetRunByRepoAndID(ctx, ctx.Repo.Repository.ID, runID)
- if err != nil {
- ctx.NotFoundOrServerError("GetRunByRepoAndID", func(err error) bool {
- return errors.Is(err, util.ErrNotExist)
- }, err)
+ run := getCurrentRunByPathParam(ctx)
+ if ctx.Written() {
return
}
- if err = actions_model.SetArtifactNeedDelete(ctx, run.ID, artifactName); err != nil {
+ artifactName := ctx.PathParam("artifact_name")
+ if err := actions_model.SetArtifactNeedDelete(ctx, run.ID, artifactName); err != nil {
ctx.ServerError("SetArtifactNeedDelete", err)
return
}
@@ -654,19 +689,12 @@ func ArtifactsDeleteView(ctx *context_module.Context) {
}
func ArtifactsDownloadView(ctx *context_module.Context) {
- runID := getRunID(ctx)
- artifactName := ctx.PathParam("artifact_name")
-
- run, err := actions_model.GetRunByRepoAndID(ctx, ctx.Repo.Repository.ID, runID)
- if err != nil {
- if errors.Is(err, util.ErrNotExist) {
- ctx.HTTPError(http.StatusNotFound, err.Error())
- return
- }
- ctx.ServerError("GetRunByRepoAndID", err)
+ run := getCurrentRunByPathParam(ctx)
+ if ctx.Written() {
return
}
+ artifactName := ctx.PathParam("artifact_name")
artifacts, err := db.Find[actions_model.ActionArtifact](ctx, actions_model.FindArtifactsOptions{
RunID: run.ID,
ArtifactName: artifactName,
@@ -794,7 +822,7 @@ func disableOrEnableWorkflowFile(ctx *context_module.Context, isEnable bool) {
cfg.DisableWorkflow(workflow)
}
- if err := repo_model.UpdateRepoUnit(ctx, cfgUnit); err != nil {
+ if err := repo_model.UpdateRepoUnitConfig(ctx, cfgUnit); err != nil {
ctx.ServerError("UpdateRepoUnit", err)
return
}
diff --git a/routers/web/repo/attachment.go b/routers/web/repo/attachment.go
index be711afe6dd..19d533f3624 100644
--- a/routers/web/repo/attachment.go
+++ b/routers/web/repo/attachment.go
@@ -179,7 +179,7 @@ func ServeAttachment(ctx *context.Context, uuid string) {
if setting.Attachment.Storage.ServeDirect() {
// If we have a signed url (S3, object storage), redirect to this directly.
- u, err := storage.Attachments.URL(attach.RelativePath(), attach.Name, ctx.Req.Method, nil)
+ u, err := storage.Attachments.ServeDirectURL(attach.RelativePath(), attach.Name, ctx.Req.Method, nil)
if u != nil && err == nil {
ctx.Redirect(u.String())
diff --git a/routers/web/repo/download.go b/routers/web/repo/download.go
index 9412d2045d4..073d3d74208 100644
--- a/routers/web/repo/download.go
+++ b/routers/web/repo/download.go
@@ -54,7 +54,7 @@ func ServeBlobOrLFS(ctx *context.Context, blob *git.Blob, lastModified *time.Tim
if setting.LFS.Storage.ServeDirect() {
// If we have a signed url (S3, object storage, blob storage), redirect to this directly.
- u, err := storage.LFS.URL(pointer.RelativePath(), blob.Name(), ctx.Req.Method, nil)
+ u, err := storage.LFS.ServeDirectURL(pointer.RelativePath(), blob.Name(), ctx.Req.Method, nil)
if u != nil && err == nil {
ctx.Redirect(u.String())
return nil
diff --git a/routers/web/repo/githttp.go b/routers/web/repo/githttp.go
index e922ed99fc8..fb9445aed03 100644
--- a/routers/web/repo/githttp.go
+++ b/routers/web/repo/githttp.go
@@ -59,7 +59,6 @@ func CorsHandler() func(next http.Handler) http.Handler {
// httpBase does the common work for git http services,
// including early response, authentication, repository lookup and permission check.
func httpBase(ctx *context.Context, optGitService ...string) *serviceHandler {
- username := ctx.PathParam("username")
reponame := strings.TrimSuffix(ctx.PathParam("reponame"), ".git")
if ctx.FormString("go-get") == "1" {
@@ -131,10 +130,7 @@ func httpBase(ctx *context.Context, optGitService ...string) *serviceHandler {
// Only public pull don't need auth.
isPublicPull := repoExist && !repo.IsPrivate && isPull
- var (
- askAuth = !isPublicPull || setting.Service.RequireSignInViewStrict
- environ []string
- )
+ askAuth := !isPublicPull || setting.Service.RequireSignInViewStrict
// don't allow anonymous pulls if organization is not public
if isPublicPull {
@@ -184,21 +180,14 @@ func httpBase(ctx *context.Context, optGitService ...string) *serviceHandler {
return nil
}
- environ = []string{
- repo_module.EnvRepoUsername + "=" + username,
- repo_module.EnvRepoName + "=" + reponame,
- repo_module.EnvPusherName + "=" + ctx.Doer.Name,
- repo_module.EnvPusherID + fmt.Sprintf("=%d", ctx.Doer.ID),
- repo_module.EnvAppURL + "=" + setting.AppURL,
- }
-
if repoExist {
// Because of special ref "refs/for" (agit) , need delay write permission check
if git.DefaultFeatures().SupportProcReceive {
accessMode = perm.AccessModeRead
}
- if taskID, ok := user_model.GetActionsUserTaskID(ctx.Doer); ok {
+ taskID, isActionsUser := user_model.GetActionsUserTaskID(ctx.Doer)
+ if isActionsUser {
p, err := access_model.GetActionsUserRepoPermission(ctx, repo, ctx.Doer, taskID)
if err != nil {
ctx.ServerError("GetActionsUserRepoPermission", err)
@@ -209,7 +198,6 @@ func httpBase(ctx *context.Context, optGitService ...string) *serviceHandler {
ctx.PlainText(http.StatusNotFound, "Repository not found")
return nil
}
- environ = append(environ, fmt.Sprintf("%s=%d", repo_module.EnvActionPerm, p.UnitAccessMode(unitType)))
} else {
p, err := access_model.GetUserRepoPermission(ctx, repo, ctx.Doer)
if err != nil {
@@ -228,16 +216,6 @@ func httpBase(ctx *context.Context, optGitService ...string) *serviceHandler {
return nil
}
}
-
- if !ctx.Doer.KeepEmailPrivate {
- environ = append(environ, repo_module.EnvPusherEmail+"="+ctx.Doer.Email)
- }
-
- if isWiki {
- environ = append(environ, repo_module.EnvRepoIsWiki+"=true")
- } else {
- environ = append(environ, repo_module.EnvRepoIsWiki+"=false")
- }
}
if !repoExist {
@@ -286,7 +264,11 @@ func httpBase(ctx *context.Context, optGitService ...string) *serviceHandler {
}
}
- environ = append(environ, repo_module.EnvRepoID+fmt.Sprintf("=%d", repo.ID))
+ var environ []string
+ if !isPull {
+ // if not "pull", then must be "push", and doer must exist
+ environ = repo_module.DoerPushingEnvironment(ctx.Doer, repo, isWiki)
+ }
return &serviceHandler{serviceType, repo, isWiki, environ}
}
diff --git a/routers/web/repo/setting/actions.go b/routers/web/repo/setting/actions.go
index 9c2c9242d34..2237828d611 100644
--- a/routers/web/repo/setting/actions.go
+++ b/routers/web/repo/setting/actions.go
@@ -8,11 +8,13 @@ import (
"net/http"
"strings"
+ "code.gitea.io/gitea/models/actions"
repo_model "code.gitea.io/gitea/models/repo"
unit_model "code.gitea.io/gitea/models/unit"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/templates"
"code.gitea.io/gitea/modules/util"
+ shared_actions "code.gitea.io/gitea/routers/web/shared/actions"
"code.gitea.io/gitea/services/context"
repo_service "code.gitea.io/gitea/services/repository"
)
@@ -34,8 +36,31 @@ func ActionsGeneralSettings(ctx *context.Context) {
return
}
+ actionsCfg := actionsUnit.ActionsConfig()
+
+ // Token permission settings
+ ctx.Data["TokenPermissionModePermissive"] = repo_model.ActionsTokenPermissionModePermissive
+ ctx.Data["TokenPermissionModeRestricted"] = repo_model.ActionsTokenPermissionModeRestricted
+
+ // Follow owner config (only for repos in orgs)
+ ctx.Data["OverrideOwnerConfig"] = actionsCfg.OverrideOwnerConfig
+ if actionsCfg.OverrideOwnerConfig {
+ ctx.Data["MaxTokenPermissions"] = actionsCfg.GetMaxTokenPermissions()
+ ctx.Data["TokenPermissionMode"] = actionsCfg.TokenPermissionMode
+ ctx.Data["EnableMaxTokenPermissions"] = actionsCfg.MaxTokenPermissions != nil
+ } else {
+ ownerActionsConfig, err := actions.GetOwnerActionsConfig(ctx, ctx.Repo.Repository.OwnerID)
+ if err != nil {
+ ctx.ServerError("GetOwnerActionsConfig", err)
+ return
+ }
+ ctx.Data["MaxTokenPermissions"] = ownerActionsConfig.GetMaxTokenPermissions()
+ ctx.Data["TokenPermissionMode"] = ownerActionsConfig.TokenPermissionMode
+ ctx.Data["EnableMaxTokenPermissions"] = ownerActionsConfig.MaxTokenPermissions != nil
+ }
+
if ctx.Repo.Repository.IsPrivate {
- collaborativeOwnerIDs := actionsUnit.ActionsConfig().CollaborativeOwnerIDs
+ collaborativeOwnerIDs := actionsCfg.CollaborativeOwnerIDs
collaborativeOwners, err := user_model.GetUsersByIDs(ctx, collaborativeOwnerIDs)
if err != nil {
ctx.ServerError("GetUsersByIDs", err)
@@ -89,8 +114,8 @@ func AddCollaborativeOwner(ctx *context.Context) {
}
actionsCfg := actionsUnit.ActionsConfig()
actionsCfg.AddCollaborativeOwner(ownerID)
- if err := repo_model.UpdateRepoUnit(ctx, actionsUnit); err != nil {
- ctx.ServerError("UpdateRepoUnit", err)
+ if err := repo_model.UpdateRepoUnitConfig(ctx, actionsUnit); err != nil {
+ ctx.ServerError("UpdateRepoUnitConfig", err)
return
}
@@ -112,10 +137,59 @@ func DeleteCollaborativeOwner(ctx *context.Context) {
return
}
actionsCfg.RemoveCollaborativeOwner(ownerID)
- if err := repo_model.UpdateRepoUnit(ctx, actionsUnit); err != nil {
- ctx.ServerError("UpdateRepoUnit", err)
+ if err := repo_model.UpdateRepoUnitConfig(ctx, actionsUnit); err != nil {
+ ctx.ServerError("UpdateRepoUnitConfig", err)
return
}
ctx.JSONOK()
}
+
+// UpdateTokenPermissions updates the token permission settings for the repository
+func UpdateTokenPermissions(ctx *context.Context) {
+ redirectURL := ctx.Repo.RepoLink + "/settings/actions/general"
+
+ actionsUnit, err := ctx.Repo.Repository.GetUnit(ctx, unit_model.TypeActions)
+ if err != nil {
+ ctx.ServerError("GetUnit", err)
+ return
+ }
+
+ actionsCfg := actionsUnit.ActionsConfig()
+
+ // Update Override Owner Config (for repos in orgs)
+ // If checked, it means we WANT to override (opt-out of following)
+ actionsCfg.OverrideOwnerConfig = ctx.FormBool("override_owner_config")
+
+ // Update permission mode (only if overriding owner config)
+ shouldUpdate := actionsCfg.OverrideOwnerConfig
+
+ if shouldUpdate {
+ permissionMode, permissionModeValid := util.EnumValue(repo_model.ActionsTokenPermissionMode(ctx.FormString("token_permission_mode")))
+ if !permissionModeValid {
+ ctx.Flash.Error("Invalid token permission mode")
+ ctx.Redirect(redirectURL)
+ return
+ }
+ actionsCfg.TokenPermissionMode = permissionMode
+ }
+
+ // Update Maximum Permissions (radio buttons: none/read/write)
+ enableMaxPermissions := ctx.FormBool("enable_max_permissions")
+ if shouldUpdate {
+ if enableMaxPermissions {
+ actionsCfg.MaxTokenPermissions = shared_actions.ParseMaxTokenPermissions(ctx)
+ } else {
+ // If not enabled, ensure any sent permissions are ignored and set to nil
+ actionsCfg.MaxTokenPermissions = nil
+ }
+ }
+
+ if err := repo_model.UpdateRepoUnitConfig(ctx, actionsUnit); err != nil {
+ ctx.ServerError("UpdateRepoUnitConfig", err)
+ return
+ }
+
+ ctx.Flash.Success(ctx.Tr("repo.settings.update_settings_success"))
+ ctx.Redirect(redirectURL)
+}
diff --git a/routers/web/shared/actions/general.go b/routers/web/shared/actions/general.go
new file mode 100644
index 00000000000..8a924f6e1fe
--- /dev/null
+++ b/routers/web/shared/actions/general.go
@@ -0,0 +1,160 @@
+// Copyright 2026 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package actions
+
+import (
+ "net/http"
+ "slices"
+ "strconv"
+
+ actions_model "code.gitea.io/gitea/models/actions"
+ "code.gitea.io/gitea/models/perm"
+ repo_model "code.gitea.io/gitea/models/repo"
+ "code.gitea.io/gitea/models/unit"
+ "code.gitea.io/gitea/modules/templates"
+ "code.gitea.io/gitea/modules/util"
+ "code.gitea.io/gitea/services/context"
+)
+
+const (
+ tplOrgSettingsActionsGeneral templates.TplName = "org/settings/actions_general"
+ tplUserSettingsActionsGeneral templates.TplName = "user/settings/actions_general"
+)
+
+// ParseMaxTokenPermissions parses the maximum token permissions from form values
+func ParseMaxTokenPermissions(ctx *context.Context) *repo_model.ActionsTokenPermissions {
+ parseMaxPerm := func(unitType unit.Type) perm.AccessMode {
+ value := ctx.FormString("max_unit_access_mode_" + strconv.Itoa(int(unitType)))
+ switch value {
+ case "write":
+ return perm.AccessModeWrite
+ case "read":
+ return perm.AccessModeRead
+ default:
+ return perm.AccessModeNone
+ }
+ }
+ ret := new(repo_model.MakeActionsTokenPermissions(perm.AccessModeNone))
+ for _, ut := range repo_model.ActionsTokenUnitTypes {
+ ret.UnitAccessModes[ut] = parseMaxPerm(ut)
+ }
+ return ret
+}
+
+// GeneralSettings renders the actions general settings page
+func GeneralSettings(ctx *context.Context) {
+ ctx.Data["Title"] = ctx.Tr("actions.actions")
+
+ rCtx, err := getRunnersCtx(ctx)
+ if err != nil {
+ ctx.ServerError("getRunnersCtx", err)
+ return
+ }
+
+ if rCtx.IsOrg {
+ ctx.Data["PageIsOrgSettings"] = true
+ ctx.Data["PageIsOrgSettingsActionsGeneral"] = true
+ } else if rCtx.IsUser {
+ ctx.Data["PageIsUserSettings"] = true
+ ctx.Data["PageIsUserSettingsActionsGeneral"] = true
+ } else {
+ ctx.NotFound(nil)
+ return
+ }
+
+ // Load User/Org Actions Config
+ actionsCfg, err := actions_model.GetOwnerActionsConfig(ctx, rCtx.OwnerID)
+ if err != nil {
+ ctx.ServerError("GetOwnerActionsConfig", err)
+ return
+ }
+
+ ctx.Data["TokenPermissionMode"] = actionsCfg.TokenPermissionMode
+ ctx.Data["TokenPermissionModePermissive"] = repo_model.ActionsTokenPermissionModePermissive
+ ctx.Data["TokenPermissionModeRestricted"] = repo_model.ActionsTokenPermissionModeRestricted
+ ctx.Data["MaxTokenPermissions"] = actionsCfg.MaxTokenPermissions
+ if actionsCfg.MaxTokenPermissions == nil {
+ ctx.Data["MaxTokenPermissions"] = (&repo_model.ActionsConfig{}).GetMaxTokenPermissions()
+ }
+ ctx.Data["EnableMaxTokenPermissions"] = actionsCfg.MaxTokenPermissions != nil
+
+ // Load Allowed Repositories
+ allowedRepos, err := repo_model.GetOwnerRepositoriesByIDs(ctx, rCtx.OwnerID, actionsCfg.AllowedCrossRepoIDs)
+ if err != nil {
+ ctx.ServerError("GetOwnerRepositoriesByIDs", err)
+ return
+ }
+
+ ctx.Data["AllowedRepos"] = allowedRepos
+ ctx.Data["OwnerID"] = rCtx.OwnerID
+
+ if rCtx.IsOrg {
+ ctx.HTML(http.StatusOK, tplOrgSettingsActionsGeneral)
+ } else {
+ ctx.HTML(http.StatusOK, tplUserSettingsActionsGeneral)
+ }
+}
+
+// UpdateGeneralSettings responses for actions general settings page
+func UpdateGeneralSettings(ctx *context.Context) {
+ rCtx, err := getRunnersCtx(ctx)
+ if err != nil {
+ ctx.ServerError("getRunnersCtx", err)
+ return
+ }
+
+ if !rCtx.IsOrg && !rCtx.IsUser {
+ ctx.NotFound(nil)
+ return
+ }
+
+ actionsCfg, err := actions_model.GetOwnerActionsConfig(ctx, rCtx.OwnerID)
+ if err != nil {
+ ctx.ServerError("GetOwnerActionsConfig", err)
+ return
+ }
+
+ if ctx.FormBool("cross_repo_add_target") {
+ targetRepoName := ctx.FormString("cross_repo_add_target_name")
+ if targetRepoName != "" {
+ targetRepo, err := repo_model.GetRepositoryByName(ctx, rCtx.OwnerID, targetRepoName)
+ if err != nil {
+ if repo_model.IsErrRepoNotExist(err) {
+ ctx.JSONError("Repository doesn't exist")
+ return
+ }
+ ctx.ServerError("GetRepositoryByName", err)
+ return
+ }
+ if !slices.Contains(actionsCfg.AllowedCrossRepoIDs, targetRepo.ID) {
+ actionsCfg.AllowedCrossRepoIDs = append(actionsCfg.AllowedCrossRepoIDs, targetRepo.ID)
+ }
+ }
+ }
+
+ if crossRepoRemoveTargetID := ctx.FormInt64("cross_repo_remove_target_id"); crossRepoRemoveTargetID != 0 {
+ actionsCfg.AllowedCrossRepoIDs = util.SliceRemoveAll(actionsCfg.AllowedCrossRepoIDs, crossRepoRemoveTargetID)
+ }
+
+ // Update Token Permission Mode
+ tokenPermissionMode, tokenPermissionModeValid := util.EnumValue(repo_model.ActionsTokenPermissionMode(ctx.FormString("token_permission_mode")))
+ if tokenPermissionModeValid {
+ actionsCfg.TokenPermissionMode = tokenPermissionMode
+ enableMaxPermissions := ctx.FormBool("enable_max_permissions")
+ // Update Maximum Permissions (radio buttons: none/read/write)
+ if enableMaxPermissions {
+ actionsCfg.MaxTokenPermissions = ParseMaxTokenPermissions(ctx)
+ } else {
+ actionsCfg.MaxTokenPermissions = nil
+ }
+ }
+
+ if err := actions_model.SetOwnerActionsConfig(ctx, rCtx.OwnerID, actionsCfg); err != nil {
+ ctx.ServerError("SetOwnerActionsConfig", err)
+ return
+ }
+
+ ctx.Flash.Success(ctx.Tr("settings.saved_successfully"))
+ ctx.JSONRedirect("") // use JSONRedirect because frontend uses form-fetch-action
+}
diff --git a/routers/web/shared/actions/runners.go b/routers/web/shared/actions/runners.go
index 8a4e93fe820..3609258440c 100644
--- a/routers/web/shared/actions/runners.go
+++ b/routers/web/shared/actions/runners.go
@@ -5,6 +5,7 @@ package actions
import (
"errors"
+ "fmt"
"net/http"
"net/url"
@@ -58,8 +59,7 @@ func getRunnersCtx(ctx *context.Context) (*runnersCtx, error) {
if ctx.Data["PageIsOrgSettings"] == true {
if _, err := shared_user.RenderUserOrgHeader(ctx); err != nil {
- ctx.ServerError("RenderUserOrgHeader", err)
- return nil, nil //nolint:nilnil // error is already handled by ctx.ServerError
+ return nil, fmt.Errorf("RenderUserOrgHeader: %w", err)
}
return &runnersCtx{
RepoID: 0,
diff --git a/routers/web/user/setting/runner.go b/routers/web/user/setting/runner.go
deleted file mode 100644
index 2bb10cceb95..00000000000
--- a/routers/web/user/setting/runner.go
+++ /dev/null
@@ -1,13 +0,0 @@
-// Copyright 2022 The Gitea Authors. All rights reserved.
-// SPDX-License-Identifier: MIT
-
-package setting
-
-import (
- "code.gitea.io/gitea/modules/setting"
- "code.gitea.io/gitea/services/context"
-)
-
-func RedirectToDefaultSetting(ctx *context.Context) {
- ctx.Redirect(setting.AppSubURL + "/user/settings/actions/runners")
-}
diff --git a/routers/web/web.go b/routers/web/web.go
index 8da7609994a..6893e380df2 100644
--- a/routers/web/web.go
+++ b/routers/web/web.go
@@ -33,7 +33,6 @@ import (
"code.gitea.io/gitea/routers/web/healthcheck"
"code.gitea.io/gitea/routers/web/misc"
"code.gitea.io/gitea/routers/web/org"
- org_setting "code.gitea.io/gitea/routers/web/org/setting"
"code.gitea.io/gitea/routers/web/repo"
"code.gitea.io/gitea/routers/web/repo/actions"
repo_setting "code.gitea.io/gitea/routers/web/repo/setting"
@@ -693,7 +692,11 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) {
}, packagesEnabled)
m.Group("/actions", func() {
- m.Get("", user_setting.RedirectToDefaultSetting)
+ m.Get("", misc.LocationRedirect("./actions/general"))
+ m.Group("/general", func() {
+ m.Get("", shared_actions.GeneralSettings)
+ m.Post("", shared_actions.UpdateGeneralSettings)
+ })
addSettingsRunnersRoutes()
addSettingsSecretsRoutes()
addSettingsVariablesRoutes()
@@ -846,7 +849,7 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) {
}, oauth2Enabled)
m.Group("/actions", func() {
- m.Get("", admin.RedirectToDefaultSetting)
+ m.Get("", misc.LocationRedirect("./actions/runners"))
addSettingsRunnersRoutes()
addSettingsVariablesRoutes()
})
@@ -998,7 +1001,11 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) {
})
m.Group("/actions", func() {
- m.Get("", org_setting.RedirectToDefaultSetting)
+ m.Get("", misc.LocationRedirect("./actions/general"))
+ m.Group("/general", func() {
+ m.Get("", shared_actions.GeneralSettings)
+ m.Post("", shared_actions.UpdateGeneralSettings)
+ })
addSettingsRunnersRoutes()
addSettingsSecretsRoutes()
addSettingsVariablesRoutes()
@@ -1202,9 +1209,9 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) {
m.Group("/actions/general", func() {
m.Get("", repo_setting.ActionsGeneralSettings)
m.Post("/actions_unit", repo_setting.ActionsUnitPost)
- })
+ }) // doesn't require actions enabled
m.Group("/actions", func() {
- m.Get("", shared_actions.RedirectToDefaultSetting)
+ m.Get("", misc.LocationRedirect("./actions/general"))
addSettingsRunnersRoutes()
addSettingsSecretsRoutes()
addSettingsVariablesRoutes()
@@ -1213,6 +1220,7 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) {
m.Post("/add", repo_setting.AddCollaborativeOwner)
m.Post("/delete", repo_setting.DeleteCollaborativeOwner)
})
+ m.Post("/token_permissions", repo_setting.UpdateTokenPermissions)
})
}, actions.MustEnableActions)
// the follow handler must be under "settings", otherwise this incomplete repo can't be accessed
@@ -1529,6 +1537,7 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) {
m.Get("/artifacts/{artifact_name}", actions.ArtifactsDownloadView)
m.Delete("/artifacts/{artifact_name}", reqRepoActionsWriter, actions.ArtifactsDeleteView)
m.Post("/rerun", reqRepoActionsWriter, actions.Rerun)
+ m.Post("/rerun-failed", reqRepoActionsWriter, actions.RerunFailed)
})
m.Group("/workflows/{workflow_name}", func() {
m.Get("/badge.svg", webAuth.AllowBasic, webAuth.AllowOAuth2, actions.GetWorkflowBadge)
@@ -1728,8 +1737,10 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) {
m.Any("/mail-preview", devtest.MailPreview)
m.Any("/mail-preview/*", devtest.MailPreviewRender)
m.Any("/{sub}", devtest.TmplCommon)
+ m.Get("/repo-action-view/runs/{run}", devtest.MockActionsView)
m.Get("/repo-action-view/runs/{run}/jobs/{job}", devtest.MockActionsView)
- m.Post("/actions-mock/runs/{run}/jobs/{job}", web.Bind(actions.ViewRequest{}), devtest.MockActionsRunsJobs)
+ m.Post("/repo-action-view/runs/{run}", web.Bind(actions.ViewRequest{}), devtest.MockActionsRunsJobs)
+ m.Post("/repo-action-view/runs/{run}/jobs/{job}", web.Bind(actions.ViewRequest{}), devtest.MockActionsRunsJobs)
})
}
diff --git a/services/actions/permission_parser.go b/services/actions/permission_parser.go
new file mode 100644
index 00000000000..9ff6134a7ac
--- /dev/null
+++ b/services/actions/permission_parser.go
@@ -0,0 +1,141 @@
+// Copyright 2026 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package actions
+
+import (
+ "code.gitea.io/gitea/models/perm"
+ repo_model "code.gitea.io/gitea/models/repo"
+ "code.gitea.io/gitea/models/unit"
+ "code.gitea.io/gitea/modules/actions/jobparser"
+ "code.gitea.io/gitea/modules/setting"
+
+ "go.yaml.in/yaml/v4"
+)
+
+// ExtractJobPermissionsFromWorkflow extracts permissions from an already parsed workflow/job.
+// It returns nil if neither workflow nor job explicitly specifies permissions.
+func ExtractJobPermissionsFromWorkflow(flow *jobparser.SingleWorkflow, job *jobparser.Job) *repo_model.ActionsTokenPermissions {
+ if flow == nil || job == nil {
+ return nil
+ }
+
+ jobPerms := parseRawPermissionsExplicit(&job.RawPermissions)
+ if jobPerms != nil {
+ return jobPerms
+ }
+
+ workflowPerms := parseRawPermissionsExplicit(&flow.RawPermissions)
+ if workflowPerms != nil {
+ return workflowPerms
+ }
+
+ return nil
+}
+
+// parseRawPermissionsExplicit parses a YAML permissions node and returns only explicit scopes.
+// It returns nil if the node does not explicitly specify permissions.
+func parseRawPermissionsExplicit(rawPerms *yaml.Node) *repo_model.ActionsTokenPermissions {
+ if rawPerms == nil || (rawPerms.Kind == yaml.ScalarNode && rawPerms.Value == "") {
+ return nil
+ }
+
+ // Unwrap DocumentNode and resolve AliasNode
+ node := rawPerms
+ for node.Kind == yaml.DocumentNode || node.Kind == yaml.AliasNode {
+ if node.Kind == yaml.DocumentNode {
+ if len(node.Content) == 0 {
+ return nil
+ }
+ node = node.Content[0]
+ } else {
+ node = node.Alias
+ }
+ }
+
+ if node.Kind == yaml.ScalarNode && node.Value == "" {
+ return nil
+ }
+
+ // Handle scalar values: "read-all" or "write-all"
+ if node.Kind == yaml.ScalarNode {
+ switch node.Value {
+ case "read-all":
+ return new(repo_model.MakeActionsTokenPermissions(perm.AccessModeRead))
+ case "write-all":
+ return new(repo_model.MakeActionsTokenPermissions(perm.AccessModeWrite))
+ default:
+ // Explicit but unrecognized scalar: return all-none permissions.
+ return new(repo_model.MakeActionsTokenPermissions(perm.AccessModeNone))
+ }
+ }
+
+ // Handle mapping: individual permission scopes
+ if node.Kind == yaml.MappingNode {
+ result := repo_model.MakeActionsTokenPermissions(perm.AccessModeNone)
+
+ // Collect all scopes into a map first to handle priority
+ scopes := make(map[string]perm.AccessMode)
+ for i := 0; i < len(node.Content); i += 2 {
+ if i+1 >= len(node.Content) {
+ break
+ }
+ keyNode := node.Content[i]
+ valueNode := node.Content[i+1]
+
+ if keyNode.Kind != yaml.ScalarNode || valueNode.Kind != yaml.ScalarNode {
+ continue
+ }
+
+ scopes[keyNode.Value] = parseAccessMode(valueNode.Value)
+ }
+
+ // 1. Apply 'contents' first (lower priority)
+ if mode, ok := scopes["contents"]; ok {
+ result.UnitAccessModes[unit.TypeCode] = mode
+ result.UnitAccessModes[unit.TypeReleases] = mode
+ }
+
+ // 2. Apply all other scopes (overwrites contents if specified)
+ for scope, mode := range scopes {
+ switch scope {
+ case "contents":
+ // already handled
+ case "code":
+ result.UnitAccessModes[unit.TypeCode] = mode
+ case "issues":
+ result.UnitAccessModes[unit.TypeIssues] = mode
+ case "pull-requests":
+ result.UnitAccessModes[unit.TypePullRequests] = mode
+ case "packages":
+ result.UnitAccessModes[unit.TypePackages] = mode
+ case "actions":
+ result.UnitAccessModes[unit.TypeActions] = mode
+ case "wiki":
+ result.UnitAccessModes[unit.TypeWiki] = mode
+ case "releases":
+ result.UnitAccessModes[unit.TypeReleases] = mode
+ case "projects":
+ result.UnitAccessModes[unit.TypeProjects] = mode
+ 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
+ }
+}
diff --git a/services/actions/permission_parser_test.go b/services/actions/permission_parser_test.go
new file mode 100644
index 00000000000..06352516fd8
--- /dev/null
+++ b/services/actions/permission_parser_test.go
@@ -0,0 +1,196 @@
+// Copyright 2026 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package actions
+
+import (
+ "testing"
+
+ "code.gitea.io/gitea/models/perm"
+ repo_model "code.gitea.io/gitea/models/repo"
+ "code.gitea.io/gitea/models/unit"
+ "code.gitea.io/gitea/modules/actions/jobparser"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "go.yaml.in/yaml/v4"
+)
+
+func TestParseRawPermissions_ReadAll(t *testing.T) {
+ var rawPerms yaml.Node
+ err := yaml.Unmarshal([]byte(`read-all`), &rawPerms)
+ assert.NoError(t, err)
+
+ result := parseRawPermissionsExplicit(&rawPerms)
+ require.NotNil(t, result)
+
+ assert.Equal(t, perm.AccessModeRead, result.UnitAccessModes[unit.TypeCode])
+ assert.Equal(t, perm.AccessModeRead, result.UnitAccessModes[unit.TypeIssues])
+ assert.Equal(t, perm.AccessModeRead, result.UnitAccessModes[unit.TypePullRequests])
+ assert.Equal(t, perm.AccessModeRead, result.UnitAccessModes[unit.TypePackages])
+ assert.Equal(t, perm.AccessModeRead, result.UnitAccessModes[unit.TypeActions])
+ assert.Equal(t, perm.AccessModeRead, result.UnitAccessModes[unit.TypeWiki])
+ assert.Equal(t, perm.AccessModeRead, result.UnitAccessModes[unit.TypeProjects])
+}
+
+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))
+ })
+ }
+}
diff --git a/services/actions/rerun.go b/services/actions/rerun.go
index 5177b90d61f..1596d9bfc5a 100644
--- a/services/actions/rerun.go
+++ b/services/actions/rerun.go
@@ -20,7 +20,27 @@ import (
"xorm.io/builder"
)
-// GetAllRerunJobs get all jobs that need to be rerun when job should be rerun
+// GetFailedRerunJobs returns all failed jobs and their downstream dependent jobs that need to be rerun
+func GetFailedRerunJobs(allJobs []*actions_model.ActionRunJob) []*actions_model.ActionRunJob {
+ rerunJobIDSet := make(container.Set[int64])
+ var jobsToRerun []*actions_model.ActionRunJob
+
+ for _, job := range allJobs {
+ if job.Status == actions_model.StatusFailure || job.Status == actions_model.StatusCancelled {
+ for _, j := range GetAllRerunJobs(job, allJobs) {
+ if !rerunJobIDSet.Contains(j.ID) {
+ rerunJobIDSet.Add(j.ID)
+ jobsToRerun = append(jobsToRerun, j)
+ }
+ }
+ }
+ }
+
+ return jobsToRerun
+}
+
+// GetAllRerunJobs returns the target job and all jobs that transitively depend on it.
+// Downstream jobs are included regardless of their current status.
func GetAllRerunJobs(job *actions_model.ActionRunJob, allJobs []*actions_model.ActionRunJob) []*actions_model.ActionRunJob {
rerunJobs := []*actions_model.ActionRunJob{job}
rerunJobsIDSet := make(container.Set[string])
@@ -49,12 +69,12 @@ func GetAllRerunJobs(job *actions_model.ActionRunJob, allJobs []*actions_model.A
return rerunJobs
}
-// RerunWorkflowRunJobs reruns all done jobs of a workflow run,
-// or reruns a selected job and all of its downstream jobs when targetJob is specified.
-func RerunWorkflowRunJobs(ctx context.Context, repo *repo_model.Repository, run *actions_model.ActionRun, jobs []*actions_model.ActionRunJob, targetJob *actions_model.ActionRunJob) error {
- // Rerun is not allowed if the run is not done.
+// prepareRunRerun validates the run, resets its state, handles concurrency, persists the
+// updated run, and fires a status-update notification.
+// It returns isRunBlocked (true when the run itself is held by a concurrency group).
+func prepareRunRerun(ctx context.Context, repo *repo_model.Repository, run *actions_model.ActionRun, jobs []*actions_model.ActionRunJob) (isRunBlocked bool, err error) {
if !run.Status.IsDone() {
- return util.NewInvalidArgumentErrorf("this workflow run is not done")
+ return false, util.NewInvalidArgumentErrorf("this workflow run is not done")
}
cfgUnit := repo.MustGetUnit(ctx, unit.TypeActions)
@@ -62,7 +82,7 @@ func RerunWorkflowRunJobs(ctx context.Context, repo *repo_model.Repository, run
// Rerun is not allowed when workflow is disabled.
cfg := cfgUnit.ActionsConfig()
if cfg.IsWorkflowDisabled(run.WorkflowID) {
- return util.NewInvalidArgumentErrorf("workflow %s is disabled", run.WorkflowID)
+ return false, util.NewInvalidArgumentErrorf("workflow %s is disabled", run.WorkflowID)
}
// Reset run's timestamps and status.
@@ -73,31 +93,31 @@ func RerunWorkflowRunJobs(ctx context.Context, repo *repo_model.Repository, run
vars, err := actions_model.GetVariablesOfRun(ctx, run)
if err != nil {
- return fmt.Errorf("get run %d variables: %w", run.ID, err)
+ return false, fmt.Errorf("get run %d variables: %w", run.ID, err)
}
if run.RawConcurrency != "" {
var rawConcurrency model.RawConcurrency
if err := yaml.Unmarshal([]byte(run.RawConcurrency), &rawConcurrency); err != nil {
- return fmt.Errorf("unmarshal raw concurrency: %w", err)
+ return false, fmt.Errorf("unmarshal raw concurrency: %w", err)
}
if err := EvaluateRunConcurrencyFillModel(ctx, run, &rawConcurrency, vars, nil); err != nil {
- return err
+ return false, err
}
run.Status, err = PrepareToStartRunWithConcurrency(ctx, run)
if err != nil {
- return err
+ return false, err
}
}
if err := actions_model.UpdateRun(ctx, run, "started", "stopped", "previous_duration", "status", "concurrency_group", "concurrency_cancel"); err != nil {
- return err
+ return false, err
}
if err := run.LoadAttributes(ctx); err != nil {
- return err
+ return false, err
}
for _, job := range jobs {
@@ -106,23 +126,38 @@ func RerunWorkflowRunJobs(ctx context.Context, repo *repo_model.Repository, run
notify_service.WorkflowRunStatusUpdate(ctx, run.Repo, run.TriggerUser, run)
- isRunBlocked := run.Status == actions_model.StatusBlocked
+ return run.Status == actions_model.StatusBlocked, nil
+}
- if targetJob == nil {
- for _, job := range jobs {
- // If the job has needs, it should be blocked to wait for its dependencies.
- shouldBlockJob := len(job.Needs) > 0 || isRunBlocked
- if err := rerunWorkflowJob(ctx, job, shouldBlockJob); err != nil {
- return err
- }
- }
+// RerunWorkflowRunJobs reruns the given jobs of a workflow run.
+// jobsToRerun must include all jobs to be rerun (the target job and its transitively dependent jobs).
+// A job is blocked (waiting for dependencies) if the run itself is blocked or if any of its
+// needs are also being rerun.
+func RerunWorkflowRunJobs(ctx context.Context, repo *repo_model.Repository, run *actions_model.ActionRun, jobsToRerun []*actions_model.ActionRunJob) error {
+ if len(jobsToRerun) == 0 {
return nil
}
- rerunJobs := GetAllRerunJobs(targetJob, jobs)
- for _, job := range rerunJobs {
- // Jobs other than the selected one should wait for dependencies.
- shouldBlockJob := job.JobID != targetJob.JobID || isRunBlocked
+ isRunBlocked, err := prepareRunRerun(ctx, repo, run, jobsToRerun)
+ if err != nil {
+ return err
+ }
+
+ rerunJobIDs := make(container.Set[string])
+ for _, j := range jobsToRerun {
+ rerunJobIDs.Add(j.JobID)
+ }
+
+ for _, job := range jobsToRerun {
+ shouldBlockJob := isRunBlocked
+ if !shouldBlockJob {
+ for _, need := range job.Needs {
+ if rerunJobIDs.Contains(need) {
+ shouldBlockJob = true
+ break
+ }
+ }
+ }
if err := rerunWorkflowJob(ctx, job, shouldBlockJob); err != nil {
return err
}
diff --git a/services/actions/rerun_test.go b/services/actions/rerun_test.go
index a98de7b7882..3b4dc5483f4 100644
--- a/services/actions/rerun_test.go
+++ b/services/actions/rerun_test.go
@@ -4,11 +4,14 @@
package actions
import (
+ "context"
"testing"
actions_model "code.gitea.io/gitea/models/actions"
+ "code.gitea.io/gitea/modules/util"
"github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
)
func TestGetAllRerunJobs(t *testing.T) {
@@ -46,3 +49,97 @@ func TestGetAllRerunJobs(t *testing.T) {
assert.ElementsMatch(t, tc.rerunJobs, rerunJobs)
}
}
+
+func TestGetFailedRerunJobs(t *testing.T) {
+ // IDs must be non-zero to distinguish jobs in the dedup set.
+ makeJob := func(id int64, jobID string, status actions_model.Status, needs ...string) *actions_model.ActionRunJob {
+ return &actions_model.ActionRunJob{ID: id, JobID: jobID, Status: status, Needs: needs}
+ }
+
+ t.Run("no failed jobs returns empty", func(t *testing.T) {
+ jobs := []*actions_model.ActionRunJob{
+ makeJob(1, "job1", actions_model.StatusSuccess),
+ makeJob(2, "job2", actions_model.StatusSkipped, "job1"),
+ }
+ assert.Empty(t, GetFailedRerunJobs(jobs))
+ })
+
+ t.Run("single failed job with no dependents", func(t *testing.T) {
+ job1 := makeJob(1, "job1", actions_model.StatusFailure)
+ job2 := makeJob(2, "job2", actions_model.StatusSuccess)
+ jobs := []*actions_model.ActionRunJob{job1, job2}
+
+ result := GetFailedRerunJobs(jobs)
+ assert.ElementsMatch(t, []*actions_model.ActionRunJob{job1}, result)
+ })
+
+ t.Run("failed job pulls in downstream dependents", func(t *testing.T) {
+ // job1 failed; job2 depends on job1 (skipped); job3 depends on job2 (skipped)
+ job1 := makeJob(1, "job1", actions_model.StatusFailure)
+ job2 := makeJob(2, "job2", actions_model.StatusSkipped, "job1")
+ job3 := makeJob(3, "job3", actions_model.StatusSkipped, "job2")
+ job4 := makeJob(4, "job4", actions_model.StatusSuccess) // unrelated, must not appear
+ jobs := []*actions_model.ActionRunJob{job1, job2, job3, job4}
+
+ result := GetFailedRerunJobs(jobs)
+ assert.ElementsMatch(t, []*actions_model.ActionRunJob{job1, job2, job3}, result)
+ })
+
+ t.Run("multiple independent failed jobs each pull in their own dependents", func(t *testing.T) {
+ // job1 failed -> job3 depends on job1
+ // job2 failed -> job4 depends on job2
+ job1 := makeJob(1, "job1", actions_model.StatusFailure)
+ job2 := makeJob(2, "job2", actions_model.StatusFailure)
+ job3 := makeJob(3, "job3", actions_model.StatusSkipped, "job1")
+ job4 := makeJob(4, "job4", actions_model.StatusSkipped, "job2")
+ jobs := []*actions_model.ActionRunJob{job1, job2, job3, job4}
+
+ result := GetFailedRerunJobs(jobs)
+ assert.ElementsMatch(t, []*actions_model.ActionRunJob{job1, job2, job3, job4}, result)
+ })
+
+ t.Run("shared downstream dependent is not duplicated", func(t *testing.T) {
+ // job1 and job2 both failed; job3 depends on both
+ job1 := makeJob(1, "job1", actions_model.StatusFailure)
+ job2 := makeJob(2, "job2", actions_model.StatusFailure)
+ job3 := makeJob(3, "job3", actions_model.StatusSkipped, "job1", "job2")
+ jobs := []*actions_model.ActionRunJob{job1, job2, job3}
+
+ result := GetFailedRerunJobs(jobs)
+ assert.ElementsMatch(t, []*actions_model.ActionRunJob{job1, job2, job3}, result)
+ assert.Len(t, result, 3) // job3 must appear exactly once
+ })
+
+ t.Run("successful downstream job of a failed job is still included", func(t *testing.T) {
+ // job1 failed; job2 succeeded but depends on job1 — downstream is always rerun
+ // regardless of its own status (GetAllRerunJobs includes all transitive dependents)
+ job1 := makeJob(1, "job1", actions_model.StatusFailure)
+ job2 := makeJob(2, "job2", actions_model.StatusSuccess, "job1")
+ jobs := []*actions_model.ActionRunJob{job1, job2}
+
+ result := GetFailedRerunJobs(jobs)
+ assert.ElementsMatch(t, []*actions_model.ActionRunJob{job1, job2}, result)
+ })
+}
+
+func TestRerunValidation(t *testing.T) {
+ runningRun := &actions_model.ActionRun{Status: actions_model.StatusRunning}
+
+ t.Run("RerunWorkflowRunJobs rejects a non-done run", func(t *testing.T) {
+ jobs := []*actions_model.ActionRunJob{
+ {ID: 1, JobID: "job1"},
+ }
+ err := RerunWorkflowRunJobs(context.Background(), nil, runningRun, jobs)
+ require.Error(t, err)
+ assert.ErrorIs(t, err, util.ErrInvalidArgument)
+ })
+
+ t.Run("RerunWorkflowRunJobs rejects a non-done run when failed jobs exist", func(t *testing.T) {
+ jobs := []*actions_model.ActionRunJob{
+ {ID: 1, JobID: "job1", Status: actions_model.StatusFailure},
+ }
+ err := RerunWorkflowRunJobs(context.Background(), nil, runningRun, GetFailedRerunJobs(jobs))
+ require.Error(t, err)
+ assert.ErrorIs(t, err, util.ErrInvalidArgument)
+ })
+}
diff --git a/services/actions/run.go b/services/actions/run.go
index c9eadc48d1e..e9fcdcaf43d 100644
--- a/services/actions/run.go
+++ b/services/actions/run.go
@@ -103,6 +103,7 @@ func InsertRun(ctx context.Context, run *actions_model.ActionRun, jobs []*jobpar
runJobs := make([]*actions_model.ActionRunJob, 0, len(jobs))
var hasWaitingJobs bool
+
for _, v := range jobs {
id, job := v.Job()
needs := job.Needs()
@@ -127,6 +128,11 @@ func InsertRun(ctx context.Context, run *actions_model.ActionRun, jobs []*jobpar
RunsOn: job.RunsOn(),
Status: util.Iif(shouldBlockJob, actions_model.StatusBlocked, actions_model.StatusWaiting),
}
+ // Parse workflow/job permissions (no clamping here)
+ if perms := ExtractJobPermissionsFromWorkflow(v, job); perms != nil {
+ runJob.TokenPermissions = perms
+ }
+
// check job concurrency
if job.RawConcurrency != nil {
rawConcurrency, err := yaml.Marshal(job.RawConcurrency)
diff --git a/services/actions/token_permission_design.md b/services/actions/token_permission_design.md
new file mode 100644
index 00000000000..d5318606d03
--- /dev/null
+++ b/services/actions/token_permission_design.md
@@ -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.
diff --git a/services/actions/workflow.go b/services/actions/workflow.go
index faa540421fb..b41741403fd 100644
--- a/services/actions/workflow.go
+++ b/services/actions/workflow.go
@@ -41,7 +41,7 @@ func EnableOrDisableWorkflow(ctx *context.APIContext, workflowID string, isEnabl
cfg.DisableWorkflow(workflow.ID)
}
- return repo_model.UpdateRepoUnit(ctx, cfgUnit)
+ return repo_model.UpdateRepoUnitConfig(ctx, cfgUnit)
}
func DispatchActionWorkflow(ctx reqctx.RequestContext, doer *user_model.User, repo *repo_model.Repository, gitRepo *git.Repository, workflowID, ref string, processInputs func(model *model.WorkflowDispatch, inputs map[string]any) error) (runID int64, _ error) {
diff --git a/services/doctor/fix16961.go b/services/doctor/fix16961.go
index 50d9ac6621a..63e33350ad5 100644
--- a/services/doctor/fix16961.go
+++ b/services/doctor/fix16961.go
@@ -296,7 +296,7 @@ func fixBrokenRepoUnits16961(ctx context.Context, logger log.Logger, autofix boo
return nil
}
- return repo_model.UpdateRepoUnit(ctx, repoUnit)
+ return repo_model.UpdateRepoUnitConfig(ctx, repoUnit)
},
)
if err != nil {
diff --git a/services/lfs/server.go b/services/lfs/server.go
index 10b4dba222c..fc09eb58ca4 100644
--- a/services/lfs/server.go
+++ b/services/lfs/server.go
@@ -42,7 +42,6 @@ type requestContext struct {
User string
Repo string
Authorization string
- Method string
RepoGitURL string
}
@@ -427,7 +426,6 @@ func getRequestContext(ctx *context.Context) *requestContext {
User: ownerName,
Repo: repoName,
Authorization: ctx.Req.Header.Get("Authorization"),
- Method: ctx.Req.Method,
RepoGitURL: httplib.GuessCurrentAppURL(ctx) + url.PathEscape(ownerName) + "/" + url.PathEscape(repoName+".git"),
}
}
@@ -490,7 +488,8 @@ func buildObjectResponse(rc *requestContext, pointer lfs_module.Pointer, downloa
var link *lfs_module.Link
if setting.LFS.Storage.ServeDirect() {
// If we have a signed url (S3, object storage), redirect to this directly.
- u, err := storage.LFS.URL(pointer.RelativePath(), pointer.Oid, rc.Method, nil)
+ // DO NOT USE the http POST method coming from the lfs batch endpoint
+ u, err := storage.LFS.ServeDirectURL(pointer.RelativePath(), pointer.Oid, http.MethodGet, nil)
if u != nil && err == nil {
link = lfs_module.NewLink(u.String()) // Presigned url does not need the Authorization header
}
diff --git a/services/packages/packages.go b/services/packages/packages.go
index 22b26b65637..3b4e11e0410 100644
--- a/services/packages/packages.go
+++ b/services/packages/packages.go
@@ -599,7 +599,7 @@ func OpenBlobStream(pb *packages_model.PackageBlob) (io.ReadSeekCloser, error) {
// OpenBlobForDownload returns the content of the specific package blob and increases the download counter.
// If the storage supports direct serving and it's enabled, only the direct serving url is returned.
-func OpenBlobForDownload(ctx context.Context, pf *packages_model.PackageFile, pb *packages_model.PackageBlob, method string, serveDirectReqParams url.Values) (io.ReadSeekCloser, *url.URL, *packages_model.PackageFile, error) {
+func OpenBlobForDownload(ctx context.Context, pf *packages_model.PackageFile, pb *packages_model.PackageBlob, method string, serveDirectReqParams *storage.ServeDirectOptions) (io.ReadSeekCloser, *url.URL, *packages_model.PackageFile, error) {
key := packages_module.BlobHash256Key(pb.HashSHA256)
cs := packages_module.NewContentStore()
diff --git a/services/pull/update_test.go b/services/pull/update_test.go
index 4b5772e35d5..0bb67544455 100644
--- a/services/pull/update_test.go
+++ b/services/pull/update_test.go
@@ -26,7 +26,7 @@ func TestIsUserAllowedToUpdate(t *testing.T) {
setRepoAllowRebaseUpdate := func(t *testing.T, repoID int64, allow bool) {
repoUnit := unittest.AssertExistsAndLoadBean(t, &repo_model.RepoUnit{RepoID: repoID, Type: unit.TypePullRequests})
repoUnit.PullRequestsConfig().AllowRebaseUpdate = allow
- require.NoError(t, repo_model.UpdateRepoUnit(t.Context(), repoUnit))
+ require.NoError(t, repo_model.UpdateRepoUnitConfig(t.Context(), repoUnit))
}
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
diff --git a/services/repository/archiver/archiver.go b/services/repository/archiver/archiver.go
index 07214d0bfa9..1d28e00655c 100644
--- a/services/repository/archiver/archiver.go
+++ b/services/repository/archiver/archiver.go
@@ -346,7 +346,7 @@ func ServeRepoArchive(ctx *gitea_context.Base, archiveReq *ArchiveRequest) error
rPath := archiver.RelativePath()
if setting.RepoArchive.Storage.ServeDirect() {
// If we have a signed url (S3, object storage), redirect to this directly.
- u, err := storage.RepoArchives.URL(rPath, downloadName, ctx.Req.Method, nil)
+ u, err := storage.RepoArchives.ServeDirectURL(rPath, downloadName, ctx.Req.Method, nil)
if u != nil && err == nil {
ctx.Redirect(u.String())
return nil
diff --git a/services/repository/transfer.go b/services/repository/transfer.go
index a601ee6f168..fbf357c3667 100644
--- a/services/repository/transfer.go
+++ b/services/repository/transfer.go
@@ -8,6 +8,7 @@ import (
"fmt"
"strings"
+ actions_model "code.gitea.io/gitea/models/actions"
"code.gitea.io/gitea/models/db"
issues_model "code.gitea.io/gitea/models/issues"
"code.gitea.io/gitea/models/organization"
@@ -246,6 +247,19 @@ func transferOwnership(ctx context.Context, doer *user_model.User, newOwnerName
return fmt.Errorf("recalculateAccesses: %w", err)
}
+ // Remove repository from old owner's Actions AllowedCrossRepoIDs if present
+ if oldActionsCfg, err := actions_model.GetOwnerActionsConfig(ctx, oldOwner.ID); err == nil {
+ newAllowedCrossRepoIDs := util.SliceRemoveAll(oldActionsCfg.AllowedCrossRepoIDs, repo.ID)
+ if len(newAllowedCrossRepoIDs) != len(oldActionsCfg.AllowedCrossRepoIDs) {
+ oldActionsCfg.AllowedCrossRepoIDs = newAllowedCrossRepoIDs
+ if err := actions_model.SetOwnerActionsConfig(ctx, oldOwner.ID, oldActionsCfg); err != nil {
+ return fmt.Errorf("SetOwnerActionsConfig: %w", err)
+ }
+ }
+ } else {
+ return fmt.Errorf("GetOwnerActionsConfig: %w", err)
+ }
+
// Update repository count.
if _, err := sess.Exec("UPDATE `user` SET num_repos=num_repos+1 WHERE id=?", newOwner.ID); err != nil {
return fmt.Errorf("increase new owner repository count: %w", err)
diff --git a/templates/devtest/repo-action-view.tmpl b/templates/devtest/repo-action-view.tmpl
index b3a52db1e29..46f040d8a6f 100644
--- a/templates/devtest/repo-action-view.tmpl
+++ b/templates/devtest/repo-action-view.tmpl
@@ -1,14 +1,14 @@
{{template "base/head" .}}
-
-
Run:CanCancel
-
Run:CanApprove
-
Run:CanRerun
+
{{template "repo/actions/view_component" (dict
"RunID" (or .RunID 10)
"JobID" (or .JobID 0)
- "ActionsURL" (print AppSubUrl "/devtest/actions-mock")
+ "ActionsURL" (print AppSubUrl "/devtest/repo-action-view")
)}}
{{template "base/footer" .}}
diff --git a/templates/org/settings/actions_general.tmpl b/templates/org/settings/actions_general.tmpl
new file mode 100644
index 00000000000..ebf9482f614
--- /dev/null
+++ b/templates/org/settings/actions_general.tmpl
@@ -0,0 +1,5 @@
+{{template "org/settings/layout_head" (dict "ctxData" .)}}
+
+ {{template "shared/actions/owner_general_settings" .}}
+
+{{template "org/settings/layout_footer" .}}
diff --git a/templates/org/settings/navbar.tmpl b/templates/org/settings/navbar.tmpl
index 58475de7e7a..4c06b2cb1ba 100644
--- a/templates/org/settings/navbar.tmpl
+++ b/templates/org/settings/navbar.tmpl
@@ -26,9 +26,12 @@
{{end}}
{{if .EnableActions}}
-
+
{{ctx.Locale.Tr "actions.actions"}}