Skip to content

Commit 70a0572

Browse files
committed
support scoped workflows
1 parent 2003cf4 commit 70a0572

60 files changed

Lines changed: 2568 additions & 177 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

custom/conf/app.example.ini

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3002,6 +3002,10 @@ LEVEL = Info
30023002
;; Comma-separated list of workflow directories, the first one to exist
30033003
;; in a repo is used to find Actions workflow files
30043004
;WORKFLOW_DIRS = .gitea/workflows,.github/workflows
3005+
;; Comma-separated list of scoped workflow directories in a source repository, the first one to exist is used.
3006+
;; 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.
3007+
;; Must not overlap with WORKFLOW_DIRS. Leave empty to disable.
3008+
;SCOPED_WORKFLOW_DIRS = .gitea/scoped_workflows
30053009
;; Maximum number of attempts a single workflow run can have. Default value is 50.
30063010
;MAX_RERUN_ATTEMPTS = 50
30073011

models/actions/run.go

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ type ActionRun struct {
3030
RepoID int64 `xorm:"unique(repo_index)"`
3131
Repo *repo_model.Repository `xorm:"-"`
3232
OwnerID int64 `xorm:"index"`
33-
WorkflowID string `xorm:"index"` // the name of workflow file
33+
WorkflowID string `xorm:"index"` // the workflow file's entry name, e.g. ci.yaml (this run's workflow identity)
3434
Index int64 `xorm:"index unique(repo_index)"` // a unique number for each run of a repository
3535
TriggerUserID int64 `xorm:"index"`
3636
TriggerUser *user_model.User `xorm:"-"`
@@ -48,6 +48,13 @@ type ActionRun struct {
4848
Version int `xorm:"version default 0"` // Status could be updated concomitantly, so an optimistic lock is needed
4949
RawConcurrency string // raw concurrency
5050

51+
// WorkflowRepoID/WorkflowCommitSHA record the (repo, commit) the run's workflow file content came from.
52+
// Always filled (repo-level run = the repo itself; scoped run = the source repo).
53+
WorkflowRepoID int64 `xorm:"NOT NULL DEFAULT 0"`
54+
WorkflowCommitSHA string `xorm:"VARCHAR(64) NOT NULL DEFAULT ''"`
55+
56+
IsScopedRun bool `xorm:"NOT NULL DEFAULT false"` // IsScopedRun explicitly classifies scoped runs.
57+
5158
// Started and Stopped are identical to the latest attempt after ActionRunAttempt was introduced.
5259
// When a rerun creates a new latest attempt, they are reset until the new attempt starts and stops.
5360
Started timeutil.TimeStamp
@@ -86,6 +93,10 @@ func (run *ActionRun) WorkflowLink() string {
8693
if run.Repo == nil {
8794
return ""
8895
}
96+
// A scoped run's workflow is disambiguated by its source repo, so carry workflow_repo_id back to the run list
97+
if run.IsScopedRun {
98+
return fmt.Sprintf("%s/actions/?workflow=%s&workflow_repo_id=%d", run.Repo.Link(), run.WorkflowID, run.WorkflowRepoID)
99+
}
89100
return fmt.Sprintf("%s/actions/?workflow=%s", run.Repo.Link(), run.WorkflowID)
90101
}
91102

models/actions/run_list.go

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
repo_model "gitea.dev/models/repo"
1111
user_model "gitea.dev/models/user"
1212
"gitea.dev/modules/container"
13+
"gitea.dev/modules/optional"
1314
"gitea.dev/modules/translation"
1415
webhook_module "gitea.dev/modules/webhook"
1516

@@ -61,7 +62,9 @@ type FindRunOptions struct {
6162
RepoID int64
6263
OwnerID int64
6364
WorkflowID string
64-
Ref string // the commit/tag/… that caused this workflow
65+
WorkflowRepoID int64 // source-aware filter: the repo a run's workflow content came from (0 = any)
66+
IsScopedRun optional.Option[bool] // is the run from a scoped workflow
67+
Ref string // the commit/tag/… that caused this workflow
6568
TriggerUserID int64
6669
TriggerEvent webhook_module.HookEventType
6770
Status []Status
@@ -77,6 +80,12 @@ func (opts FindRunOptions) ToConds() builder.Cond {
7780
if opts.WorkflowID != "" {
7881
cond = cond.And(builder.Eq{"`action_run`.workflow_id": opts.WorkflowID})
7982
}
83+
if opts.WorkflowRepoID > 0 {
84+
cond = cond.And(builder.Eq{"`action_run`.workflow_repo_id": opts.WorkflowRepoID})
85+
}
86+
if opts.IsScopedRun.Has() {
87+
cond = cond.And(builder.Eq{"`action_run`.is_scoped_run": opts.IsScopedRun.Value()})
88+
}
8089
if opts.TriggerUserID > 0 {
8190
cond = cond.And(builder.Eq{"`action_run`.trigger_user_id": opts.TriggerUserID})
8291
}

models/actions/run_list_test.go

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,12 @@ package actions
66
import (
77
"testing"
88

9+
"gitea.dev/models/db"
910
"gitea.dev/models/unittest"
1011
"gitea.dev/modules/translation"
1112

1213
"github.com/stretchr/testify/assert"
14+
"github.com/stretchr/testify/require"
1315
)
1416

1517
func TestGetRunWorkflowIDs(t *testing.T) {
@@ -35,3 +37,48 @@ func TestGetStatusInfoList(t *testing.T) {
3537
{Status: int(StatusCancelling), StatusName: StatusCancelling.String(), DisplayedStatus: "actions.status.cancelling"},
3638
}, statusInfoList)
3739
}
40+
41+
// TestFindRunOptions_WorkflowRepoID: two runs share the bare WorkflowID but come from different content-source repos;
42+
// the source-aware WorkflowRepoID filter must separate them.
43+
func TestFindRunOptions_WorkflowRepoID(t *testing.T) {
44+
assert.NoError(t, unittest.PrepareTestDatabase())
45+
46+
const (
47+
repoID = int64(4)
48+
sourceA = int64(111)
49+
sourceB = int64(222)
50+
workflowID = "u3-shared.yaml"
51+
)
52+
for _, spec := range []struct{ id, workflowRepoID int64 }{
53+
{99801, sourceA},
54+
{99802, sourceB},
55+
} {
56+
require.NoError(t, db.Insert(t.Context(), &ActionRun{
57+
ID: spec.id,
58+
Index: spec.id,
59+
RepoID: repoID,
60+
OwnerID: 1,
61+
TriggerUserID: 1,
62+
WorkflowID: workflowID,
63+
WorkflowRepoID: spec.workflowRepoID,
64+
IsScopedRun: true,
65+
}))
66+
}
67+
68+
// no source filter -> both
69+
all, err := db.Find[ActionRun](t.Context(), FindRunOptions{RepoID: repoID, WorkflowID: workflowID})
70+
require.NoError(t, err)
71+
assert.Len(t, all, 2)
72+
73+
// filter by source A -> only the run whose content came from A
74+
onlyA, err := db.Find[ActionRun](t.Context(), FindRunOptions{RepoID: repoID, WorkflowID: workflowID, WorkflowRepoID: sourceA})
75+
require.NoError(t, err)
76+
require.Len(t, onlyA, 1)
77+
assert.EqualValues(t, 99801, onlyA[0].ID)
78+
79+
// filter by source B -> only the run whose content came from B
80+
onlyB, err := db.Find[ActionRun](t.Context(), FindRunOptions{RepoID: repoID, WorkflowID: workflowID, WorkflowRepoID: sourceB})
81+
require.NoError(t, err)
82+
require.Len(t, onlyB, 1)
83+
assert.EqualValues(t, 99802, onlyB[0].ID)
84+
}

models/actions/run_test.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,3 +44,15 @@ func TestActionRun_Duration_NonNegative(t *testing.T) {
4444
}
4545
assert.Equal(t, time.Duration(0), run.Duration())
4646
}
47+
48+
func TestActionRun_WorkflowLink(t *testing.T) {
49+
repo := &repo_model.Repository{OwnerName: "org", Name: "consumer"}
50+
51+
// a repo-level run links by file name only
52+
repoLevel := &ActionRun{Repo: repo, WorkflowID: "ci.yaml", WorkflowRepoID: repo.ID}
53+
assert.Equal(t, repo.Link()+"/actions/?workflow=ci.yaml", repoLevel.WorkflowLink())
54+
55+
// a scoped run carries its source repo id back, so the list stays filtered to that source
56+
scoped := &ActionRun{Repo: repo, WorkflowID: "ci.yaml", WorkflowRepoID: 42, IsScopedRun: true}
57+
assert.Equal(t, repo.Link()+"/actions/?workflow=ci.yaml&workflow_repo_id=42", scoped.WorkflowLink())
58+
}

models/actions/scoped_workflow.go

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
// Copyright 2026 The Gitea Authors. All rights reserved.
2+
// SPDX-License-Identifier: MIT
3+
4+
package actions
5+
6+
import (
7+
"context"
8+
"fmt"
9+
"slices"
10+
11+
"gitea.dev/models/db"
12+
repo_model "gitea.dev/models/repo"
13+
"gitea.dev/modules/timeutil"
14+
"gitea.dev/modules/util"
15+
16+
"xorm.io/builder"
17+
)
18+
19+
// ActionScopedWorkflowSource registers a repository as a source of scoped workflows, either for an owner (user/org) or for the whole instance.
20+
type ActionScopedWorkflowSource struct {
21+
ID int64 `xorm:"pk autoincr"`
22+
23+
// 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).
24+
OwnerID int64 `xorm:"UNIQUE(owner_repo) NOT NULL DEFAULT 0"`
25+
// SourceRepoID is the source repository providing the workflow files; always non-zero.
26+
SourceRepoID int64 `xorm:"INDEX UNIQUE(owner_repo) NOT NULL DEFAULT 0"`
27+
28+
// RequiredWorkflowIDs are the workflow IDs marked as required.
29+
RequiredWorkflowIDs []string `xorm:"JSON TEXT 'required_workflow_ids'"`
30+
31+
CreatedUnix timeutil.TimeStamp `xorm:"created"`
32+
UpdatedUnix timeutil.TimeStamp `xorm:"updated"`
33+
}
34+
35+
func init() {
36+
db.RegisterModel(new(ActionScopedWorkflowSource))
37+
}
38+
39+
// IsWorkflowRequired reports whether the given workflow ID (entry name) is marked required in this source.
40+
func (s *ActionScopedWorkflowSource) IsWorkflowRequired(workflowID string) bool {
41+
return slices.Contains(s.RequiredWorkflowIDs, workflowID)
42+
}
43+
44+
type FindScopedWorkflowSourceOpts struct {
45+
db.ListOptions
46+
OwnerIDs []int64
47+
SourceRepoID int64
48+
}
49+
50+
func (opts FindScopedWorkflowSourceOpts) ToConds() builder.Cond {
51+
cond := builder.NewCond()
52+
if len(opts.OwnerIDs) > 0 {
53+
cond = cond.And(builder.In("owner_id", opts.OwnerIDs))
54+
}
55+
if opts.SourceRepoID != 0 {
56+
cond = cond.And(builder.Eq{"source_repo_id": opts.SourceRepoID})
57+
}
58+
return cond
59+
}
60+
61+
// GetEffectiveScopedWorkflowSources returns the scoped-workflow sources effective for a repo owned by repoOwnerID:
62+
// the owner's own sources plus instance-level (owner_id=0) sources.
63+
func GetEffectiveScopedWorkflowSources(ctx context.Context, repoOwnerID int64) ([]*ActionScopedWorkflowSource, error) {
64+
owners := []int64{0}
65+
if repoOwnerID != 0 {
66+
owners = append(owners, repoOwnerID)
67+
}
68+
return db.Find[ActionScopedWorkflowSource](ctx, FindScopedWorkflowSourceOpts{OwnerIDs: owners})
69+
}
70+
71+
// IsScopedWorkflowSourceEffective reports whether sourceRepoID is a scoped-workflow source effective for a repo owned by repoOwnerID.
72+
func IsScopedWorkflowSourceEffective(ctx context.Context, repoOwnerID, sourceRepoID int64) (bool, error) {
73+
owners := []int64{0}
74+
if repoOwnerID != 0 {
75+
owners = append(owners, repoOwnerID)
76+
}
77+
return db.Exist[ActionScopedWorkflowSource](ctx, FindScopedWorkflowSourceOpts{OwnerIDs: owners, SourceRepoID: sourceRepoID}.ToConds())
78+
}
79+
80+
// IsWorkflowRequiredInSources reports whether workflowID from sourceRepoID is required by any of the given sources.
81+
func IsWorkflowRequiredInSources(sources []*ActionScopedWorkflowSource, sourceRepoID int64, workflowID string) bool {
82+
for _, s := range sources {
83+
if s.SourceRepoID == sourceRepoID && s.IsWorkflowRequired(workflowID) {
84+
return true
85+
}
86+
}
87+
return false
88+
}
89+
90+
// ScopedStatusContextPrefix returns the source-repo prefix that makes a scoped run's commit-status context distinct from same-named workflows.
91+
func ScopedStatusContextPrefix(ctx context.Context, sourceRepoID int64) string {
92+
if sourceRepo, err := repo_model.GetRepositoryByID(ctx, sourceRepoID); err == nil {
93+
return sourceRepo.FullName()
94+
}
95+
return fmt.Sprintf("scoped:%d", sourceRepoID)
96+
}
97+
98+
// IsScopedWorkflowRequired reports whether workflowID from sourceRepoID is required for a repo owned by consumerOwnerID.
99+
func IsScopedWorkflowRequired(ctx context.Context, consumerOwnerID, sourceRepoID int64, workflowID string) (bool, error) {
100+
sources, err := GetEffectiveScopedWorkflowSources(ctx, consumerOwnerID)
101+
if err != nil {
102+
return false, err
103+
}
104+
return IsWorkflowRequiredInSources(sources, sourceRepoID, workflowID), nil
105+
}
106+
107+
// GetScopedWorkflowSourcesByOwner returns the sources an owner (user/org, or 0 for instance) registered.
108+
func GetScopedWorkflowSourcesByOwner(ctx context.Context, ownerID int64) ([]*ActionScopedWorkflowSource, error) {
109+
return db.Find[ActionScopedWorkflowSource](ctx, FindScopedWorkflowSourceOpts{OwnerIDs: []int64{ownerID}})
110+
}
111+
112+
// GetScopedWorkflowSource returns the (owner, repo) source registration or a NotExist error.
113+
func GetScopedWorkflowSource(ctx context.Context, ownerID, repoID int64) (*ActionScopedWorkflowSource, error) {
114+
src := &ActionScopedWorkflowSource{}
115+
has, err := db.GetEngine(ctx).Where("owner_id = ? AND source_repo_id = ?", ownerID, repoID).Get(src)
116+
if err != nil {
117+
return nil, err
118+
}
119+
if !has {
120+
return nil, util.NewNotExistErrorf("scoped workflow source (owner %d, repo %d) does not exist", ownerID, repoID)
121+
}
122+
return src, nil
123+
}
124+
125+
// AddScopedWorkflowSource registers repoID as a source for ownerID (no-op if already registered).
126+
func AddScopedWorkflowSource(ctx context.Context, ownerID, repoID int64) error {
127+
exists, err := db.GetEngine(ctx).Where("owner_id = ? AND source_repo_id = ?", ownerID, repoID).Exist(new(ActionScopedWorkflowSource))
128+
if err != nil {
129+
return err
130+
}
131+
if exists {
132+
return nil
133+
}
134+
return db.Insert(ctx, &ActionScopedWorkflowSource{OwnerID: ownerID, SourceRepoID: repoID})
135+
}
136+
137+
// SetScopedWorkflowSourceRequired replaces the required-workflow set (the workflows' entry names / run WorkflowIDs).
138+
func SetScopedWorkflowSourceRequired(ctx context.Context, ownerID, repoID int64, workflowIDs []string) error {
139+
_, err := db.GetEngine(ctx).Where("owner_id = ? AND source_repo_id = ?", ownerID, repoID).
140+
Cols("required_workflow_ids").
141+
Update(&ActionScopedWorkflowSource{RequiredWorkflowIDs: workflowIDs})
142+
return err
143+
}
144+
145+
// RemoveScopedWorkflowSource removes the (owner, repo) source registration.
146+
func RemoveScopedWorkflowSource(ctx context.Context, ownerID, repoID int64) error {
147+
_, err := db.GetEngine(ctx).Where("owner_id = ? AND source_repo_id = ?", ownerID, repoID).Delete(new(ActionScopedWorkflowSource))
148+
return err
149+
}

0 commit comments

Comments
 (0)