feat(actions): support owner-level and global scoped workflows (#38154)

## Summary

This PR adds **scoped workflows** to Gitea Actions. Workflows defined
centrally in a "source" repository that automatically run on every
repository in scope: an organization's repositories, or (for instance
admins) every repository on the instance. Each scoped run executes in
the consuming repository's own context (its runners, secrets, and
branch) while its content is read from the source repository, so an org
or instance can mandate shared CI across many repositories without
copying workflow files into each one.

An owner or instance admin registers source repositories on a settings
page and can mark individual workflows as **required**. A required
scoped workflow cannot be opted out by a consuming repository and gates
its pull-request merges; an optional one can be disabled per repository.
Scoped workflows live under a dedicated `SCOPED_WORKFLOW_DIRS` (default
`.gitea/scoped_workflows`), kept separate from regular `WORKFLOW_DIRS`.

## Main changes

### Configuration 
New `SCOPED_WORKFLOW_DIRS` setting, validated to not overlap with
`WORKFLOW_DIRS`. Default: `.gitea/scoped_workflows`

### Data model & migration
- New `action_scoped_workflow_source` table mapping a registering owner
(`owner_id`, where `0` = instance-level) to a source repository, with a
per-workflow `WorkflowConfigs` map.
- `ActionRun` gains `WorkflowRepoID` / `WorkflowCommitSHA` (the pinned
content source) and an `IsScopedRun` flag.

###  Detection & run creation
On consumer events, scoped workflows from the effective sources (the
owner's own sources plus instance-level ones) are matched and turned
into runs that execute in the consumer's context, with content pinned to
the source repo's default-branch commit.

`on: workflow_run` and `on: schedule` are currently not supported.

###  Opt-out
A consuming repository can disable an optional scoped workflow (tracked
separately from regular `DisabledWorkflows`); required scoped workflows
can never be disabled, opted out, or bypassed.

###  Commit status 
A scoped run's status context format is `"<source repo full name>:
<workflow display name> / <job> (<event>)"`
(for example: `my-org/scoped-workflows: db-tests / test-sqlite
(pull_request)`),
keeping it distinct from a same-named repo-level workflow and from other
sources.

###  Required status checks
Admins mark workflows required and supply status-check patterns.
`EffectiveRequiredContexts` appends those patterns to the branch
protection's required contexts and they are matched
must-present-and-pass. If the status checks from scoped workflows fail,
the PR cannot be merged.

NOTE: scoped workflows' required status checks patterns can protect any
target branch that has a protection rule, even though the rule's "Status
Check" is disabled. A target branch with no protection rule cannot be
protected.

<details>
  <summary>Screenshots</summary>

<img width="1400" alt="image"
src="https://github.com/user-attachments/assets/a5d1db33-15ec-487e-93be-2bc04b4e6643"
/>

</details>


###  Reusable workflows (`uses:`)
A scoped workflow's local `uses: ./...` resolves against the source
repository. `uses:` directory validation honors the
instance-configurable `WORKFLOW_DIRS` and `SCOPED_WORKFLOW_DIRS`
(previously hardcoded to `.gitea`/`.github/workflows`).

###  Manual dispatch
`workflow_dispatch` is supported for scoped workflows (web and API),
resolving inputs/content from the source repo.

###  Performance
A process-local LRU cache keyed by source repo ID for the per-source
workflow parse, so instance-level and owner-level sources don't open the
source repo and parse workflow files on every event.

### UI
Org / user / admin pages to register and remove sources, search
repositories, and mark workflows required with their status-check
patterns. The repository Actions sidebar groups scoped workflows by
source with owner/instance labels and required/disabled badges.

<details>
  <summary>Screenshots</summary>

Scoped workflows setting page:

<img width="1600" alt="image"
src="https://github.com/user-attachments/assets/9d19f667-97a5-4935-92b2-e53f105e3642"
/>


Consumer repo's Actions runs list:

<img width="1600" alt="image"
src="https://github.com/user-attachments/assets/a77241f9-0aa9-41aa-ba73-12a9a688cb64"
/>

- `Owner`: this is a owner-level scoped workflows source repo
- `Global`: this is a global scoped workflows source repo
- `Required`: this scoped workflow is required, repo admin cannot
disable it

</details>

---

Docs: https://gitea.com/gitea/docs/pulls/447

---------

Co-authored-by: bircni <bircni@icloud.com>
This commit is contained in:
Zettat123
2026-06-28 03:31:35 -06:00
committed by GitHub
parent c9920b7bd0
commit f46c9a9769
71 changed files with 3399 additions and 249 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -15,9 +15,11 @@ import (
type UsesKind int
const (
// UsesKindLocalSameRepo is "./.gitea/workflows/foo.yml" - a path inside the calling repository.
// UsesKindLocalSameRepo is "./<dir>/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/<dir>/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:
// - "./<dir>/foo.yml" (UsesKindLocalSameRepo, no @ref)
// - "OWNER/REPO/<dir>/foo.yml@REF" (UsesKindLocalCrossRepo)
//
// It deliberately does NOT validate that <dir> 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/<file>.yml)`, s)
return nil, fmt.Errorf(`invalid local "uses:" %q (expect ./<dir>/<file>.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/<file>.yml@ref)`, s)
return nil, fmt.Errorf(`invalid cross-repo "uses:" %q (expect owner/repo/<dir>/<file>.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
}

View File

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

View File

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

View File

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

View File

@@ -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: "<display> / <job> (<event>)".
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: "<prefix>: <display> / <job> (<event>)".
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,

View File

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

View File

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

View File

@@ -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 <code>%s</code> 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",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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() {

View File

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

View File

@@ -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:
// "<source FullName>: <display> / <job> (<event>)" 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[<id>];
// 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)
}

View File

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

View File

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

View File

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

View File

@@ -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 "<display name> / <job> (<event>)"; the scoped one is the same but sets off the source repo with a colon,
// so the two stay distinct (and have different hashes) despite the same `name:`.
repoStatuses := findCommitStatusesForContext(t, consumer.ID, branch.CommitID, "ci / build (push)")
require.Len(t, repoStatuses, 1)
scopedStatuses := findCommitStatusesForContext(t, consumer.ID, branch.CommitID, source.FullName()+": ci / build (push)")
require.Len(t, scopedStatuses, 1)
assert.NotEqual(t, repoStatuses[0].ContextHash, scopedStatuses[0].ContextHash,
"scoped status must not collide with the same-named repo-level workflow")
}
func findCommitStatusesForContext(t *testing.T, repoID int64, sha, context string) []*git_model.CommitStatus {
t.Helper()

View File

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

View File

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

View File

@@ -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/<target-branch>/<topic-branch> to the repo.
// So we can treat it as a fork pull request because it may be from an untrusted user
isForkPullRequest = true
default:
// unknown flow, assume it's a fork pull request to be safe
isForkPullRequest = true
}
}
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/<target-branch>/<topic-branch> to the repo.
// So we can treat it as a fork pull request because it may be from an untrusted user
return true
default:
// unknown flow, assume it's a fork pull request to be safe
return true
}
}
// detectAndHandleScopedWorkflows detects scoped workflows registered for the consuming repo
func detectAndHandleScopedWorkflows(
ctx context.Context,
input *notifyInput,
ref git.RefName,
consumerGitRepo *git.Repository,
consumerCommit *git.Commit,
) error {
// TODO: support workflow_run and schedule
if input.Event == webhook_module.HookEventWorkflowRun || input.Event == webhook_module.HookEventSchedule {
return nil
}
sources, err := actions_model.GetEffectiveScopedWorkflowSources(ctx, input.Repo.OwnerID)
if err != nil {
return fmt.Errorf("GetEffectiveScopedWorkflowSources: %w", err)
}
if len(sources) == 0 {
return nil
}
p, err := json.Marshal(input.Payload)
if err != nil {
return fmt.Errorf("json.Marshal: %w", err)
}
isForkPullRequest := isForkPullRequestInput(input)
actionsConfig := input.Repo.MustGetUnit(ctx, unit_model.TypeActions).ActionsConfig()
// 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
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,84 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package actions
import (
"context"
"fmt"
git_model "gitea.dev/models/git"
repo_model "gitea.dev/models/repo"
actions_module "gitea.dev/modules/actions"
"gitea.dev/modules/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
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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}}
</div>
{{template "admin/layout_footer" .}}

View File

@@ -72,7 +72,7 @@
{{end}}
{{end}}
{{if .EnableActions}}
<details class="item" {{if or .PageIsSharedSettingsRunners .PageIsSharedSettingsVariables}}open{{end}}>
<details class="item" {{if or .PageIsSharedSettingsRunners .PageIsSharedSettingsVariables .PageIsSharedSettingsScopedWorkflows}}open{{end}}>
<summary>{{ctx.Locale.Tr "actions.actions"}}</summary>
<div class="menu">
<a class="{{if .PageIsSharedSettingsRunners}}active {{end}}item" href="{{AppSubUrl}}/-/admin/actions/runners">
@@ -81,6 +81,9 @@
<a class="{{if .PageIsSharedSettingsVariables}}active {{end}}item" href="{{AppSubUrl}}/-/admin/actions/variables">
{{ctx.Locale.Tr "actions.variables"}}
</a>
<a class="{{if .PageIsSharedSettingsScopedWorkflows}}active {{end}}item" href="{{AppSubUrl}}/-/admin/actions/scoped-workflows">
{{ctx.Locale.Tr "actions.scoped_workflows"}}
</a>
</div>
</details>
{{end}}

View File

@@ -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}}
</div>
{{template "org/settings/layout_footer" .}}

View File

@@ -26,7 +26,7 @@
</a>
{{end}}
{{if .EnableActions}}
<details class="item" {{if or .PageIsOrgSettingsActionsGeneral .PageIsSharedSettingsRunners .PageIsSharedSettingsSecrets .PageIsSharedSettingsVariables}}open{{end}}>
<details class="item" {{if or .PageIsOrgSettingsActionsGeneral .PageIsSharedSettingsRunners .PageIsSharedSettingsSecrets .PageIsSharedSettingsVariables .PageIsSharedSettingsScopedWorkflows}}open{{end}}>
<summary>{{ctx.Locale.Tr "actions.actions"}}</summary>
<div class="menu">
<a class="{{if .PageIsOrgSettingsActionsGeneral}}active {{end}}item" href="{{.OrgLink}}/settings/actions">
@@ -41,6 +41,9 @@
<a class="{{if .PageIsSharedSettingsVariables}}active {{end}}item" href="{{.OrgLink}}/settings/actions/variables">
{{ctx.Locale.Tr "actions.variables"}}
</a>
<a class="{{if .PageIsSharedSettingsScopedWorkflows}}active {{end}}item" href="{{.OrgLink}}/settings/actions/scoped-workflows">
{{ctx.Locale.Tr "actions.scoped_workflows"}}
</a>
</div>
</details>
{{end}}

View File

@@ -10,8 +10,8 @@
<div class="ui fluid vertical menu">
<a class="item {{if not $.CurWorkflow}}active{{end}}" href="?actor={{$.CurActor}}&status={{$.CurStatus}}&branch={{$.CurBranch}}">{{ctx.Locale.Tr "actions.runs.all_workflows"}}</a>
{{range .workflows}}
<a class="item flex-text-block {{if eq .Entry.Name $.CurWorkflow}}active{{end}}" href="?workflow={{.Entry.Name}}&actor={{$.CurActor}}&status={{$.CurStatus}}&branch={{$.CurBranch}}">
<span class="gt-ellipsis" title="{{.DisplayName}}">{{.DisplayName}}</span>
<a class="item flex-text-block {{if and (eq .Entry.Name $.CurWorkflow) (not $.CurWorkflowScopedRepoID)}}active{{end}}" href="?workflow={{.Entry.Name}}&actor={{$.CurActor}}&status={{$.CurStatus}}&branch={{$.CurBranch}}">
<span class="gt-ellipsis" data-tooltip-content="{{.DisplayName}}">{{.DisplayName}}</span>
{{if .ErrMsg}}
<span class="flex-text-inline tw-shrink-0" data-tooltip-content="{{.ErrMsg}}">{{svg "octicon-alert" 16 "tw-text-red"}}</span>
@@ -22,6 +22,24 @@
{{end}}
</a>
{{end}}
{{range .ScopedWorkflowGroups}}
<details class="item scoped-workflow-group"{{if .IsActive}} open{{end}}>
<summary>
<span class="gt-ellipsis tw-min-w-0" data-tooltip-content="{{.SourceRepoName}}">{{if .FromInstance}}{{.SourceRepoName}}{{else}}{{.SourceRepoShortName}}{{end}}</span>
<span class="ui label">{{if .FromInstance}}{{ctx.Locale.Tr "actions.workflow.scope_global"}}{{else}}{{ctx.Locale.Tr "actions.workflow.scope_owner"}}{{end}}</span>
</summary>
{{range .Workflows}}
<a class="item flex-text-block {{if and (eq .EntryName $.CurWorkflow) (eq .SourceRepoID $.CurWorkflowScopedRepoID)}}active{{end}}" href="?workflow={{.EntryName}}&scoped_workflow_source_repo_id={{.SourceRepoID}}&actor={{$.CurActor}}&status={{$.CurStatus}}&branch={{$.CurBranch}}">
<span class="gt-ellipsis tw-min-w-0" data-tooltip-content="{{.EntryName}}">{{.DisplayName}}</span>
{{if .Required}}
<span class="ui label">{{ctx.Locale.Tr "actions.workflow.required"}}</span>
{{else if .Disabled}}
<span class="ui red label">{{ctx.Locale.Tr "disabled"}}</span>
{{end}}
</a>
{{end}}
</details>
{{end}}
{{if .OtherWorkflows}}
<details class="item"{{if not $.CurWorkflowIsListed}} open{{end}}>
<summary data-tooltip-content="{{ctx.Locale.Tr "actions.runs.other_workflows_tooltip"}}">
@@ -33,7 +51,7 @@
<div class="menu items-full-width">
{{range .OtherWorkflows}}
<a class="item {{if eq . $.CurWorkflow}}active{{end}}" href="?workflow={{.}}&actor={{$.CurActor}}&status={{$.CurStatus}}">
<span class="gt-ellipsis">{{.}}</span>
<span class="gt-ellipsis" data-tooltip-content="{{.}}">{{.}}</span>
</a>
{{end}}
</div>
@@ -54,11 +72,11 @@
<i class="icon">{{svg "octicon-search"}}</i>
<input type="text" placeholder="{{ctx.Locale.Tr "actions.runs.actor"}}">
</div>
<a class="item{{if not $.CurActor}} selected{{end}}" href="?workflow={{$.CurWorkflow}}&status={{$.CurStatus}}&branch={{$.CurBranch}}&actor=0">
<a class="item{{if not $.CurActor}} selected{{end}}" href="?workflow={{$.CurWorkflow}}&scoped_workflow_source_repo_id={{$.CurWorkflowRepoID}}&status={{$.CurStatus}}&branch={{$.CurBranch}}&actor=0">
{{ctx.Locale.Tr "actions.runs.actors_no_select"}}
</a>
{{range .Actors}}
<a class="item{{if eq .ID $.CurActor}} selected{{end}}" href="?workflow={{$.CurWorkflow}}&actor={{.ID}}&status={{$.CurStatus}}&branch={{$.CurBranch}}">
<a class="item{{if eq .ID $.CurActor}} selected{{end}}" href="?workflow={{$.CurWorkflow}}&scoped_workflow_source_repo_id={{$.CurWorkflowRepoID}}&actor={{.ID}}&status={{$.CurStatus}}&branch={{$.CurBranch}}">
{{ctx.AvatarUtils.Avatar . 20}} {{.GetDisplayName}}
</a>
{{end}}
@@ -73,11 +91,11 @@
<i class="icon">{{svg "octicon-search"}}</i>
<input type="text" placeholder="{{ctx.Locale.Tr "actions.runs.status"}}">
</div>
<a class="item{{if not $.CurStatus}} selected{{end}}" href="?workflow={{$.CurWorkflow}}&actor={{$.CurActor}}&branch={{$.CurBranch}}&status=0">
<a class="item{{if not $.CurStatus}} selected{{end}}" href="?workflow={{$.CurWorkflow}}&scoped_workflow_source_repo_id={{$.CurWorkflowRepoID}}&actor={{$.CurActor}}&branch={{$.CurBranch}}&status=0">
{{ctx.Locale.Tr "actions.runs.status_no_select"}}
</a>
{{range .StatusInfoList}}
<a class="item{{if eq .Status $.CurStatus}} selected{{end}}" href="?workflow={{$.CurWorkflow}}&actor={{$.CurActor}}&status={{.Status}}&branch={{$.CurBranch}}">
<a class="item{{if eq .Status $.CurStatus}} selected{{end}}" href="?workflow={{$.CurWorkflow}}&scoped_workflow_source_repo_id={{$.CurWorkflowRepoID}}&actor={{$.CurActor}}&status={{.Status}}&branch={{$.CurBranch}}">
<span class="flex-text-inline tw-gap-2">
{{template "repo/icons/action_status" (dict "Status" .StatusName)}}
{{.DisplayedStatus}}
@@ -95,11 +113,11 @@
<i class="icon">{{svg "octicon-search"}}</i>
<input type="text" placeholder="{{ctx.Locale.Tr "actions.runs.branch"}}">
</div>
<a class="item{{if not $.CurBranch}} selected{{end}}" href="?workflow={{$.CurWorkflow}}&actor={{$.CurActor}}&status={{$.CurStatus}}">
<a class="item{{if not $.CurBranch}} selected{{end}}" href="?workflow={{$.CurWorkflow}}&scoped_workflow_source_repo_id={{$.CurWorkflowRepoID}}&actor={{$.CurActor}}&status={{$.CurStatus}}">
{{ctx.Locale.Tr "actions.runs.branches_no_select"}}
</a>
{{range .RunBranches}}
<a class="item{{if eq . $.CurBranch}} selected{{end}}" href="?workflow={{$.CurWorkflow}}&actor={{$.CurActor}}&status={{$.CurStatus}}&branch={{.}}">
<a class="item{{if eq . $.CurBranch}} selected{{end}}" href="?workflow={{$.CurWorkflow}}&scoped_workflow_source_repo_id={{$.CurWorkflowRepoID}}&actor={{$.CurActor}}&status={{$.CurStatus}}&branch={{.}}">
{{.}}
</a>
{{end}}
@@ -116,8 +134,8 @@
</div>
{{end}}
{{if and .AllowDisableOrEnableWorkflow .CurWorkflowIsListed $.CurWorkflow}}
<a class="item link-action" data-url="{{$.Link}}/{{if .CurWorkflowDisabled}}enable{{else}}disable{{end}}?workflow={{$.CurWorkflow}}&actor={{.CurActor}}&status={{$.CurStatus}}&branch={{$.CurBranch}}">
{{if .CurWorkflowDisabled}}{{ctx.Locale.Tr "actions.workflow.enable"}}{{else}}{{ctx.Locale.Tr "actions.workflow.disable"}}{{end}}
<a class="item {{if .CurWorkflowRequired}}disabled{{else}}link-action{{end}}"{{if not .CurWorkflowRequired}} data-url="{{$.Link}}/{{if .CurWorkflowDisabled}}enable{{else}}disable{{end}}?workflow={{$.CurWorkflow}}&scoped_workflow_source_repo_id={{$.CurWorkflowRepoID}}&actor={{.CurActor}}&status={{$.CurStatus}}&branch={{$.CurBranch}}"{{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}}
</a>
{{end}}
</div>

View File

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

View File

@@ -7,7 +7,7 @@
</div>
<div id="runWorkflowDispatchModal" class="ui tiny modal">
<div class="content">
<form id="runWorkflowDispatchForm" class="ui form ignore-dirty" action="{{$.Link}}/run?workflow={{$.CurWorkflow}}&actor={{$.CurActor}}&status={{$.CurStatus}}&branch={{$.CurBranch}}" method="post">
<form id="runWorkflowDispatchForm" class="ui form ignore-dirty" action="{{$.Link}}/run?workflow={{$.CurWorkflow}}&scoped_workflow_source_repo_id={{$.CurWorkflowRepoID}}&actor={{$.CurActor}}&status={{$.CurStatus}}&branch={{$.CurBranch}}" method="post">
<div class="ui inline field required tw-flex tw-items-center">
<span class="ui inline required field">
<label>{{ctx.Locale.Tr "actions.workflow.from_ref"}}:</label>
@@ -15,7 +15,7 @@
<div class="ui inline field dropdown button select-branch branch-selector-dropdown ellipsis-text-items">
<input type="hidden" name="ref" value="refs/heads/{{index .Branches 0}}"
data-fetch-trigger="change" data-fetch-sync="$body #runWorkflowDispatchModalInputs"
data-fetch-url="{{$.Link}}/workflow-dispatch-inputs?workflow={{$.CurWorkflow}}"
data-fetch-url="{{$.Link}}/workflow-dispatch-inputs?workflow={{$.CurWorkflow}}&scoped_workflow_source_repo_id={{$.CurWorkflowRepoID}}"
>
{{svg "octicon-git-branch" 14}}
<div class="default text">{{index .Branches 0}}</div>

View File

@@ -0,0 +1,88 @@
<h4 class="ui top attached header">
{{ctx.Locale.Tr "actions.scoped_workflows"}}
</h4>
<div class="ui attached segment">
<p>{{.ScopedWorkflowsDesc}}</p>
<p>{{ctx.Locale.Tr "actions.scoped_workflows.add_help" .ScopedWorkflowDirs}}</p>
<div class="ui warning message">{{ctx.Locale.Tr "actions.scoped_workflows.security_note"}}</div>
<form class="ui form form-fetch-action flex-text-block" method="post" action="{{.Link}}/add">
<div data-global-init="initSearchRepoBox" data-uid="{{.RepoSearchUID}}"{{if .ScopedWorkflowsSearchExclusive}} data-exclusive="true"{{end}}{{if .ScopedWorkflowsSearchFullName}} data-full-name="true"{{end}} class="ui search tw-flex-1">
<div class="ui input tw-w-full">
<input class="prompt" name="repo_name" required placeholder="{{ctx.Locale.Tr "search.repo_kind"}}" autocomplete="off">
</div>
</div>
<button class="ui primary button">{{ctx.Locale.Tr "actions.scoped_workflows.source.add"}}</button>
</form>
</div>
{{if .ScopedWorkflowSources}}
<div class="ui attached segment">
{{range $i, $src := .ScopedWorkflowSources}}
{{if $i}}<div class="ui divider"></div>{{end}}
<div class="flex-text-block tw-justify-between">
<a class="tw-font-semibold gt-ellipsis tw-min-w-0" href="{{$src.Repo.Link}}">{{$src.Repo.FullName}}</a>
<form class="ui form form-fetch-action" method="post" action="{{$.Link}}/remove">
<input type="hidden" name="repo_id" value="{{$src.Repo.ID}}">
<button class="ui red tiny button">{{ctx.Locale.Tr "remove"}}</button>
</form>
</div>
{{if $src.ScopedWorkflowInfos}}
<form class="ui form form-fetch-action tw-mt-3" method="post" action="{{$.Link}}/required" data-global-init="initScopedWorkflowRequired">
<input type="hidden" name="repo_id" value="{{$src.Repo.ID}}">
<div class="text grey tw-mb-2">{{ctx.Locale.Tr "actions.scoped_workflows.required.label"}}</div>
<table class="ui table tw-table-fixed">
<thead>
<tr>
<th class="tw-w-1/5">{{ctx.Locale.Tr "actions.runs.workflow_file"}}</th>
<th class="tw-w-24 tw-pr-4">{{ctx.Locale.Tr "actions.workflow.required"}}</th>
<th>{{ctx.Locale.Tr "actions.scoped_workflows.required.patterns"}} <span class="tw-font-normal tw-text-text-light-2">({{ctx.Locale.Tr "actions.scoped_workflows.required.patterns_note"}})</span></th>
</tr>
</thead>
<tbody>
{{range $src.ScopedWorkflowInfos}}
<tr>
<td>{{.EntryName}}{{if .Missing}} <span class="ui red mini label">{{ctx.Locale.Tr "actions.scoped_workflows.required.missing_file"}}</span>{{end}}<input type="hidden" name="workflow_ids" value="{{.EntryName}}"></td>
<td class="collapsing tw-pr-4">
<div class="ui checkbox">
<input type="checkbox" class="js-scoped-required-toggle" name="required_workflow_ids" value="{{.EntryName}}" aria-label="{{.EntryName}}"{{if .Required}} checked{{end}}>
<label></label>
</div>
</td>
<td>
<textarea class="js-scoped-required-patterns{{if not .Required}} tw-hidden{{end}}" name="required_patterns[{{.EntryName}}]" rows="2" aria-label="{{ctx.Locale.Tr "actions.scoped_workflows.required.patterns_aria" .EntryName}}" data-default-pattern="{{$src.Repo.FullName}}: {{.DisplayName}} / *" placeholder="{{$src.Repo.FullName}}: {{.DisplayName}} / *">{{.Patterns}}</textarea>
<span class="js-scoped-required-hint text grey{{if .Required}} tw-hidden{{end}}">{{ctx.Locale.Tr "actions.scoped_workflows.required.patterns_hint"}}</span>
{{if or .Contexts (not .Missing)}}
<table class="ui celled table js-scoped-required-contexts tw-mt-2{{if not .Required}} tw-hidden{{end}}">
<thead>
<tr><th>{{ctx.Locale.Tr "actions.scoped_workflows.required.expected_contexts"}}</th></tr>
</thead>
<tbody>
{{if .Contexts}}
{{range .Contexts}}
<tr>
<td>
<span class="js-scoped-context" data-context="{{.}}">{{.}}</span>
<span class="js-scoped-context-matched tw-font-semibold tw-italic tw-hidden">{{ctx.Locale.Tr "repo.settings.protect_status_check_matched"}}</span>
</td>
</tr>
{{end}}
{{else}}
<tr><td class="tw-text-red">{{ctx.Locale.Tr "actions.scoped_workflows.required.no_status_contexts"}}</td></tr>
{{end}}
</tbody>
</table>
{{end}}
</td>
</tr>
{{end}}
</tbody>
</table>
<div class="text grey tw-mb-2">{{ctx.Locale.Tr "actions.scoped_workflows.required.patterns_help"}}</div>
<button class="ui tiny primary button">{{ctx.Locale.Tr "save"}}</button>
</form>
{{else}}
<div class="text grey tw-mt-2">{{ctx.Locale.Tr "actions.scoped_workflows.no_files"}}</div>
{{end}}
{{end}}
</div>
{{end}}

View File

@@ -6549,6 +6549,13 @@
"description": "Whether the response should include the workflow run ID and URLs.",
"name": "return_run_details",
"in": "query"
},
{
"type": "integer",
"format": "int64",
"description": "For a scoped workflow, the ID of the source repository providing it; omit or 0 for a repo-level workflow.",
"name": "scoped_workflow_source_repo_id",
"in": "query"
}
],
"responses": {
@@ -6696,6 +6703,13 @@
"name": "exclude_pull_requests",
"in": "query"
},
{
"type": "integer",
"format": "int64",
"description": "For a scoped workflow, the ID of the source repository providing it; omit or 0 for a repo-level workflow.",
"name": "scoped_workflow_source_repo_id",
"in": "query"
},
{
"type": "integer",
"description": "page number of results to return (1-based)",

View File

@@ -17759,6 +17759,15 @@
"schema": {
"type": "boolean"
}
},
{
"description": "For a scoped workflow, the ID of the source repository providing it; omit or 0 for a repo-level workflow.",
"in": "query",
"name": "scoped_workflow_source_repo_id",
"schema": {
"format": "int64",
"type": "integer"
}
}
],
"requestBody": {
@@ -17934,6 +17943,15 @@
"type": "boolean"
}
},
{
"description": "For a scoped workflow, the ID of the source repository providing it; omit or 0 for a repo-level workflow.",
"in": "query",
"name": "scoped_workflow_source_repo_id",
"schema": {
"format": "int64",
"type": "integer"
}
},
{
"description": "page number of results to return (1-based)",
"in": "query",

View File

@@ -6,6 +6,8 @@
{{template "shared/actions/runner_list" .}}
{{else if eq .PageType "variables"}}
{{template "shared/variables/variable_list" .}}
{{else if eq .PageType "scoped-workflows"}}
{{template "shared/actions/scoped_workflows" .}}
{{end}}
</div>

View File

@@ -34,7 +34,7 @@
</a>
{{end}}
{{if .EnableActions}}
<details class="item" {{if or .PageIsUserSettingsActionsGeneral .PageIsSharedSettingsRunners .PageIsSharedSettingsSecrets .PageIsSharedSettingsVariables}}open{{end}}>
<details class="item" {{if or .PageIsUserSettingsActionsGeneral .PageIsSharedSettingsRunners .PageIsSharedSettingsSecrets .PageIsSharedSettingsVariables .PageIsSharedSettingsScopedWorkflows}}open{{end}}>
<summary>{{ctx.Locale.Tr "actions.actions"}}</summary>
<div class="menu">
<a class="{{if .PageIsUserSettingsActionsGeneral}}active {{end}}item" href="{{AppSubUrl}}/user/settings/actions/general">
@@ -49,6 +49,9 @@
<a class="{{if .PageIsSharedSettingsVariables}}active {{end}}item" href="{{AppSubUrl}}/user/settings/actions/variables">
{{ctx.Locale.Tr "actions.variables"}}
</a>
<a class="{{if .PageIsSharedSettingsScopedWorkflows}}active {{end}}item" href="{{AppSubUrl}}/user/settings/actions/scoped-workflows">
{{ctx.Locale.Tr "actions.scoped_workflows"}}
</a>
</div>
</details>
{{end}}

View File

@@ -0,0 +1,495 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package integration
import (
"encoding/base64"
"fmt"
"net/http"
"net/url"
"strconv"
"testing"
"time"
runnerv1 "gitea.dev/actions-proto-go/runner/v1"
actions_model "gitea.dev/models/actions"
auth_model "gitea.dev/models/auth"
repo_model "gitea.dev/models/repo"
unit_model "gitea.dev/models/unit"
"gitea.dev/models/unittest"
user_model "gitea.dev/models/user"
"gitea.dev/modules/commitstatus"
"gitea.dev/modules/queue"
api "gitea.dev/modules/structs"
"gitea.dev/services/forms"
repo_service "gitea.dev/services/repository"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
const scopedPushWorkflow = `name: Scoped Push
on: push
jobs:
scoped-job:
runs-on: ubuntu-latest
steps:
- run: echo scoped
`
const scopedPRWorkflow = `name: Scoped PR
on: pull_request
jobs:
scoped-pr-job:
runs-on: ubuntu-latest
steps:
- run: echo scoped-pr
`
func TestActionsScopedWorkflows(t *testing.T) {
onGiteaRun(t, func(t *testing.T, u *url.URL) {
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
user2Session := loginUser(t, user2.Name)
user2Token := getTokenForLoggedInUser(t, user2Session, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser)
// createTestRepo creates an Actions-enabled repo owned by user2, used as a scoped-workflow source or consumer.
createTestRepo := func(t *testing.T, name string, private bool) *repo_model.Repository {
apiRepo := createActionsTestRepo(t, user2Token, name, private)
return unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: apiRepo.ID})
}
// registerUserScopedSource registers `source` as a user-level scoped-workflow source for user2 and marks `required` entry names
registerUserScopedSource := func(t *testing.T, source *repo_model.Repository, required ...string) {
addReq := NewRequestWithValues(t, "POST", "/user/settings/actions/scoped-workflows/add",
map[string]string{"repo_name": source.Name})
user2Session.MakeRequest(t, addReq, http.StatusOK)
t.Cleanup(func() {
removeReq := NewRequestWithValues(t, "POST", "/user/settings/actions/scoped-workflows/remove",
map[string]string{"repo_id": strconv.FormatInt(source.ID, 10)})
user2Session.MakeRequest(t, removeReq, http.StatusOK)
})
if len(required) > 0 {
vals := url.Values{"repo_id": {strconv.FormatInt(source.ID, 10)}, "workflow_ids": required, "required_workflow_ids": required}
for _, id := range required {
// a pattern that matches the source's scoped check regardless of its `name:` (each test source has one workflow)
vals.Set("required_patterns["+id+"]", source.FullName()+": * / *")
}
reqReq := NewRequestWithURLValues(t, "POST", "/user/settings/actions/scoped-workflows/required", vals)
user2Session.MakeRequest(t, reqReq, http.StatusOK)
}
}
t.Run("Trigger and run creation", func(t *testing.T) {
// Registered at INSTANCE level via the admin route (owner/name resolution + OwnerID=0 storage);
// the trigger->execute->rerun below proves an instance-level source drives a consumer run end-to-end and that a rerun stays scoped.
adminSession := loginUser(t, "user1")
source := createTestRepo(t, "sw-trigger-source", false)
// commit the scoped workflow BEFORE registering so the source's own push does not self-trigger.
createRepoWorkflowFile(t, user2, user2Token, source, ".gitea/scoped_workflows/push.yaml", scopedPushWorkflow)
adminAdd := NewRequestWithValues(t, "POST", "/-/admin/actions/scoped-workflows/add", map[string]string{"repo_name": source.FullName()})
adminSession.MakeRequest(t, adminAdd, http.StatusOK)
t.Cleanup(func() {
rm := NewRequestWithValues(t, "POST", "/-/admin/actions/scoped-workflows/remove", map[string]string{"repo_id": strconv.FormatInt(source.ID, 10)})
adminSession.MakeRequest(t, rm, http.StatusOK)
})
unittest.AssertExistsAndLoadBean(t, &actions_model.ActionScopedWorkflowSource{OwnerID: 0, SourceRepoID: source.ID})
consumer := createTestRepo(t, "sw-trigger-consumer", false)
runner := newMockRunner()
runner.registerAsRepoRunner(t, consumer.OwnerName, consumer.Name, "sw-trigger-runner", []string{"ubuntu-latest"}, false)
createRepoWorkflowFile(t, user2, user2Token, consumer, "marker.txt", "trigger")
run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{RepoID: consumer.ID, IsScopedRun: true})
assert.Equal(t, source.ID, run.WorkflowRepoID, "content source is the source repo")
assert.Equal(t, "push.yaml", run.WorkflowID)
assert.Equal(t, 1, unittest.GetCount(t, &actions_model.ActionRun{RepoID: consumer.ID}), "only the scoped run, no repo-level run")
job := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: run.ID})
// runs in the CONSUMER's context and reaches a terminal state
task := runner.fetchTask(t)
_, taskJob, taskRun := getTaskAndJobAndRunByTaskID(t, task.Id)
assert.Equal(t, consumer.ID, taskJob.RepoID)
assert.Equal(t, run.ID, taskRun.ID)
runner.execTask(t, task, &mockTaskOutcome{result: runnerv1.Result_RESULT_SUCCESS})
run = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: run.ID})
assert.Equal(t, actions_model.StatusSuccess, run.Status)
// rerun: the rerun is still a scoped run and again executes in the consumer's context
rerunReq := NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/jobs/%d/rerun", consumer.OwnerName, consumer.Name, run.ID, job.ID))
user2Session.MakeRequest(t, rerunReq, http.StatusOK)
unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunAttempt{RunID: run.ID, Attempt: 2})
task2 := runner.fetchTask(t)
_, taskJob2, taskRun2 := getTaskAndJobAndRunByTaskID(t, task2.Id)
assert.Equal(t, consumer.ID, taskJob2.RepoID)
assert.True(t, taskRun2.IsScopedRun, "the rerun is still a scoped run")
runner.execTask(t, task2, &mockTaskOutcome{result: runnerv1.Result_RESULT_SUCCESS})
run = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: run.ID})
assert.Equal(t, actions_model.StatusSuccess, run.Status)
})
t.Run("Opt-out", func(t *testing.T) {
// opt-out: a consumer can disable a non-required scoped workflow, but a required one cannot be disabled.
source := createTestRepo(t, "sw-optout-source", false)
createRepoWorkflowFile(t, user2, user2Token, source, ".gitea/scoped_workflows/push.yaml", scopedPushWorkflow)
registerUserScopedSource(t, source) // non-required
// non-required: the kebab "Disable Workflow" item is an active link; disabling then makes a push produce no scoped run.
consumer := createTestRepo(t, "sw-optout-consumer", false)
optBody := user2Session.MakeRequest(t, NewRequest(t, "GET",
fmt.Sprintf("/%s/%s/actions?workflow=push.yaml&scoped_workflow_source_repo_id=%d", consumer.OwnerName, consumer.Name, source.ID)),
http.StatusOK).Body.String()
assert.Contains(t, optBody, "Disable Workflow")
assert.Contains(t, optBody, "disable?workflow=push.yaml", "non-required scoped workflow: Disable Workflow is a clickable link")
disableReq := NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/disable?workflow=push.yaml&scoped_workflow_source_repo_id=%d", consumer.OwnerName, consumer.Name, source.ID))
user2Session.MakeRequest(t, disableReq, http.StatusOK)
createRepoWorkflowFile(t, user2, user2Token, consumer, "marker.txt", "trigger")
assert.Equal(t, 0, unittest.GetCount(t, &actions_model.ActionRun{RepoID: consumer.ID, IsScopedRun: true}), "opted-out scoped workflow must not run")
// required: the kebab "Disable Workflow" item is rendered disabled (no link), and the disable endpoint rejects it.
reqSource := createTestRepo(t, "sw-optout-req-source", false)
createRepoWorkflowFile(t, user2, user2Token, reqSource, ".gitea/scoped_workflows/push.yaml", scopedPushWorkflow)
registerUserScopedSource(t, reqSource, "push.yaml") // required
reqConsumer := createTestRepo(t, "sw-optout-req-consumer", false)
requiredBody := user2Session.MakeRequest(t, NewRequest(t, "GET",
fmt.Sprintf("/%s/%s/actions?workflow=push.yaml&scoped_workflow_source_repo_id=%d", reqConsumer.OwnerName, reqConsumer.Name, reqSource.ID)),
http.StatusOK).Body.String()
assert.Contains(t, requiredBody, "Disable Workflow")
assert.Contains(t, requiredBody, `class="item disabled"`, "required scoped workflow: Disable Workflow is rendered disabled")
assert.NotContains(t, requiredBody, "disable?workflow=push.yaml", "required scoped workflow: Disable Workflow has no clickable link")
rejectReq := NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/disable?workflow=push.yaml&scoped_workflow_source_repo_id=%d", reqConsumer.OwnerName, reqConsumer.Name, reqSource.ID))
user2Session.MakeRequest(t, rejectReq, http.StatusBadRequest) // scoped_required_cannot_disable
})
t.Run("Local uses resolves to source", func(t *testing.T) {
// uses: ./ in a scoped workflow resolves against the SOURCE repo, not the consumer.
// Here the reusable lib lives in the SCOPED workflow dir (allowed by ResolveUses), exercising that path end-to-end.
source := createTestRepo(t, "sw-uses-source", false)
createRepoWorkflowFile(t, user2, user2Token, source, ".gitea/scoped_workflows/lib.yaml", `name: Lib
on:
workflow_call:
jobs:
lib_job_source:
runs-on: ubuntu-latest
steps:
- run: echo from-source
`)
createRepoWorkflowFile(t, user2, user2Token, source, ".gitea/scoped_workflows/caller.yaml", `name: Caller
on: push
jobs:
caller_job:
uses: ./.gitea/scoped_workflows/lib.yaml
`)
consumer := createTestRepo(t, "sw-uses-consumer", false)
// a DIFFERENT lib at the same path in the consumer; if uses:./ mis-resolved we would see this job
createRepoWorkflowFile(t, user2, user2Token, consumer, ".gitea/scoped_workflows/lib.yaml", `name: Lib
on:
workflow_call:
jobs:
lib_job_consumer:
runs-on: ubuntu-latest
steps:
- run: echo from-consumer
`)
// register only AFTER both repos' scoped files exist, so the setup pushes do not trigger
registerUserScopedSource(t, source)
createRepoWorkflowFile(t, user2, user2Token, consumer, "marker.txt", "trigger")
run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{RepoID: consumer.ID, IsScopedRun: true, WorkflowID: "caller.yaml"})
callerJob := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: run.ID, JobID: "caller_job"})
assert.True(t, callerJob.IsReusableCaller)
assert.True(t, callerJob.IsExpanded)
assert.Equal(t, source.ID, callerJob.WorkflowSourceRepoID, "top-level caller's content source is the source repo")
// the expanded child comes from the SOURCE's lib.yaml, not the consumer's same-path file
unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: run.ID, JobID: "lib_job_source", ParentJobID: callerJob.ID})
unittest.AssertNotExistsBean(t, &actions_model.ActionRunJob{RunID: run.ID, JobID: "lib_job_consumer"})
})
t.Run("Workflow dispatch", func(t *testing.T) {
// a scoped on:workflow_dispatch workflow can be triggered manually from the consumer, via both the web form and the API
source := createTestRepo(t, "sw-dispatch-source", false)
createRepoWorkflowFile(t, user2, user2Token, source, ".gitea/scoped_workflows/dispatch.yaml", `name: Scoped Dispatch
on: workflow_dispatch
jobs:
dispatch-job:
runs-on: ubuntu-latest
steps:
- run: echo dispatch
`)
registerUserScopedSource(t, source)
consumer := createTestRepo(t, "sw-dispatch-consumer", false)
// web form: /run?...&scoped_workflow_source_repo_id=
webReq := NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/run?workflow=dispatch.yaml&scoped_workflow_source_repo_id=%d&ref=refs/heads/%s",
consumer.OwnerName, consumer.Name, source.ID, consumer.DefaultBranch))
user2Session.MakeRequest(t, webReq, http.StatusSeeOther)
run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{RepoID: consumer.ID, IsScopedRun: true, WorkflowID: "dispatch.yaml"})
assert.Equal(t, source.ID, run.WorkflowRepoID, "content source is the source repo")
assert.NotEmpty(t, run.WorkflowCommitSHA, "scoped dispatch records the source default-branch commit")
assert.Contains(t, run.Ref, consumer.DefaultBranch, "dispatch runs on the chosen consumer ref")
// API: /actions/workflows/dispatch.yaml/dispatches?scoped_workflow_source_repo_id=
apiReq := NewRequestWithURLValues(t, "POST",
fmt.Sprintf("/api/v1/repos/%s/%s/actions/workflows/dispatch.yaml/dispatches?scoped_workflow_source_repo_id=%d", consumer.OwnerName, consumer.Name, source.ID),
url.Values{"ref": {consumer.DefaultBranch}}).AddTokenAuth(user2Token)
MakeRequest(t, apiReq, http.StatusNoContent)
assert.Equal(t, 2, unittest.GetCount(t, &actions_model.ActionRun{RepoID: consumer.ID, IsScopedRun: true, WorkflowID: "dispatch.yaml", Event: "workflow_dispatch"}),
"both the web form and the API created a scoped dispatch run")
})
t.Run("Required scoped check gates the PR merge", func(t *testing.T) {
// A required scoped workflow's check gates PR merges on a protected branch and cannot be bypassed,
// whether or not the branch enables its own status check. The scoped check is added to the required set dynamically.
source := createTestRepo(t, "sw-gate-source", false)
createRepoWorkflowFile(t, user2, user2Token, source, ".gitea/scoped_workflows/pr.yaml", scopedPRWorkflow)
registerUserScopedSource(t, source, "pr.yaml") // required
// protectAndOpenPR protects consumer's default branch and opens a PR on `branch`, returning a merge-request builder.
// When statusCheckEnabled it also configures "ci/manual" as the only CONFIGURED required context and satisfies it,
// so the scoped check is the only thing that can gate the merge; otherwise the rule's own status check stays off.
protectAndOpenPR := func(t *testing.T, consumer *repo_model.Repository, branch string, statusCheckEnabled bool) func() *RequestWrapper {
pbValues := map[string]string{
"rule_name": consumer.DefaultBranch,
"enable_push": "true",
"block_admin_merge_override": "true", // otherwise the repo owner bypasses the status check
}
if statusCheckEnabled {
pbValues["enable_status_check"] = "true"
pbValues["status_check_contexts"] = "ci/manual"
}
user2Session.MakeRequest(t, NewRequestWithValues(t, "POST", fmt.Sprintf("/%s/%s/settings/branches/edit", consumer.OwnerName, consumer.Name), pbValues), http.StatusSeeOther)
prFile := &api.CreateFileOptions{
FileOptions: api.FileOptions{
BranchName: consumer.DefaultBranch, NewBranchName: branch, Message: "pr change",
Author: api.Identity{Name: user2.Name, Email: user2.Email},
Committer: api.Identity{Name: user2.Name, Email: user2.Email},
Dates: api.CommitDateOptions{Author: time.Now(), Committer: time.Now()},
},
ContentBase64: base64.StdEncoding.EncodeToString([]byte("pr change")),
}
createWorkflowFile(t, user2Token, consumer.OwnerName, consumer.Name, "pr-change.txt", prFile)
apiCtx := NewAPITestContext(t, user2.Name, consumer.Name, auth_model.AccessTokenScopeWriteRepository)
pr, err := doAPICreatePullRequest(apiCtx, consumer.OwnerName, consumer.Name, consumer.DefaultBranch, branch)(t)
require.NoError(t, err)
if statusCheckEnabled {
// satisfy the configured "ci/manual" check so only the scoped check can gate the merge
manualStatus := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/statuses/%s", consumer.OwnerName, consumer.Name, pr.Head.Sha),
api.CreateStatusOption{State: commitstatus.CommitStatusSuccess, Context: "ci/manual", TargetURL: "http://test.ci/"}).AddTokenAuth(user2Token)
user2Session.MakeRequest(t, manualStatus, http.StatusCreated)
}
return func() *RequestWrapper {
return NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d/merge", consumer.OwnerName, consumer.Name, pr.Index),
&forms.MergePullRequestForm{Do: string(repo_model.MergeStyleMerge), MergeMessageField: "merge"}).AddTokenAuth(user2Token)
}
}
t.Run("pending blocks, success allows", func(t *testing.T) {
consumer := createTestRepo(t, "sw-gate-consumer", false)
runner := newMockRunner()
runner.registerAsRepoRunner(t, consumer.OwnerName, consumer.Name, "sw-gate-runner", []string{"ubuntu-latest"}, false)
mergeReq := protectAndOpenPR(t, consumer, "gate-pr", true)
run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{RepoID: consumer.ID, IsScopedRun: true})
assert.Equal(t, source.ID, run.WorkflowRepoID)
// the pending required scoped check blocks the merge
assert.NoError(t, queue.GetManager().FlushAll(t.Context(), 5*time.Second))
user2Session.MakeRequest(t, mergeReq(), http.StatusMethodNotAllowed)
// the required scoped run succeeds -> merge allowed
task := runner.fetchTask(t)
runner.execTask(t, task, &mockTaskOutcome{result: runnerv1.Result_RESULT_SUCCESS})
assert.NoError(t, queue.GetManager().FlushAll(t.Context(), 5*time.Second))
user2Session.MakeRequest(t, mergeReq(), http.StatusOK)
})
t.Run("Actions disabled blocks merge (no bypass)", func(t *testing.T) {
// must-present: disabling the consumer's Actions unit so the required scoped workflow cannot run.
// Must BLOCK the merge (the required check is absent), not bypass it.
consumer := createTestRepo(t, "sw-noact-consumer", false)
require.NoError(t, repo_service.UpdateRepositoryUnits(t.Context(), consumer, nil, []unit_model.Type{unit_model.TypeActions}))
mergeReq := protectAndOpenPR(t, consumer, "noact-pr", true)
assert.Equal(t, 0, unittest.GetCount(t, &actions_model.ActionRun{RepoID: consumer.ID, IsScopedRun: true}),
"Actions disabled, so no scoped run is created")
// the required scoped check never posted a status -> must-present blocks the merge (no bypass)
assert.NoError(t, queue.GetManager().FlushAll(t.Context(), 5*time.Second))
user2Session.MakeRequest(t, mergeReq(), http.StatusMethodNotAllowed)
})
t.Run("status check disabled: the scoped check still gates", func(t *testing.T) {
// the scoped check gates the merge even when the branch's OWN status check is off
consumer := createTestRepo(t, "sw-nocheck-consumer", false)
runner := newMockRunner()
runner.registerAsRepoRunner(t, consumer.OwnerName, consumer.Name, "sw-nocheck-runner", []string{"ubuntu-latest"}, false)
mergeReq := protectAndOpenPR(t, consumer, "nocheck-pr", false)
unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{RepoID: consumer.ID, IsScopedRun: true})
// pending scoped check blocks the merge despite the branch's own status check being off
assert.NoError(t, queue.GetManager().FlushAll(t.Context(), 5*time.Second))
user2Session.MakeRequest(t, mergeReq(), http.StatusMethodNotAllowed)
// the required scoped run succeeds -> merge allowed
task := runner.fetchTask(t)
runner.execTask(t, task, &mockTaskOutcome{result: runnerv1.Result_RESULT_SUCCESS})
assert.NoError(t, queue.GetManager().FlushAll(t.Context(), 5*time.Second))
user2Session.MakeRequest(t, mergeReq(), http.StatusOK)
})
})
t.Run("Settings page required patterns", func(t *testing.T) {
source := createTestRepo(t, "sw-settings-source", false)
createRepoWorkflowFile(t, user2, user2Token, source, ".gitea/scoped_workflows/push.yaml", scopedPushWorkflow)
createRepoWorkflowFile(t, user2, user2Token, source, ".gitea/scoped_workflows/manual.yaml", `name: Manual
on: workflow_dispatch
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- run: echo
`) // workflow_dispatch posts no status -> the settings page must warn instead of listing contexts
registerUserScopedSource(t, source) // registered; each phase configures it via the /required endpoint
pattern := source.FullName() + ": * / *"
setConfigs := func(t *testing.T, vals url.Values) {
vals.Set("repo_id", strconv.FormatInt(source.ID, 10))
user2Session.MakeRequest(t, NewRequestWithURLValues(t, "POST", "/user/settings/actions/scoped-workflows/required", vals), http.StatusOK)
}
loadSource := func(t *testing.T) *actions_model.ActionScopedWorkflowSource {
return unittest.AssertExistsAndLoadBean(t, &actions_model.ActionScopedWorkflowSource{OwnerID: user2.ID, SourceRepoID: source.ID})
}
settingsBody := func(t *testing.T) string {
return user2Session.MakeRequest(t, NewRequest(t, "GET", "/user/settings/actions/scoped-workflows"), http.StatusOK).Body.String()
}
t.Run("renders the saved pattern and display-name default", func(t *testing.T) {
setConfigs(t, url.Values{"workflow_ids": {"push.yaml"}, "required_workflow_ids": {"push.yaml"}, "required_patterns[push.yaml]": {pattern}})
body := settingsBody(t)
assert.Contains(t, body, `name="required_patterns[push.yaml]"`, "patterns textarea uses the field name the parser expects")
assert.Contains(t, body, pattern, "the saved pattern round-trips into the textarea")
// the default prefill must use the workflow display name so it matches the status context the run posts (name: Scoped Push)
assert.Contains(t, body, `data-default-pattern="`+source.FullName()+`: Scoped Push / *"`)
// the expected-checks preview derives the exact context a run posts (job scoped-job, event push) for live glob matching
assert.Contains(t, body, `data-context="`+source.FullName()+`: Scoped Push / scoped-job (push)"`)
})
t.Run("live pattern kept as history after un-require", func(t *testing.T) {
setConfigs(t, url.Values{"workflow_ids": {"push.yaml"}, "required_workflow_ids": {"push.yaml"}, "required_patterns[push.yaml]": {pattern}})
// un-require: the row still submits workflow_ids + its patterns (the hidden textarea), but not required_workflow_ids
setConfigs(t, url.Values{"workflow_ids": {"push.yaml"}, "required_patterns[push.yaml]": {pattern}})
cfg := loadSource(t).WorkflowConfigs["push.yaml"]
require.NotNil(t, cfg)
assert.False(t, cfg.Required, "no longer required")
assert.Equal(t, []string{pattern}, cfg.Patterns, "pattern retained as history")
assert.Contains(t, settingsBody(t), pattern, "history pattern still rendered, so re-requiring restores it")
})
t.Run("orphan config dropped when un-required", func(t *testing.T) {
// An orphan entry (gone.yaml: required for a file that no longer exists in the source) has no history worth keeping:
// un-checking Required must drop it entirely, unlike a live un-required workflow.
setConfigs(t, url.Values{
"workflow_ids": {"push.yaml", "gone.yaml"}, "required_workflow_ids": {"push.yaml", "gone.yaml"},
"required_patterns[push.yaml]": {pattern}, "required_patterns[gone.yaml]": {pattern},
})
require.True(t, loadSource(t).IsWorkflowRequired("gone.yaml"), "orphan kept while still required")
// un-require gone.yaml (its row + patterns are still submitted, as the settings page does); push.yaml stays required
setConfigs(t, url.Values{
"workflow_ids": {"push.yaml", "gone.yaml"}, "required_workflow_ids": {"push.yaml"},
"required_patterns[push.yaml]": {pattern}, "required_patterns[gone.yaml]": {pattern},
})
src := loadSource(t)
assert.Nil(t, src.WorkflowConfigs["gone.yaml"], "orphan dropped after un-require, not kept as history")
assert.True(t, src.IsWorkflowRequired("push.yaml"), "live required workflow kept")
})
t.Run("warns when a workflow posts no status checks", func(t *testing.T) {
// manual.yaml only runs on workflow_dispatch, which posts no commit status: instead of listing expected checks,
// its row shows a warning not to mark it required (must-present would block forever).
body := settingsBody(t)
assert.Contains(t, body, "posts no status checks", "the no-status-check warning is shown")
assert.NotContains(t, body, `data-context="`+source.FullName()+`: Manual /`, "a workflow_dispatch-only workflow must list no expected contexts")
})
})
t.Run("Distinct sources same filename", func(t *testing.T) {
// two DIFFERENT source repos with the same filename run independently
s1 := createTestRepo(t, "sw-multi-s1", false)
createRepoWorkflowFile(t, user2, user2Token, s1, ".gitea/scoped_workflows/ci.yaml", scopedPushWorkflow)
s2 := createTestRepo(t, "sw-multi-s2", false)
createRepoWorkflowFile(t, user2, user2Token, s2, ".gitea/scoped_workflows/ci.yaml", scopedPushWorkflow)
registerUserScopedSource(t, s1)
registerUserScopedSource(t, s2)
consumer := createTestRepo(t, "sw-multi-consumer", false)
createRepoWorkflowFile(t, user2, user2Token, consumer, "marker.txt", "trigger")
assert.Equal(t, 2, unittest.GetCount(t, &actions_model.ActionRun{RepoID: consumer.ID, IsScopedRun: true}), "same-named ci.yaml from two sources run independently")
unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{RepoID: consumer.ID, IsScopedRun: true, WorkflowRepoID: s1.ID})
unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{RepoID: consumer.ID, IsScopedRun: true, WorkflowRepoID: s2.ID})
})
t.Run("Detection cache invalidates on source push", func(t *testing.T) {
// The detection parse is cached per (source, default-branch SHA).
source := createTestRepo(t, "sw-cache-source", false)
created := createWorkflowFile(t, user2Token, source.OwnerName, source.Name, ".gitea/scoped_workflows/ci.yaml",
getWorkflowCreateFileOptions(user2, source.DefaultBranch, "create ci", `name: CI
on: pull_request
jobs:
j:
runs-on: ubuntu-latest
steps:
- run: echo a
`))
registerUserScopedSource(t, source)
consumer := createTestRepo(t, "sw-cache-consumer", false)
// warm the cache at the source's current SHA: the source triggers on pull_request, so a consumer push is no match
createRepoWorkflowFile(t, user2, user2Token, consumer, "m1.txt", "trigger")
assert.Equal(t, 0, unittest.GetCount(t, &actions_model.ActionRun{RepoID: consumer.ID, IsScopedRun: true}),
"source triggers on pull_request, so a consumer push must not create a scoped run")
// switch the source's trigger to push on its default branch
updateReq := NewRequestWithJSON(t, "PUT",
fmt.Sprintf("/api/v1/repos/%s/%s/contents/.gitea/scoped_workflows/ci.yaml", source.OwnerName, source.Name),
&api.UpdateFileOptions{
SHA: created.Content.SHA,
FileOptions: api.FileOptions{BranchName: source.DefaultBranch, Message: "switch to push"},
ContentBase64: base64.StdEncoding.EncodeToString([]byte(`name: CI
on: push
jobs:
j:
runs-on: ubuntu-latest
steps:
- run: echo a
`)),
}).AddTokenAuth(user2Token)
MakeRequest(t, updateReq, http.StatusOK)
// the next consumer push must re-detect against the new SHA (on: push) and create the scoped run
createRepoWorkflowFile(t, user2, user2Token, consumer, "m2.txt", "trigger")
assert.Equal(t, 1, unittest.GetCount(t, &actions_model.ActionRun{RepoID: consumer.ID, IsScopedRun: true, Event: "push"}),
"after the source switches to on: push, the next consumer push creates a scoped run")
})
t.Run("Deletion cleans up source registration", func(t *testing.T) {
source := createTestRepo(t, "sw-delete-source", false)
addReq := NewRequestWithValues(t, "POST", "/user/settings/actions/scoped-workflows/add", map[string]string{"repo_name": source.Name})
user2Session.MakeRequest(t, addReq, http.StatusOK)
unittest.AssertExistsAndLoadBean(t, &actions_model.ActionScopedWorkflowSource{OwnerID: user2.ID, SourceRepoID: source.ID})
delReq := NewRequest(t, "DELETE", fmt.Sprintf("/api/v1/repos/%s/%s", source.OwnerName, source.Name)).AddTokenAuth(user2Token)
MakeRequest(t, delReq, http.StatusNoContent)
unittest.AssertNotExistsBean(t, &actions_model.ActionScopedWorkflowSource{SourceRepoID: source.ID})
})
})
}

View File

@@ -66,3 +66,28 @@
max-width: 110px;
}
}
.repository.actions .ui.vertical.menu details.scoped-workflow-group > .item {
display: flex;
padding-left: 2.5em;
}
.repository.actions .ui.vertical.menu details.scoped-workflow-group > .item > .gt-ellipsis {
font-size: 0.85714286em;
}
.repository.actions .ui.vertical.menu details.scoped-workflow-group > .item > .label {
margin-left: auto;
flex-shrink: 0;
}
.repository.actions .ui.vertical.menu details.scoped-workflow-group > summary {
justify-content: flex-start;
gap: var(--gap-block);
padding: 0.92857143em 1.14285714em;
}
.repository.actions .ui.vertical.menu details.scoped-workflow-group > summary::after {
order: 1;
}
.repository.actions .ui.vertical.menu details.scoped-workflow-group > summary > .label {
order: 2;
margin-left: auto;
}

View File

@@ -119,7 +119,7 @@ onBeforeUnmount(() => {
:jobs="topLevelJobs"
:run-link="run.link"
:workflow-id="run.workflowID"
:workflow-link="`${run.link}/workflow`"
:workflow-link="run.canViewWorkflowFile ? `${run.link}/workflow` : ''"
:trigger-event="run.triggerEvent"
:locale="locale"
/>

View File

@@ -121,6 +121,7 @@ export function createEmptyActionsRun(): ActionsRun {
done: false,
workflowID: '',
workflowLink: '',
canViewWorkflowFile: true,
isSchedule: false,
runAttempt: 0,
attempts: [],

View File

@@ -283,10 +283,14 @@ onBeforeUnmount(() => {
<div class="left-list-header">{{ locale.runDetails }}</div>
<div class="flex-items-block action-view-sidebar-list">
<div class="item">
<a class="flex-text-block silenced" :href="`${run.link}/workflow`">
<a v-if="run.canViewWorkflowFile" class="flex-text-block silenced" :href="`${run.link}/workflow`">
<SvgIcon name="octicon-file-code" class="tw-text-text"/>
<span class="gt-ellipsis">{{ locale.workflowFile }}</span>
</a>
<span v-else class="flex-text-block silenced" :data-tooltip-content="locale.workflowFileNoPermission">
<SvgIcon name="octicon-lock" class="tw-text-text"/>
<span class="gt-ellipsis">{{ locale.workflowFileNoPermission }}</span>
</span>
</div>
</div>
</div>

View File

@@ -6,6 +6,7 @@ import {addDelegatedEventListener, queryElems} from '../utils/dom.ts';
import {registerGlobalInitFunc, registerGlobalSelectorFunc} from '../modules/observer.ts';
import {initAvatarUploaderWithCropper} from './comp/Cropper.ts';
import {initCompSearchRepoBox} from './comp/SearchRepoBox.ts';
import {initScopedWorkflowRequired} from './comp/ScopedWorkflows.ts';
const {appUrl, appSubUrl} = window.config;
@@ -104,6 +105,7 @@ export function initGlobalComponent() {
registerGlobalInitFunc('initTabSwitcher', initTabSwitcher);
registerGlobalInitFunc('initAvatarUploader', initAvatarUploaderWithCropper);
registerGlobalInitFunc('initSearchRepoBox', initCompSearchRepoBox);
registerGlobalInitFunc('initScopedWorkflowRequired', initScopedWorkflowRequired);
}
// for performance considerations, it only uses performant syntax

View File

@@ -0,0 +1,104 @@
import {initScopedWorkflowRequired} from './ScopedWorkflows.ts';
function setupForm(required = false) {
window.document.body.innerHTML = `
<form>
<table><tbody>
<tr>
<td>ci.yaml<input type="hidden" name="workflow_ids" value="ci.yaml"></td>
<td><div class="ui checkbox"><input type="checkbox" class="js-scoped-required-toggle" ${required ? 'checked' : ''}><label></label></div></td>
<td>
<textarea class="js-scoped-required-patterns${required ? '' : ' tw-hidden'}" data-default-pattern="org/src: CI / *">${required ? 'org/src: CI / *' : ''}</textarea>
<span class="js-scoped-required-hint${required ? ' tw-hidden' : ''}">hint</span>
</td>
</tr>
</tbody></table>
</form>`;
const form = document.querySelector('form')!;
const checkbox = form.querySelector<HTMLInputElement>('.js-scoped-required-toggle')!;
const textarea = form.querySelector<HTMLTextAreaElement>('.js-scoped-required-patterns')!;
const hint = form.querySelector<HTMLElement>('.js-scoped-required-hint')!;
return {form, checkbox, textarea, hint};
}
test('required toggle shows/prefills the patterns textarea (and hides the hint) and reverses otherwise, keeping the value', () => {
const {form, checkbox, textarea, hint} = setupForm();
initScopedWorkflowRequired(form);
expect(textarea.classList.contains('tw-hidden')).toBe(true); // initial: not required -> textarea hidden
expect(hint.classList.contains('tw-hidden')).toBe(false); // ... and the hint shown in its place
// check -> textarea shown and prefilled; hint hidden
checkbox.checked = true;
checkbox.dispatchEvent(new Event('change', {bubbles: true}));
expect(textarea.classList.contains('tw-hidden')).toBe(false);
expect(hint.classList.contains('tw-hidden')).toBe(true);
expect(textarea.value).toBe('org/src: CI / *');
// admin edits the pattern
textarea.value = 'org/src: CI / build (pull_request)';
// uncheck -> textarea hidden (value kept, still submits as history), hint shown again
checkbox.checked = false;
checkbox.dispatchEvent(new Event('change', {bubbles: true}));
expect(textarea.classList.contains('tw-hidden')).toBe(true);
expect(hint.classList.contains('tw-hidden')).toBe(false);
expect(textarea.value).toBe('org/src: CI / build (pull_request)');
// re-check -> shown again with the same value (not re-prefilled to the default)
checkbox.checked = true;
checkbox.dispatchEvent(new Event('change', {bubbles: true}));
expect(textarea.classList.contains('tw-hidden')).toBe(false);
expect(textarea.value).toBe('org/src: CI / build (pull_request)');
});
test('an already-required row stays shown with its stored patterns (not re-prefilled)', () => {
const {form, textarea} = setupForm(true);
textarea.value = 'org/src: custom / build (push)'; // a stored, admin-edited pattern
initScopedWorkflowRequired(form);
expect(textarea.classList.contains('tw-hidden')).toBe(false);
expect(textarea.value).toBe('org/src: custom / build (push)');
});
function setupFormWithContexts(patterns: string) {
window.document.body.innerHTML = `
<form>
<table><tbody>
<tr>
<td>ci.yaml<input type="hidden" name="workflow_ids" value="ci.yaml"></td>
<td><div class="ui checkbox"><input type="checkbox" class="js-scoped-required-toggle" checked><label></label></div></td>
<td>
<textarea class="js-scoped-required-patterns" data-default-pattern="org/src: CI / *">${patterns}</textarea>
<span class="js-scoped-required-hint tw-hidden">hint</span>
<table class="js-scoped-required-contexts"><tbody>
<tr><td><span class="js-scoped-context" data-context="org/src: CI / lint (push)"></span><span class="js-scoped-context-matched tw-hidden">Matched</span></td></tr>
<tr><td><span class="js-scoped-context" data-context="org/src: CI / build (push)"></span><span class="js-scoped-context-matched tw-hidden">Matched</span></td></tr>
</tbody></table>
</td>
</tr>
</tbody></table>
</form>`;
const form = document.querySelector('form')!;
const [lintMark, buildMark] = Array.from(form.querySelectorAll<HTMLElement>('.js-scoped-context-matched'));
return {form, lintMark, buildMark};
}
test('an exact pattern marks only the context it matches', () => {
const {form, lintMark, buildMark} = setupFormWithContexts('org/src: CI / lint (push)');
initScopedWorkflowRequired(form);
expect(lintMark.classList.contains('tw-hidden')).toBe(false); // matched
expect(buildMark.classList.contains('tw-hidden')).toBe(true); // not matched
});
test('a wildcard pattern marks every matching context', () => {
const {form, lintMark, buildMark} = setupFormWithContexts('org/src: CI / *');
initScopedWorkflowRequired(form);
expect(lintMark.classList.contains('tw-hidden')).toBe(false);
expect(buildMark.classList.contains('tw-hidden')).toBe(false);
});
test('a wildcard crossing "/" matches every matching context', () => {
const {form, lintMark, buildMark} = setupFormWithContexts('org/src: *');
initScopedWorkflowRequired(form);
expect(lintMark.classList.contains('tw-hidden')).toBe(false);
expect(buildMark.classList.contains('tw-hidden')).toBe(false);
});

View File

@@ -0,0 +1,38 @@
import {addDelegatedEventListener, onInputDebounce, toggleElem} from '../../utils/dom.ts';
import {globMatch} from '../../utils/glob.ts';
// markRowMatchedContexts marks each expected status-check context whose row's textarea patterns match it.
function markRowMatchedContexts(row: HTMLElement) {
const textarea = row.querySelector<HTMLTextAreaElement>('.js-scoped-required-patterns')!;
const patterns = textarea.value.split(/[\r\n]+/).map((p) => p.trim()).filter(Boolean);
for (const ctxEl of row.querySelectorAll<HTMLElement>('.js-scoped-context')) {
const context = ctxEl.getAttribute('data-context')!;
const matched = patterns.some((p) => globMatch(context, p));
toggleElem(ctxEl.parentElement!.querySelector('.js-scoped-context-matched')!, matched);
}
}
// syncScopedRequiredRow shows a scoped workflow's status-check patterns textarea (and its expected-checks preview) only while the workflow is required.
function syncScopedRequiredRow(checkbox: HTMLInputElement) {
const row = checkbox.closest('tr')!;
const textarea = row.querySelector<HTMLTextAreaElement>('.js-scoped-required-patterns')!;
toggleElem(textarea, checkbox.checked);
toggleElem(row.querySelector('.js-scoped-required-hint')!, !checkbox.checked); // the "mark as required" hint shown in the textarea's place
const contexts = row.querySelector('.js-scoped-required-contexts'); // only rendered when the workflow has expected checks
if (contexts) toggleElem(contexts, checkbox.checked);
if (checkbox.checked && !textarea.value.trim()) {
textarea.value = textarea.getAttribute('data-default-pattern')!;
}
if (checkbox.checked) markRowMatchedContexts(row);
}
export function initScopedWorkflowRequired(form: HTMLElement) {
for (const checkbox of form.querySelectorAll<HTMLInputElement>('.js-scoped-required-toggle')) {
syncScopedRequiredRow(checkbox);
}
for (const textarea of form.querySelectorAll<HTMLTextAreaElement>('.js-scoped-required-patterns')) {
const row = textarea.closest('tr')!;
textarea.addEventListener('input', onInputDebounce(() => markRowMatchedContexts(row)));
}
addDelegatedEventListener(form, 'change', '.js-scoped-required-toggle', (checkbox: HTMLInputElement) => syncScopedRequiredRow(checkbox));
}

View File

@@ -7,10 +7,12 @@ type RepoSearchResponse = {data: Array<{repository: {full_name: string}}>};
export function initCompSearchRepoBox(el: HTMLElement) {
const uid = el.getAttribute('data-uid');
const exclusive = el.getAttribute('data-exclusive');
// when set, the selected value is the full "owner/name" rather than the bare repo name, so a cross-owner search can be resolved unambiguously
const fullName = el.getAttribute('data-full-name') === 'true';
let url = `${appSubUrl}/repo/search?q={query}&uid=${uid}`;
if (exclusive === 'true') url += `&exclusive=true`;
attachSearchBox(el, url, (response: RepoSearchResponse) => response.data.map((item) => ({
title: item.repository.full_name.split('/')[1],
title: fullName ? item.repository.full_name : item.repository.full_name.split('/')[1],
description: item.repository.full_name,
})));
}

View File

@@ -88,6 +88,7 @@ export function initRepositoryActionView() {
logsAlwaysAutoScroll: el.getAttribute('data-locale-logs-always-auto-scroll'),
logsAlwaysExpandRunning: el.getAttribute('data-locale-logs-always-expand-running'),
workflowFile: el.getAttribute('data-locale-workflow-file'),
workflowFileNoPermission: el.getAttribute('data-locale-workflow-file-no-permission'),
runDetails: el.getAttribute('data-locale-run-details'),
workflowDependencies: el.getAttribute('data-locale-workflow-dependencies'),
graphJobsCount1: el.getAttribute('data-locale-graph-jobs-count-1'),

View File

@@ -18,6 +18,7 @@ export type ActionsRun = {
done: boolean,
workflowID: string,
workflowLink: string,
canViewWorkflowFile: boolean,
isSchedule: boolean,
runAttempt: number,
attempts: Array<ActionsRunAttempt>,