mirror of
https://github.com/go-gitea/gitea.git
synced 2026-06-29 14:21:25 +00:00
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:
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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").
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
179
models/actions/scoped_workflow.go
Normal file
179
models/actions/scoped_workflow.go
Normal 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
|
||||
}
|
||||
139
models/actions/scoped_workflow_test.go
Normal file
139
models/actions/scoped_workflow_test.go
Normal 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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
42
models/migrations/v1_27/v342.go
Normal file
42
models/migrations/v1_27/v342.go
Normal 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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
51
models/repo/repo_unit_actions_test.go
Normal file
51
models/repo/repo_unit_actions_test.go
Normal 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"))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"},
|
||||
|
||||
83
modules/actions/scoped_workflows.go
Normal file
83
modules/actions/scoped_workflows.go
Normal 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
|
||||
}
|
||||
62
modules/actions/scoped_workflows_test.go
Normal file
62
modules/actions/scoped_workflows_test.go
Normal 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))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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))}}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
|
||||
368
routers/web/shared/actions/scoped_workflows.go
Normal file
368
routers/web/shared/actions/scoped_workflows.go
Normal 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)
|
||||
}
|
||||
75
routers/web/shared/actions/scoped_workflows_test.go
Normal file
75
routers/web/shared/actions/scoped_workflows_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
84
services/actions/scoped_workflow_cache.go
Normal file
84
services/actions/scoped_workflow_cache.go
Normal 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
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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" .}}
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -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" .}}
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"}}"
|
||||
|
||||
@@ -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>
|
||||
|
||||
88
templates/shared/actions/scoped_workflows.tmpl
Normal file
88
templates/shared/actions/scoped_workflows.tmpl
Normal 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}}
|
||||
14
templates/swagger/v1_json.tmpl
generated
14
templates/swagger/v1_json.tmpl
generated
@@ -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)",
|
||||
|
||||
18
templates/swagger/v1_openapi3_json.tmpl
generated
18
templates/swagger/v1_openapi3_json.tmpl
generated
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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}}
|
||||
|
||||
495
tests/integration/actions_scoped_workflow_test.go
Normal file
495
tests/integration/actions_scoped_workflow_test.go
Normal 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})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -121,6 +121,7 @@ export function createEmptyActionsRun(): ActionsRun {
|
||||
done: false,
|
||||
workflowID: '',
|
||||
workflowLink: '',
|
||||
canViewWorkflowFile: true,
|
||||
isSchedule: false,
|
||||
runAttempt: 0,
|
||||
attempts: [],
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
104
web_src/js/features/comp/ScopedWorkflows.test.ts
Normal file
104
web_src/js/features/comp/ScopedWorkflows.test.ts
Normal 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);
|
||||
});
|
||||
38
web_src/js/features/comp/ScopedWorkflows.ts
Normal file
38
web_src/js/features/comp/ScopedWorkflows.ts
Normal 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));
|
||||
}
|
||||
@@ -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,
|
||||
})));
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -18,6 +18,7 @@ export type ActionsRun = {
|
||||
done: boolean,
|
||||
workflowID: string,
|
||||
workflowLink: string,
|
||||
canViewWorkflowFile: boolean,
|
||||
isSchedule: boolean,
|
||||
runAttempt: number,
|
||||
attempts: Array<ActionsRunAttempt>,
|
||||
|
||||
Reference in New Issue
Block a user