diff --git a/custom/conf/app.example.ini b/custom/conf/app.example.ini index 548c39d4b6c..e5451af76ae 100644 --- a/custom/conf/app.example.ini +++ b/custom/conf/app.example.ini @@ -3002,6 +3002,10 @@ LEVEL = Info ;; Comma-separated list of workflow directories, the first one to exist ;; in a repo is used to find Actions workflow files ;WORKFLOW_DIRS = .gitea/workflows,.github/workflows +;; Comma-separated list of scoped workflow directories in a source repository, the first one to exist is used. +;; Files here are picked up only when the repo is registered as a scoped-workflow source; in any other repo they neither run repo-level nor scope-level. +;; Must not overlap with WORKFLOW_DIRS. Leave empty so no directory is scanned; no scoped workflows are found or run. +;SCOPED_WORKFLOW_DIRS = .gitea/scoped_workflows ;; Maximum number of attempts a single workflow run can have. Default value is 50. ;MAX_RERUN_ATTEMPTS = 50 diff --git a/models/actions/run.go b/models/actions/run.go index 8d6d804340c..79b71459351 100644 --- a/models/actions/run.go +++ b/models/actions/run.go @@ -7,6 +7,7 @@ import ( "context" "errors" "fmt" + "net/url" "strings" "time" @@ -50,6 +51,13 @@ type ActionRun struct { Version int `xorm:"version default 0"` // Status could be updated concomitantly, so an optimistic lock is needed RawConcurrency string // raw concurrency + // WorkflowRepoID/WorkflowCommitSHA record the (repo, commit) the run's workflow file content came from. + // Always filled (repo-level run = the repo itself; scoped run = the source repo). + WorkflowRepoID int64 `xorm:"NOT NULL DEFAULT 0"` + WorkflowCommitSHA string `xorm:"VARCHAR(64) NOT NULL DEFAULT ''"` + + IsScopedRun bool `xorm:"NOT NULL DEFAULT false"` // IsScopedRun explicitly classifies scoped runs. + // Started and Stopped are identical to the latest attempt after ActionRunAttempt was introduced. // When a rerun creates a new latest attempt, they are reset until the new attempt starts and stops. Started timeutil.TimeStamp @@ -88,7 +96,11 @@ func (run *ActionRun) WorkflowLink() string { if run.Repo == nil { return "" } - return fmt.Sprintf("%s/actions/?workflow=%s", run.Repo.Link(), run.WorkflowID) + // A scoped run's workflow is disambiguated by its source repo, so carry scoped_workflow_source_repo_id back to the run list + if run.IsScopedRun { + return fmt.Sprintf("%s/actions/?workflow=%s&scoped_workflow_source_repo_id=%d", run.Repo.Link(), url.QueryEscape(run.WorkflowID), run.WorkflowRepoID) + } + return fmt.Sprintf("%s/actions/?workflow=%s", run.Repo.Link(), url.QueryEscape(run.WorkflowID)) } // RefLink return the url of run's ref @@ -291,7 +303,10 @@ func GetWorkflowLatestRun(ctx context.Context, repoID int64, workflowFile, branc var run ActionRun q := db.GetEngine(ctx).Where("repo_id=?", repoID). And("ref = ?", branch). - And("workflow_id = ?", workflowFile) + And("workflow_id = ?", workflowFile). + // TODO: the badge only reflects the repo's own (repo-level) runs; a same-named scoped run must not leak in. + // Support a scoped-workflow badge later by making this source-aware. + And("is_scoped_run = ?", false) if event != "" { q.And("event = ?", event) } diff --git a/models/actions/run_list.go b/models/actions/run_list.go index e07b30c265f..6f836d3e114 100644 --- a/models/actions/run_list.go +++ b/models/actions/run_list.go @@ -10,6 +10,7 @@ import ( repo_model "gitea.dev/models/repo" user_model "gitea.dev/models/user" "gitea.dev/modules/container" + "gitea.dev/modules/optional" "gitea.dev/modules/translation" webhook_module "gitea.dev/modules/webhook" @@ -61,7 +62,9 @@ type FindRunOptions struct { RepoID int64 OwnerID int64 WorkflowID string - Ref string // the commit/tag/… that caused this workflow + WorkflowRepoID int64 // source-aware filter: the repo a run's workflow content came from (0 = any) + IsScopedRun optional.Option[bool] // is the run from a scoped workflow + Ref string // the commit/tag/… that caused this workflow TriggerUserID int64 TriggerEvent webhook_module.HookEventType Status []Status @@ -77,6 +80,12 @@ func (opts FindRunOptions) ToConds() builder.Cond { if opts.WorkflowID != "" { cond = cond.And(builder.Eq{"`action_run`.workflow_id": opts.WorkflowID}) } + if opts.WorkflowRepoID > 0 { + cond = cond.And(builder.Eq{"`action_run`.workflow_repo_id": opts.WorkflowRepoID}) + } + if opts.IsScopedRun.Has() { + cond = cond.And(builder.Eq{"`action_run`.is_scoped_run": opts.IsScopedRun.Value()}) + } if opts.TriggerUserID > 0 { cond = cond.And(builder.Eq{"`action_run`.trigger_user_id": opts.TriggerUserID}) } @@ -156,9 +165,20 @@ func GetRunBranches(ctx context.Context, repoID int64) ([]string, error) { // GetRunWorkflowIDs returns all distinct WorkflowIDs that have at least // one ActionRun in the given repo. func GetRunWorkflowIDs(ctx context.Context, repoID int64) ([]string, error) { + return getRunWorkflowIDs(ctx, repoID, builder.NewCond()) +} + +// GetRepoRunWorkflowIDs returns all distinct WorkflowIDs that have at least +// one repo-level ActionRun in the given repo. +func GetRepoRunWorkflowIDs(ctx context.Context, repoID int64) ([]string, error) { + return getRunWorkflowIDs(ctx, repoID, builder.Eq{"is_scoped_run": false}) +} + +func getRunWorkflowIDs(ctx context.Context, repoID int64, extraCond builder.Cond) ([]string, error) { ids := make([]string, 0, 10) + cond := builder.Eq{"repo_id": repoID} return ids, db.GetEngine(ctx).Table("action_run"). - Where(builder.Eq{"repo_id": repoID}). + Where(cond.And(extraCond)). Distinct("workflow_id"). Cols("workflow_id"). Asc("workflow_id"). diff --git a/models/actions/run_list_test.go b/models/actions/run_list_test.go index 74f630bb553..556896d1074 100644 --- a/models/actions/run_list_test.go +++ b/models/actions/run_list_test.go @@ -6,10 +6,13 @@ package actions import ( "testing" + "gitea.dev/models/db" "gitea.dev/models/unittest" + "gitea.dev/modules/optional" "gitea.dev/modules/translation" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestGetRunWorkflowIDs(t *testing.T) { @@ -24,6 +27,46 @@ func TestGetRunWorkflowIDs(t *testing.T) { assert.Empty(t, ids) } +func TestGetRepoRunWorkflowIDs(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + + const ( + repoID = int64(4) + repoWorkflowID = "repo-orphan.yaml" + scopedWorkflowID = "scoped-only.yaml" + sharedWorkflowID = "shared-name.yaml" + scopedWorkflowRepo = int64(111) + ) + for _, spec := range []struct { + id int64 + workflowID string + workflowRepoID int64 + isScopedRun bool + }{ + {99811, repoWorkflowID, repoID, false}, + {99812, scopedWorkflowID, scopedWorkflowRepo, true}, + {99813, sharedWorkflowID, repoID, false}, + {99814, sharedWorkflowID, scopedWorkflowRepo, true}, + } { + require.NoError(t, db.Insert(t.Context(), &ActionRun{ + ID: spec.id, + Index: spec.id, + RepoID: repoID, + OwnerID: 1, + TriggerUserID: 1, + WorkflowID: spec.workflowID, + WorkflowRepoID: spec.workflowRepoID, + IsScopedRun: spec.isScopedRun, + })) + } + + ids, err := GetRepoRunWorkflowIDs(t.Context(), repoID) + require.NoError(t, err) + assert.Contains(t, ids, repoWorkflowID) + assert.Contains(t, ids, sharedWorkflowID) + assert.NotContains(t, ids, scopedWorkflowID) +} + func TestGetStatusInfoList(t *testing.T) { statusInfoList := GetStatusInfoList(t.Context(), translation.MockLocale{}) @@ -35,3 +78,85 @@ func TestGetStatusInfoList(t *testing.T) { {Status: int(StatusCancelling), StatusName: StatusCancelling.String(), DisplayedStatus: "actions.status.cancelling"}, }, statusInfoList) } + +// TestFindRunOptions_WorkflowRepoID: two runs share the bare WorkflowID but come from different content-source repos; +// the source-aware WorkflowRepoID filter must separate them. +func TestFindRunOptions_WorkflowRepoID(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + + const ( + repoID = int64(4) + sourceA = int64(111) + sourceB = int64(222) + workflowID = "u3-shared.yaml" + ) + for _, spec := range []struct{ id, workflowRepoID int64 }{ + {99801, sourceA}, + {99802, sourceB}, + } { + require.NoError(t, db.Insert(t.Context(), &ActionRun{ + ID: spec.id, + Index: spec.id, + RepoID: repoID, + OwnerID: 1, + TriggerUserID: 1, + WorkflowID: workflowID, + WorkflowRepoID: spec.workflowRepoID, + IsScopedRun: true, + })) + } + + // no source filter -> both + all, err := db.Find[ActionRun](t.Context(), FindRunOptions{RepoID: repoID, WorkflowID: workflowID}) + require.NoError(t, err) + assert.Len(t, all, 2) + + // filter by source A -> only the run whose content came from A + onlyA, err := db.Find[ActionRun](t.Context(), FindRunOptions{RepoID: repoID, WorkflowID: workflowID, WorkflowRepoID: sourceA}) + require.NoError(t, err) + require.Len(t, onlyA, 1) + assert.EqualValues(t, 99801, onlyA[0].ID) + + // filter by source B -> only the run whose content came from B + onlyB, err := db.Find[ActionRun](t.Context(), FindRunOptions{RepoID: repoID, WorkflowID: workflowID, WorkflowRepoID: sourceB}) + require.NoError(t, err) + require.Len(t, onlyB, 1) + assert.EqualValues(t, 99802, onlyB[0].ID) +} + +func TestFindRunOptions_IsScopedRun(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + + const ( + repoID = int64(4) + workflowID = "scoped-flag.yaml" + ) + for _, spec := range []struct { + id int64 + scoped bool + }{ + {99821, false}, + {99822, true}, + } { + require.NoError(t, db.Insert(t.Context(), &ActionRun{ + ID: spec.id, + Index: spec.id, + RepoID: repoID, + OwnerID: 1, + TriggerUserID: 1, + WorkflowID: workflowID, + WorkflowRepoID: repoID, + IsScopedRun: spec.scoped, + })) + } + + repoLevel, err := db.Find[ActionRun](t.Context(), FindRunOptions{RepoID: repoID, WorkflowID: workflowID, IsScopedRun: optional.Some(false)}) + require.NoError(t, err) + require.Len(t, repoLevel, 1) + assert.EqualValues(t, 99821, repoLevel[0].ID) + + scoped, err := db.Find[ActionRun](t.Context(), FindRunOptions{RepoID: repoID, WorkflowID: workflowID, IsScopedRun: optional.Some(true)}) + require.NoError(t, err) + require.Len(t, scoped, 1) + assert.EqualValues(t, 99822, scoped[0].ID) +} diff --git a/models/actions/run_test.go b/models/actions/run_test.go index 356ed28a74e..08330cd9997 100644 --- a/models/actions/run_test.go +++ b/models/actions/run_test.go @@ -13,6 +13,7 @@ import ( "gitea.dev/modules/timeutil" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestUpdateRepoRunsNumbers(t *testing.T) { @@ -44,3 +45,57 @@ func TestActionRun_Duration_NonNegative(t *testing.T) { } assert.Equal(t, time.Duration(0), run.Duration()) } + +func TestActionRun_WorkflowLink(t *testing.T) { + repo := &repo_model.Repository{OwnerName: "org", Name: "consumer"} + + // a repo-level run links by file name only + repoLevel := &ActionRun{Repo: repo, WorkflowID: "ci.yaml", WorkflowRepoID: repo.ID} + assert.Equal(t, repo.Link()+"/actions/?workflow=ci.yaml", repoLevel.WorkflowLink()) + + // a scoped run carries its source repo id back, so the list stays filtered to that source + scoped := &ActionRun{Repo: repo, WorkflowID: "ci.yaml", WorkflowRepoID: 42, IsScopedRun: true} + assert.Equal(t, repo.Link()+"/actions/?workflow=ci.yaml&scoped_workflow_source_repo_id=42", scoped.WorkflowLink()) +} + +func TestGetWorkflowLatestRun_RepoLevelOnly(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + + const ( + repoID = int64(4) + workflowID = "badge-source-aware.yaml" + ref = "refs/heads/main" + ) + require.NoError(t, db.Insert(t.Context(), &ActionRun{ + ID: 99811, + Index: 99811, + RepoID: repoID, + OwnerID: 1, + TriggerUserID: 1, + WorkflowID: workflowID, + Ref: ref, + Event: "push", + Status: StatusSuccess, + WorkflowRepoID: repoID, + WorkflowCommitSHA: "repo-level-sha", + })) + require.NoError(t, db.Insert(t.Context(), &ActionRun{ + ID: 99812, + Index: 99812, + RepoID: repoID, + OwnerID: 1, + TriggerUserID: 1, + WorkflowID: workflowID, + Ref: ref, + Event: "push", + Status: StatusFailure, + WorkflowRepoID: 111, + WorkflowCommitSHA: "scoped-sha", + IsScopedRun: true, + })) + + run, err := GetWorkflowLatestRun(t.Context(), repoID, workflowID, ref, "push") + require.NoError(t, err) + assert.EqualValues(t, 99811, run.ID) + assert.False(t, run.IsScopedRun) +} diff --git a/models/actions/scoped_workflow.go b/models/actions/scoped_workflow.go new file mode 100644 index 00000000000..310e1a041de --- /dev/null +++ b/models/actions/scoped_workflow.go @@ -0,0 +1,179 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package actions + +import ( + "context" + "fmt" + + "gitea.dev/models/db" + repo_model "gitea.dev/models/repo" + "gitea.dev/modules/timeutil" + "gitea.dev/modules/util" + + "xorm.io/builder" +) + +// ActionScopedWorkflowSource registers a repository as a source of scoped workflows, either for an owner (user/org) or for the whole instance. +type ActionScopedWorkflowSource struct { + ID int64 `xorm:"pk autoincr"` + + // OwnerID is the scope the source applies to: a user/org ID (applies to that owner's repos), or 0 for instance-level (applies to every repo). + OwnerID int64 `xorm:"UNIQUE(owner_repo) NOT NULL DEFAULT 0"` + // SourceRepoID is the source repository providing the workflow files; always non-zero. + SourceRepoID int64 `xorm:"INDEX UNIQUE(owner_repo) NOT NULL DEFAULT 0"` + + // WorkflowConfigs maps a workflow ID (entry name) to its merge-gate config. + WorkflowConfigs map[string]*ScopedWorkflowConfig `xorm:"JSON TEXT 'workflow_configs'"` + + CreatedUnix timeutil.TimeStamp `xorm:"created"` + UpdatedUnix timeutil.TimeStamp `xorm:"updated"` +} + +// ScopedWorkflowConfig is one scoped workflow's config within a source registration. +type ScopedWorkflowConfig struct { + Required bool `json:"required"` + Patterns []string `json:"patterns"` // the status-check patterns that must be present and pass, only effective when Required is true +} + +func init() { + db.RegisterModel(new(ActionScopedWorkflowSource)) +} + +// IsWorkflowRequired reports whether the given workflow ID (entry name) is marked required in this source. +func (s *ActionScopedWorkflowSource) IsWorkflowRequired(workflowID string) bool { + c, ok := s.WorkflowConfigs[workflowID] + return ok && c.Required +} + +type FindScopedWorkflowSourceOpts struct { + db.ListOptions + OwnerIDs []int64 + SourceRepoID int64 +} + +func (opts FindScopedWorkflowSourceOpts) ToConds() builder.Cond { + cond := builder.NewCond() + if len(opts.OwnerIDs) > 0 { + cond = cond.And(builder.In("owner_id", opts.OwnerIDs)) + } + if opts.SourceRepoID != 0 { + cond = cond.And(builder.Eq{"source_repo_id": opts.SourceRepoID}) + } + return cond +} + +// GetEffectiveScopedWorkflowSources returns the scoped-workflow sources effective for a repo owned by repoOwnerID: +// the owner's own sources plus instance-level (owner_id=0) sources. +func GetEffectiveScopedWorkflowSources(ctx context.Context, repoOwnerID int64) ([]*ActionScopedWorkflowSource, error) { + owners := []int64{0} + if repoOwnerID != 0 { + owners = append(owners, repoOwnerID) + } + return db.Find[ActionScopedWorkflowSource](ctx, FindScopedWorkflowSourceOpts{OwnerIDs: owners}) +} + +// IsScopedWorkflowSourceEffective reports whether sourceRepoID is a scoped-workflow source effective for a repo owned by repoOwnerID. +func IsScopedWorkflowSourceEffective(ctx context.Context, repoOwnerID, sourceRepoID int64) (bool, error) { + owners := []int64{0} + if repoOwnerID != 0 { + owners = append(owners, repoOwnerID) + } + return db.Exist[ActionScopedWorkflowSource](ctx, FindScopedWorkflowSourceOpts{OwnerIDs: owners, SourceRepoID: sourceRepoID}.ToConds()) +} + +// IsWorkflowRequiredInSources reports whether workflowID from sourceRepoID is required by any of the given sources. +func IsWorkflowRequiredInSources(sources []*ActionScopedWorkflowSource, sourceRepoID int64, workflowID string) bool { + for _, s := range sources { + if s.SourceRepoID == sourceRepoID && s.IsWorkflowRequired(workflowID) { + return true + } + } + return false +} + +// ScopedStatusContextPrefix returns the source-repo prefix that makes a scoped run's commit-status context distinct from same-named workflows. +func ScopedStatusContextPrefix(ctx context.Context, sourceRepoID int64) string { + if sourceRepo, err := repo_model.GetRepositoryByID(ctx, sourceRepoID); err == nil { + return sourceRepo.FullName() + } + return fmt.Sprintf("scoped:%d", sourceRepoID) +} + +// IsScopedWorkflowRequired reports whether workflowID from sourceRepoID is required for a repo owned by consumerOwnerID. +func IsScopedWorkflowRequired(ctx context.Context, consumerOwnerID, sourceRepoID int64, workflowID string) (bool, error) { + sources, err := GetEffectiveScopedWorkflowSources(ctx, consumerOwnerID) + if err != nil { + return false, err + } + return IsWorkflowRequiredInSources(sources, sourceRepoID, workflowID), nil +} + +// IsScopedWorkflowOptedOutloads the consumer's effective sources then calls ScopedWorkflowOptedOut +func IsScopedWorkflowOptedOut(ctx context.Context, cfg *repo_model.ActionsConfig, consumerOwnerID, sourceRepoID int64, workflowID string) (bool, error) { + if !cfg.IsScopedWorkflowDisabled(sourceRepoID, workflowID) { + return false, nil + } + sources, err := GetEffectiveScopedWorkflowSources(ctx, consumerOwnerID) + if err != nil { + return false, err + } + return ScopedWorkflowOptedOut(cfg, sources, sourceRepoID, workflowID), nil +} + +// ScopedWorkflowOptedOut reports whether a consumer's opt-out of (sourceRepoID, workflowID) is in effect. +func ScopedWorkflowOptedOut(cfg *repo_model.ActionsConfig, sources []*ActionScopedWorkflowSource, sourceRepoID int64, workflowID string) bool { + return !IsWorkflowRequiredInSources(sources, sourceRepoID, workflowID) && cfg.IsScopedWorkflowDisabled(sourceRepoID, workflowID) +} + +// GetScopedWorkflowSourcesByOwner returns the sources an owner (user/org, or 0 for instance) registered. +func GetScopedWorkflowSourcesByOwner(ctx context.Context, ownerID int64) ([]*ActionScopedWorkflowSource, error) { + return db.Find[ActionScopedWorkflowSource](ctx, FindScopedWorkflowSourceOpts{OwnerIDs: []int64{ownerID}}) +} + +// GetScopedWorkflowSource returns the (owner, repo) source registration or a NotExist error. +func GetScopedWorkflowSource(ctx context.Context, ownerID, repoID int64) (*ActionScopedWorkflowSource, error) { + src := &ActionScopedWorkflowSource{} + has, err := db.GetEngine(ctx).Where("owner_id = ? AND source_repo_id = ?", ownerID, repoID).Get(src) + if err != nil { + return nil, err + } + if !has { + return nil, util.NewNotExistErrorf("scoped workflow source (owner %d, repo %d) does not exist", ownerID, repoID) + } + return src, nil +} + +// AddScopedWorkflowSource registers repoID as a source for ownerID (no-op if already registered). +func AddScopedWorkflowSource(ctx context.Context, ownerID, repoID int64) error { + exists, err := db.GetEngine(ctx).Where("owner_id = ? AND source_repo_id = ?", ownerID, repoID).Exist(new(ActionScopedWorkflowSource)) + if err != nil { + return err + } + if exists { + return nil + } + if err := db.Insert(ctx, &ActionScopedWorkflowSource{OwnerID: ownerID, SourceRepoID: repoID}); err != nil { + // Re-check and treat an already-present row as the intended no-op. + if exists, existErr := db.GetEngine(ctx).Where("owner_id = ? AND source_repo_id = ?", ownerID, repoID).Exist(new(ActionScopedWorkflowSource)); existErr == nil && exists { + return nil + } + return err + } + return nil +} + +// SetScopedWorkflowSourceConfigs replaces the per-workflow merge-gate configs (workflow ID -> config). +func SetScopedWorkflowSourceConfigs(ctx context.Context, ownerID, repoID int64, configs map[string]*ScopedWorkflowConfig) error { + _, err := db.GetEngine(ctx).Where("owner_id = ? AND source_repo_id = ?", ownerID, repoID). + Cols("workflow_configs"). + Update(&ActionScopedWorkflowSource{WorkflowConfigs: configs}) + return err +} + +// RemoveScopedWorkflowSource removes the (owner, repo) source registration. +func RemoveScopedWorkflowSource(ctx context.Context, ownerID, repoID int64) error { + _, err := db.GetEngine(ctx).Where("owner_id = ? AND source_repo_id = ?", ownerID, repoID).Delete(new(ActionScopedWorkflowSource)) + return err +} diff --git a/models/actions/scoped_workflow_test.go b/models/actions/scoped_workflow_test.go new file mode 100644 index 00000000000..f3b90f6cd17 --- /dev/null +++ b/models/actions/scoped_workflow_test.go @@ -0,0 +1,139 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package actions + +import ( + "testing" + + "gitea.dev/models/db" + "gitea.dev/models/unittest" + "gitea.dev/modules/util" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestScopedWorkflowSource_IsWorkflowRequired(t *testing.T) { + src := &ActionScopedWorkflowSource{WorkflowConfigs: map[string]*ScopedWorkflowConfig{ + "a.yml": {Required: true, Patterns: []string{"p"}}, + "b.yml": {Required: true, Patterns: []string{"p"}}, + "c.yml": {Required: false, Patterns: []string{"p"}}, // patterns kept as history, not required + }} + assert.True(t, src.IsWorkflowRequired("a.yml")) + assert.True(t, src.IsWorkflowRequired("b.yml")) + assert.False(t, src.IsWorkflowRequired("c.yml"), "config kept as history but not required") + assert.False(t, src.IsWorkflowRequired("d.yml")) + + empty := &ActionScopedWorkflowSource{} + assert.False(t, empty.IsWorkflowRequired("a.yml")) +} + +func TestIsWorkflowRequiredInSources(t *testing.T) { + // repo 100 registered twice (org optional + instance required). + sources := []*ActionScopedWorkflowSource{ + {OwnerID: 2, SourceRepoID: 100, WorkflowConfigs: nil}, + {OwnerID: 0, SourceRepoID: 100, WorkflowConfigs: map[string]*ScopedWorkflowConfig{"a.yml": {Required: true, Patterns: []string{"p"}}}}, + {OwnerID: 0, SourceRepoID: 200, WorkflowConfigs: map[string]*ScopedWorkflowConfig{"b.yml": {Required: true, Patterns: []string{"p"}}}}, + } + + assert.True(t, IsWorkflowRequiredInSources(sources, 100, "a.yml"), "required at instance level wins over org optional") + assert.False(t, IsWorkflowRequiredInSources(sources, 100, "z.yml")) + assert.False(t, IsWorkflowRequiredInSources(sources, 200, "a.yml"), "a.yml is required for repo 100, not repo 200") + assert.True(t, IsWorkflowRequiredInSources(sources, 200, "b.yml")) + assert.False(t, IsWorkflowRequiredInSources(sources, 999, "a.yml"), "unknown source repo") +} + +func TestGetEffectiveScopedWorkflowSources(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + ctx := t.Context() + + rows := []*ActionScopedWorkflowSource{ + {OwnerID: 2, SourceRepoID: 100, WorkflowConfigs: nil}, // org 2 registers repo 100 (optional) + {OwnerID: 0, SourceRepoID: 100, WorkflowConfigs: map[string]*ScopedWorkflowConfig{"a.yml": {Required: true, Patterns: []string{"p"}}}}, // instance also registers repo 100 (required) + {OwnerID: 0, SourceRepoID: 200, WorkflowConfigs: map[string]*ScopedWorkflowConfig{"b.yml": {Required: true, Patterns: []string{"p"}}}}, // instance source 200 + {OwnerID: 3, SourceRepoID: 300, WorkflowConfigs: map[string]*ScopedWorkflowConfig{"c.yml": {Required: true, Patterns: []string{"p"}}}}, // a different owner's source + } + for _, r := range rows { + require.NoError(t, db.Insert(ctx, r)) + } + + // owner 2 sees its own sources plus instance-level ones, but not owner 3's. + owner2, err := GetEffectiveScopedWorkflowSources(ctx, 2) + require.NoError(t, err) + assert.Len(t, owner2, 3) + + required, err := IsScopedWorkflowRequired(ctx, 2, 100, "a.yml") + require.NoError(t, err) + assert.True(t, required, "instance marks a.yml required → required for owner 2 even though org left it optional") + + required, err = IsScopedWorkflowRequired(ctx, 2, 100, "x.yml") + require.NoError(t, err) + assert.False(t, required) + + required, err = IsScopedWorkflowRequired(ctx, 2, 200, "b.yml") + require.NoError(t, err) + assert.True(t, required) + + // owner 3's source must not be effective for owner 2. + required, err = IsScopedWorkflowRequired(ctx, 2, 300, "c.yml") + require.NoError(t, err) + assert.False(t, required) + + // IsScopedWorkflowSourceEffective: owner-level and instance-level sources are effective; another owner's is not. + effective, err := IsScopedWorkflowSourceEffective(ctx, 2, 100) + require.NoError(t, err) + assert.True(t, effective, "owner 2's own source") + + effective, err = IsScopedWorkflowSourceEffective(ctx, 2, 200) + require.NoError(t, err) + assert.True(t, effective, "instance-level source is effective for any owner") + + effective, err = IsScopedWorkflowSourceEffective(ctx, 2, 300) + require.NoError(t, err) + assert.False(t, effective, "owner 3's source is not effective for owner 2") + + effective, err = IsScopedWorkflowSourceEffective(ctx, 2, 999) + require.NoError(t, err) + assert.False(t, effective, "unknown source repo") + + effective, err = IsScopedWorkflowSourceEffective(ctx, 3, 300) + require.NoError(t, err) + assert.True(t, effective, "owner 3's own source is effective for owner 3") +} + +func TestScopedWorkflowSourceCRUD(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + ctx := t.Context() + + // add is idempotent + require.NoError(t, AddScopedWorkflowSource(ctx, 5, 10)) + require.NoError(t, AddScopedWorkflowSource(ctx, 5, 10)) + sources, err := GetScopedWorkflowSourcesByOwner(ctx, 5) + require.NoError(t, err) + assert.Len(t, sources, 1) + + // set the per-workflow configs (entry name -> {required, patterns}); a.yml required, b.yml kept as history (not required) + configs := map[string]*ScopedWorkflowConfig{ + "a.yml": {Required: true, Patterns: []string{"src: a.yml / *"}}, + "b.yml": {Required: false, Patterns: []string{"src: b.yml / build (push)"}}, + } + require.NoError(t, SetScopedWorkflowSourceConfigs(ctx, 5, 10, configs)) + src, err := GetScopedWorkflowSource(ctx, 5, 10) + require.NoError(t, err) + assert.Equal(t, configs, src.WorkflowConfigs) + + // clearing the configs works + require.NoError(t, SetScopedWorkflowSourceConfigs(ctx, 5, 10, nil)) + src, err = GetScopedWorkflowSource(ctx, 5, 10) + require.NoError(t, err) + assert.Empty(t, src.WorkflowConfigs) + + // remove + require.NoError(t, RemoveScopedWorkflowSource(ctx, 5, 10)) + _, err = GetScopedWorkflowSource(ctx, 5, 10) + assert.ErrorIs(t, err, util.ErrNotExist) + sources, err = GetScopedWorkflowSourcesByOwner(ctx, 5) + require.NoError(t, err) + assert.Empty(t, sources) +} diff --git a/models/migrations/migrations.go b/models/migrations/migrations.go index ab580710f39..f77ce032014 100644 --- a/models/migrations/migrations.go +++ b/models/migrations/migrations.go @@ -419,6 +419,7 @@ func prepareMigrationTasks() []*migration { newMigration(339, "Extend action c_u index to include created_unix for faster dashboard feed queries", v1_27.AddCreatedUnixToActionUserIsDeletedIndex), newMigration(340, "Add ContinueOnError column to ActionRunJob", v1_27.AddContinueOnErrorToActionRunJob), newMigration(341, "Convert legacy MSSQL DATETIME columns to DATETIME2", v1_27.FixLegacyMSSQLDateTimeColumns), + newMigration(342, "Add scoped workflows schema", v1_27.AddScopedWorkflowsSchema), } return preparedMigrations } diff --git a/models/migrations/v1_27/v342.go b/models/migrations/v1_27/v342.go new file mode 100644 index 00000000000..7f57f552f62 --- /dev/null +++ b/models/migrations/v1_27/v342.go @@ -0,0 +1,42 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package v1_27 + +import ( + "gitea.dev/models/db" + "gitea.dev/modules/timeutil" + + "xorm.io/xorm" +) + +func AddScopedWorkflowsSchema(x db.EngineMigration) error { + // Create the action_scoped_workflow_source table + type ScopedWorkflowConfig struct { + Required bool `json:"required"` + Patterns []string `json:"patterns"` + } + type ActionScopedWorkflowSource struct { + ID int64 `xorm:"pk autoincr"` + OwnerID int64 `xorm:"UNIQUE(owner_repo) NOT NULL DEFAULT 0"` + SourceRepoID int64 `xorm:"INDEX UNIQUE(owner_repo) NOT NULL DEFAULT 0"` + WorkflowConfigs map[string]*ScopedWorkflowConfig `xorm:"JSON TEXT 'workflow_configs'"` + CreatedUnix timeutil.TimeStamp `xorm:"created"` + UpdatedUnix timeutil.TimeStamp `xorm:"updated"` + } + if err := x.Sync(new(ActionScopedWorkflowSource)); err != nil { + return err + } + + // Add the columns that record where a run's workflow content came from + type ActionRun struct { + WorkflowRepoID int64 `xorm:"NOT NULL DEFAULT 0"` + WorkflowCommitSHA string `xorm:"VARCHAR(64) NOT NULL DEFAULT ''"` + IsScopedRun bool `xorm:"NOT NULL DEFAULT false"` + } + _, err := x.SyncWithOptions(xorm.SyncOptions{ + IgnoreDropIndices: true, + IgnoreConstrains: true, + }, new(ActionRun)) + return err +} diff --git a/models/repo/repo_unit_actions.go b/models/repo/repo_unit_actions.go index da162027b4c..3bba5de12af 100644 --- a/models/repo/repo_unit_actions.go +++ b/models/repo/repo_unit_actions.go @@ -70,6 +70,8 @@ func MakeRestrictedPermissions() ActionsTokenPermissions { type ActionsConfig struct { DisabledWorkflows []string + // DisabledScopedWorkflows maps a scoped workflow's source repository ID to the entry names opted out of in this repository. + DisabledScopedWorkflows map[int64][]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 @@ -98,6 +100,29 @@ func (cfg *ActionsConfig) DisableWorkflow(file string) { cfg.DisabledWorkflows = append(cfg.DisabledWorkflows, file) } +func (cfg *ActionsConfig) IsScopedWorkflowDisabled(sourceRepoID int64, workflowID string) bool { + return slices.Contains(cfg.DisabledScopedWorkflows[sourceRepoID], workflowID) +} + +func (cfg *ActionsConfig) DisableScopedWorkflow(sourceRepoID int64, workflowID string) { + if slices.Contains(cfg.DisabledScopedWorkflows[sourceRepoID], workflowID) { + return + } + if cfg.DisabledScopedWorkflows == nil { + cfg.DisabledScopedWorkflows = make(map[int64][]string) + } + cfg.DisabledScopedWorkflows[sourceRepoID] = append(cfg.DisabledScopedWorkflows[sourceRepoID], workflowID) +} + +func (cfg *ActionsConfig) EnableScopedWorkflow(sourceRepoID int64, workflowID string) { + workflowIDs := util.SliceRemoveAll(cfg.DisabledScopedWorkflows[sourceRepoID], workflowID) + if len(workflowIDs) == 0 { + delete(cfg.DisabledScopedWorkflows, sourceRepoID) + return + } + cfg.DisabledScopedWorkflows[sourceRepoID] = workflowIDs +} + func (cfg *ActionsConfig) AddCollaborativeOwner(ownerID int64) { if !slices.Contains(cfg.CollaborativeOwnerIDs, ownerID) { cfg.CollaborativeOwnerIDs = append(cfg.CollaborativeOwnerIDs, ownerID) diff --git a/models/repo/repo_unit_actions_test.go b/models/repo/repo_unit_actions_test.go new file mode 100644 index 00000000000..552c090fd92 --- /dev/null +++ b/models/repo/repo_unit_actions_test.go @@ -0,0 +1,51 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestActionsConfig_ScopedWorkflowOptOut(t *testing.T) { + cfg := &ActionsConfig{} + + assert.False(t, cfg.IsScopedWorkflowDisabled(100, "ci.yml")) + + cfg.DisableScopedWorkflow(100, "ci.yml") + assert.True(t, cfg.IsScopedWorkflowDisabled(100, "ci.yml")) + + // idempotent + cfg.DisableScopedWorkflow(100, "ci.yml") + assert.Len(t, cfg.DisabledScopedWorkflows, 1) + + // keyed by source repo: the same filename from a different source repo is independent + assert.False(t, cfg.IsScopedWorkflowDisabled(200, "ci.yml")) + + // must not collide with the repo-level DisabledWorkflows list (bare filename) + assert.False(t, cfg.IsWorkflowDisabled("ci.yml")) + cfg.DisableWorkflow("ci.yml") + assert.True(t, cfg.IsWorkflowDisabled("ci.yml")) + assert.True(t, cfg.IsScopedWorkflowDisabled(100, "ci.yml"), "repo-level disable must not touch the scoped entry") + + cfg.EnableScopedWorkflow(100, "ci.yml") + assert.False(t, cfg.IsScopedWorkflowDisabled(100, "ci.yml")) + assert.True(t, cfg.IsWorkflowDisabled("ci.yml"), "enabling the scoped entry must not touch the repo-level disable") +} + +func TestActionsConfig_ScopedWorkflowSerialization(t *testing.T) { + cfg := &ActionsConfig{} + cfg.DisableScopedWorkflow(100, "ci.yml") + cfg.DisableWorkflow("repo.yml") + + bs, err := cfg.ToDB() + require.NoError(t, err) + + got := &ActionsConfig{} + require.NoError(t, got.FromDB(bs)) + assert.True(t, got.IsScopedWorkflowDisabled(100, "ci.yml")) + assert.True(t, got.IsWorkflowDisabled("repo.yml")) +} diff --git a/modules/actions/jobparser/uses.go b/modules/actions/jobparser/uses.go index d829537c699..3d0e3d44f94 100644 --- a/modules/actions/jobparser/uses.go +++ b/modules/actions/jobparser/uses.go @@ -15,9 +15,11 @@ import ( type UsesKind int const ( - // UsesKindLocalSameRepo is "./.gitea/workflows/foo.yml" - a path inside the calling repository. + // UsesKindLocalSameRepo is ".//foo.yml" - a path inside the calling repository. + // For example: "./.gitea/workflows/foo.yml" UsesKindLocalSameRepo UsesKind = iota + 1 - // UsesKindLocalCrossRepo is "owner/repo/.gitea/workflows/foo.yml@ref" - a workflow in another repo on the same instance. + // UsesKindLocalCrossRepo is "owner/repo//foo.yml@ref" - a workflow in another repo on the same instance. + // For example: "owner/repo/.gitea/workflows/foo.yml@ref" UsesKindLocalCrossRepo ) @@ -31,14 +33,16 @@ type UsesRef struct { } var ( - reLocalSameRepo = regexp.MustCompile(`^\./\.(gitea|github)/workflows/([^@]+\.ya?ml)$`) - reLocalCrossRepo = regexp.MustCompile(`^([-.\w]+)/([-.\w]+)/\.(gitea|github)/workflows/([^@]+\.ya?ml)@(.+)$`) + reLocalSameRepo = regexp.MustCompile(`^\./([^@]+\.ya?ml)$`) + reLocalCrossRepo = regexp.MustCompile(`^([-.\w]+)/([-.\w]+)/([^@]+\.ya?ml)@(.+)$`) ) -// ParseUses parses a reusable workflow "uses:" value. -// Only two forms are supported: -// - "./.gitea/workflows/foo.yml" (UsesKindLocalSameRepo, no @ref) -// - "OWNER/REPO/.gitea/workflows/foo.yml@REF" (UsesKindLocalCrossRepo) +// ParseUses parses the SYNTAX of a reusable workflow "uses:" value into a UsesRef. Two forms are supported: +// - ".//foo.yml" (UsesKindLocalSameRepo, no @ref) +// - "OWNER/REPO//foo.yml@REF" (UsesKindLocalCrossRepo) +// +// It deliberately does NOT validate that is an allowed workflow directory: the allowed directories are instance-configurable (WORKFLOW_DIRS / SCOPED_WORKFLOW_DIRS). +// The caller (services/actions.ResolveUses) enforces the directory allowlist. The returned Path is the cleaned, repo-relative file path. func ParseUses(s string) (*UsesRef, error) { s = strings.TrimSpace(s) if s == "" { @@ -48,9 +52,9 @@ func ParseUses(s string) (*UsesRef, error) { if strings.HasPrefix(s, "./") { m := reLocalSameRepo.FindStringSubmatch(s) if m == nil { - return nil, fmt.Errorf(`invalid local "uses:" %q (expect ./.gitea/workflows/.yml)`, s) + return nil, fmt.Errorf(`invalid local "uses:" %q (expect .//.yml)`, s) } - p := fmt.Sprintf(".%s/workflows/%s", m[1], m[2]) + p := m[1] if path.Clean(p) != p { return nil, fmt.Errorf("invalid workflow path %q", s) } @@ -59,9 +63,9 @@ func ParseUses(s string) (*UsesRef, error) { m := reLocalCrossRepo.FindStringSubmatch(s) if m == nil { - return nil, fmt.Errorf(`invalid cross-repo "uses:" %q (expect owner/repo/.gitea/workflows/.yml@ref)`, s) + return nil, fmt.Errorf(`invalid cross-repo "uses:" %q (expect owner/repo//.yml@ref)`, s) } - p := fmt.Sprintf(".%s/workflows/%s", m[3], m[4]) + p := m[3] if path.Clean(p) != p { return nil, fmt.Errorf("invalid workflow path %q", s) } @@ -70,6 +74,6 @@ func ParseUses(s string) (*UsesRef, error) { Owner: m[1], Repo: m[2], Path: p, - Ref: m[5], + Ref: m[4], }, nil } diff --git a/modules/actions/jobparser/uses_test.go b/modules/actions/jobparser/uses_test.go index c6692054dc4..01f76d67de3 100644 --- a/modules/actions/jobparser/uses_test.go +++ b/modules/actions/jobparser/uses_test.go @@ -42,6 +42,17 @@ func TestParseUses(t *testing.T) { in: "./.gitea/workflows/sub/build.yml", want: UsesRef{Kind: UsesKindLocalSameRepo, Path: ".gitea/workflows/sub/build.yml"}, }, + { + // ParseUses is dir-agnostic; the allowed directories (WORKFLOW_DIRS / SCOPED_WORKFLOW_DIRS) are enforced by ResolveUses. + name: "scoped workflows dir parses", + in: "./.gitea/scoped_workflows/lib.yml", + want: UsesRef{Kind: UsesKindLocalSameRepo, Path: ".gitea/scoped_workflows/lib.yml"}, + }, + { + name: "non-default dir parses (allowlist enforced downstream)", + in: "./.gitea/custom_workflows/x.yaml", + want: UsesRef{Kind: UsesKindLocalSameRepo, Path: ".gitea/custom_workflows/x.yaml"}, + }, { name: "leading/trailing whitespace is trimmed", in: " ./.gitea/workflows/build.yml ", @@ -118,6 +129,17 @@ func TestParseUses(t *testing.T) { Ref: "v1", }, }, + { + name: "scoped workflows dir parses (allowlist enforced by ResolveUses)", + in: "owner/repo/.gitea/scoped_workflows/lib.yml@v1", + want: UsesRef{ + Kind: UsesKindLocalCrossRepo, + Owner: "owner", + Repo: "repo", + Path: ".gitea/scoped_workflows/lib.yml", + Ref: "v1", + }, + }, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { @@ -136,23 +158,20 @@ func TestParseUses(t *testing.T) { {name: "empty string", in: ""}, {name: "whitespace only", in: " "}, - // Same-repo malformed + // Same-repo malformed (note: a wrong *directory* parses and should be rejected by the caller) {name: "same-repo with @ref", in: "./.gitea/workflows/build.yml@v1"}, - {name: "same-repo wrong directory", in: "./not-workflows/build.yml"}, {name: "same-repo wrong extension", in: "./.gitea/workflows/build.txt"}, {name: "same-repo missing extension", in: "./.gitea/workflows/build"}, {name: "same-repo absolute path", in: "/.gitea/workflows/build.yml"}, {name: "same-repo path traversal", in: "./.gitea/workflows/../escape.yml"}, {name: "same-repo double slash", in: "./.gitea/workflows//build.yml"}, {name: "same-repo redundant ./", in: "./.gitea/workflows/./build.yml"}, - {name: "same-repo no filename", in: "./.gitea/workflows/.yml"}, // Cross-repo malformed {name: "cross-repo missing @ref", in: "owner/repo/.gitea/workflows/build.yml"}, {name: "cross-repo empty ref", in: "owner/repo/.gitea/workflows/build.yml@"}, {name: "cross-repo missing owner", in: "/repo/.gitea/workflows/build.yml@v1"}, {name: "cross-repo missing repo", in: "owner//.gitea/workflows/build.yml@v1"}, - {name: "cross-repo wrong workflows dir", in: "owner/repo/workflows/build.yml@v1"}, {name: "cross-repo wrong extension", in: "owner/repo/.gitea/workflows/build.txt@v1"}, {name: "cross-repo path traversal", in: "owner/repo/.gitea/workflows/../escape.yml@v1"}, {name: "cross-repo double slash in path", in: "owner/repo/.gitea/workflows//build.yml@v1"}, diff --git a/modules/actions/scoped_workflows.go b/modules/actions/scoped_workflows.go new file mode 100644 index 00000000000..e528d0d1be6 --- /dev/null +++ b/modules/actions/scoped_workflows.go @@ -0,0 +1,83 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package actions + +import ( + "gitea.dev/modules/actions/jobparser" + "gitea.dev/modules/git" + "gitea.dev/modules/log" + "gitea.dev/modules/setting" + api "gitea.dev/modules/structs" + webhook_module "gitea.dev/modules/webhook" +) + +// ListScopedWorkflows lists scoped workflow files (under SCOPED_WORKFLOW_DIRS) at the given commit. +func ListScopedWorkflows(commit *git.Commit) (string, git.Entries, error) { + return listWorkflowsInDirs(commit, setting.Actions.ScopedWorkflowDirs) +} + +// ParsedScopedWorkflow is one scoped workflow's source-side parse result +type ParsedScopedWorkflow struct { + EntryName string + DisplayName string // the workflow `name:` or base file name + Content []byte // raw content of the workflow file + Events []*jobparser.Event // decoded `on:` events +} + +// ParseScopedWorkflows lists and parses the scoped workflow files at sourceCommit (under SCOPED_WORKFLOW_DIRS). +func ParseScopedWorkflows(sourceCommit *git.Commit) ([]*ParsedScopedWorkflow, error) { + _, entries, err := ListScopedWorkflows(sourceCommit) + if err != nil { + return nil, err + } + + parsed := make([]*ParsedScopedWorkflow, 0, len(entries)) + for _, entry := range entries { + content, err := GetContentFromEntry(entry) + if err != nil { + return nil, err + } + + // one workflow may have multiple events + events, err := GetEventsFromContent(content) + if err != nil { + log.Warn("ignore invalid scoped workflow %q: %v", entry.Name(), err) + continue + } + parsed = append(parsed, &ParsedScopedWorkflow{ + EntryName: entry.Name(), + DisplayName: WorkflowDisplayName(entry.Name(), content), + Content: content, + Events: events, + }) + } + return parsed, nil +} + +// MatchScopedWorkflows evaluates already-parsed scoped workflows against one consuming event, returning those whose `on:` matches. +func MatchScopedWorkflows( + parsed []*ParsedScopedWorkflow, + consumerGitRepo *git.Repository, + consumerCommit *git.Commit, + triggedEvent webhook_module.HookEventType, + payload api.Payloader, +) []*DetectedWorkflow { + workflows := make([]*DetectedWorkflow, 0, len(parsed)) + for _, p := range parsed { + for _, evt := range p.Events { + if evt.IsSchedule() { + // schedule is a non-target for scoped workflows + continue + } + if detectMatched(consumerGitRepo, consumerCommit, triggedEvent, payload, evt) { + workflows = append(workflows, &DetectedWorkflow{ + EntryName: p.EntryName, + TriggerEvent: evt, + Content: p.Content, + }) + } + } + } + return workflows +} diff --git a/modules/actions/scoped_workflows_test.go b/modules/actions/scoped_workflows_test.go new file mode 100644 index 00000000000..ae7085d26e2 --- /dev/null +++ b/modules/actions/scoped_workflows_test.go @@ -0,0 +1,62 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package actions + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestIsWorkflowInDirs(t *testing.T) { + tests := []struct { + name string + dirs []string + path string + expected bool + }{ + { + name: "default scoped dir with yml", + dirs: []string{".gitea/scoped_workflows", ".github/scoped_workflows"}, + path: ".gitea/scoped_workflows/security.yml", + expected: true, + }, + { + name: "default scoped dir with yaml", + dirs: []string{".gitea/scoped_workflows", ".github/scoped_workflows"}, + path: ".github/scoped_workflows/lint.yaml", + expected: true, + }, + { + name: "normal workflow path is not a scoped workflow", + dirs: []string{".gitea/scoped_workflows"}, + path: ".gitea/workflows/ci.yml", + expected: false, + }, + { + name: "non-yaml file", + dirs: []string{".gitea/scoped_workflows"}, + path: ".gitea/scoped_workflows/readme.md", + expected: false, + }, + { + name: "feature disabled (no scoped dirs)", + dirs: []string{}, + path: ".gitea/scoped_workflows/security.yml", + expected: false, + }, + { + name: "directory boundary", + dirs: []string{".gitea/scoped_workflows"}, + path: ".gitea/scoped_workflows2/security.yml", + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.expected, isWorkflowInDirs(tt.path, tt.dirs)) + }) + } +} diff --git a/modules/actions/workflows.go b/modules/actions/workflows.go index 28dc45dc7a2..3ea9c4b6cd0 100644 --- a/modules/actions/workflows.go +++ b/modules/actions/workflows.go @@ -5,6 +5,8 @@ package actions import ( "bytes" + "fmt" + "path" "slices" "strings" @@ -38,11 +40,20 @@ func init() { } func IsWorkflow(path string) bool { + return isWorkflowInDirs(path, setting.Actions.WorkflowDirs) +} + +// IsWorkflowOrScopedWorkflow reports whether path is a workflow file under WORKFLOW_DIRS or SCOPED_WORKFLOW_DIRS. +func IsWorkflowOrScopedWorkflow(path string) bool { + return isWorkflowInDirs(path, setting.Actions.WorkflowDirs) || isWorkflowInDirs(path, setting.Actions.ScopedWorkflowDirs) +} + +func isWorkflowInDirs(path string, dirs []string) bool { if (!strings.HasSuffix(path, ".yaml")) && (!strings.HasSuffix(path, ".yml")) { return false } - for _, workflowDir := range setting.Actions.WorkflowDirs { + for _, workflowDir := range dirs { if strings.HasPrefix(path, workflowDir+"/") { return true } @@ -51,10 +62,14 @@ func IsWorkflow(path string) bool { } func ListWorkflows(commit *git.Commit) (string, git.Entries, error) { + return listWorkflowsInDirs(commit, setting.Actions.WorkflowDirs) +} + +func listWorkflowsInDirs(commit *git.Commit, dirs []string) (string, git.Entries, error) { var tree *git.Tree var err error var workflowDir string - for _, workflowDir = range setting.Actions.WorkflowDirs { + for _, workflowDir = range dirs { tree, err = commit.SubTree(workflowDir) if err == nil { break @@ -117,6 +132,40 @@ func ValidateWorkflowContent(content []byte) error { return err } +// WorkflowDisplayName returns a workflow's display name: its `name:` if non-blank, otherwise the base file name. +// This is the value used as the workflow segment of its commit-status context. +func WorkflowDisplayName(file string, content []byte) string { + displayName := path.Base(file) + if wfs, err := jobparser.Parse(content); err == nil && len(wfs) > 0 { + if name := strings.TrimSpace(wfs[0].Name); name != "" { + displayName = name + } + } + return displayName +} + +// WorkflowStatusContextName builds a workflow job's commit-status context name: " / ()". +func WorkflowStatusContextName(displayName, jobName, event string) string { + return strings.TrimSpace(fmt.Sprintf("%s / %s (%s)", displayName, jobName, event)) +} + +// ScopedWorkflowStatusContextName prefixes a scoped run's status-check context with its source repo, set off by a colon: ": / ()". +func ScopedWorkflowStatusContextName(prefix, displayName, jobName, event string) string { + return strings.TrimSpace(fmt.Sprintf("%s: %s", prefix, WorkflowStatusContextName(displayName, jobName, event))) +} + +// ShouldEventCreateCommitStatus reports whether a run triggered by the given workflow `on:` event posts a commit status, +// so its context can serve as a required status check. +// TODO: this allowlist duplicates the truth in services/actions.getCommitStatusEventNameAndCommitID, which decides the actual event string and whether a status is posted. +// The two are kept in sync by hand and can drift; unify them into a single source so adding a status-producing event in one place automatically updates the other. +func ShouldEventCreateCommitStatus(event string) bool { + switch event { + case "push", "pull_request", "pull_request_target", "pull_request_review", "pull_request_review_comment", "release": + return true + } + return false +} + func DetectWorkflows( gitRepo *git.Repository, commit *git.Commit, diff --git a/modules/setting/actions.go b/modules/setting/actions.go index d8e229267c7..69e847f80f7 100644 --- a/modules/setting/actions.go +++ b/modules/setting/actions.go @@ -29,12 +29,14 @@ var ( AbandonedJobTimeout time.Duration `ini:"ABANDONED_JOB_TIMEOUT"` SkipWorkflowStrings []string `ini:"SKIP_WORKFLOW_STRINGS"` WorkflowDirs []string `ini:"WORKFLOW_DIRS"` + ScopedWorkflowDirs []string `ini:"SCOPED_WORKFLOW_DIRS"` MaxRerunAttempts int64 `ini:"MAX_RERUN_ATTEMPTS"` }{ Enabled: true, DefaultActionsURL: defaultActionsURLGitHub, SkipWorkflowStrings: []string{"[skip ci]", "[ci skip]", "[no ci]", "[skip actions]", "[actions skip]"}, WorkflowDirs: []string{".gitea/workflows", ".github/workflows"}, + ScopedWorkflowDirs: []string{".gitea/scoped_workflows"}, MaxRerunAttempts: defaultMaxRerunAttempts, } ) @@ -130,20 +132,39 @@ func loadActionsFrom(rootCfg ConfigProvider) error { return fmt.Errorf("invalid [actions] LOG_COMPRESSION: %q", Actions.LogCompression) } - workflowDirs := make([]string, 0, len(Actions.WorkflowDirs)) - for _, dir := range Actions.WorkflowDirs { + workflowDirs := normalizeWorkflowDirs(Actions.WorkflowDirs) + if len(workflowDirs) == 0 { + return errors.New("[actions] WORKFLOW_DIRS must contain at least one entry") + } + Actions.WorkflowDirs = workflowDirs + + // SCOPED_WORKFLOW_DIRS may be empty (feature disabled), but it must not overlap with WORKFLOW_DIRS: + // a scoped dir nested in (or equal to) a workflow dir would make the same file run both repo-level and scope-level. + Actions.ScopedWorkflowDirs = normalizeWorkflowDirs(Actions.ScopedWorkflowDirs) + for _, scopedDir := range Actions.ScopedWorkflowDirs { + for _, workflowDir := range Actions.WorkflowDirs { + if scopedDir == workflowDir || + strings.HasPrefix(scopedDir, workflowDir+"/") || + strings.HasPrefix(workflowDir, scopedDir+"/") { + return fmt.Errorf("[actions] SCOPED_WORKFLOW_DIRS entry %q overlaps with WORKFLOW_DIRS entry %q", scopedDir, workflowDir) + } + } + } + + return nil +} + +// normalizeWorkflowDirs trims, normalizes separators and drops empty/trailing-slash entries. +func normalizeWorkflowDirs(dirs []string) []string { + normalized := make([]string, 0, len(dirs)) + for _, dir := range dirs { dir = strings.TrimSpace(dir) if dir == "" { continue } dir = strings.ReplaceAll(dir, `\`, `/`) dir = strings.TrimRight(dir, "/") - workflowDirs = append(workflowDirs, dir) + normalized = append(normalized, dir) } - if len(workflowDirs) == 0 { - return errors.New("[actions] WORKFLOW_DIRS must contain at least one entry") - } - Actions.WorkflowDirs = workflowDirs - - return nil + return normalized } diff --git a/modules/setting/actions_test.go b/modules/setting/actions_test.go index 5c7ab268c1e..5b2e5ac3d9f 100644 --- a/modules/setting/actions_test.go +++ b/modules/setting/actions_test.go @@ -5,8 +5,11 @@ package setting import ( "path/filepath" + "slices" "testing" + "gitea.dev/modules/test" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -238,3 +241,71 @@ DEFAULT_ACTIONS_URL = gitea }) } } + +func Test_ScopedWorkflowDirs(t *testing.T) { + defer test.MockVariableValue(&Actions)() + + defaultWorkflowDirs := []string{".gitea/workflows", ".github/workflows"} + defaultScopedDirs := []string{".gitea/scoped_workflows"} + + tests := []struct { + name string + iniStr string + wantScoped []string + wantErr bool + }{ + { + name: "default", + iniStr: `[actions]`, + wantScoped: defaultScopedDirs, + }, + { + name: "custom dir", + iniStr: "[actions]\nSCOPED_WORKFLOW_DIRS = .gitea/my-scoped", + wantScoped: []string{".gitea/my-scoped"}, + }, + { + name: "empty disables the feature", + iniStr: "[actions]\nSCOPED_WORKFLOW_DIRS = , ,", + wantScoped: []string{}, + }, + { + name: "overlap equal with workflow dir", + iniStr: "[actions]\nWORKFLOW_DIRS = .gitea/workflows\nSCOPED_WORKFLOW_DIRS = .gitea/workflows", + wantErr: true, + }, + { + name: "scoped dir nested under workflow dir", + iniStr: "[actions]\nWORKFLOW_DIRS = .gitea/workflows\nSCOPED_WORKFLOW_DIRS = .gitea/workflows/scoped", + wantErr: true, + }, + { + name: "workflow dir nested under scoped dir", + iniStr: "[actions]\nWORKFLOW_DIRS = .gitea/workflows/ci\nSCOPED_WORKFLOW_DIRS = .gitea/workflows", + wantErr: true, + }, + { + name: "no overlap", + iniStr: "[actions]\nSCOPED_WORKFLOW_DIRS = .gitea/scoped", + wantScoped: []string{".gitea/scoped"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // reset to defaults so MapTo starts clean (absent keys keep the defaults) + Actions.WorkflowDirs = slices.Clone(defaultWorkflowDirs) + Actions.ScopedWorkflowDirs = slices.Clone(defaultScopedDirs) + + cfg, err := NewConfigProviderFromData(tt.iniStr) + require.NoError(t, err) + err = loadActionsFrom(cfg) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, tt.wantScoped, Actions.ScopedWorkflowDirs) + }) + } +} diff --git a/options/locale/locale_en-US.json b/options/locale/locale_en-US.json index 45b2892481f..737b4e7d3ce 100644 --- a/options/locale/locale_en-US.json +++ b/options/locale/locale_en-US.json @@ -3779,6 +3779,7 @@ "actions.runs.commit": "Commit", "actions.runs.run_details": "Run Details", "actions.runs.workflow_file": "Workflow file", + "actions.runs.workflow_file_no_permission": "No permission to view the workflow file", "actions.runs.scheduled": "Scheduled", "actions.runs.pushed_by": "pushed by", "actions.runs.invalid_workflow_helper": "Workflow config file is invalid. Please check your config file: %s", @@ -3832,6 +3833,32 @@ "actions.workflow.enable": "Enable Workflow", "actions.workflow.enable_success": "Workflow '%s' enabled successfully.", "actions.workflow.disabled": "Workflow is disabled.", + "actions.workflow.scope_owner": "Owner", + "actions.workflow.scope_global": "Global", + "actions.workflow.required": "Required", + "actions.workflow.scoped_required_cannot_disable": "This scoped workflow is required and cannot be disabled.", + "actions.scoped_workflows": "Scoped Workflows", + "actions.scoped_workflows.desc_org": "Register repositories as scoped-workflow sources. Workflow files under the scoped workflow directories of a source repository's default branch run on every repository of this organization, in that repository's own context.", + "actions.scoped_workflows.desc_user": "Register repositories as scoped-workflow sources. Workflow files under the scoped workflow directories of a source repository's default branch run on every repository you own, in that repository's own context.", + "actions.scoped_workflows.desc_global": "Register repositories as scoped-workflow sources. Workflow files under the scoped workflow directories of a source repository's default branch run on every repository on this instance, in that repository's own context. Because instance-level sources are evaluated on every repository's events, registering them can add overhead on large instances.", + "actions.scoped_workflows.add_help": "To provide scoped workflows from a repository, commit the workflow files under %s on its default branch, then register the repository as a source below.", + "actions.scoped_workflows.security_note": "A source repository's workflow content is executed in every repository it applies to, and its step scripts and their output are written to that repository's Actions logs and readable by anyone who can view the consuming repository's Actions. Registering a private repository as a source therefore discloses its workflow logic through those logs. Only register repositories whose workflow content may be shared with every consuming repository. If a scoped workflow references a reusable workflow from a private repository, make sure every consuming repository can read it, otherwise the workflow will fail there.", + "actions.scoped_workflows.source.add": "Add source repository", + "actions.scoped_workflows.source.add_success": "Source repository added.", + "actions.scoped_workflows.source.remove_success": "Source repository removed.", + "actions.scoped_workflows.source.not_found": "Repository not found.", + "actions.scoped_workflows.required.update_success": "Required workflows updated.", + "actions.scoped_workflows.required.label": "Mark workflows as required (a required workflow cannot be disabled by repositories):", + "actions.scoped_workflows.required.patterns": "Required status check patterns", + "actions.scoped_workflows.required.patterns_aria": "Required status check patterns for %s", + "actions.scoped_workflows.required.patterns_note": "only enforced while the workflow is required", + "actions.scoped_workflows.required.patterns_hint": "Mark the workflow as required to configure its status check patterns.", + "actions.scoped_workflows.required.patterns_help": "One status check pattern (glob) per line. A consuming pull request can only be merged once a status matching every pattern has passed. This is enforced on any target branch that has a protection rule, even one with its own status checks disabled; a target branch with no protection rule is not gated.", + "actions.scoped_workflows.required.patterns_empty": "Each required workflow needs at least one status check pattern.", + "actions.scoped_workflows.required.missing_file": "file no longer in source", + "actions.scoped_workflows.required.expected_contexts": "Expected status checks (a check that matches a pattern is marked)", + "actions.scoped_workflows.required.no_status_contexts": "This workflow posts no status checks, so marking it required would block every consuming pull request from merging. Uncheck Required.", + "actions.scoped_workflows.no_files": "No scoped workflow files were found on the default branch.", "actions.workflow.run": "Run Workflow", "actions.workflow.create_status_badge": "Create status badge", "actions.workflow.status_badge": "Status Badge", diff --git a/routers/api/v1/repo/action.go b/routers/api/v1/repo/action.go index 3b920ac5511..ddbd5bf59fa 100644 --- a/routers/api/v1/repo/action.go +++ b/routers/api/v1/repo/action.go @@ -1024,6 +1024,11 @@ func ActionsListWorkflowRuns(ctx *context.APIContext) { // description: if true, the `pull_requests` field on each returned run is emptied // type: boolean // required: false + // - name: scoped_workflow_source_repo_id + // description: For a scoped workflow, the ID of the source repository providing it; omit or 0 for a repo-level workflow. + // in: query + // type: integer + // format: int64 // - name: page // in: query // description: page number of results to return (1-based) @@ -1043,20 +1048,25 @@ func ActionsListWorkflowRuns(ctx *context.APIContext) { // "$ref": "#/responses/notFound" workflowID := ctx.PathParam("workflow_id") - // Existing runs prove the workflow is/was valid and cover historical workflows - // whose file was later removed. Fall back to a git lookup for never-run workflows. + scopedWorkflowSourceRepoID := ctx.FormInt64("scoped_workflow_source_repo_id") + // Existing runs prove the workflow is/was valid and cover historical workflows whose file was later removed. + // Repo-level never-run workflows fall back to a git lookup; scoped workflows are selected by source repo ID and may return an empty run list. runExists, err := db.Exist[actions_model.ActionRun](ctx, actions_model.FindRunOptions{ - RepoID: ctx.Repo.Repository.ID, - WorkflowID: workflowID, + RepoID: ctx.Repo.Repository.ID, + WorkflowID: workflowID, + WorkflowRepoID: scopedWorkflowSourceRepoID, + IsScopedRun: optional.Some(scopedWorkflowSourceRepoID > 0), }.ToConds()) if err != nil { ctx.APIErrorInternal(err) return } if !runExists { - if _, err := convert.GetActionWorkflow(ctx, ctx.Repo.GitRepo, ctx.Repo.Repository, workflowID); err != nil { - ctx.APIErrorAuto(err) - return + if scopedWorkflowSourceRepoID == 0 { + if _, err := convert.GetActionWorkflow(ctx, ctx.Repo.GitRepo, ctx.Repo.Repository, workflowID); err != nil { + ctx.APIErrorAuto(err) + return + } } } @@ -1141,6 +1151,11 @@ func ActionsDispatchWorkflow(ctx *context.APIContext) { // description: Whether the response should include the workflow run ID and URLs. // in: query // type: boolean + // - name: scoped_workflow_source_repo_id + // description: For a scoped workflow, the ID of the source repository providing it; omit or 0 for a repo-level workflow. + // in: query + // type: integer + // format: int64 // responses: // "200": // "$ref": "#/responses/RunDetails" @@ -1162,7 +1177,9 @@ func ActionsDispatchWorkflow(ctx *context.APIContext) { return } - runID, err := actions_service.DispatchActionWorkflow(ctx, ctx.Doer, ctx.Repo.Repository, ctx.Repo.GitRepo, workflowID, opt.Ref, func(workflowDispatch *model.WorkflowDispatch, inputs map[string]any) error { + // a non-zero scoped_workflow_source_repo_id dispatches a scoped workflow from that source repo; 0/absent is repo-level. + scopedWorkflowSourceRepoID := ctx.FormInt64("scoped_workflow_source_repo_id") + runID, err := actions_service.DispatchActionWorkflow(ctx, ctx.Doer, ctx.Repo.Repository, ctx.Repo.GitRepo, workflowID, opt.Ref, scopedWorkflowSourceRepoID, func(workflowDispatch *model.WorkflowDispatch, inputs map[string]any) error { if strings.Contains(ctx.Req.Header.Get("Content-Type"), "form-urlencoded") { // The chi framework's "Binding" doesn't support to bind the form map values into a map[string]string // So we have to manually read the `inputs[key]` from the form diff --git a/routers/api/v1/shared/action.go b/routers/api/v1/shared/action.go index d62d0d3a224..08192b7f3db 100644 --- a/routers/api/v1/shared/action.go +++ b/routers/api/v1/shared/action.go @@ -148,6 +148,11 @@ func ListRuns(ctx *context.APIContext, ownerID, repoID int64, workflowID string) WorkflowID: workflowID, ListOptions: listOptions, } + if workflowID != "" { + workflowSourceRepoID := ctx.FormInt64("scoped_workflow_source_repo_id") + opts.IsScopedRun = optional.Some(workflowSourceRepoID > 0) + opts.WorkflowRepoID = workflowSourceRepoID + } if event := ctx.FormString("event"); event != "" { opts.TriggerEvent = webhook.HookEventType(event) diff --git a/routers/web/repo/actions/actions.go b/routers/web/repo/actions/actions.go index 45c94fea5c2..80b85256220 100644 --- a/routers/web/repo/actions/actions.go +++ b/routers/web/repo/actions/actions.go @@ -103,16 +103,22 @@ func List(ctx *context.Context) { if ctx.Written() { return } - otherWorkflows := prepareOtherWorkflows(ctx, workflows, curWorkflowID) + curWorkflowRepoID := ctx.FormInt64("scoped_workflow_source_repo_id") + ctx.Data["CurWorkflowRepoID"] = curWorkflowRepoID + scopedNames := prepareScopedWorkflows(ctx, curWorkflowID, curWorkflowRepoID) if ctx.Written() { return } - prepareWorkflowDispatchTemplate(ctx, workflows, curWorkflowID) + otherWorkflows := prepareOtherWorkflows(ctx, workflows, scopedNames, curWorkflowID) + if ctx.Written() { + return + } + prepareWorkflowDispatchTemplate(ctx, workflows, curWorkflowID, curWorkflowRepoID) if ctx.Written() { return } - prepareWorkflowList(ctx, workflows, otherWorkflows) + prepareWorkflowList(ctx, workflows, otherWorkflows, len(scopedNames) > 0) if ctx.Written() { return } @@ -122,7 +128,7 @@ func List(ctx *context.Context) { // prepareOtherWorkflows surfaces historical runs whose workflow file no longer // exists on the default branch (renamed, removed, or only on other branches). -func prepareOtherWorkflows(ctx *context.Context, workflows []WorkflowInfo, curWorkflowID string) []string { +func prepareOtherWorkflows(ctx *context.Context, workflows []WorkflowInfo, scopedNames container.Set[string], curWorkflowID string) []string { listed := make(container.Set[string], len(workflows)) for _, w := range workflows { listed.Add(w.Entry.Name()) @@ -130,9 +136,10 @@ func prepareOtherWorkflows(ctx *context.Context, workflows []WorkflowInfo, curWo var other []string if ctx.Repo.Repository.NumActionRuns > 0 { - ids, err := actions_model.GetRunWorkflowIDs(ctx, ctx.Repo.Repository.ID) + // "Other workflows" lists repo-level orphans only: GetRepoRunWorkflowIDs excludes scoped runs. + ids, err := actions_model.GetRepoRunWorkflowIDs(ctx, ctx.Repo.Repository.ID) if err != nil { - ctx.ServerError("GetRunWorkflowIDs", err) + ctx.ServerError("GetRepoRunWorkflowIDs", err) return nil } other = container.FilterSlice(ids, func(id string) (string, bool) { @@ -141,7 +148,8 @@ func prepareOtherWorkflows(ctx *context.Context, workflows []WorkflowInfo, curWo } ctx.Data["OtherWorkflows"] = other - ctx.Data["CurWorkflowIsListed"] = curWorkflowID == "" || listed.Contains(curWorkflowID) + // A selected workflow counts as "listed" if it is a repo-level file or an active scoped workflow. + ctx.Data["CurWorkflowIsListed"] = curWorkflowID == "" || listed.Contains(curWorkflowID) || scopedNames.Contains(curWorkflowID) return other } @@ -171,7 +179,7 @@ func WorkflowDispatchInputs(ctx *context.Context) { if ctx.Written() { return } - prepareWorkflowDispatchTemplate(ctx, workflows, curWorkflowID) + prepareWorkflowDispatchTemplate(ctx, workflows, curWorkflowID, ctx.FormInt64("scoped_workflow_source_repo_id")) if ctx.Written() { return } @@ -239,6 +247,7 @@ func prepareWorkflowTemplate(ctx *context.Context, commit *git.Commit) (workflow ctx.Data["workflows"] = workflows ctx.Data["RepoLink"] = ctx.Repo.Repository.Link() + ctx.Data["RepoID"] = ctx.Repo.Repository.ID ctx.Data["AllowDisableOrEnableWorkflow"] = ctx.Repo.Permission.IsAdmin() actionsConfig := ctx.Repo.Repository.MustGetUnit(ctx, unit.TypeActions).ActionsConfig() ctx.Data["ActionsConfig"] = actionsConfig @@ -248,21 +257,165 @@ func prepareWorkflowTemplate(ctx *context.Context, commit *git.Commit) (workflow return workflows, curWorkflowID } -func prepareWorkflowDispatchTemplate(ctx *context.Context, workflowInfos []WorkflowInfo, curWorkflowID string) { - actionsConfig := ctx.Repo.Repository.MustGetUnit(ctx, unit.TypeActions).ActionsConfig() - if curWorkflowID == "" || !ctx.Repo.Permission.CanWrite(unit.TypeActions) || actionsConfig.IsWorkflowDisabled(curWorkflowID) { +// ScopedWorkflowInfo describes a scoped workflow effective for the current repo, listed under its source group. +type ScopedWorkflowInfo struct { + SourceRepoID int64 + EntryName string + DisplayName string + Required bool + Disabled bool +} + +// ScopedWorkflowSourceGroup groups the scoped workflows contributed by one source repo for the All-Workflows sidebar. +type ScopedWorkflowSourceGroup struct { + SourceRepoID int64 + SourceRepoName string // owner/name of the source repo; shown for instance-level sources and used as the tooltip + SourceRepoShortName string // name only; shown for owner-level sources, where the owner is always the current owner + FromInstance bool // registered at instance level (owner_id == 0) rather than by the owner + IsActive bool // the currently-selected workflow belongs to this source; render the group expanded + Workflows []ScopedWorkflowInfo +} + +// prepareScopedWorkflows lists the scoped workflows effective for the repo's owner (and instance) for the All-Workflows sidebar. +func prepareScopedWorkflows(ctx *context.Context, curWorkflowID string, curWorkflowRepoID int64) container.Set[string] { + scopedNames := make(container.Set[string]) + + repo := ctx.Repo.Repository + sources, err := actions_model.GetEffectiveScopedWorkflowSources(ctx, repo.OwnerID) + if err != nil { + ctx.ServerError("GetEffectiveScopedWorkflowSources", err) + return scopedNames + } + if len(sources) == 0 { + return scopedNames + } + + actionsConfig := repo.MustGetUnit(ctx, unit.TypeActions).ActionsConfig() + + groups := make([]ScopedWorkflowSourceGroup, 0, len(sources)) + seen := make(map[int64]bool, len(sources)) + for _, source := range sources { + if seen[source.SourceRepoID] { + continue + } + seen[source.SourceRepoID] = true + + sourceRepo, err := repo_model.GetRepositoryByID(ctx, source.SourceRepoID) + if err != nil { + log.Error("scoped workflows list: load source repo %d: %v", source.SourceRepoID, err) + continue + } + if sourceRepo.IsEmpty { + continue + } + + _, entries, err := actions_service.LoadParsedScopedWorkflows(ctx, sourceRepo) + if err != nil { + log.Error("scoped workflows list: parse %s: %v", sourceRepo.FullName(), err) + continue + } + if len(entries) == 0 { + continue + } + + group := ScopedWorkflowSourceGroup{ + SourceRepoID: sourceRepo.ID, + SourceRepoName: sourceRepo.FullName(), + SourceRepoShortName: sourceRepo.Name, + FromInstance: source.OwnerID == 0, + } + for _, e := range entries { + scopedNames.Add(e.EntryName) + required := actions_model.IsWorkflowRequiredInSources(sources, sourceRepo.ID, e.EntryName) + disabled := actionsConfig.IsScopedWorkflowDisabled(sourceRepo.ID, e.EntryName) + group.Workflows = append(group.Workflows, ScopedWorkflowInfo{ + SourceRepoID: sourceRepo.ID, + EntryName: e.EntryName, + DisplayName: e.DisplayName, + Required: required, + Disabled: disabled, + }) + + if curWorkflowID == e.EntryName && curWorkflowRepoID == sourceRepo.ID { + ctx.Data["CurWorkflowDisabled"] = disabled + ctx.Data["CurWorkflowScopedRepoID"] = sourceRepo.ID + ctx.Data["CurWorkflowRequired"] = required + group.IsActive = true // keep this group expanded so the selected workflow stays visible + } + } + groups = append(groups, group) + } + + ctx.Data["ScopedWorkflowGroups"] = groups + return scopedNames +} + +// loadScopedWorkflowModel reads and parses a scoped workflow's content from its source repo's default branch. +func loadScopedWorkflowModel(ctx *context.Context, repo *repo_model.Repository, sourceRepoID int64, workflowID string) *act_model.Workflow { + effective, err := actions_model.IsScopedWorkflowSourceEffective(ctx, repo.OwnerID, sourceRepoID) + if err != nil { + log.Error("scoped dispatch: IsScopedWorkflowSourceEffective: %v", err) + return nil + } + if !effective { + return nil + } + + sourceRepo, err := repo_model.GetRepositoryByID(ctx, sourceRepoID) + if err != nil || sourceRepo.IsEmpty { + return nil + } + content, err := actions_service.ScopedWorkflowContent(ctx, sourceRepo, workflowID) + if err != nil { + log.Error("scoped dispatch: content of %s in %s: %v", workflowID, sourceRepo.RelativePath(), err) + return nil + } + if content == nil { + return nil // the workflow does not exist on the source's default branch + } + wf, err := act_model.ReadWorkflow(bytes.NewReader(content)) + if err != nil { + return nil + } + return wf +} + +func prepareWorkflowDispatchTemplate(ctx *context.Context, workflowInfos []WorkflowInfo, curWorkflowID string, curWorkflowRepoID int64) { + repo := ctx.Repo.Repository + if curWorkflowID == "" || !ctx.Repo.Permission.CanWrite(unit.TypeActions) { + return + } + actionsConfig := repo.MustGetUnit(ctx, unit.TypeActions).ActionsConfig() + + isScoped := curWorkflowRepoID > 0 + if isScoped { + // a required scoped workflow can never be opted out, so a stale disabled flag must not hide its dispatch form + optedOut, err := actions_model.IsScopedWorkflowOptedOut(ctx, actionsConfig, repo.OwnerID, curWorkflowRepoID, curWorkflowID) + if err != nil { + log.Error("IsScopedWorkflowOptedOut: %v", err) + return + } + if optedOut { + return + } + } else if actionsConfig.IsWorkflowDisabled(curWorkflowID) { return } var curWorkflow *act_model.Workflow - for _, workflowInfo := range workflowInfos { - if workflowInfo.Entry.Name() == curWorkflowID { - if workflowInfo.Workflow == nil { - log.Debug("CurWorkflowID %s is found but its workflowInfo.Workflow is nil", curWorkflowID) - return + if isScoped { + // a scoped workflow's content lives in its source repo, not in workflowInfos (the consumer's own files) + curWorkflow = loadScopedWorkflowModel(ctx, repo, curWorkflowRepoID, curWorkflowID) + } else { + for _, workflowInfo := range workflowInfos { + if workflowInfo.Entry.Name() == curWorkflowID { + if workflowInfo.Workflow == nil { + log.Debug("CurWorkflowID %s is found but its workflowInfo.Workflow is nil", curWorkflowID) + return + } + curWorkflow = workflowInfo.Workflow + break } - curWorkflow = workflowInfo.Workflow - break } } @@ -303,10 +456,11 @@ func prepareWorkflowDispatchTemplate(ctx *context.Context, workflowInfos []Workf ctx.Data["Tags"] = tags } -func prepareWorkflowList(ctx *context.Context, workflows []WorkflowInfo, otherWorkflows []string) { +func prepareWorkflowList(ctx *context.Context, workflows []WorkflowInfo, otherWorkflows []string, hasScopedWorkflows bool) { actorID := ctx.FormInt64("actor") status := ctx.FormInt("status") workflowID := ctx.FormString("workflow") + scopedWorkflowSourceRepoID := ctx.FormInt64("scoped_workflow_source_repo_id") branch := ctx.FormString("branch") page := ctx.FormInt("page") if page <= 0 { @@ -327,9 +481,15 @@ func prepareWorkflowList(ctx *context.Context, workflows []WorkflowInfo, otherWo Page: page, PageSize: convert.ToCorrectPageSize(ctx.FormInt("limit")), }, - RepoID: ctx.Repo.Repository.ID, - WorkflowID: workflowID, - TriggerUserID: actorID, + RepoID: ctx.Repo.Repository.ID, + WorkflowID: workflowID, + WorkflowRepoID: scopedWorkflowSourceRepoID, + TriggerUserID: actorID, + } + + // Constrain scoped vs repo-level only for a listed workflow, whose link carries scoped_workflow_source_repo_id. + if workflowID != "" && !slices.Contains(otherWorkflows, workflowID) { + opts.IsScopedRun = optional.Some(scopedWorkflowSourceRepoID > 0) } // if status is not StatusUnknown, it means user has selected a status filter @@ -422,7 +582,11 @@ func prepareWorkflowList(ctx *context.Context, workflows []WorkflowInfo, otherWo } } ctx.Data["WorkflowNames"] = workflowNames - prepareWorkflowBadgeTemplate(ctx, workflowID, workflowDisplayName) + // A scoped workflow has no repo-level badge on this repo (the badge endpoint reads is_scoped_run=false runs), + // so don't offer the "create status badge" entry for it. + if scopedWorkflowSourceRepoID == 0 { + prepareWorkflowBadgeTemplate(ctx, workflowID, workflowDisplayName) + } actors, err := actions_model.GetActors(ctx, ctx.Repo.Repository.ID) if err != nil { @@ -443,7 +607,7 @@ func prepareWorkflowList(ctx *context.Context, workflows []WorkflowInfo, otherWo pager := context.NewPagination(total, opts.PageSize, opts.Page, 5) pager.AddParamFromRequest(ctx.Req) ctx.Data["Page"] = pager - ctx.Data["HasWorkflowsOrRuns"] = len(workflows) > 0 || len(otherWorkflows) > 0 || len(runs) > 0 + ctx.Data["HasWorkflowsOrRuns"] = len(workflows) > 0 || len(otherWorkflows) > 0 || len(runs) > 0 || hasScopedWorkflows ctx.Data["CanWriteRepoUnitActions"] = ctx.Repo.Permission.CanWrite(unit.TypeActions) } @@ -583,10 +747,11 @@ func decodeNode(node yaml.Node, out any) bool { return true } -func actionsListRedirectURL(repoLink, workflow, actor, status, branch string) string { - return fmt.Sprintf("%s/actions?workflow=%s&actor=%s&status=%s&branch=%s", +func actionsListRedirectURL(repoLink, workflow, scopedWorkflowSourceRepoID, actor, status, branch string) string { + return fmt.Sprintf("%s/actions?workflow=%s&scoped_workflow_source_repo_id=%s&actor=%s&status=%s&branch=%s", repoLink, url.QueryEscape(workflow), + url.QueryEscape(scopedWorkflowSourceRepoID), url.QueryEscape(actor), url.QueryEscape(status), url.QueryEscape(branch), diff --git a/routers/web/repo/actions/view.go b/routers/web/repo/actions/view.go index 34f8ae44754..5bf5353d30c 100644 --- a/routers/web/repo/actions/view.go +++ b/routers/web/repo/actions/view.go @@ -21,12 +21,14 @@ import ( "gitea.dev/models/db" git_model "gitea.dev/models/git" issues_model "gitea.dev/models/issues" + access_model "gitea.dev/models/perm/access" repo_model "gitea.dev/models/repo" "gitea.dev/models/unit" "gitea.dev/modules/actions" "gitea.dev/modules/base" "gitea.dev/modules/cache" "gitea.dev/modules/git" + "gitea.dev/modules/gitrepo" "gitea.dev/modules/httplib" "gitea.dev/modules/json" "gitea.dev/modules/log" @@ -243,6 +245,11 @@ func ViewWorkflowFile(ctx *context_module.Context) { return } + if run.IsScopedRun { + viewScopedWorkflowFile(ctx, run) + return + } + commit, err := ctx.Repo.GitRepo.GetCommit(run.CommitSHA) if err != nil { ctx.NotFoundOrServerError("GetCommit", func(err error) bool { @@ -293,25 +300,26 @@ type ViewResponse struct { // ViewLink is the attempt-aware URL for navigation, e.g. "/owner/repo/actions/runs/123" for the latest attempt // or "/owner/repo/actions/runs/123/attempts/2" for a historical attempt. // Use this when the target should reflect the currently-viewed attempt. - ViewLink string `json:"viewLink"` - Index int64 `json:"index"` // the per-repository run number, displayed as "#N" - Title string `json:"title"` - TitleHTML template.HTML `json:"titleHTML"` - Status string `json:"status"` - 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"` - WorkflowLink string `json:"workflowLink"` - IsSchedule bool `json:"isSchedule"` - RunAttempt int64 `json:"runAttempt"` - Attempts []*ViewRunAttempt `json:"attempts"` - Jobs []*ViewJob `json:"jobs"` - Commit ViewCommit `json:"commit"` - PullRequest *ViewPullRequest `json:"pullRequest,omitempty"` + ViewLink string `json:"viewLink"` + Index int64 `json:"index"` // the per-repository run number, displayed as "#N" + Title string `json:"title"` + TitleHTML template.HTML `json:"titleHTML"` + Status string `json:"status"` + 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"` + WorkflowLink string `json:"workflowLink"` + CanViewWorkflowFile bool `json:"canViewWorkflowFile"` + IsSchedule bool `json:"isSchedule"` + RunAttempt int64 `json:"runAttempt"` + Attempts []*ViewRunAttempt `json:"attempts"` + Jobs []*ViewJob `json:"jobs"` + Commit ViewCommit `json:"commit"` + PullRequest *ViewPullRequest `json:"pullRequest,omitempty"` // Summary view: run duration and trigger time/event Duration string `json:"duration"` TriggeredAt int64 `json:"triggeredAt"` // unix seconds for relative time @@ -600,6 +608,11 @@ func fillViewRunResponseSummary(ctx *context_module.Context, resp *ViewResponse, if isLatestAttempt { resp.State.Run.WorkflowLink = run.WorkflowLink() } + resp.State.Run.CanViewWorkflowFile = true + if run.IsScopedRun { + // For a scoped run the workflow file lives in the source repo; only show its link when the viewer can read that repo. + resp.State.Run.CanViewWorkflowFile = canViewScopedWorkflowFile(ctx, run) + } resp.State.Run.IsSchedule = run.IsSchedule() resp.State.Run.Jobs = make([]*ViewJob, 0, len(jobs)) // marshal to '[]' instead fo 'null' in json for _, v := range jobs { @@ -848,7 +861,16 @@ func checkRunRerunAllowed(ctx *context_module.Context, run *actions_model.Action } cfgUnit := ctx.Repo.Repository.MustGetUnit(ctx, unit.TypeActions) cfg := cfgUnit.ActionsConfig() - if cfg.IsWorkflowDisabled(run.WorkflowID) { + disabled := cfg.IsWorkflowDisabled(run.WorkflowID) + if run.IsScopedRun { + optedOut, err := actions_model.IsScopedWorkflowOptedOut(ctx, cfg, ctx.Repo.Repository.OwnerID, run.WorkflowRepoID, run.WorkflowID) + if err != nil { + ctx.ServerError("IsScopedWorkflowOptedOut", err) + return false + } + disabled = optedOut + } + if disabled { ctx.JSONError(ctx.Locale.Tr("actions.workflow.disabled")) return false } @@ -1276,7 +1298,24 @@ func disableOrEnableWorkflowFile(ctx *context_module.Context, isEnable bool) { cfgUnit := ctx.Repo.Repository.MustGetUnit(ctx, unit.TypeActions) cfg := cfgUnit.ActionsConfig() - if isEnable { + scopedRepoID := ctx.FormInt64("scoped_workflow_source_repo_id") + if scopedRepoID > 0 { + if !isEnable { + // a required scoped workflow can never be opted out + required, err := actions_model.IsScopedWorkflowRequired(ctx, ctx.Repo.Repository.OwnerID, scopedRepoID, workflow) + if err != nil { + ctx.ServerError("IsScopedWorkflowRequired", err) + return + } + if required { + ctx.JSONError(ctx.Locale.Tr("actions.workflow.scoped_required_cannot_disable")) + return + } + cfg.DisableScopedWorkflow(scopedRepoID, workflow) + } else { + cfg.EnableScopedWorkflow(scopedRepoID, workflow) + } + } else if isEnable { cfg.EnableWorkflow(workflow) } else { cfg.DisableWorkflow(workflow) @@ -1293,13 +1332,13 @@ func disableOrEnableWorkflowFile(ctx *context_module.Context, isEnable bool) { ctx.Flash.Success(ctx.Tr("actions.workflow.disable_success", workflow)) } - redirectURL := actionsListRedirectURL(ctx.Repo.RepoLink, workflow, + redirectURL := actionsListRedirectURL(ctx.Repo.RepoLink, workflow, ctx.FormString("scoped_workflow_source_repo_id"), ctx.FormString("actor"), ctx.FormString("status"), ctx.FormString("branch")) ctx.JSONRedirect(redirectURL) } func Run(ctx *context_module.Context) { - redirectURL := actionsListRedirectURL(ctx.Repo.RepoLink, ctx.FormString("workflow"), + redirectURL := actionsListRedirectURL(ctx.Repo.RepoLink, ctx.FormString("workflow"), ctx.FormString("scoped_workflow_source_repo_id"), ctx.FormString("actor"), ctx.FormString("status"), ctx.FormString("branch")) workflowID := ctx.FormString("workflow") @@ -1313,7 +1352,8 @@ func Run(ctx *context_module.Context) { ctx.ServerError("ref", nil) return } - _, err := actions_service.DispatchActionWorkflow(ctx, ctx.Doer, ctx.Repo.Repository, ctx.Repo.GitRepo, workflowID, ref, func(workflowDispatch *model.WorkflowDispatch, inputs map[string]any) error { + sourceRepoID := ctx.FormInt64("scoped_workflow_source_repo_id") + _, err := actions_service.DispatchActionWorkflow(ctx, ctx.Doer, ctx.Repo.Repository, ctx.Repo.GitRepo, workflowID, ref, sourceRepoID, func(workflowDispatch *model.WorkflowDispatch, inputs map[string]any) error { for name, config := range workflowDispatch.Inputs { value := ctx.Req.PostFormValue(name) if config.Type == "boolean" { @@ -1339,3 +1379,65 @@ func Run(ctx *context_module.Context) { ctx.Flash.Success(ctx.Tr("actions.workflow.run_success", workflowID)) ctx.Redirect(redirectURL) } + +// viewScopedWorkflowFile redirects to the scoped workflow file in its SOURCE repo. +func viewScopedWorkflowFile(ctx *context_module.Context, run *actions_model.ActionRun) { + sourceRepo, err := repo_model.GetRepositoryByID(ctx, run.WorkflowRepoID) + if err != nil { + ctx.NotFoundOrServerError("GetRepositoryByID", func(err error) bool { + return errors.Is(err, util.ErrNotExist) + }, err) + return + } + + perm, err := access_model.GetDoerRepoPermission(ctx, sourceRepo, ctx.Doer) + if err != nil { + ctx.ServerError("GetUserRepoPermission", err) + return + } + if !perm.CanRead(unit.TypeCode) { + ctx.NotFound(nil) + return + } + + sourceGitRepo, err := gitrepo.OpenRepository(ctx, sourceRepo) + if err != nil { + ctx.ServerError("OpenRepository", err) + return + } + defer sourceGitRepo.Close() + + commit, err := sourceGitRepo.GetCommit(run.WorkflowCommitSHA) + if err != nil { + ctx.NotFoundOrServerError("GetCommit", func(err error) bool { + return errors.Is(err, util.ErrNotExist) + }, err) + return + } + rpath, entries, err := actions.ListScopedWorkflows(commit) + if err != nil { + ctx.ServerError("ListScopedWorkflows", err) + return + } + for _, entry := range entries { + if entry.Name() == run.WorkflowID { + ctx.Redirect(fmt.Sprintf("%s/src/commit/%s/%s/%s", sourceRepo.Link(), url.PathEscape(run.WorkflowCommitSHA), util.PathEscapeSegments(rpath), util.PathEscapeSegments(run.WorkflowID))) + return + } + } + ctx.NotFound(nil) +} + +// canViewScopedWorkflowFile reports whether the viewer may follow the "Workflow file" link of a scoped run. +func canViewScopedWorkflowFile(ctx *context_module.Context, run *actions_model.ActionRun) bool { + sourceRepo, err := repo_model.GetRepositoryByID(ctx, run.WorkflowRepoID) + if err != nil { + return false + } + perm, err := access_model.GetDoerRepoPermission(ctx, sourceRepo, ctx.Doer) + if err != nil { + log.Error("GetUserRepoPermission: %v", err) + return false + } + return perm.CanRead(unit.TypeCode) +} diff --git a/routers/web/repo/issue_view.go b/routers/web/repo/issue_view.go index ac5a3beb036..5774903c19a 100644 --- a/routers/web/repo/issue_view.go +++ b/routers/web/repo/issue_view.go @@ -949,7 +949,9 @@ func (prInfo *pullRequestViewInfo) prepareMergeBox(ctx *context.Context, issue * // admin can merge without checks, writer can merge when checks succeed // admin and writer both can make an auto merge schedule (not affected by overridable blockers) - data.hasStatusCheckBlocker = data.enableStatusCheck && !data.StatusCheckData.RequiredChecksState.IsSuccess() + // Required scoped workflow checks gate the merge even when the rule's own status check is disabled (see IsPullCommitStatusPass), + // so block on any required status context, not only when enableStatusCheck is on. + data.hasStatusCheckBlocker = (data.enableStatusCheck || data.hasRequiredStatusContexts) && !data.StatusCheckData.RequiredChecksState.IsSuccess() // this logic is from: // {{$notAllOverridableChecksOk := or .IsBlockedByApprovals .IsBlockedByRejection .IsBlockedByOfficialReviewRequests .IsBlockedByOutdatedBranch .IsBlockedByChangedProtectedFiles (and .EnableStatusCheck (not $requiredStatusCheckState.IsSuccess))}} diff --git a/routers/web/repo/pull.go b/routers/web/repo/pull.go index 81283a25383..62b956d06cd 100644 --- a/routers/web/repo/pull.go +++ b/routers/web/repo/pull.go @@ -276,6 +276,10 @@ type pullMergeBoxData struct { enableStatusCheck bool StatusCheckData *pullCommitStatusCheckData ShowStatusCheck bool + // hasRequiredStatusContexts is true when at least one required status-check context must be satisfied: + // the branch protection's own contexts and/or required scoped workflow checks. + // The latter gate the merge even when the rule's own status check is disabled. + hasRequiredStatusContexts bool hasOverridableBlockers bool canMergeNow bool // PR is mergeable, either no blocker, or doer can bypass the blockers @@ -423,6 +427,16 @@ func (prInfo *pullRequestViewInfo) prepareMergeBoxStatusCheckData(ctx *context.C if err != nil { log.Error("GetLatestCommitStatus: %v", err) } + + // Effective required contexts = branch-protection contexts + required scoped workflow checks. + requiredContexts := pbRequiredContexts + if effective, err := pull_service.EffectiveRequiredContexts(ctx, ctx.Repo.Repository, prInfo.ProtectedBranchRule); err != nil { + log.Error("EffectiveRequiredContexts: %v", err) + } else { + requiredContexts = effective + } + data.hasRequiredStatusContexts = len(requiredContexts) > 0 + if !ctx.Repo.Permission.CanRead(unit.TypeActions) { git_model.CommitStatusesHideActionsURL(ctx, commitStatuses) } @@ -433,7 +447,9 @@ func (prInfo *pullRequestViewInfo) prepareMergeBoxStatusCheckData(ctx *context.C statusCheckData.pullCommitStatusState = combinedCommitStatus.State } - data.ShowStatusCheck = data.enableStatusCheck || len(statusCheckData.PullCommitStatuses) > 0 + // Required scoped workflow checks gate the merge even when the branch protection's own status check is disabled, + // so the status-check section must render when there are any required contexts, not only when enableStatusCheck is on. + data.ShowStatusCheck = data.enableStatusCheck || data.hasRequiredStatusContexts || len(statusCheckData.PullCommitStatuses) > 0 runs, err := actions_service.GetRunsFromCommitStatuses(ctx, commitStatuses) if err != nil { @@ -449,7 +465,7 @@ func (prInfo *pullRequestViewInfo) prepareMergeBoxStatusCheckData(ctx *context.C } var missingRequiredChecks []string - for _, requiredContext := range pbRequiredContexts { + for _, requiredContext := range requiredContexts { contextFound := false matchesRequiredContext := createRequiredContextMatcher(requiredContext) for _, presentStatus := range commitStatuses { @@ -466,7 +482,7 @@ func (prInfo *pullRequestViewInfo) prepareMergeBoxStatusCheckData(ctx *context.C statusCheckData.MissingRequiredChecks = missingRequiredChecks statusCheckData.IsContextRequired = func(context string) bool { - for _, c := range pbRequiredContexts { + for _, c := range requiredContexts { if c == context { return true } @@ -481,9 +497,9 @@ func (prInfo *pullRequestViewInfo) prepareMergeBoxStatusCheckData(ctx *context.C } return false } - statusCheckData.RequiredChecksState = pull_service.MergeRequiredContextsCommitStatus(commitStatuses, pbRequiredContexts) + statusCheckData.RequiredChecksState = pull_service.MergeRequiredContextsCommitStatus(commitStatuses, requiredContexts) - if data.enableStatusCheck { + if data.enableStatusCheck || data.hasRequiredStatusContexts { if statusCheckData.RequiredChecksState.IsError() || statusCheckData.RequiredChecksState.IsFailure() { data.infoProtectionBlockers.AddErrorItem(ctx.Locale.Tr("repo.pulls.required_status_check_failed")) } else if !statusCheckData.RequiredChecksState.IsSuccess() { diff --git a/routers/web/repo/pull_merge_box.go b/routers/web/repo/pull_merge_box.go index 4a167c2912f..d76aacde2f7 100644 --- a/routers/web/repo/pull_merge_box.go +++ b/routers/web/repo/pull_merge_box.go @@ -63,7 +63,7 @@ func (prInfo *pullRequestViewInfo) prepareMergeBoxIconColor() { showAsWarningColor = showAsWarningColor || statusCheckData.pullCommitStatusState.IsWarning() || statusCheckData.pullCommitStatusState.IsPending() || - (mergeBoxData.enableStatusCheck && (statusCheckData.RequiredChecksState.IsWarning() || statusCheckData.RequiredChecksState.IsPending())) + ((mergeBoxData.enableStatusCheck || mergeBoxData.hasRequiredStatusContexts) && (statusCheckData.RequiredChecksState.IsWarning() || statusCheckData.RequiredChecksState.IsPending())) } hasBlockers := len(mergeBoxData.infoCommitBlockers.items) > 0 || len(mergeBoxData.infoProtectionBlockers.items) > 0 diff --git a/routers/web/shared/actions/scoped_workflows.go b/routers/web/shared/actions/scoped_workflows.go new file mode 100644 index 00000000000..0db7c51819f --- /dev/null +++ b/routers/web/shared/actions/scoped_workflows.go @@ -0,0 +1,368 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package actions + +import ( + "errors" + "net/http" + "slices" + "strings" + + actions_model "gitea.dev/models/actions" + repo_model "gitea.dev/models/repo" + actions_module "gitea.dev/modules/actions" + "gitea.dev/modules/actions/jobparser" + "gitea.dev/modules/container" + "gitea.dev/modules/log" + "gitea.dev/modules/setting" + "gitea.dev/modules/templates" + "gitea.dev/modules/util" + shared_user "gitea.dev/routers/web/shared/user" + actions_service "gitea.dev/services/actions" + "gitea.dev/services/context" +) + +const ( + tplOrgScopedWorkflows templates.TplName = "org/settings/actions" + tplUserScopedWorkflows templates.TplName = "user/settings/actions" + tplAdminScopedWorkflows templates.TplName = "admin/actions" +) + +type scopedWorkflowsCtx struct { + OwnerID int64 // 0 = instance-level + IsOrg bool + IsUser bool + IsGlobal bool + Template templates.TplName + RedirectLink string + // SearchUID is the uid passed to the repo-search box. For org/user it scopes the search to that owner; + // for admin (0) it searches all repos and therefore requires admin access on the route. + SearchUID int64 +} + +func getScopedWorkflowsCtx(ctx *context.Context) (*scopedWorkflowsCtx, 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 &scopedWorkflowsCtx{ + OwnerID: ctx.Org.Organization.ID, + IsOrg: true, + Template: tplOrgScopedWorkflows, + RedirectLink: ctx.Org.OrgLink + "/settings/actions/scoped-workflows", + SearchUID: ctx.Org.Organization.ID, + }, nil + } + + if ctx.Data["PageIsUserSettings"] == true { + return &scopedWorkflowsCtx{ + OwnerID: ctx.Doer.ID, + IsUser: true, + Template: tplUserScopedWorkflows, + RedirectLink: setting.AppSubURL + "/user/settings/actions/scoped-workflows", + SearchUID: ctx.Doer.ID, + }, nil + } + + if ctx.Data["PageIsAdmin"] == true { + return &scopedWorkflowsCtx{ + OwnerID: 0, + IsGlobal: true, + Template: tplAdminScopedWorkflows, + RedirectLink: setting.AppSubURL + "/-/admin/actions/scoped-workflows", + SearchUID: 0, + }, nil + } + + return nil, errors.New("unable to set scoped workflows context") +} + +// scopedWorkflowInfo is one scoped workflow shown on the settings page, merged with its stored merge-gate config. +type scopedWorkflowInfo struct { + EntryName string + DisplayName string + Required bool + Patterns string // newline-joined stored status-check patterns (kept even when not required, as history) + Contexts []string // the commit-status contexts this workflow is expected to post, to preview which patterns match + Missing bool // the workflow file no longer exists on the source default branch, but a stored config lingers and must stay clearable +} + +// scopedWorkflowSourceView is the per-source data shown on the settings page. +type scopedWorkflowSourceView struct { + Repo *repo_model.Repository + ScopedWorkflowInfos []scopedWorkflowInfo +} + +func ScopedWorkflows(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("actions.scoped_workflows") + ctx.Data["PageType"] = "scoped-workflows" + ctx.Data["PageIsSharedSettingsScopedWorkflows"] = true + + swCtx, err := getScopedWorkflowsCtx(ctx) + if err != nil { + ctx.ServerError("getScopedWorkflowsCtx", err) + return + } + if ctx.Written() { + return + } + + switch { + case swCtx.IsOrg: + ctx.Data["ScopedWorkflowsDesc"] = ctx.Tr("actions.scoped_workflows.desc_org") + case swCtx.IsUser: + ctx.Data["ScopedWorkflowsDesc"] = ctx.Tr("actions.scoped_workflows.desc_user") + default: // instance-level + ctx.Data["ScopedWorkflowsDesc"] = ctx.Tr("actions.scoped_workflows.desc_global") + } + + sources, err := actions_model.GetScopedWorkflowSourcesByOwner(ctx, swCtx.OwnerID) + if err != nil { + ctx.ServerError("GetScopedWorkflowSourcesByOwner", err) + return + } + + views := make([]*scopedWorkflowSourceView, 0, len(sources)) + for _, src := range sources { + repo, err := repo_model.GetRepositoryByID(ctx, src.SourceRepoID) + if err != nil { + log.Error("scoped workflows settings: load source repo %d: %v", src.SourceRepoID, err) + continue + } + views = append(views, &scopedWorkflowSourceView{ + Repo: repo, + ScopedWorkflowInfos: listSourceScopedWorkflowFiles(ctx, repo, src.WorkflowConfigs), + }) + } + + ctx.Data["ScopedWorkflowSources"] = views + ctx.Data["RepoSearchUID"] = swCtx.SearchUID + // owner/user scopes the repo search to the owner (exclusive); + // instance-level (admin) searches all repos and so must submit owner/name to disambiguate the selection across owners. + ctx.Data["ScopedWorkflowsSearchExclusive"] = !swCtx.IsGlobal + ctx.Data["ScopedWorkflowsSearchFullName"] = swCtx.IsGlobal + ctx.Data["RedirectLink"] = swCtx.RedirectLink + ctx.Data["ScopedWorkflowDirs"] = strings.Join(setting.Actions.ScopedWorkflowDirs, ", ") + ctx.HTML(http.StatusOK, swCtx.Template) +} + +// parsePatternLines splits a textarea value into trimmed, non-empty status-check patterns (one per line). +func parsePatternLines(raw string) []string { + var patterns []string + for line := range strings.SplitSeq(raw, "\n") { + if p := strings.TrimSpace(line); p != "" { + patterns = append(patterns, p) + } + } + return patterns +} + +// deriveScopedStatusContexts returns the commit-status contexts a scoped workflow is expected to post on a consumer: +// ": / ()" for each parsed job (matrix-expanded, matching run creation) and triggering event. +// Job names that depend on run-context expressions cannot resolve here (no run context) and appear as authored; a glob pattern still matches them. +func deriveScopedStatusContexts(prefix, displayName string, content []byte, events []*jobparser.Event) []string { + parsed, err := jobparser.Parse(content) + if err != nil { + return nil + } + eventNames := make([]string, 0, len(events)) + for _, e := range events { + // only events whose runs post a commit status can be a required check; workflow_dispatch, schedule, etc. post none. + if actions_module.ShouldEventCreateCommitStatus(e.Name) { + eventNames = append(eventNames, e.Name) + } + } + seen := make(container.Set[string]) + contexts := make([]string, 0, len(parsed)*len(eventNames)) + for _, sw := range parsed { + _, job := sw.Job() + if job == nil { + continue + } + jobName := util.EllipsisDisplayString(job.Name, 255) // run creation truncates job names the same way + for _, ev := range eventNames { + ctxName := actions_module.ScopedWorkflowStatusContextName(prefix, displayName, jobName, ev) + if seen.Contains(ctxName) { + continue + } + seen.Add(ctxName) + contexts = append(contexts, ctxName) + } + } + return contexts +} + +func listSourceScopedWorkflowFiles(ctx *context.Context, repo *repo_model.Repository, configs map[string]*actions_model.ScopedWorkflowConfig) []scopedWorkflowInfo { + rendered := make(container.Set[string], len(configs)) + files := make([]scopedWorkflowInfo, 0, len(configs)) + + // An empty source repo (or one that fails to parse) has no live workflow files, but a previously-saved config may still linger; + // fall through to surface those as orphan rows below so they remain clearable. + if !repo.IsEmpty { + _, parsed, err := actions_service.LoadParsedScopedWorkflows(ctx, repo) + if err != nil { + log.Error("scoped workflows settings: parse %s: %v", repo.RelativePath(), err) + } else { + for _, p := range parsed { + info := scopedWorkflowInfo{ + EntryName: p.EntryName, + DisplayName: p.DisplayName, + Contexts: deriveScopedStatusContexts(repo.FullName(), p.DisplayName, p.Content, p.Events), + } + if cfg := configs[p.EntryName]; cfg != nil { + info.Required = cfg.Required + info.Patterns = strings.Join(cfg.Patterns, "\n") + } + rendered.Add(p.EntryName) + files = append(files, info) + } + } + } + + // Surface configs whose workflow file no longer exists on the source default branch as orphan rows. + // A required orphan still gates merges (must-present), so the owner/admin must be able to see and clear it; + // otherwise the only escape would be removing the whole source registration. + orphans := make([]scopedWorkflowInfo, 0, len(configs)) + for name, cfg := range configs { + if cfg == nil || rendered.Contains(name) { + continue + } + orphans = append(orphans, scopedWorkflowInfo{ + EntryName: name, + DisplayName: name, + Required: cfg.Required, + Patterns: strings.Join(cfg.Patterns, "\n"), + Missing: true, + }) + } + // map iteration order is random; sort orphans for a stable settings page + slices.SortFunc(orphans, func(a, b scopedWorkflowInfo) int { return strings.Compare(a.EntryName, b.EntryName) }) + return append(files, orphans...) +} + +func ScopedWorkflowAdd(ctx *context.Context) { + swCtx, err := getScopedWorkflowsCtx(ctx) + if err != nil { + ctx.ServerError("getScopedWorkflowsCtx", err) + return + } + if ctx.Written() { + return + } + + repoName := ctx.FormString("repo_name") + var repo *repo_model.Repository + if swCtx.IsGlobal { + // instance-level: the source may be any repo on the instance, identified by owner/name + ownerName, name, ok := strings.Cut(repoName, "/") + if !ok { + ctx.JSONError(ctx.Tr("actions.scoped_workflows.source.not_found")) + return + } + repo, err = repo_model.GetRepositoryByOwnerAndName(ctx, ownerName, name) + } else { + // owner-level: resolve within the owner, which also enforces that the source is one of the owner's own repositories + repo, err = repo_model.GetRepositoryByName(ctx, swCtx.OwnerID, repoName) + } + if err != nil { + ctx.JSONError(ctx.Tr("actions.scoped_workflows.source.not_found")) + return + } + + if err := actions_model.AddScopedWorkflowSource(ctx, swCtx.OwnerID, repo.ID); err != nil { + ctx.ServerError("AddScopedWorkflowSource", err) + return + } + ctx.Flash.Success(ctx.Tr("actions.scoped_workflows.source.add_success")) + ctx.JSONRedirect(swCtx.RedirectLink) +} + +func ScopedWorkflowSetRequired(ctx *context.Context) { + swCtx, err := getScopedWorkflowsCtx(ctx) + if err != nil { + ctx.ServerError("getScopedWorkflowsCtx", err) + return + } + if ctx.Written() { + return + } + + repoID := ctx.FormInt64("repo_id") + + // the source must be registered for this owner + if _, err := actions_model.GetScopedWorkflowSource(ctx, swCtx.OwnerID, repoID); err != nil { + if errors.Is(err, util.ErrNotExist) { + ctx.JSONError(ctx.Tr("actions.scoped_workflows.source.not_found")) + } else { + ctx.ServerError("GetScopedWorkflowSource", err) + } + return + } + + // Live workflow entry names on the source default branch, used to distinguish orphan configs (whose workflow file no longer exists) from live ones. + sourceRepo, err := repo_model.GetRepositoryByID(ctx, repoID) + if err != nil { + ctx.ServerError("GetRepositoryByID", err) + return + } + liveSet := make(container.Set[string]) + if !sourceRepo.IsEmpty { // an empty source has no live workflows + _, parsed, err := actions_service.LoadParsedScopedWorkflows(ctx, sourceRepo) + if err != nil { + ctx.ServerError("LoadParsedScopedWorkflows", err) + return + } + for _, p := range parsed { + liveSet.Add(p.EntryName) + } + } + + // Every workflow row submits its ID in workflow_ids and its patterns (one per line) in required_patterns[]; + // checked rows additionally submit their ID in required_workflow_ids. + // A required workflow must have at least one pattern. + requiredSet := make(container.Set[string]) + for _, workflowID := range ctx.FormStrings("required_workflow_ids") { + requiredSet.Add(workflowID) + } + configs := make(map[string]*actions_model.ScopedWorkflowConfig) + for _, workflowID := range ctx.FormStrings("workflow_ids") { + patterns := parsePatternLines(ctx.FormString("required_patterns[" + workflowID + "]")) + required := requiredSet.Contains(workflowID) + if required && len(patterns) == 0 { + ctx.JSONError(ctx.Tr("actions.scoped_workflows.required.patterns_empty")) + return + } + // Keep a config only if it is required, or it is a still-existing. + // An orphan (file no longer in the source) that is not required is dropped. + if required || (liveSet.Contains(workflowID) && len(patterns) > 0) { + configs[workflowID] = &actions_model.ScopedWorkflowConfig{Required: required, Patterns: patterns} + } + } + if err := actions_model.SetScopedWorkflowSourceConfigs(ctx, swCtx.OwnerID, repoID, configs); err != nil { + ctx.ServerError("SetScopedWorkflowSourceConfigs", err) + return + } + ctx.Flash.Success(ctx.Tr("actions.scoped_workflows.required.update_success")) + ctx.JSONRedirect(swCtx.RedirectLink) +} + +func ScopedWorkflowRemove(ctx *context.Context) { + swCtx, err := getScopedWorkflowsCtx(ctx) + if err != nil { + ctx.ServerError("getScopedWorkflowsCtx", err) + return + } + if ctx.Written() { + return + } + + repoID := ctx.FormInt64("repo_id") + if err := actions_model.RemoveScopedWorkflowSource(ctx, swCtx.OwnerID, repoID); err != nil { + ctx.ServerError("RemoveScopedWorkflowSource", err) + return + } + ctx.Flash.Success(ctx.Tr("actions.scoped_workflows.source.remove_success")) + ctx.JSONRedirect(swCtx.RedirectLink) +} diff --git a/routers/web/shared/actions/scoped_workflows_test.go b/routers/web/shared/actions/scoped_workflows_test.go new file mode 100644 index 00000000000..7d96c431ee5 --- /dev/null +++ b/routers/web/shared/actions/scoped_workflows_test.go @@ -0,0 +1,75 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package actions + +import ( + "testing" + + actions_module "gitea.dev/modules/actions" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDeriveScopedStatusContexts(t *testing.T) { + t.Run("jobs x events; job name is its name: or its id", func(t *testing.T) { + content := []byte(`name: CI +on: [push, pull_request] +jobs: + lint: + runs-on: ubuntu-latest + steps: + - run: echo + build: + name: Build It + runs-on: ubuntu-latest + steps: + - run: echo +`) + events, err := actions_module.GetEventsFromContent(content) + require.NoError(t, err) + got := deriveScopedStatusContexts("org/src", "CI", content, events) + assert.ElementsMatch(t, []string{ + "org/src: CI / lint (push)", + "org/src: CI / lint (pull_request)", + "org/src: CI / Build It (push)", + "org/src: CI / Build It (pull_request)", + }, got) + }) + + t.Run("only status-producing events; workflow_dispatch/schedule/workflow_call skipped", func(t *testing.T) { + content := []byte(`name: CI +on: + push: + workflow_dispatch: + workflow_call: + schedule: + - cron: "0 0 * * *" +jobs: + j: + runs-on: ubuntu-latest + steps: + - run: echo +`) + events, err := actions_module.GetEventsFromContent(content) + require.NoError(t, err) + got := deriveScopedStatusContexts("org/src", "CI", content, events) + assert.Equal(t, []string{"org/src: CI / j (push)"}, got) // only push posts a commit status + }) + + t.Run("a workflow_dispatch-only workflow has no expected contexts", func(t *testing.T) { + content := []byte(`name: Deploy +on: workflow_dispatch +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - run: echo +`) + events, err := actions_module.GetEventsFromContent(content) + require.NoError(t, err) + got := deriveScopedStatusContexts("org/src", "Deploy", content, events) + assert.Empty(t, got) // workflow_dispatch posts no commit status -> nothing to preview (and it cannot be a required check) + }) +} diff --git a/routers/web/web.go b/routers/web/web.go index 4525516f5a4..0bfae44e869 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -500,6 +500,15 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) { }) } + addSettingsScopedWorkflowsRoutes := func() { + m.Group("/scoped-workflows", func() { + m.Get("", shared_actions.ScopedWorkflows) + m.Post("/add", shared_actions.ScopedWorkflowAdd) + m.Post("/required", shared_actions.ScopedWorkflowSetRequired) + m.Post("/remove", shared_actions.ScopedWorkflowRemove) + }) + } + // FIXME: not all routes need go through same middleware. // Especially some AJAX requests, we can reduce middleware number to improve performance. @@ -702,6 +711,7 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) { addSettingsRunnersRoutes() addSettingsSecretsRoutes() addSettingsVariablesRoutes() + addSettingsScopedWorkflowsRoutes() }, actions.MustEnableActions) m.Get("/organization", user_setting.Organization) @@ -865,6 +875,7 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) { addSettingsRunnersRoutes() m.Post("/runners/bulk", shared_actions.RunnerBulkActionPost) addSettingsVariablesRoutes() + addSettingsScopedWorkflowsRoutes() }) }, adminReq, ctxDataSet("EnableOAuth2", setting.OAuth2.Enabled, "EnablePackages", setting.Packages.Enabled)) // ***** END: Admin ***** @@ -1022,6 +1033,7 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) { addSettingsRunnersRoutes() addSettingsSecretsRoutes() addSettingsVariablesRoutes() + addSettingsScopedWorkflowsRoutes() }, actions.MustEnableActions) m.Post("/rename", web.Bind(forms.RenameOrgForm{}), org.SettingsRenamePost) diff --git a/services/actions/commit_status.go b/services/actions/commit_status.go index 2b8ed19f928..1fa3fd027e5 100644 --- a/services/actions/commit_status.go +++ b/services/actions/commit_status.go @@ -7,8 +7,6 @@ import ( "context" "errors" "fmt" - "path" - "strings" actions_model "gitea.dev/models/actions" "gitea.dev/models/db" @@ -16,7 +14,6 @@ import ( repo_model "gitea.dev/models/repo" user_model "gitea.dev/models/user" actions_module "gitea.dev/modules/actions" - "gitea.dev/modules/actions/jobparser" "gitea.dev/modules/commitstatus" "gitea.dev/modules/log" "gitea.dev/modules/util" @@ -45,8 +42,14 @@ func CreateCommitStatusForRunJobs(ctx context.Context, run *actions_model.Action return } + // Compute the scoped source-repo prefix once per run; it is identical for every job. + var scopedPrefix string + if run.IsScopedRun { + scopedPrefix = actions_model.ScopedStatusContextPrefix(ctx, run.WorkflowRepoID) + } + for _, job := range jobs { - if err = createCommitStatus(ctx, run.Repo, event, commitID, run, job); err != nil { + if err = createCommitStatus(ctx, run.Repo, event, commitID, scopedPrefix, run, job); err != nil { log.Error("Failed to create commit status for job %d: %v", job.ID, err) } } @@ -136,16 +139,15 @@ func getCommitStatusEventNameAndCommitID(run *actions_model.ActionRun) (event, c return event, commitID, nil } -func createCommitStatus(ctx context.Context, repo *repo_model.Repository, event, commitID string, run *actions_model.ActionRun, job *actions_model.ActionRunJob) error { - // TODO: store workflow name as a field in ActionRun to avoid parsing - runName := path.Base(run.WorkflowID) - // fall back to the file name when the workflow has no non-blank `name:` - if wfs, err := jobparser.Parse(job.WorkflowPayload); err == nil && len(wfs) > 0 { - if name := strings.TrimSpace(wfs[0].Name); name != "" { - runName = name - } +func createCommitStatus(ctx context.Context, repo *repo_model.Repository, event, commitID, scopedPrefix string, run *actions_model.ActionRun, job *actions_model.ActionRunJob) error { + displayName := actions_module.WorkflowDisplayName(run.WorkflowID, job.WorkflowPayload) + ctxName := actions_module.WorkflowStatusContextName(displayName, job.Name, event) // git_model.NewCommitStatus also trims spaces + if run.IsScopedRun { + // A scoped run is prefixed with its source repo (set off by a colon) so it stays distinct from a same-named repo-level workflow. + // scopedPrefix is computed once per run by the caller. The settings page derives the same string to preview expected checks. + ctxName = actions_module.ScopedWorkflowStatusContextName(scopedPrefix, displayName, job.Name, event) } - ctxName := strings.TrimSpace(fmt.Sprintf("%s / %s (%s)", runName, job.Name, event)) // git_model.NewCommitStatus also trims spaces + // Mix the workflow file path into the hash so two workflow files that // share the same `name:` and job name produce distinct commit statuses // even though they render identically — matching GitHub's behavior diff --git a/services/actions/commit_status_test.go b/services/actions/commit_status_test.go index e20dcf85118..af71d16e209 100644 --- a/services/actions/commit_status_test.go +++ b/services/actions/commit_status_test.go @@ -72,7 +72,7 @@ func TestCreateCommitStatus_Dedupe(t *testing.T) { expectedContext := "status-dedupe-test.yaml / status-dedupe-job (push)" expectedTargetURL := run.Link() + "/jobs/99002" - require.NoError(t, createCommitStatus(t.Context(), repo, "push", commit.ID.String(), run, job)) + require.NoError(t, createCommitStatus(t.Context(), repo, "push", commit.ID.String(), "", run, job)) statuses := findCommitStatusesForContext(t, repo.ID, commit.ID.String(), expectedContext) require.Len(t, statuses, 1) @@ -81,7 +81,7 @@ func TestCreateCommitStatus_Dedupe(t *testing.T) { assert.Equal(t, expectedTargetURL, statuses[0].TargetURL) job.Status = actions_model.StatusRunning - require.NoError(t, createCommitStatus(t.Context(), repo, "push", commit.ID.String(), run, job)) + require.NoError(t, createCommitStatus(t.Context(), repo, "push", commit.ID.String(), "", run, job)) statuses = findCommitStatusesForContext(t, repo.ID, commit.ID.String(), expectedContext) require.Len(t, statuses, 2) @@ -90,12 +90,12 @@ func TestCreateCommitStatus_Dedupe(t *testing.T) { assert.Equal(t, "In progress", statuses[1].Description) assert.Equal(t, expectedTargetURL, statuses[1].TargetURL) - require.NoError(t, createCommitStatus(t.Context(), repo, "push", commit.ID.String(), run, job)) + require.NoError(t, createCommitStatus(t.Context(), repo, "push", commit.ID.String(), "", run, job)) statuses = findCommitStatusesForContext(t, repo.ID, commit.ID.String(), expectedContext) assert.Len(t, statuses, 2) job.Status = actions_model.StatusSuccess - require.NoError(t, createCommitStatus(t.Context(), repo, "push", commit.ID.String(), run, job)) + require.NoError(t, createCommitStatus(t.Context(), repo, "push", commit.ID.String(), "", run, job)) statuses = findCommitStatusesForContext(t, repo.ID, commit.ID.String(), expectedContext) require.Len(t, statuses, 3) assert.Equal(t, commitstatus.CommitStatusSuccess, statuses[2].State) @@ -126,7 +126,7 @@ func TestGetCommitActionsStatusMap(t *testing.T) { RunID: run.ID, RepoID: repo.ID, OwnerID: repo.OwnerID, Name: tc.jobName, Status: tc.status, } require.NoError(t, db.Insert(t.Context(), job)) - require.NoError(t, createCommitStatus(t.Context(), repo, "push", branch.CommitID, run, job)) + require.NoError(t, createCommitStatus(t.Context(), repo, "push", branch.CommitID, "", run, job)) } statuses, err := git_model.GetLatestCommitStatus(t.Context(), repo.ID, branch.CommitID, db.ListOptionsAll) @@ -185,7 +185,7 @@ jobs: WorkflowPayload: payload, } require.NoError(t, db.Insert(t.Context(), job)) - require.NoError(t, createCommitStatus(t.Context(), repo, "pull_request", branch.CommitID, run, job)) + require.NoError(t, createCommitStatus(t.Context(), repo, "pull_request", branch.CommitID, "", run, job)) } statuses, err := git_model.GetLatestCommitStatus(t.Context(), repo.ID, branch.CommitID, db.ListOptionsAll) @@ -242,7 +242,7 @@ func TestCreateCommitStatus_LegacyHashRecovery(t *testing.T) { Name: "my-job", Status: actions_model.StatusSuccess, } require.NoError(t, db.Insert(t.Context(), job)) - require.NoError(t, createCommitStatus(t.Context(), repo, "push", branch.CommitID, run, job)) + require.NoError(t, createCommitStatus(t.Context(), repo, "push", branch.CommitID, "", run, job)) latest, err := git_model.GetLatestCommitStatus(t.Context(), repo.ID, branch.CommitID, db.ListOptionsAll) require.NoError(t, err) @@ -292,7 +292,7 @@ func TestCreateCommitStatus_UnnamedWorkflowUsesFileName(t *testing.T) { `), } require.NoError(t, db.Insert(t.Context(), job)) - require.NoError(t, createCommitStatus(t.Context(), repo, "push", branch.CommitID, run, job)) + require.NoError(t, createCommitStatus(t.Context(), repo, "push", branch.CommitID, "", run, job)) statuses := findCommitStatusesForContext(t, repo.ID, branch.CommitID, tc.workflowID+" / my-test (push)") require.Len(t, statuses, 1) @@ -300,6 +300,67 @@ func TestCreateCommitStatus_UnnamedWorkflowUsesFileName(t *testing.T) { } } +// TestCreateCommitStatus_ScopedSourcePrefix: a scoped run's commit status Context is prefixed with the source repo's full name, +// so it is distinct (display AND hash) from a same-named repo-level workflow. +func TestCreateCommitStatus_ScopedSourcePrefix(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + + consumer := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 4}) + source := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + branch := unittest.AssertExistsAndLoadBean(t, &git_model.Branch{RepoID: consumer.ID, Name: consumer.DefaultBranch}) + + payload := []byte(`name: ci +on: push +jobs: + build: + runs-on: ubuntu-latest + steps: + - run: echo hi +`) + + // A repo-level run and a scoped run share the same workflow name and job name; + // only the scoped one points its content source at another repo (WorkflowRepoID=source.ID, IsScopedRun=true). + for _, spec := range []struct { + runID, jobID int64 + scoped bool + }{ + {99501, 99511, false}, + {99502, 99512, true}, + } { + workflowRepoID := consumer.ID + if spec.scoped { + workflowRepoID = source.ID + } + run := &actions_model.ActionRun{ + ID: spec.runID, Index: spec.runID, RepoID: consumer.ID, Repo: consumer, OwnerID: consumer.OwnerID, TriggerUserID: consumer.OwnerID, + WorkflowID: "ci.yaml", CommitSHA: branch.CommitID, + WorkflowRepoID: workflowRepoID, WorkflowCommitSHA: branch.CommitID, IsScopedRun: spec.scoped, + } + require.NoError(t, db.Insert(t.Context(), run)) + job := &actions_model.ActionRunJob{ + ID: spec.jobID, RunID: run.ID, RepoID: consumer.ID, OwnerID: consumer.OwnerID, + Name: "build", Status: actions_model.StatusWaiting, WorkflowPayload: payload, + } + require.NoError(t, db.Insert(t.Context(), job)) + // mirror CreateCommitStatusForRunJobs: compute the scoped prefix once per run + scopedPrefix := "" + if run.IsScopedRun { + scopedPrefix = actions_model.ScopedStatusContextPrefix(t.Context(), run.WorkflowRepoID) + } + require.NoError(t, createCommitStatus(t.Context(), consumer, "push", branch.CommitID, scopedPrefix, run, job)) + } + + // repo-level Context is the bare " / ()"; the scoped one is the same but sets off the source repo with a colon, + // so the two stay distinct (and have different hashes) despite the same `name:`. + repoStatuses := findCommitStatusesForContext(t, consumer.ID, branch.CommitID, "ci / build (push)") + require.Len(t, repoStatuses, 1) + scopedStatuses := findCommitStatusesForContext(t, consumer.ID, branch.CommitID, source.FullName()+": ci / build (push)") + require.Len(t, scopedStatuses, 1) + + assert.NotEqual(t, repoStatuses[0].ContextHash, scopedStatuses[0].ContextHash, + "scoped status must not collide with the same-named repo-level workflow") +} + func findCommitStatusesForContext(t *testing.T, repoID int64, sha, context string) []*git_model.CommitStatus { t.Helper() diff --git a/services/actions/context_test.go b/services/actions/context_test.go index 252399a37d8..8100f1e2380 100644 --- a/services/actions/context_test.go +++ b/services/actions/context_test.go @@ -63,16 +63,18 @@ jobs: `) run := &actions_model.ActionRun{ - Title: "before parse", - RepoID: 4, - OwnerID: 1, - WorkflowID: "expr-runid.yaml", - TriggerUserID: 1, - Ref: "refs/heads/master", - CommitSHA: "c2d72f548424103f01ee1dc02889c1e2bff816b0", - Event: "push", - TriggerEvent: "push", - EventPayload: "{}", + Title: "before parse", + RepoID: 4, + OwnerID: 1, + WorkflowID: "expr-runid.yaml", + TriggerUserID: 1, + Ref: "refs/heads/master", + CommitSHA: "c2d72f548424103f01ee1dc02889c1e2bff816b0", + Event: "push", + TriggerEvent: "push", + EventPayload: "{}", + WorkflowRepoID: 4, + WorkflowCommitSHA: "c2d72f548424103f01ee1dc02889c1e2bff816b0", } require.NoError(t, PrepareRunAndInsert(ctx, content, run, nil)) require.Positive(t, run.ID) diff --git a/services/actions/notifier.go b/services/actions/notifier.go index 5670c2a634d..ae8ff2b0e7d 100644 --- a/services/actions/notifier.go +++ b/services/actions/notifier.go @@ -16,7 +16,6 @@ import ( repo_model "gitea.dev/models/repo" user_model "gitea.dev/models/user" "gitea.dev/modules/git" - "gitea.dev/modules/gitrepo" "gitea.dev/modules/log" "gitea.dev/modules/repository" "gitea.dev/modules/setting" @@ -802,21 +801,17 @@ func (n *actionsNotifier) WorkflowRunStatusUpdate(ctx context.Context, repo *rep status := convert.ToWorkflowRunAction(run.Status) - gitRepo, err := gitrepo.OpenRepository(ctx, repo) + convertedWorkflow, err := convert.ResolveActionWorkflowForRun(ctx, repo, run) if err != nil { - log.Error("OpenRepository: %v", err) + if errors.Is(err, util.ErrNotExist) { + // The workflow definition is gone (e.g. a scoped source repo/file was deleted, or the file no longer exists at the recorded commit), skip + log.Debug("WorkflowRunStatusUpdate: workflow %q for run %d not found: %v", run.WorkflowID, run.ID, err) + return + } + log.Error("WorkflowRunStatusUpdate resolve workflow: %v", err) return } - defer gitRepo.Close() - convertedWorkflow, err := convert.GetActionWorkflowByRef(ctx, gitRepo, repo, run.WorkflowID, git.RefName(run.Ref)) - if err != nil && errors.Is(err, util.ErrNotExist) { - convertedWorkflow, err = convert.GetActionWorkflow(ctx, gitRepo, repo, run.WorkflowID) - } - if err != nil { - log.Error("GetActionWorkflow: %v", err) - return - } run.Repo = repo convertedRun, err := convert.ToActionWorkflowRun(ctx, run, nil, false) if err != nil { diff --git a/services/actions/notifier_helper.go b/services/actions/notifier_helper.go index e67d1028f76..7a8b5bd67ec 100644 --- a/services/actions/notifier_helper.go +++ b/services/actions/notifier_helper.go @@ -19,6 +19,7 @@ import ( unit_model "gitea.dev/models/unit" user_model "gitea.dev/models/user" actions_module "gitea.dev/modules/actions" + "gitea.dev/modules/container" "gitea.dev/modules/git" "gitea.dev/modules/gitrepo" "gitea.dev/modules/json" @@ -239,7 +240,11 @@ func notify(ctx context.Context, input *notifyInput) error { } } - return handleWorkflows(ctx, detectedWorkflows, commit, input, ref) + if err := handleWorkflows(ctx, detectedWorkflows, commit, input, ref); err != nil { + return err + } + + return detectAndHandleScopedWorkflows(ctx, input, ref, gitRepo, commit) } func skipWorkflows(ctx context.Context, input *notifyInput, commit *git.Commit) bool { @@ -303,51 +308,63 @@ func handleWorkflows( return fmt.Errorf("json.Marshal: %w", err) } - isForkPullRequest := false - if pr := input.PullRequest; pr != nil { - switch pr.Flow { - case issues_model.PullRequestFlowGithub: - isForkPullRequest = pr.IsFromFork() - case issues_model.PullRequestFlowAGit: - // There is no fork concept in agit flow, anyone with read permission can push refs/for// to the repo. - // So we can treat it as a fork pull request because it may be from an untrusted user - isForkPullRequest = true - default: - // unknown flow, assume it's a fork pull request to be safe - isForkPullRequest = true - } - } + isForkPullRequest := isForkPullRequestInput(input) for _, dwf := range detectedWorkflows { - run := &actions_model.ActionRun{ - Title: commit.MessageTitle(), - RepoID: input.Repo.ID, - Repo: input.Repo, - OwnerID: input.Repo.OwnerID, - WorkflowID: dwf.EntryName, - TriggerUserID: input.Doer.ID, - TriggerUser: input.Doer, - Ref: ref.String(), - CommitSHA: commit.ID.String(), - IsForkPullRequest: isForkPullRequest, - Event: input.Event, - EventPayload: string(p), - TriggerEvent: dwf.TriggerEvent.Name, - Status: actions_model.StatusWaiting, - } - - need, err := ifNeedApproval(ctx, run, input.Repo, input.Doer) - if err != nil { - log.Error("check if need approval for repo %d with user %d: %v", input.Repo.ID, input.Doer.ID, err) + // repo-level run: the workflow content is this repo at this commit + if err := buildApproveAndInsertRun(ctx, input, ref, commit, string(p), isForkPullRequest, dwf, input.Repo.ID, commit.ID.String(), false); err != nil { + log.Error("repo %s: %v", input.Repo.RelativePath(), err) continue } + } + return nil +} - run.NeedApproval = need +// buildApproveAndInsertRun assembles an ActionRun for a detected workflow, runs the +// fork-PR approval gate, and inserts it. Repo-level and scoped runs share this path so +// run construction and the approval flow have a single implementation that can't drift. +// workflowRepoID/workflowCommitSHA point at the repo+commit the workflow content comes +// from (the repo itself for repo-level runs, the source repo for scoped runs). +func buildApproveAndInsertRun( + ctx context.Context, + input *notifyInput, + ref git.RefName, + commit *git.Commit, + payload string, + isForkPullRequest bool, + dwf *actions_module.DetectedWorkflow, + workflowRepoID int64, + workflowCommitSHA string, + isScopedRun bool, +) error { + run := &actions_model.ActionRun{ + Title: commit.MessageTitle(), + RepoID: input.Repo.ID, + Repo: input.Repo, + OwnerID: input.Repo.OwnerID, + WorkflowID: dwf.EntryName, + TriggerUserID: input.Doer.ID, + TriggerUser: input.Doer, + Ref: ref.String(), + CommitSHA: commit.ID.String(), + IsForkPullRequest: isForkPullRequest, + Event: input.Event, + EventPayload: payload, + TriggerEvent: dwf.TriggerEvent.Name, + Status: actions_model.StatusWaiting, + WorkflowRepoID: workflowRepoID, + WorkflowCommitSHA: workflowCommitSHA, + IsScopedRun: isScopedRun, + } - if err := PrepareRunAndInsert(ctx, dwf.Content, run, nil); err != nil { - log.Error("PrepareRunAndInsert: %v", err) - continue - } + need, err := ifNeedApproval(ctx, run, input.Repo, input.Doer) + if err != nil { + return fmt.Errorf("check if need approval for user %d: %w", input.Doer.ID, err) + } + run.NeedApproval = need + + if err := PrepareRunAndInsert(ctx, dwf.Content, run, nil); err != nil { + return fmt.Errorf("PrepareRunAndInsert: %w", err) } return nil } @@ -551,3 +568,113 @@ func DetectAndHandleSchedules(ctx context.Context, repo *repo_model.Repository) return handleSchedules(ctx, scheduleWorkflows, commit, notifyInput, git.RefNameFromBranch(repo.DefaultBranch)) } + +// isForkPullRequestInput reports whether the run should be treated as a fork pull request. +func isForkPullRequestInput(input *notifyInput) bool { + pr := input.PullRequest + if pr == nil { + return false + } + switch pr.Flow { + case issues_model.PullRequestFlowGithub: + return pr.IsFromFork() + case issues_model.PullRequestFlowAGit: + // There is no fork concept in agit flow, anyone with read permission can push refs/for// to the repo. + // So we can treat it as a fork pull request because it may be from an untrusted user + return true + default: + // unknown flow, assume it's a fork pull request to be safe + return true + } +} + +// detectAndHandleScopedWorkflows detects scoped workflows registered for the consuming repo +func detectAndHandleScopedWorkflows( + ctx context.Context, + input *notifyInput, + ref git.RefName, + consumerGitRepo *git.Repository, + consumerCommit *git.Commit, +) error { + // TODO: support workflow_run and schedule + if input.Event == webhook_module.HookEventWorkflowRun || input.Event == webhook_module.HookEventSchedule { + return nil + } + + sources, err := actions_model.GetEffectiveScopedWorkflowSources(ctx, input.Repo.OwnerID) + if err != nil { + return fmt.Errorf("GetEffectiveScopedWorkflowSources: %w", err) + } + if len(sources) == 0 { + return nil + } + + p, err := json.Marshal(input.Payload) + if err != nil { + return fmt.Errorf("json.Marshal: %w", err) + } + isForkPullRequest := isForkPullRequestInput(input) + actionsConfig := input.Repo.MustGetUnit(ctx, unit_model.TypeActions).ActionsConfig() + + // The same source repo may be registered at both the owner and instance level; dedup + // the IDs and batch-load them in one query instead of one round-trip per source. + seen := make(container.Set[int64], len(sources)) + for _, source := range sources { + seen.Add(source.SourceRepoID) + } + sourceRepoIDs := seen.Values() + + sourceRepos, err := repo_model.GetRepositoriesMapByIDs(ctx, sourceRepoIDs) + if err != nil { + return fmt.Errorf("GetRepositoriesMapByIDs: %w", err) + } + + for _, sourceRepoID := range sourceRepoIDs { + sourceRepo := sourceRepos[sourceRepoID] + if sourceRepo == nil { + // don't abort the other effective sources for this event + log.Error("scoped workflows: source repo %d for consumer %s not found", sourceRepoID, input.Repo.RelativePath()) + continue + } + if sourceRepo.IsEmpty { + continue + } + + sourceCommitSHA, detected, err := detectScopedWorkflowsForSource(ctx, input, consumerGitRepo, consumerCommit, sourceRepo) + if err != nil { + log.Error("scoped workflows: source %d for consumer %s: %v", sourceRepoID, input.Repo.RelativePath(), err) + continue + } + + for _, dwf := range detected { + // A consuming repo can opt out of a non-required scoped workflow. + // A required workflow (marked required at any effective level) can never be opted out. + if actions_model.ScopedWorkflowOptedOut(actionsConfig, sources, sourceRepo.ID, dwf.EntryName) { + continue + } + + if err := buildApproveAndInsertRun(ctx, input, ref, consumerCommit, string(p), isForkPullRequest, dwf, sourceRepo.ID, sourceCommitSHA, true); err != nil { + log.Error("scoped workflows: source %s workflow %s: %v", sourceRepo.RelativePath(), dwf.EntryName, err) + continue + } + } + } + + return nil +} + +// detectScopedWorkflowsForSource detects the scoped workflows from the source repo at its default branch +func detectScopedWorkflowsForSource( + ctx context.Context, + input *notifyInput, + consumerGitRepo *git.Repository, + consumerCommit *git.Commit, + sourceRepo *repo_model.Repository, +) (sourceCommitSHA string, detected []*actions_module.DetectedWorkflow, err error) { + // scoped workflow content is always taken from the source repo's default branch; the parse is cached per (source, default-branch SHA) and reused across consuming repos/events + sourceCommitSHA, parsed, err := LoadParsedScopedWorkflows(ctx, sourceRepo) + if err != nil { + return "", nil, err + } + return sourceCommitSHA, actions_module.MatchScopedWorkflows(parsed, consumerGitRepo, consumerCommit, input.Event, input.Payload), nil +} diff --git a/services/actions/rerun.go b/services/actions/rerun.go index 17076a594c0..6ec24634382 100644 --- a/services/actions/rerun.go +++ b/services/actions/rerun.go @@ -88,7 +88,16 @@ func validateRerun(ctx context.Context, run *actions_model.ActionRun, repo *repo } cfgUnit := repo.MustGetUnit(ctx, unit.TypeActions) cfg := cfgUnit.ActionsConfig() - if cfg.IsWorkflowDisabled(run.WorkflowID) { + if run.IsScopedRun { + // a required scoped workflow can never be opted out, so a stale disabled flag must not block rerun + optedOut, err := actions_model.IsScopedWorkflowOptedOut(ctx, cfg, repo.OwnerID, run.WorkflowRepoID, run.WorkflowID) + if err != nil { + return err + } + if optedOut { + return util.NewInvalidArgumentErrorf("scoped workflow %s is disabled", run.WorkflowID) + } + } else if cfg.IsWorkflowDisabled(run.WorkflowID) { return util.NewInvalidArgumentErrorf("workflow %s is disabled", run.WorkflowID) } diff --git a/services/actions/reusable_workflow.go b/services/actions/reusable_workflow.go index cf77824c4d4..2e220cd5ea6 100644 --- a/services/actions/reusable_workflow.go +++ b/services/actions/reusable_workflow.go @@ -13,6 +13,7 @@ import ( perm_model "gitea.dev/models/perm" access_model "gitea.dev/models/perm/access" repo_model "gitea.dev/models/repo" + actions_module "gitea.dev/modules/actions" "gitea.dev/modules/actions/jobparser" "gitea.dev/modules/container" "gitea.dev/modules/gitrepo" @@ -60,6 +61,11 @@ func loadReusableWorkflowSource(ctx context.Context, run *actions_model.ActionRu return nil, 0, "", err } if !ok { + if run.IsScopedRun { + // A scoped workflow's cross-repo "uses:" is resolved with the consuming repo's read permission, + // so the referenced repo must be readable by every consumer. Make that explicit in the failure. + return nil, 0, "", fmt.Errorf("no permission to read reusable workflow %s/%s: a scoped workflow's cross-repo \"uses:\" is resolved with the consuming repository %q read permission", ref.Owner, ref.Repo, run.Repo.RelativePath()) + } return nil, 0, "", fmt.Errorf("no permission to read reusable workflow from %s/%s", ref.Owner, ref.Repo) } bytes, resolvedSHA, err := readWorkflowFromRepo(ctx, repo, ref.Ref, ref.Path) @@ -359,5 +365,13 @@ func ResolveUses(ctx context.Context, uses string) (*jobparser.UsesRef, error) { // RoutePath is the instance-relative path (AppSubURL already stripped), e.g. "/owner/repo/.gitea/workflows/file.yml@ref". uses = strings.TrimPrefix(gsu.RoutePath, "/") } - return jobparser.ParseUses(uses) + ref, err := jobparser.ParseUses(uses) + if err != nil { + return nil, err + } + // jobparser only validates syntax; enforce the (instance-configurable) directory allowlist here. + if !actions_module.IsWorkflowOrScopedWorkflow(ref.Path) { + return nil, fmt.Errorf(`"uses:" path %q must be under a configured workflow directory (WORKFLOW_DIRS or SCOPED_WORKFLOW_DIRS)`, ref.Path) + } + return ref, nil } diff --git a/services/actions/reusable_workflow_test.go b/services/actions/reusable_workflow_test.go index a7bb41ba8ae..28b6696c361 100644 --- a/services/actions/reusable_workflow_test.go +++ b/services/actions/reusable_workflow_test.go @@ -139,6 +139,8 @@ func buildCallerChain(t *testing.T, callerUses ...string) []*actions_model.Actio func TestResolveUses(t *testing.T) { defer test.MockVariableValue(&setting.AppURL, "https://gitea.example.com/sub/")() defer test.MockVariableValue(&setting.AppSubURL, "/sub")() + defer test.MockVariableValue(&setting.Actions.WorkflowDirs, []string{".gitea/workflows", ".github/workflows"})() + defer test.MockVariableValue(&setting.Actions.ScopedWorkflowDirs, []string{".gitea/scoped_workflows"})() ctx := t.Context() t.Run("LocalForms", func(t *testing.T) { @@ -152,6 +154,34 @@ func TestResolveUses(t *testing.T) { assert.Equal(t, jobparser.UsesRef{Kind: jobparser.UsesKindLocalCrossRepo, Owner: "owner", Repo: "repo", Path: ".gitea/workflows/build.yml", Ref: "v1"}, *ref) }) + t.Run("DirectoryAllowlist", func(t *testing.T) { + // SCOPED_WORKFLOW_DIRS is allowed (local and cross-repo). + ref, err := ResolveUses(ctx, "./.gitea/scoped_workflows/lib.yml") + require.NoError(t, err) + assert.Equal(t, ".gitea/scoped_workflows/lib.yml", ref.Path) + + ref, err = ResolveUses(ctx, "owner/repo/.gitea/scoped_workflows/lib.yml@v1") + require.NoError(t, err) + assert.Equal(t, ".gitea/scoped_workflows/lib.yml", ref.Path) + + // A directory that is neither WORKFLOW_DIRS nor SCOPED_WORKFLOW_DIRS parses but is rejected by the allowlist. + _, err = ResolveUses(ctx, "./not-workflows/build.yml") + require.Error(t, err) + _, err = ResolveUses(ctx, "owner/repo/lib/build.yml@v1") + require.Error(t, err) + }) + + t.Run("ConfigurableWorkflowDirs", func(t *testing.T) { + // A non-default WORKFLOW_DIRS is honored (the hardcoded ".gitea/workflows" is no longer special). + defer test.MockVariableValue(&setting.Actions.WorkflowDirs, []string{".gitea/ci"})() + ref, err := ResolveUses(ctx, "./.gitea/ci/build.yml") + require.NoError(t, err) + assert.Equal(t, ".gitea/ci/build.yml", ref.Path) + + _, err = ResolveUses(ctx, "./.gitea/workflows/build.yml") // no longer a configured dir + require.Error(t, err) + }) + t.Run("LocalInstanceURL", func(t *testing.T) { // An absolute URL on this instance (incl. AppSubURL) resolves to the equivalent cross-repo ref. ref, err := ResolveUses(ctx, "https://gitea.example.com/sub/owner/repo/.gitea/workflows/ci.yml@refs/heads/main") diff --git a/services/actions/run.go b/services/actions/run.go index 58e50de0c21..fc2631e19d7 100644 --- a/services/actions/run.go +++ b/services/actions/run.go @@ -21,6 +21,10 @@ import ( // It parses the workflow content, evaluates concurrency if needed, and inserts the run and its jobs into the database. // The title will be cut off at 255 characters if it's longer than 255 characters. func PrepareRunAndInsert(ctx context.Context, content []byte, run *actions_model.ActionRun, inputsWithDefaults map[string]any) error { + if run.WorkflowRepoID == 0 { + return fmt.Errorf("WorkflowRepoID must be set before insert (repo %d, workflow %q)", run.RepoID, run.WorkflowID) + } + if err := run.LoadAttributes(ctx); err != nil { return fmt.Errorf("LoadAttributes: %w", err) } @@ -162,8 +166,8 @@ func InsertRun(ctx context.Context, run *actions_model.ActionRun, content []byte Needs: needs, RunsOn: job.RunsOn(), Status: util.Iif(shouldBlockJob, actions_model.StatusBlocked, actions_model.StatusWaiting), - WorkflowSourceRepoID: run.RepoID, - WorkflowSourceCommitSHA: run.CommitSHA, + WorkflowSourceRepoID: run.WorkflowRepoID, + WorkflowSourceCommitSHA: run.WorkflowCommitSHA, ContinueOnError: job.GetContinueOnError(), } // Parse workflow/job permissions (no clamping here) diff --git a/services/actions/schedule_tasks.go b/services/actions/schedule_tasks.go index 5995d003bec..d67bf8dd29d 100644 --- a/services/actions/schedule_tasks.go +++ b/services/actions/schedule_tasks.go @@ -118,6 +118,9 @@ func CreateScheduleTask(ctx context.Context, spec *actions_model.ActionScheduleS TriggerEvent: string(webhook_module.HookEventSchedule), ScheduleID: cron.ID, Status: actions_model.StatusWaiting, + // schedule runs the repo's own workflow at the recorded commit + WorkflowRepoID: cron.RepoID, + WorkflowCommitSHA: cron.CommitSHA, } // FIXME cron.Content might be outdated if the workflow file has been changed. diff --git a/services/actions/scoped_workflow_cache.go b/services/actions/scoped_workflow_cache.go new file mode 100644 index 00000000000..3dcfb44d8a5 --- /dev/null +++ b/services/actions/scoped_workflow_cache.go @@ -0,0 +1,84 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package actions + +import ( + "context" + "fmt" + + git_model "gitea.dev/models/git" + repo_model "gitea.dev/models/repo" + actions_module "gitea.dev/modules/actions" + "gitea.dev/modules/gitrepo" + "gitea.dev/modules/log" + + lru "github.com/hashicorp/golang-lru/v2" +) + +// cachedScopedWorkflows is one source repo's parsed scoped workflows together with the default-branch SHA they were parsed at. +type cachedScopedWorkflows struct { + sha string + parsed []*actions_module.ParsedScopedWorkflow +} + +// scopedWorkflowCache caches each scoped-workflow source repo's parsed workflows, keyed by source repo ID. +// There is exactly one entry per source: a default-branch update is detected by SHA mismatch and overwrites the entry, so stale parses never accumulate. +var scopedWorkflowCache *lru.Cache[int64, *cachedScopedWorkflows] + +const defaultScopedWorkflowCacheSize = 1024 + +func init() { + c, err := lru.New[int64, *cachedScopedWorkflows](defaultScopedWorkflowCacheSize) + if err != nil { + log.Fatal("failed to new scopedWorkflowCache, err: %v", err) + } + scopedWorkflowCache = c +} + +// LoadParsedScopedWorkflows returns the source repo's parsed scoped workflows at its current default-branch HEAD. +func LoadParsedScopedWorkflows(ctx context.Context, sourceRepo *repo_model.Repository) (sha string, parsed []*actions_module.ParsedScopedWorkflow, err error) { + branch, err := git_model.GetBranch(ctx, sourceRepo.ID, sourceRepo.DefaultBranch) + if err != nil { + return "", nil, fmt.Errorf("get source default branch: %w", err) + } + sha = branch.CommitID + + if v, ok := scopedWorkflowCache.Get(sourceRepo.ID); ok && v.sha == sha { + // cache hit at the current default-branch HEAD + return sha, v.parsed, nil + } + + // cache miss: open the source repo at the exact SHA we keyed on + sourceGitRepo, err := gitrepo.OpenRepository(ctx, sourceRepo) + if err != nil { + return "", nil, fmt.Errorf("open source repo: %w", err) + } + defer sourceGitRepo.Close() + + sourceCommit, err := sourceGitRepo.GetCommit(sha) + if err != nil { + return "", nil, fmt.Errorf("get source commit %s: %w", sha, err) + } + parsed, err = actions_module.ParseScopedWorkflows(sourceCommit) + if err != nil { + return "", nil, err + } + // overwrite this source's single entry (a stale entry from a previous HEAD is replaced, not accumulated) + scopedWorkflowCache.Add(sourceRepo.ID, &cachedScopedWorkflows{sha: sha, parsed: parsed}) + return sha, parsed, nil +} + +// ScopedWorkflowContent returns one scoped workflow's raw content (by entry name) at the source repo's current default-branch HEAD, or nil if no such workflow exists there. +func ScopedWorkflowContent(ctx context.Context, sourceRepo *repo_model.Repository, entryName string) ([]byte, error) { + _, parsed, err := LoadParsedScopedWorkflows(ctx, sourceRepo) + if err != nil { + return nil, err + } + for _, p := range parsed { + if p.EntryName == entryName { + return p.Content, nil + } + } + return nil, nil +} diff --git a/services/actions/workflow.go b/services/actions/workflow.go index a748b0859ce..5b5628cff1d 100644 --- a/services/actions/workflow.go +++ b/services/actions/workflow.go @@ -43,7 +43,9 @@ func EnableOrDisableWorkflow(ctx *context.APIContext, workflowID string, isEnabl return repo_model.UpdateRepoUnitConfig(ctx, cfgUnit) } -func DispatchActionWorkflow(ctx reqctx.RequestContext, doer *user_model.User, repo *repo_model.Repository, gitRepo *git.Repository, workflowID, ref string, processInputs func(model *model.WorkflowDispatch, inputs map[string]any) error) (runID int64, _ error) { +// DispatchActionWorkflow manually triggers a workflow_dispatch run. +// scopedWorkflowSourceRepoID selects the workflow source: 0 means a repo-level workflow in this repo; a non-zero value is the source repo of a scoped workflow. +func DispatchActionWorkflow(ctx reqctx.RequestContext, doer *user_model.User, repo *repo_model.Repository, gitRepo *git.Repository, workflowID, ref string, scopedWorkflowSourceRepoID int64, processInputs func(model *model.WorkflowDispatch, inputs map[string]any) error) (runID int64, _ error) { if workflowID == "" { return 0, util.ErrorWrapTranslatable( util.NewNotExistErrorf("workflowID is empty"), @@ -58,10 +60,20 @@ func DispatchActionWorkflow(ctx reqctx.RequestContext, doer *user_model.User, re ) } - // can not rerun job when workflow is disabled + isScoped := scopedWorkflowSourceRepoID > 0 + cfgUnit := repo.MustGetUnit(ctx, unit.TypeActions) cfg := cfgUnit.ActionsConfig() - if cfg.IsWorkflowDisabled(workflowID) { + var workflowDisabled bool + if isScoped { + var err error + if workflowDisabled, err = actions_model.IsScopedWorkflowOptedOut(ctx, cfg, repo.OwnerID, scopedWorkflowSourceRepoID, workflowID); err != nil { + return 0, err + } + } else { + workflowDisabled = cfg.IsWorkflowDisabled(workflowID) + } + if workflowDisabled { return 0, util.ErrorWrapTranslatable( util.NewPermissionDeniedErrorf("workflow is disabled"), "actions.workflow.disabled", @@ -87,15 +99,6 @@ func DispatchActionWorkflow(ctx reqctx.RequestContext, doer *user_model.User, re ) } - // get workflow entry from runTargetCommit - _, entries, err := actions.ListWorkflows(runTargetCommit) - if err != nil { - return 0, err - } - - // find workflow from commit - var entry *git.TreeEntry - run := &actions_model.ActionRun{ Title: runTargetCommit.MessageTitle(), RepoID: repo.ID, @@ -110,24 +113,13 @@ func DispatchActionWorkflow(ctx reqctx.RequestContext, doer *user_model.User, re Event: "workflow_dispatch", TriggerEvent: "workflow_dispatch", Status: actions_model.StatusWaiting, + // local dispatch: own repo at the target commit; the scoped path overrides these below + WorkflowRepoID: repo.ID, + WorkflowCommitSHA: runTargetCommit.ID.String(), } - for _, e := range entries { - if e.Name() != workflowID { - continue - } - entry = e - break - } - - if entry == nil { - return 0, util.ErrorWrapTranslatable( - util.NewNotExistErrorf("workflow %q doesn't exist", workflowID), - "actions.workflow.not_found", workflowID, - ) - } - - content, err := actions.GetContentFromEntry(entry) + // resolve the workflow content and record its source on the run (scoped runs read from the source repo) + content, err := resolveDispatchWorkflowContent(ctx, repo, runTargetCommit, workflowID, scopedWorkflowSourceRepoID, isScoped, run) if err != nil { return 0, err } @@ -176,3 +168,62 @@ func DispatchActionWorkflow(ctx reqctx.RequestContext, doer *user_model.User, re } return run.ID, nil } + +// resolveDispatchWorkflowContent returns the YAML for a dispatched workflow and records its source on the run. +// - Repo-level: from the consumer's runTargetCommit. +// - Scoped: from the source repo's default branch. +func resolveDispatchWorkflowContent(ctx reqctx.RequestContext, repo *repo_model.Repository, runTargetCommit *git.Commit, workflowID string, sourceRepoID int64, isScoped bool, run *actions_model.ActionRun) ([]byte, error) { + if isScoped { + return resolveScopedDispatchContent(ctx, repo, sourceRepoID, workflowID, run) + } + + _, entries, err := actions.ListWorkflows(runTargetCommit) + if err != nil { + return nil, err + } + for _, e := range entries { + if e.Name() == workflowID { + return actions.GetContentFromEntry(e) + } + } + return nil, util.ErrorWrapTranslatable( + util.NewNotExistErrorf("workflow %q doesn't exist", workflowID), + "actions.workflow.not_found", workflowID, + ) +} + +func resolveScopedDispatchContent(ctx reqctx.RequestContext, repo *repo_model.Repository, sourceRepoID int64, workflowID string, run *actions_model.ActionRun) ([]byte, error) { + // the source must be an effective scoped source for this consumer repo + effective, err := actions_model.IsScopedWorkflowSourceEffective(ctx, repo.OwnerID, sourceRepoID) + if err != nil { + return nil, err + } + if !effective { + return nil, util.ErrorWrapTranslatable( + util.NewNotExistErrorf("scoped workflow source %d is not effective for this repository", sourceRepoID), + "actions.workflow.not_found", workflowID, + ) + } + + sourceRepo, err := repo_model.GetRepositoryByID(ctx, sourceRepoID) + if err != nil { + return nil, err + } + + sha, parsed, err := LoadParsedScopedWorkflows(ctx, sourceRepo) + if err != nil { + return nil, err + } + for _, p := range parsed { + if p.EntryName == workflowID { + run.WorkflowRepoID = sourceRepo.ID + run.WorkflowCommitSHA = sha + run.IsScopedRun = true + return p.Content, nil + } + } + return nil, util.ErrorWrapTranslatable( + util.NewNotExistErrorf("scoped workflow %q doesn't exist", workflowID), + "actions.workflow.not_found", workflowID, + ) +} diff --git a/services/convert/convert.go b/services/convert/convert.go index 357217908fb..745cdd98978 100644 --- a/services/convert/convert.go +++ b/services/convert/convert.go @@ -7,6 +7,7 @@ package convert import ( "bytes" "context" + "errors" "fmt" "net/url" "path" @@ -29,6 +30,7 @@ import ( "gitea.dev/modules/actions" "gitea.dev/modules/container" "gitea.dev/modules/git" + "gitea.dev/modules/gitrepo" "gitea.dev/modules/httplib" "gitea.dev/modules/log" "gitea.dev/modules/setting" @@ -641,6 +643,64 @@ func getActionWorkflowFromCommit(ctx context.Context, repo *repo_model.Repositor return nil, util.NewNotExistErrorf("workflow %q not found", workflowID) } +// GetScopedActionWorkflow resolves a scoped workflow definition (under SCOPED_WORKFLOW_DIRS) from the source repo at commitSHA. +func GetScopedActionWorkflow(ctx context.Context, sourceGitRepo *git.Repository, sourceRepo *repo_model.Repository, workflowID, commitSHA string) (*api.ActionWorkflow, error) { + commit, err := sourceGitRepo.GetCommit(commitSHA) + if err != nil { + return nil, err + } + + folder, entries, err := actions.ListScopedWorkflows(commit) + if err != nil { + return nil, err + } + + for _, entry := range entries { + if entry.Name() == workflowID { + // An empty ref pins HTMLURL to commit (the run's WorkflowCommitSHA) rather than the moving default branch. + wf := getActionWorkflowEntry(ctx, sourceRepo, commit, git.RefName(""), folder, entry) + // TODO: a scoped workflow has no repo-level representation on the source: the workflow API scans WORKFLOW_DIRS (not SCOPED_WORKFLOW_DIRS), + // and the badge only reflects the source's repo-level runs, so neither link resolves a scoped workflow. + // Blank them for now and populate once a scoped-aware workflow/badge endpoint exists. + wf.URL = "" + wf.BadgeURL = "" + return wf, nil + } + } + + return nil, util.NewNotExistErrorf("scoped workflow %q not found", workflowID) +} + +// ResolveActionWorkflowForRun returns the api.ActionWorkflow describing a run's workflow definition. +// For a scoped run the definition lives in the source repo (run.WorkflowRepoID @ run.WorkflowCommitSHA) under SCOPED_WORKFLOW_DIRS, +// not in the consuming repo, so it is resolved against the source repo. +func ResolveActionWorkflowForRun(ctx context.Context, repo *repo_model.Repository, run *actions_model.ActionRun) (*api.ActionWorkflow, error) { + if run.IsScopedRun { + sourceRepo, err := repo_model.GetRepositoryByID(ctx, run.WorkflowRepoID) + if err != nil { + return nil, err + } + sourceGitRepo, err := gitrepo.OpenRepository(ctx, sourceRepo) + if err != nil { + return nil, err + } + defer sourceGitRepo.Close() + return GetScopedActionWorkflow(ctx, sourceGitRepo, sourceRepo, run.WorkflowID, run.WorkflowCommitSHA) + } + + gitRepo, err := gitrepo.OpenRepository(ctx, repo) + if err != nil { + return nil, err + } + defer gitRepo.Close() + + convertedWorkflow, err := GetActionWorkflowByRef(ctx, gitRepo, repo, run.WorkflowID, git.RefName(run.Ref)) + if err != nil && errors.Is(err, util.ErrNotExist) { + convertedWorkflow, err = GetActionWorkflow(ctx, gitRepo, repo, run.WorkflowID) + } + return convertedWorkflow, err +} + // ToActionArtifact convert a actions_model.ActionArtifact to an api.ActionArtifact func ToActionArtifact(repo *repo_model.Repository, art *actions_model.ActionArtifact) (*api.ActionArtifact, error) { url := fmt.Sprintf("%s/actions/artifacts/%d", repo.APIURL(), art.ID) diff --git a/services/org/org.go b/services/org/org.go index 2f2ee950311..e2596ec26c9 100644 --- a/services/org/org.go +++ b/services/org/org.go @@ -39,6 +39,7 @@ func deleteOrganization(ctx context.Context, org *org_model.Organization) error &user_model.Blocking{BlockerID: org.ID}, &actions_model.ActionRunner{OwnerID: org.ID}, &actions_model.ActionRunnerToken{OwnerID: org.ID}, + &actions_model.ActionScopedWorkflowSource{OwnerID: org.ID}, ); err != nil { return fmt.Errorf("DeleteBeans: %w", err) } diff --git a/services/pull/commit_status.go b/services/pull/commit_status.go index 6fafcf3b511..948e26aaa54 100644 --- a/services/pull/commit_status.go +++ b/services/pull/commit_status.go @@ -8,11 +8,15 @@ import ( "context" "errors" "fmt" + "slices" + actions_model "gitea.dev/models/actions" "gitea.dev/models/db" git_model "gitea.dev/models/git" issues_model "gitea.dev/models/issues" + repo_model "gitea.dev/models/repo" "gitea.dev/modules/commitstatus" + "gitea.dev/modules/container" "gitea.dev/modules/gitrepo" "gitea.dev/modules/glob" "gitea.dev/modules/log" @@ -69,11 +73,25 @@ func MergeRequiredContextsCommitStatus(commitStatuses []*git_model.CommitStatus, func IsPullCommitStatusPass(ctx context.Context, pr *issues_model.PullRequest) (bool, error) { pb, err := git_model.GetFirstMatchProtectedBranchRule(ctx, pr.BaseRepoID, pr.BaseBranch) if err != nil { - return false, fmt.Errorf("GetLatestCommitStatus: %w", err) + return false, fmt.Errorf("GetFirstMatchProtectedBranchRule: %w", err) } - if pb == nil || !pb.EnableStatusCheck { + if pb == nil { return true, nil } + if !pb.EnableStatusCheck { + // The branch's own status check is off, but required scoped checks (mandated by the owner or instance admin) still gate the merge. + if err := pr.LoadBaseRepo(ctx); err != nil { + return false, err + } + required, err := EffectiveRequiredContexts(ctx, pr.BaseRepo, pb) + if err != nil { + return false, err + } + if len(required) == 0 { + // With none in effect there is nothing to enforce, so don't block + return true, nil + } + } state, err := GetPullRequestCommitStatusState(ctx, pr) if err != nil { @@ -130,10 +148,57 @@ func GetPullRequestCommitStatusState(ctx context.Context, pr *issues_model.PullR if err != nil { return "", fmt.Errorf("LoadProtectedBranch: %w", err) } - var requiredContexts []string - if pb != nil { - requiredContexts = pb.StatusCheckContexts + requiredContexts, err := EffectiveRequiredContexts(ctx, pr.BaseRepo, pb) + if err != nil { + return "", err } return MergeRequiredContextsCommitStatus(commitStatuses, requiredContexts), nil } + +// EffectiveRequiredContexts returns the required status-check contexts for a PR head: +// 1. every required scoped workflow's status-check patterns effective for the repo (always) +// 2. the branch protection's own configured contexts, only when its status check is enabled +func EffectiveRequiredContexts(ctx context.Context, repo *repo_model.Repository, pb *git_model.ProtectedBranch) ([]string, error) { + if pb == nil { + return nil, nil + } + + sources, err := actions_model.GetEffectiveScopedWorkflowSources(ctx, repo.OwnerID) + if err != nil { + return nil, fmt.Errorf("GetEffectiveScopedWorkflowSources: %w", err) + } + + // Every required scoped workflow's admin-authored status-check patterns, matched must-present-and-pass: + // a required scoped check that posts no matching status blocks the merge. + seen := make(container.Set[string]) + var scoped []string + for _, source := range sources { + for _, cfg := range source.WorkflowConfigs { + if !cfg.Required { + continue + } + for _, p := range cfg.Patterns { + if seen.Add(p) { + scoped = append(scoped, p) + } + } + } + } + + slices.Sort(scoped) // sort for stable output + + // With the branch protection's own status check disabled, only the required scoped checks (mandated by the owner or instance admin) gate the merge. + if !pb.EnableStatusCheck { + return scoped, nil + } + + // Status check enabled: the rule's configured contexts, then the scoped patterns not already among them. + required := slices.Clone(pb.StatusCheckContexts) + for _, p := range scoped { + if !slices.Contains(pb.StatusCheckContexts, p) { + required = append(required, p) + } + } + return required, nil +} diff --git a/services/pull/commit_status_test.go b/services/pull/commit_status_test.go index 612ae268cbe..2fda2f256a6 100644 --- a/services/pull/commit_status_test.go +++ b/services/pull/commit_status_test.go @@ -7,10 +7,15 @@ package pull import ( "testing" + actions_model "gitea.dev/models/actions" + "gitea.dev/models/db" git_model "gitea.dev/models/git" + repo_model "gitea.dev/models/repo" + "gitea.dev/models/unittest" "gitea.dev/modules/commitstatus" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestMergeRequiredContextsCommitStatus(t *testing.T) { @@ -90,3 +95,62 @@ func TestMergeRequiredContextsCommitStatus(t *testing.T) { assert.Equal(t, c.expected, MergeRequiredContextsCommitStatus(c.commitStatuses, c.requiredContexts), "case %d", i) } } + +// TestEffectiveRequiredContexts: every required scoped workflow's stored status-check patterns are appended to the +// branch protection's configured contexts unconditionally (must-present; the matching is done downstream). +func TestEffectiveRequiredContexts(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + consumer := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 4}) // owned by user5 + pbOn := &git_model.ProtectedBranch{EnableStatusCheck: true, StatusCheckContexts: []string{"configured/check"}} + + t.Run("nil protected branch: nil", func(t *testing.T) { + got, err := EffectiveRequiredContexts(t.Context(), consumer, nil) + require.NoError(t, err) + assert.Nil(t, got) + }) + + t.Run("status checks disabled, no required scoped: nothing required", func(t *testing.T) { + pbOff := &git_model.ProtectedBranch{EnableStatusCheck: false, StatusCheckContexts: []string{"configured/check"}} + got, err := EffectiveRequiredContexts(t.Context(), consumer, pbOff) + require.NoError(t, err) + assert.Empty(t, got) // the rule's own status check is off and no required scoped workflow applies -> nothing gates + }) + + t.Run("owner with no scoped sources: configured contexts unchanged", func(t *testing.T) { + noSourceRepo := &repo_model.Repository{ID: consumer.ID, OwnerID: 99999} + got, err := EffectiveRequiredContexts(t.Context(), noSourceRepo, pbOn) + require.NoError(t, err) + assert.Equal(t, []string{"configured/check"}, got) + }) + + t.Run("required workflow patterns appended", func(t *testing.T) { + require.NoError(t, db.Insert(t.Context(), &actions_model.ActionScopedWorkflowSource{ + OwnerID: consumer.OwnerID, + SourceRepoID: 1, + WorkflowConfigs: map[string]*actions_model.ScopedWorkflowConfig{ + "ci.yaml": {Required: true, Patterns: []string{"org/src: ci.yaml / build (pull_request)", "org/src: ci.yaml / lint (pull_request)"}}, + "old.yaml": {Required: false, Patterns: []string{"org/src: old.yaml / *"}}, // kept as history, must NOT be enforced + }, + })) + // No status is passed/needed: required patterns are enforced even though nothing has posted them yet (must-present). + got, err := EffectiveRequiredContexts(t.Context(), consumer, pbOn) + require.NoError(t, err) + assert.ElementsMatch(t, []string{ + "configured/check", + "org/src: ci.yaml / build (pull_request)", + "org/src: ci.yaml / lint (pull_request)", + }, got) + assert.NotContains(t, got, "org/src: old.yaml / *", "a non-required (history) config must not be enforced") + }) + + t.Run("status checks disabled, with required scoped: only the scoped patterns gate", func(t *testing.T) { + pbOff := &git_model.ProtectedBranch{EnableStatusCheck: false, StatusCheckContexts: []string{"configured/check"}} + got, err := EffectiveRequiredContexts(t.Context(), consumer, pbOff) + require.NoError(t, err) + // "configured/check" is dropped (the rule's own status check is off); only the required scoped patterns remain. + assert.ElementsMatch(t, []string{ + "org/src: ci.yaml / build (pull_request)", + "org/src: ci.yaml / lint (pull_request)", + }, got) + }) +} diff --git a/services/repository/delete.go b/services/repository/delete.go index 0666a1616b5..db843076102 100644 --- a/services/repository/delete.go +++ b/services/repository/delete.go @@ -179,6 +179,7 @@ func DeleteRepositoryDirectly(ctx context.Context, repoID int64, ignoreOrgTeams &actions_model.ActionArtifact{RepoID: repoID}, &actions_model.ActionRunJobSummary{RepoID: repoID}, &actions_model.ActionRunnerToken{RepoID: repoID}, + &actions_model.ActionScopedWorkflowSource{SourceRepoID: repoID}, &issues_model.IssuePin{RepoID: repoID}, ); err != nil { return fmt.Errorf("deleteBeans: %w", err) diff --git a/services/user/delete.go b/services/user/delete.go index 8679afaf124..5592ac7c4b1 100644 --- a/services/user/delete.go +++ b/services/user/delete.go @@ -95,6 +95,7 @@ func deleteUser(ctx context.Context, u *user_model.User, purge bool) (err error) &user_model.Blocking{BlockerID: u.ID}, &user_model.Blocking{BlockeeID: u.ID}, &actions_model.ActionRunnerToken{OwnerID: u.ID}, + &actions_model.ActionScopedWorkflowSource{OwnerID: u.ID}, ); err != nil { return fmt.Errorf("deleteBeans: %w", err) } diff --git a/services/webhook/notifier.go b/services/webhook/notifier.go index b9aaf52f85e..2586d09798e 100644 --- a/services/webhook/notifier.go +++ b/services/webhook/notifier.go @@ -17,7 +17,6 @@ import ( repo_model "gitea.dev/models/repo" user_model "gitea.dev/models/user" "gitea.dev/modules/git" - "gitea.dev/modules/gitrepo" "gitea.dev/modules/httplib" "gitea.dev/modules/log" "gitea.dev/modules/repository" @@ -1029,19 +1028,14 @@ func (*webhookNotifier) WorkflowRunStatusUpdate(ctx context.Context, repo *repo_ status := convert.ToWorkflowRunAction(run.Status) - gitRepo, err := gitrepo.OpenRepository(ctx, repo) + // Resolve the workflow definition from its source repo. + convertedWorkflow, err := convert.ResolveActionWorkflowForRun(ctx, repo, run) if err != nil { - log.Error("OpenRepository: %v", err) - return - } - defer gitRepo.Close() - - convertedWorkflow, err := convert.GetActionWorkflowByRef(ctx, gitRepo, repo, run.WorkflowID, git.RefName(run.Ref)) - if err != nil && errors.Is(err, util.ErrNotExist) { - convertedWorkflow, err = convert.GetActionWorkflow(ctx, gitRepo, repo, run.WorkflowID) - } - if err != nil { - log.Error("GetActionWorkflow: %v", err) + if errors.Is(err, util.ErrNotExist) { + log.Debug("WorkflowRunStatusUpdate: workflow %q for run %d not found: %v", run.WorkflowID, run.ID, err) + return + } + log.Error("ResolveActionWorkflowForRun: %v", err) return } diff --git a/templates/admin/actions.tmpl b/templates/admin/actions.tmpl index 1bddb674c03..6be9bdf73b0 100644 --- a/templates/admin/actions.tmpl +++ b/templates/admin/actions.tmpl @@ -6,5 +6,8 @@ {{if eq .PageType "variables"}} {{template "shared/variables/variable_list" .}} {{end}} + {{if eq .PageType "scoped-workflows"}} + {{template "shared/actions/scoped_workflows" .}} + {{end}} {{template "admin/layout_footer" .}} diff --git a/templates/admin/navbar.tmpl b/templates/admin/navbar.tmpl index b6c9f155e9c..a54fa530e24 100644 --- a/templates/admin/navbar.tmpl +++ b/templates/admin/navbar.tmpl @@ -72,7 +72,7 @@ {{end}} {{end}} {{if .EnableActions}} -
+
{{ctx.Locale.Tr "actions.actions"}}
{{end}} diff --git a/templates/org/settings/actions.tmpl b/templates/org/settings/actions.tmpl index 1abf895627b..87ea0cd65e3 100644 --- a/templates/org/settings/actions.tmpl +++ b/templates/org/settings/actions.tmpl @@ -6,6 +6,8 @@ {{template "shared/secrets/add_list" .}} {{else if eq .PageType "variables"}} {{template "shared/variables/variable_list" .}} + {{else if eq .PageType "scoped-workflows"}} + {{template "shared/actions/scoped_workflows" .}} {{end}} {{template "org/settings/layout_footer" .}} diff --git a/templates/org/settings/navbar.tmpl b/templates/org/settings/navbar.tmpl index b14b4248fc5..5239a203268 100644 --- a/templates/org/settings/navbar.tmpl +++ b/templates/org/settings/navbar.tmpl @@ -26,7 +26,7 @@ {{end}} {{if .EnableActions}} -
+
{{ctx.Locale.Tr "actions.actions"}}
{{end}} diff --git a/templates/repo/actions/list.tmpl b/templates/repo/actions/list.tmpl index 2aac08d61b1..eb87abe747e 100644 --- a/templates/repo/actions/list.tmpl +++ b/templates/repo/actions/list.tmpl @@ -10,8 +10,8 @@ - + {{ctx.Locale.Tr "actions.runs.actors_no_select"}} {{range .Actors}} - + {{ctx.AvatarUtils.Avatar . 20}} {{.GetDisplayName}} {{end}} @@ -73,11 +91,11 @@ {{svg "octicon-search"}} - + {{ctx.Locale.Tr "actions.runs.status_no_select"}} {{range .StatusInfoList}} - + {{template "repo/icons/action_status" (dict "Status" .StatusName)}} {{.DisplayedStatus}} @@ -95,11 +113,11 @@ {{svg "octicon-search"}} - + {{ctx.Locale.Tr "actions.runs.branches_no_select"}} {{range .RunBranches}} - + {{.}} {{end}} @@ -116,8 +134,8 @@ {{end}} {{if and .AllowDisableOrEnableWorkflow .CurWorkflowIsListed $.CurWorkflow}} - - {{if .CurWorkflowDisabled}}{{ctx.Locale.Tr "actions.workflow.enable"}}{{else}}{{ctx.Locale.Tr "actions.workflow.disable"}}{{end}} + + {{if .CurWorkflowRequired}}{{ctx.Locale.Tr "actions.workflow.disable"}}{{else if .CurWorkflowDisabled}}{{ctx.Locale.Tr "actions.workflow.enable"}}{{else}}{{ctx.Locale.Tr "actions.workflow.disable"}}{{end}} {{end}} diff --git a/templates/repo/actions/view_component.tmpl b/templates/repo/actions/view_component.tmpl index 0d508e69e55..fbaf31838d3 100644 --- a/templates/repo/actions/view_component.tmpl +++ b/templates/repo/actions/view_component.tmpl @@ -26,6 +26,7 @@ data-locale-total-duration="{{ctx.Locale.Tr "actions.runs.total_duration"}}" data-locale-run-details="{{ctx.Locale.Tr "actions.runs.run_details"}}" data-locale-workflow-file="{{ctx.Locale.Tr "actions.runs.workflow_file"}}" + data-locale-workflow-file-no-permission="{{ctx.Locale.Tr "actions.runs.workflow_file_no_permission"}}" data-locale-status-unknown="{{ctx.Locale.Tr "actions.status.unknown"}}" data-locale-status-waiting="{{ctx.Locale.Tr "actions.status.waiting"}}" data-locale-status-running="{{ctx.Locale.Tr "actions.status.running"}}" diff --git a/templates/repo/actions/workflow_dispatch.tmpl b/templates/repo/actions/workflow_dispatch.tmpl index ad9b0e94ccc..92e6b755031 100644 --- a/templates/repo/actions/workflow_dispatch.tmpl +++ b/templates/repo/actions/workflow_dispatch.tmpl @@ -7,7 +7,7 @@