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

## Summary

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

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

## Main changes

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

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

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

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

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

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

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

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

<details>
  <summary>Screenshots</summary>

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

</details>


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

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

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

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

<details>
  <summary>Screenshots</summary>

Scoped workflows setting page:

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


Consumer repo's Actions runs list:

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

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

</details>

---

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

---------

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

View File

@@ -1024,6 +1024,11 @@ func ActionsListWorkflowRuns(ctx *context.APIContext) {
// description: if true, the `pull_requests` field on each returned run is emptied
// type: boolean
// required: false
// - name: scoped_workflow_source_repo_id
// description: For a scoped workflow, the ID of the source repository providing it; omit or 0 for a repo-level workflow.
// in: query
// type: integer
// format: int64
// - name: page
// in: query
// description: page number of results to return (1-based)
@@ -1043,20 +1048,25 @@ func ActionsListWorkflowRuns(ctx *context.APIContext) {
// "$ref": "#/responses/notFound"
workflowID := ctx.PathParam("workflow_id")
// Existing runs prove the workflow is/was valid and cover historical workflows
// whose file was later removed. Fall back to a git lookup for never-run workflows.
scopedWorkflowSourceRepoID := ctx.FormInt64("scoped_workflow_source_repo_id")
// Existing runs prove the workflow is/was valid and cover historical workflows whose file was later removed.
// Repo-level never-run workflows fall back to a git lookup; scoped workflows are selected by source repo ID and may return an empty run list.
runExists, err := db.Exist[actions_model.ActionRun](ctx, actions_model.FindRunOptions{
RepoID: ctx.Repo.Repository.ID,
WorkflowID: workflowID,
RepoID: ctx.Repo.Repository.ID,
WorkflowID: workflowID,
WorkflowRepoID: scopedWorkflowSourceRepoID,
IsScopedRun: optional.Some(scopedWorkflowSourceRepoID > 0),
}.ToConds())
if err != nil {
ctx.APIErrorInternal(err)
return
}
if !runExists {
if _, err := convert.GetActionWorkflow(ctx, ctx.Repo.GitRepo, ctx.Repo.Repository, workflowID); err != nil {
ctx.APIErrorAuto(err)
return
if scopedWorkflowSourceRepoID == 0 {
if _, err := convert.GetActionWorkflow(ctx, ctx.Repo.GitRepo, ctx.Repo.Repository, workflowID); err != nil {
ctx.APIErrorAuto(err)
return
}
}
}
@@ -1141,6 +1151,11 @@ func ActionsDispatchWorkflow(ctx *context.APIContext) {
// description: Whether the response should include the workflow run ID and URLs.
// in: query
// type: boolean
// - name: scoped_workflow_source_repo_id
// description: For a scoped workflow, the ID of the source repository providing it; omit or 0 for a repo-level workflow.
// in: query
// type: integer
// format: int64
// responses:
// "200":
// "$ref": "#/responses/RunDetails"
@@ -1162,7 +1177,9 @@ func ActionsDispatchWorkflow(ctx *context.APIContext) {
return
}
runID, err := actions_service.DispatchActionWorkflow(ctx, ctx.Doer, ctx.Repo.Repository, ctx.Repo.GitRepo, workflowID, opt.Ref, func(workflowDispatch *model.WorkflowDispatch, inputs map[string]any) error {
// a non-zero scoped_workflow_source_repo_id dispatches a scoped workflow from that source repo; 0/absent is repo-level.
scopedWorkflowSourceRepoID := ctx.FormInt64("scoped_workflow_source_repo_id")
runID, err := actions_service.DispatchActionWorkflow(ctx, ctx.Doer, ctx.Repo.Repository, ctx.Repo.GitRepo, workflowID, opt.Ref, scopedWorkflowSourceRepoID, func(workflowDispatch *model.WorkflowDispatch, inputs map[string]any) error {
if strings.Contains(ctx.Req.Header.Get("Content-Type"), "form-urlencoded") {
// The chi framework's "Binding" doesn't support to bind the form map values into a map[string]string
// So we have to manually read the `inputs[key]` from the form

View File

@@ -148,6 +148,11 @@ func ListRuns(ctx *context.APIContext, ownerID, repoID int64, workflowID string)
WorkflowID: workflowID,
ListOptions: listOptions,
}
if workflowID != "" {
workflowSourceRepoID := ctx.FormInt64("scoped_workflow_source_repo_id")
opts.IsScopedRun = optional.Some(workflowSourceRepoID > 0)
opts.WorkflowRepoID = workflowSourceRepoID
}
if event := ctx.FormString("event"); event != "" {
opts.TriggerEvent = webhook.HookEventType(event)

View File

@@ -103,16 +103,22 @@ func List(ctx *context.Context) {
if ctx.Written() {
return
}
otherWorkflows := prepareOtherWorkflows(ctx, workflows, curWorkflowID)
curWorkflowRepoID := ctx.FormInt64("scoped_workflow_source_repo_id")
ctx.Data["CurWorkflowRepoID"] = curWorkflowRepoID
scopedNames := prepareScopedWorkflows(ctx, curWorkflowID, curWorkflowRepoID)
if ctx.Written() {
return
}
prepareWorkflowDispatchTemplate(ctx, workflows, curWorkflowID)
otherWorkflows := prepareOtherWorkflows(ctx, workflows, scopedNames, curWorkflowID)
if ctx.Written() {
return
}
prepareWorkflowDispatchTemplate(ctx, workflows, curWorkflowID, curWorkflowRepoID)
if ctx.Written() {
return
}
prepareWorkflowList(ctx, workflows, otherWorkflows)
prepareWorkflowList(ctx, workflows, otherWorkflows, len(scopedNames) > 0)
if ctx.Written() {
return
}
@@ -122,7 +128,7 @@ func List(ctx *context.Context) {
// prepareOtherWorkflows surfaces historical runs whose workflow file no longer
// exists on the default branch (renamed, removed, or only on other branches).
func prepareOtherWorkflows(ctx *context.Context, workflows []WorkflowInfo, curWorkflowID string) []string {
func prepareOtherWorkflows(ctx *context.Context, workflows []WorkflowInfo, scopedNames container.Set[string], curWorkflowID string) []string {
listed := make(container.Set[string], len(workflows))
for _, w := range workflows {
listed.Add(w.Entry.Name())
@@ -130,9 +136,10 @@ func prepareOtherWorkflows(ctx *context.Context, workflows []WorkflowInfo, curWo
var other []string
if ctx.Repo.Repository.NumActionRuns > 0 {
ids, err := actions_model.GetRunWorkflowIDs(ctx, ctx.Repo.Repository.ID)
// "Other workflows" lists repo-level orphans only: GetRepoRunWorkflowIDs excludes scoped runs.
ids, err := actions_model.GetRepoRunWorkflowIDs(ctx, ctx.Repo.Repository.ID)
if err != nil {
ctx.ServerError("GetRunWorkflowIDs", err)
ctx.ServerError("GetRepoRunWorkflowIDs", err)
return nil
}
other = container.FilterSlice(ids, func(id string) (string, bool) {
@@ -141,7 +148,8 @@ func prepareOtherWorkflows(ctx *context.Context, workflows []WorkflowInfo, curWo
}
ctx.Data["OtherWorkflows"] = other
ctx.Data["CurWorkflowIsListed"] = curWorkflowID == "" || listed.Contains(curWorkflowID)
// A selected workflow counts as "listed" if it is a repo-level file or an active scoped workflow.
ctx.Data["CurWorkflowIsListed"] = curWorkflowID == "" || listed.Contains(curWorkflowID) || scopedNames.Contains(curWorkflowID)
return other
}
@@ -171,7 +179,7 @@ func WorkflowDispatchInputs(ctx *context.Context) {
if ctx.Written() {
return
}
prepareWorkflowDispatchTemplate(ctx, workflows, curWorkflowID)
prepareWorkflowDispatchTemplate(ctx, workflows, curWorkflowID, ctx.FormInt64("scoped_workflow_source_repo_id"))
if ctx.Written() {
return
}
@@ -239,6 +247,7 @@ func prepareWorkflowTemplate(ctx *context.Context, commit *git.Commit) (workflow
ctx.Data["workflows"] = workflows
ctx.Data["RepoLink"] = ctx.Repo.Repository.Link()
ctx.Data["RepoID"] = ctx.Repo.Repository.ID
ctx.Data["AllowDisableOrEnableWorkflow"] = ctx.Repo.Permission.IsAdmin()
actionsConfig := ctx.Repo.Repository.MustGetUnit(ctx, unit.TypeActions).ActionsConfig()
ctx.Data["ActionsConfig"] = actionsConfig
@@ -248,21 +257,165 @@ func prepareWorkflowTemplate(ctx *context.Context, commit *git.Commit) (workflow
return workflows, curWorkflowID
}
func prepareWorkflowDispatchTemplate(ctx *context.Context, workflowInfos []WorkflowInfo, curWorkflowID string) {
actionsConfig := ctx.Repo.Repository.MustGetUnit(ctx, unit.TypeActions).ActionsConfig()
if curWorkflowID == "" || !ctx.Repo.Permission.CanWrite(unit.TypeActions) || actionsConfig.IsWorkflowDisabled(curWorkflowID) {
// ScopedWorkflowInfo describes a scoped workflow effective for the current repo, listed under its source group.
type ScopedWorkflowInfo struct {
SourceRepoID int64
EntryName string
DisplayName string
Required bool
Disabled bool
}
// ScopedWorkflowSourceGroup groups the scoped workflows contributed by one source repo for the All-Workflows sidebar.
type ScopedWorkflowSourceGroup struct {
SourceRepoID int64
SourceRepoName string // owner/name of the source repo; shown for instance-level sources and used as the tooltip
SourceRepoShortName string // name only; shown for owner-level sources, where the owner is always the current owner
FromInstance bool // registered at instance level (owner_id == 0) rather than by the owner
IsActive bool // the currently-selected workflow belongs to this source; render the group expanded
Workflows []ScopedWorkflowInfo
}
// prepareScopedWorkflows lists the scoped workflows effective for the repo's owner (and instance) for the All-Workflows sidebar.
func prepareScopedWorkflows(ctx *context.Context, curWorkflowID string, curWorkflowRepoID int64) container.Set[string] {
scopedNames := make(container.Set[string])
repo := ctx.Repo.Repository
sources, err := actions_model.GetEffectiveScopedWorkflowSources(ctx, repo.OwnerID)
if err != nil {
ctx.ServerError("GetEffectiveScopedWorkflowSources", err)
return scopedNames
}
if len(sources) == 0 {
return scopedNames
}
actionsConfig := repo.MustGetUnit(ctx, unit.TypeActions).ActionsConfig()
groups := make([]ScopedWorkflowSourceGroup, 0, len(sources))
seen := make(map[int64]bool, len(sources))
for _, source := range sources {
if seen[source.SourceRepoID] {
continue
}
seen[source.SourceRepoID] = true
sourceRepo, err := repo_model.GetRepositoryByID(ctx, source.SourceRepoID)
if err != nil {
log.Error("scoped workflows list: load source repo %d: %v", source.SourceRepoID, err)
continue
}
if sourceRepo.IsEmpty {
continue
}
_, entries, err := actions_service.LoadParsedScopedWorkflows(ctx, sourceRepo)
if err != nil {
log.Error("scoped workflows list: parse %s: %v", sourceRepo.FullName(), err)
continue
}
if len(entries) == 0 {
continue
}
group := ScopedWorkflowSourceGroup{
SourceRepoID: sourceRepo.ID,
SourceRepoName: sourceRepo.FullName(),
SourceRepoShortName: sourceRepo.Name,
FromInstance: source.OwnerID == 0,
}
for _, e := range entries {
scopedNames.Add(e.EntryName)
required := actions_model.IsWorkflowRequiredInSources(sources, sourceRepo.ID, e.EntryName)
disabled := actionsConfig.IsScopedWorkflowDisabled(sourceRepo.ID, e.EntryName)
group.Workflows = append(group.Workflows, ScopedWorkflowInfo{
SourceRepoID: sourceRepo.ID,
EntryName: e.EntryName,
DisplayName: e.DisplayName,
Required: required,
Disabled: disabled,
})
if curWorkflowID == e.EntryName && curWorkflowRepoID == sourceRepo.ID {
ctx.Data["CurWorkflowDisabled"] = disabled
ctx.Data["CurWorkflowScopedRepoID"] = sourceRepo.ID
ctx.Data["CurWorkflowRequired"] = required
group.IsActive = true // keep this group expanded so the selected workflow stays visible
}
}
groups = append(groups, group)
}
ctx.Data["ScopedWorkflowGroups"] = groups
return scopedNames
}
// loadScopedWorkflowModel reads and parses a scoped workflow's content from its source repo's default branch.
func loadScopedWorkflowModel(ctx *context.Context, repo *repo_model.Repository, sourceRepoID int64, workflowID string) *act_model.Workflow {
effective, err := actions_model.IsScopedWorkflowSourceEffective(ctx, repo.OwnerID, sourceRepoID)
if err != nil {
log.Error("scoped dispatch: IsScopedWorkflowSourceEffective: %v", err)
return nil
}
if !effective {
return nil
}
sourceRepo, err := repo_model.GetRepositoryByID(ctx, sourceRepoID)
if err != nil || sourceRepo.IsEmpty {
return nil
}
content, err := actions_service.ScopedWorkflowContent(ctx, sourceRepo, workflowID)
if err != nil {
log.Error("scoped dispatch: content of %s in %s: %v", workflowID, sourceRepo.RelativePath(), err)
return nil
}
if content == nil {
return nil // the workflow does not exist on the source's default branch
}
wf, err := act_model.ReadWorkflow(bytes.NewReader(content))
if err != nil {
return nil
}
return wf
}
func prepareWorkflowDispatchTemplate(ctx *context.Context, workflowInfos []WorkflowInfo, curWorkflowID string, curWorkflowRepoID int64) {
repo := ctx.Repo.Repository
if curWorkflowID == "" || !ctx.Repo.Permission.CanWrite(unit.TypeActions) {
return
}
actionsConfig := repo.MustGetUnit(ctx, unit.TypeActions).ActionsConfig()
isScoped := curWorkflowRepoID > 0
if isScoped {
// a required scoped workflow can never be opted out, so a stale disabled flag must not hide its dispatch form
optedOut, err := actions_model.IsScopedWorkflowOptedOut(ctx, actionsConfig, repo.OwnerID, curWorkflowRepoID, curWorkflowID)
if err != nil {
log.Error("IsScopedWorkflowOptedOut: %v", err)
return
}
if optedOut {
return
}
} else if actionsConfig.IsWorkflowDisabled(curWorkflowID) {
return
}
var curWorkflow *act_model.Workflow
for _, workflowInfo := range workflowInfos {
if workflowInfo.Entry.Name() == curWorkflowID {
if workflowInfo.Workflow == nil {
log.Debug("CurWorkflowID %s is found but its workflowInfo.Workflow is nil", curWorkflowID)
return
if isScoped {
// a scoped workflow's content lives in its source repo, not in workflowInfos (the consumer's own files)
curWorkflow = loadScopedWorkflowModel(ctx, repo, curWorkflowRepoID, curWorkflowID)
} else {
for _, workflowInfo := range workflowInfos {
if workflowInfo.Entry.Name() == curWorkflowID {
if workflowInfo.Workflow == nil {
log.Debug("CurWorkflowID %s is found but its workflowInfo.Workflow is nil", curWorkflowID)
return
}
curWorkflow = workflowInfo.Workflow
break
}
curWorkflow = workflowInfo.Workflow
break
}
}
@@ -303,10 +456,11 @@ func prepareWorkflowDispatchTemplate(ctx *context.Context, workflowInfos []Workf
ctx.Data["Tags"] = tags
}
func prepareWorkflowList(ctx *context.Context, workflows []WorkflowInfo, otherWorkflows []string) {
func prepareWorkflowList(ctx *context.Context, workflows []WorkflowInfo, otherWorkflows []string, hasScopedWorkflows bool) {
actorID := ctx.FormInt64("actor")
status := ctx.FormInt("status")
workflowID := ctx.FormString("workflow")
scopedWorkflowSourceRepoID := ctx.FormInt64("scoped_workflow_source_repo_id")
branch := ctx.FormString("branch")
page := ctx.FormInt("page")
if page <= 0 {
@@ -327,9 +481,15 @@ func prepareWorkflowList(ctx *context.Context, workflows []WorkflowInfo, otherWo
Page: page,
PageSize: convert.ToCorrectPageSize(ctx.FormInt("limit")),
},
RepoID: ctx.Repo.Repository.ID,
WorkflowID: workflowID,
TriggerUserID: actorID,
RepoID: ctx.Repo.Repository.ID,
WorkflowID: workflowID,
WorkflowRepoID: scopedWorkflowSourceRepoID,
TriggerUserID: actorID,
}
// Constrain scoped vs repo-level only for a listed workflow, whose link carries scoped_workflow_source_repo_id.
if workflowID != "" && !slices.Contains(otherWorkflows, workflowID) {
opts.IsScopedRun = optional.Some(scopedWorkflowSourceRepoID > 0)
}
// if status is not StatusUnknown, it means user has selected a status filter
@@ -422,7 +582,11 @@ func prepareWorkflowList(ctx *context.Context, workflows []WorkflowInfo, otherWo
}
}
ctx.Data["WorkflowNames"] = workflowNames
prepareWorkflowBadgeTemplate(ctx, workflowID, workflowDisplayName)
// A scoped workflow has no repo-level badge on this repo (the badge endpoint reads is_scoped_run=false runs),
// so don't offer the "create status badge" entry for it.
if scopedWorkflowSourceRepoID == 0 {
prepareWorkflowBadgeTemplate(ctx, workflowID, workflowDisplayName)
}
actors, err := actions_model.GetActors(ctx, ctx.Repo.Repository.ID)
if err != nil {
@@ -443,7 +607,7 @@ func prepareWorkflowList(ctx *context.Context, workflows []WorkflowInfo, otherWo
pager := context.NewPagination(total, opts.PageSize, opts.Page, 5)
pager.AddParamFromRequest(ctx.Req)
ctx.Data["Page"] = pager
ctx.Data["HasWorkflowsOrRuns"] = len(workflows) > 0 || len(otherWorkflows) > 0 || len(runs) > 0
ctx.Data["HasWorkflowsOrRuns"] = len(workflows) > 0 || len(otherWorkflows) > 0 || len(runs) > 0 || hasScopedWorkflows
ctx.Data["CanWriteRepoUnitActions"] = ctx.Repo.Permission.CanWrite(unit.TypeActions)
}
@@ -583,10 +747,11 @@ func decodeNode(node yaml.Node, out any) bool {
return true
}
func actionsListRedirectURL(repoLink, workflow, actor, status, branch string) string {
return fmt.Sprintf("%s/actions?workflow=%s&actor=%s&status=%s&branch=%s",
func actionsListRedirectURL(repoLink, workflow, scopedWorkflowSourceRepoID, actor, status, branch string) string {
return fmt.Sprintf("%s/actions?workflow=%s&scoped_workflow_source_repo_id=%s&actor=%s&status=%s&branch=%s",
repoLink,
url.QueryEscape(workflow),
url.QueryEscape(scopedWorkflowSourceRepoID),
url.QueryEscape(actor),
url.QueryEscape(status),
url.QueryEscape(branch),

View File

@@ -21,12 +21,14 @@ import (
"gitea.dev/models/db"
git_model "gitea.dev/models/git"
issues_model "gitea.dev/models/issues"
access_model "gitea.dev/models/perm/access"
repo_model "gitea.dev/models/repo"
"gitea.dev/models/unit"
"gitea.dev/modules/actions"
"gitea.dev/modules/base"
"gitea.dev/modules/cache"
"gitea.dev/modules/git"
"gitea.dev/modules/gitrepo"
"gitea.dev/modules/httplib"
"gitea.dev/modules/json"
"gitea.dev/modules/log"
@@ -243,6 +245,11 @@ func ViewWorkflowFile(ctx *context_module.Context) {
return
}
if run.IsScopedRun {
viewScopedWorkflowFile(ctx, run)
return
}
commit, err := ctx.Repo.GitRepo.GetCommit(run.CommitSHA)
if err != nil {
ctx.NotFoundOrServerError("GetCommit", func(err error) bool {
@@ -293,25 +300,26 @@ type ViewResponse struct {
// ViewLink is the attempt-aware URL for navigation, e.g. "/owner/repo/actions/runs/123" for the latest attempt
// or "/owner/repo/actions/runs/123/attempts/2" for a historical attempt.
// Use this when the target should reflect the currently-viewed attempt.
ViewLink string `json:"viewLink"`
Index int64 `json:"index"` // the per-repository run number, displayed as "#N"
Title string `json:"title"`
TitleHTML template.HTML `json:"titleHTML"`
Status string `json:"status"`
CanCancel bool `json:"canCancel"`
CanApprove bool `json:"canApprove"` // the run needs an approval and the doer has permission to approve
CanRerun bool `json:"canRerun"`
CanRerunFailed bool `json:"canRerunFailed"`
CanDeleteArtifact bool `json:"canDeleteArtifact"`
Done bool `json:"done"`
WorkflowID string `json:"workflowID"`
WorkflowLink string `json:"workflowLink"`
IsSchedule bool `json:"isSchedule"`
RunAttempt int64 `json:"runAttempt"`
Attempts []*ViewRunAttempt `json:"attempts"`
Jobs []*ViewJob `json:"jobs"`
Commit ViewCommit `json:"commit"`
PullRequest *ViewPullRequest `json:"pullRequest,omitempty"`
ViewLink string `json:"viewLink"`
Index int64 `json:"index"` // the per-repository run number, displayed as "#N"
Title string `json:"title"`
TitleHTML template.HTML `json:"titleHTML"`
Status string `json:"status"`
CanCancel bool `json:"canCancel"`
CanApprove bool `json:"canApprove"` // the run needs an approval and the doer has permission to approve
CanRerun bool `json:"canRerun"`
CanRerunFailed bool `json:"canRerunFailed"`
CanDeleteArtifact bool `json:"canDeleteArtifact"`
Done bool `json:"done"`
WorkflowID string `json:"workflowID"`
WorkflowLink string `json:"workflowLink"`
CanViewWorkflowFile bool `json:"canViewWorkflowFile"`
IsSchedule bool `json:"isSchedule"`
RunAttempt int64 `json:"runAttempt"`
Attempts []*ViewRunAttempt `json:"attempts"`
Jobs []*ViewJob `json:"jobs"`
Commit ViewCommit `json:"commit"`
PullRequest *ViewPullRequest `json:"pullRequest,omitempty"`
// Summary view: run duration and trigger time/event
Duration string `json:"duration"`
TriggeredAt int64 `json:"triggeredAt"` // unix seconds for relative time
@@ -600,6 +608,11 @@ func fillViewRunResponseSummary(ctx *context_module.Context, resp *ViewResponse,
if isLatestAttempt {
resp.State.Run.WorkflowLink = run.WorkflowLink()
}
resp.State.Run.CanViewWorkflowFile = true
if run.IsScopedRun {
// For a scoped run the workflow file lives in the source repo; only show its link when the viewer can read that repo.
resp.State.Run.CanViewWorkflowFile = canViewScopedWorkflowFile(ctx, run)
}
resp.State.Run.IsSchedule = run.IsSchedule()
resp.State.Run.Jobs = make([]*ViewJob, 0, len(jobs)) // marshal to '[]' instead fo 'null' in json
for _, v := range jobs {
@@ -848,7 +861,16 @@ func checkRunRerunAllowed(ctx *context_module.Context, run *actions_model.Action
}
cfgUnit := ctx.Repo.Repository.MustGetUnit(ctx, unit.TypeActions)
cfg := cfgUnit.ActionsConfig()
if cfg.IsWorkflowDisabled(run.WorkflowID) {
disabled := cfg.IsWorkflowDisabled(run.WorkflowID)
if run.IsScopedRun {
optedOut, err := actions_model.IsScopedWorkflowOptedOut(ctx, cfg, ctx.Repo.Repository.OwnerID, run.WorkflowRepoID, run.WorkflowID)
if err != nil {
ctx.ServerError("IsScopedWorkflowOptedOut", err)
return false
}
disabled = optedOut
}
if disabled {
ctx.JSONError(ctx.Locale.Tr("actions.workflow.disabled"))
return false
}
@@ -1276,7 +1298,24 @@ func disableOrEnableWorkflowFile(ctx *context_module.Context, isEnable bool) {
cfgUnit := ctx.Repo.Repository.MustGetUnit(ctx, unit.TypeActions)
cfg := cfgUnit.ActionsConfig()
if isEnable {
scopedRepoID := ctx.FormInt64("scoped_workflow_source_repo_id")
if scopedRepoID > 0 {
if !isEnable {
// a required scoped workflow can never be opted out
required, err := actions_model.IsScopedWorkflowRequired(ctx, ctx.Repo.Repository.OwnerID, scopedRepoID, workflow)
if err != nil {
ctx.ServerError("IsScopedWorkflowRequired", err)
return
}
if required {
ctx.JSONError(ctx.Locale.Tr("actions.workflow.scoped_required_cannot_disable"))
return
}
cfg.DisableScopedWorkflow(scopedRepoID, workflow)
} else {
cfg.EnableScopedWorkflow(scopedRepoID, workflow)
}
} else if isEnable {
cfg.EnableWorkflow(workflow)
} else {
cfg.DisableWorkflow(workflow)
@@ -1293,13 +1332,13 @@ func disableOrEnableWorkflowFile(ctx *context_module.Context, isEnable bool) {
ctx.Flash.Success(ctx.Tr("actions.workflow.disable_success", workflow))
}
redirectURL := actionsListRedirectURL(ctx.Repo.RepoLink, workflow,
redirectURL := actionsListRedirectURL(ctx.Repo.RepoLink, workflow, ctx.FormString("scoped_workflow_source_repo_id"),
ctx.FormString("actor"), ctx.FormString("status"), ctx.FormString("branch"))
ctx.JSONRedirect(redirectURL)
}
func Run(ctx *context_module.Context) {
redirectURL := actionsListRedirectURL(ctx.Repo.RepoLink, ctx.FormString("workflow"),
redirectURL := actionsListRedirectURL(ctx.Repo.RepoLink, ctx.FormString("workflow"), ctx.FormString("scoped_workflow_source_repo_id"),
ctx.FormString("actor"), ctx.FormString("status"), ctx.FormString("branch"))
workflowID := ctx.FormString("workflow")
@@ -1313,7 +1352,8 @@ func Run(ctx *context_module.Context) {
ctx.ServerError("ref", nil)
return
}
_, err := actions_service.DispatchActionWorkflow(ctx, ctx.Doer, ctx.Repo.Repository, ctx.Repo.GitRepo, workflowID, ref, func(workflowDispatch *model.WorkflowDispatch, inputs map[string]any) error {
sourceRepoID := ctx.FormInt64("scoped_workflow_source_repo_id")
_, err := actions_service.DispatchActionWorkflow(ctx, ctx.Doer, ctx.Repo.Repository, ctx.Repo.GitRepo, workflowID, ref, sourceRepoID, func(workflowDispatch *model.WorkflowDispatch, inputs map[string]any) error {
for name, config := range workflowDispatch.Inputs {
value := ctx.Req.PostFormValue(name)
if config.Type == "boolean" {
@@ -1339,3 +1379,65 @@ func Run(ctx *context_module.Context) {
ctx.Flash.Success(ctx.Tr("actions.workflow.run_success", workflowID))
ctx.Redirect(redirectURL)
}
// viewScopedWorkflowFile redirects to the scoped workflow file in its SOURCE repo.
func viewScopedWorkflowFile(ctx *context_module.Context, run *actions_model.ActionRun) {
sourceRepo, err := repo_model.GetRepositoryByID(ctx, run.WorkflowRepoID)
if err != nil {
ctx.NotFoundOrServerError("GetRepositoryByID", func(err error) bool {
return errors.Is(err, util.ErrNotExist)
}, err)
return
}
perm, err := access_model.GetDoerRepoPermission(ctx, sourceRepo, ctx.Doer)
if err != nil {
ctx.ServerError("GetUserRepoPermission", err)
return
}
if !perm.CanRead(unit.TypeCode) {
ctx.NotFound(nil)
return
}
sourceGitRepo, err := gitrepo.OpenRepository(ctx, sourceRepo)
if err != nil {
ctx.ServerError("OpenRepository", err)
return
}
defer sourceGitRepo.Close()
commit, err := sourceGitRepo.GetCommit(run.WorkflowCommitSHA)
if err != nil {
ctx.NotFoundOrServerError("GetCommit", func(err error) bool {
return errors.Is(err, util.ErrNotExist)
}, err)
return
}
rpath, entries, err := actions.ListScopedWorkflows(commit)
if err != nil {
ctx.ServerError("ListScopedWorkflows", err)
return
}
for _, entry := range entries {
if entry.Name() == run.WorkflowID {
ctx.Redirect(fmt.Sprintf("%s/src/commit/%s/%s/%s", sourceRepo.Link(), url.PathEscape(run.WorkflowCommitSHA), util.PathEscapeSegments(rpath), util.PathEscapeSegments(run.WorkflowID)))
return
}
}
ctx.NotFound(nil)
}
// canViewScopedWorkflowFile reports whether the viewer may follow the "Workflow file" link of a scoped run.
func canViewScopedWorkflowFile(ctx *context_module.Context, run *actions_model.ActionRun) bool {
sourceRepo, err := repo_model.GetRepositoryByID(ctx, run.WorkflowRepoID)
if err != nil {
return false
}
perm, err := access_model.GetDoerRepoPermission(ctx, sourceRepo, ctx.Doer)
if err != nil {
log.Error("GetUserRepoPermission: %v", err)
return false
}
return perm.CanRead(unit.TypeCode)
}

View File

@@ -949,7 +949,9 @@ func (prInfo *pullRequestViewInfo) prepareMergeBox(ctx *context.Context, issue *
// admin can merge without checks, writer can merge when checks succeed
// admin and writer both can make an auto merge schedule (not affected by overridable blockers)
data.hasStatusCheckBlocker = data.enableStatusCheck && !data.StatusCheckData.RequiredChecksState.IsSuccess()
// Required scoped workflow checks gate the merge even when the rule's own status check is disabled (see IsPullCommitStatusPass),
// so block on any required status context, not only when enableStatusCheck is on.
data.hasStatusCheckBlocker = (data.enableStatusCheck || data.hasRequiredStatusContexts) && !data.StatusCheckData.RequiredChecksState.IsSuccess()
// this logic is from:
// {{$notAllOverridableChecksOk := or .IsBlockedByApprovals .IsBlockedByRejection .IsBlockedByOfficialReviewRequests .IsBlockedByOutdatedBranch .IsBlockedByChangedProtectedFiles (and .EnableStatusCheck (not $requiredStatusCheckState.IsSuccess))}}

View File

@@ -276,6 +276,10 @@ type pullMergeBoxData struct {
enableStatusCheck bool
StatusCheckData *pullCommitStatusCheckData
ShowStatusCheck bool
// hasRequiredStatusContexts is true when at least one required status-check context must be satisfied:
// the branch protection's own contexts and/or required scoped workflow checks.
// The latter gate the merge even when the rule's own status check is disabled.
hasRequiredStatusContexts bool
hasOverridableBlockers bool
canMergeNow bool // PR is mergeable, either no blocker, or doer can bypass the blockers
@@ -423,6 +427,16 @@ func (prInfo *pullRequestViewInfo) prepareMergeBoxStatusCheckData(ctx *context.C
if err != nil {
log.Error("GetLatestCommitStatus: %v", err)
}
// Effective required contexts = branch-protection contexts + required scoped workflow checks.
requiredContexts := pbRequiredContexts
if effective, err := pull_service.EffectiveRequiredContexts(ctx, ctx.Repo.Repository, prInfo.ProtectedBranchRule); err != nil {
log.Error("EffectiveRequiredContexts: %v", err)
} else {
requiredContexts = effective
}
data.hasRequiredStatusContexts = len(requiredContexts) > 0
if !ctx.Repo.Permission.CanRead(unit.TypeActions) {
git_model.CommitStatusesHideActionsURL(ctx, commitStatuses)
}
@@ -433,7 +447,9 @@ func (prInfo *pullRequestViewInfo) prepareMergeBoxStatusCheckData(ctx *context.C
statusCheckData.pullCommitStatusState = combinedCommitStatus.State
}
data.ShowStatusCheck = data.enableStatusCheck || len(statusCheckData.PullCommitStatuses) > 0
// Required scoped workflow checks gate the merge even when the branch protection's own status check is disabled,
// so the status-check section must render when there are any required contexts, not only when enableStatusCheck is on.
data.ShowStatusCheck = data.enableStatusCheck || data.hasRequiredStatusContexts || len(statusCheckData.PullCommitStatuses) > 0
runs, err := actions_service.GetRunsFromCommitStatuses(ctx, commitStatuses)
if err != nil {
@@ -449,7 +465,7 @@ func (prInfo *pullRequestViewInfo) prepareMergeBoxStatusCheckData(ctx *context.C
}
var missingRequiredChecks []string
for _, requiredContext := range pbRequiredContexts {
for _, requiredContext := range requiredContexts {
contextFound := false
matchesRequiredContext := createRequiredContextMatcher(requiredContext)
for _, presentStatus := range commitStatuses {
@@ -466,7 +482,7 @@ func (prInfo *pullRequestViewInfo) prepareMergeBoxStatusCheckData(ctx *context.C
statusCheckData.MissingRequiredChecks = missingRequiredChecks
statusCheckData.IsContextRequired = func(context string) bool {
for _, c := range pbRequiredContexts {
for _, c := range requiredContexts {
if c == context {
return true
}
@@ -481,9 +497,9 @@ func (prInfo *pullRequestViewInfo) prepareMergeBoxStatusCheckData(ctx *context.C
}
return false
}
statusCheckData.RequiredChecksState = pull_service.MergeRequiredContextsCommitStatus(commitStatuses, pbRequiredContexts)
statusCheckData.RequiredChecksState = pull_service.MergeRequiredContextsCommitStatus(commitStatuses, requiredContexts)
if data.enableStatusCheck {
if data.enableStatusCheck || data.hasRequiredStatusContexts {
if statusCheckData.RequiredChecksState.IsError() || statusCheckData.RequiredChecksState.IsFailure() {
data.infoProtectionBlockers.AddErrorItem(ctx.Locale.Tr("repo.pulls.required_status_check_failed"))
} else if !statusCheckData.RequiredChecksState.IsSuccess() {

View File

@@ -63,7 +63,7 @@ func (prInfo *pullRequestViewInfo) prepareMergeBoxIconColor() {
showAsWarningColor = showAsWarningColor ||
statusCheckData.pullCommitStatusState.IsWarning() || statusCheckData.pullCommitStatusState.IsPending() ||
(mergeBoxData.enableStatusCheck && (statusCheckData.RequiredChecksState.IsWarning() || statusCheckData.RequiredChecksState.IsPending()))
((mergeBoxData.enableStatusCheck || mergeBoxData.hasRequiredStatusContexts) && (statusCheckData.RequiredChecksState.IsWarning() || statusCheckData.RequiredChecksState.IsPending()))
}
hasBlockers := len(mergeBoxData.infoCommitBlockers.items) > 0 || len(mergeBoxData.infoProtectionBlockers.items) > 0

View File

@@ -0,0 +1,368 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package actions
import (
"errors"
"net/http"
"slices"
"strings"
actions_model "gitea.dev/models/actions"
repo_model "gitea.dev/models/repo"
actions_module "gitea.dev/modules/actions"
"gitea.dev/modules/actions/jobparser"
"gitea.dev/modules/container"
"gitea.dev/modules/log"
"gitea.dev/modules/setting"
"gitea.dev/modules/templates"
"gitea.dev/modules/util"
shared_user "gitea.dev/routers/web/shared/user"
actions_service "gitea.dev/services/actions"
"gitea.dev/services/context"
)
const (
tplOrgScopedWorkflows templates.TplName = "org/settings/actions"
tplUserScopedWorkflows templates.TplName = "user/settings/actions"
tplAdminScopedWorkflows templates.TplName = "admin/actions"
)
type scopedWorkflowsCtx struct {
OwnerID int64 // 0 = instance-level
IsOrg bool
IsUser bool
IsGlobal bool
Template templates.TplName
RedirectLink string
// SearchUID is the uid passed to the repo-search box. For org/user it scopes the search to that owner;
// for admin (0) it searches all repos and therefore requires admin access on the route.
SearchUID int64
}
func getScopedWorkflowsCtx(ctx *context.Context) (*scopedWorkflowsCtx, error) {
if ctx.Data["PageIsOrgSettings"] == true {
if _, err := shared_user.RenderUserOrgHeader(ctx); err != nil {
ctx.ServerError("RenderUserOrgHeader", err)
return nil, nil //nolint:nilnil // error is already handled by ctx.ServerError
}
return &scopedWorkflowsCtx{
OwnerID: ctx.Org.Organization.ID,
IsOrg: true,
Template: tplOrgScopedWorkflows,
RedirectLink: ctx.Org.OrgLink + "/settings/actions/scoped-workflows",
SearchUID: ctx.Org.Organization.ID,
}, nil
}
if ctx.Data["PageIsUserSettings"] == true {
return &scopedWorkflowsCtx{
OwnerID: ctx.Doer.ID,
IsUser: true,
Template: tplUserScopedWorkflows,
RedirectLink: setting.AppSubURL + "/user/settings/actions/scoped-workflows",
SearchUID: ctx.Doer.ID,
}, nil
}
if ctx.Data["PageIsAdmin"] == true {
return &scopedWorkflowsCtx{
OwnerID: 0,
IsGlobal: true,
Template: tplAdminScopedWorkflows,
RedirectLink: setting.AppSubURL + "/-/admin/actions/scoped-workflows",
SearchUID: 0,
}, nil
}
return nil, errors.New("unable to set scoped workflows context")
}
// scopedWorkflowInfo is one scoped workflow shown on the settings page, merged with its stored merge-gate config.
type scopedWorkflowInfo struct {
EntryName string
DisplayName string
Required bool
Patterns string // newline-joined stored status-check patterns (kept even when not required, as history)
Contexts []string // the commit-status contexts this workflow is expected to post, to preview which patterns match
Missing bool // the workflow file no longer exists on the source default branch, but a stored config lingers and must stay clearable
}
// scopedWorkflowSourceView is the per-source data shown on the settings page.
type scopedWorkflowSourceView struct {
Repo *repo_model.Repository
ScopedWorkflowInfos []scopedWorkflowInfo
}
func ScopedWorkflows(ctx *context.Context) {
ctx.Data["Title"] = ctx.Tr("actions.scoped_workflows")
ctx.Data["PageType"] = "scoped-workflows"
ctx.Data["PageIsSharedSettingsScopedWorkflows"] = true
swCtx, err := getScopedWorkflowsCtx(ctx)
if err != nil {
ctx.ServerError("getScopedWorkflowsCtx", err)
return
}
if ctx.Written() {
return
}
switch {
case swCtx.IsOrg:
ctx.Data["ScopedWorkflowsDesc"] = ctx.Tr("actions.scoped_workflows.desc_org")
case swCtx.IsUser:
ctx.Data["ScopedWorkflowsDesc"] = ctx.Tr("actions.scoped_workflows.desc_user")
default: // instance-level
ctx.Data["ScopedWorkflowsDesc"] = ctx.Tr("actions.scoped_workflows.desc_global")
}
sources, err := actions_model.GetScopedWorkflowSourcesByOwner(ctx, swCtx.OwnerID)
if err != nil {
ctx.ServerError("GetScopedWorkflowSourcesByOwner", err)
return
}
views := make([]*scopedWorkflowSourceView, 0, len(sources))
for _, src := range sources {
repo, err := repo_model.GetRepositoryByID(ctx, src.SourceRepoID)
if err != nil {
log.Error("scoped workflows settings: load source repo %d: %v", src.SourceRepoID, err)
continue
}
views = append(views, &scopedWorkflowSourceView{
Repo: repo,
ScopedWorkflowInfos: listSourceScopedWorkflowFiles(ctx, repo, src.WorkflowConfigs),
})
}
ctx.Data["ScopedWorkflowSources"] = views
ctx.Data["RepoSearchUID"] = swCtx.SearchUID
// owner/user scopes the repo search to the owner (exclusive);
// instance-level (admin) searches all repos and so must submit owner/name to disambiguate the selection across owners.
ctx.Data["ScopedWorkflowsSearchExclusive"] = !swCtx.IsGlobal
ctx.Data["ScopedWorkflowsSearchFullName"] = swCtx.IsGlobal
ctx.Data["RedirectLink"] = swCtx.RedirectLink
ctx.Data["ScopedWorkflowDirs"] = strings.Join(setting.Actions.ScopedWorkflowDirs, ", ")
ctx.HTML(http.StatusOK, swCtx.Template)
}
// parsePatternLines splits a textarea value into trimmed, non-empty status-check patterns (one per line).
func parsePatternLines(raw string) []string {
var patterns []string
for line := range strings.SplitSeq(raw, "\n") {
if p := strings.TrimSpace(line); p != "" {
patterns = append(patterns, p)
}
}
return patterns
}
// deriveScopedStatusContexts returns the commit-status contexts a scoped workflow is expected to post on a consumer:
// "<source FullName>: <display> / <job> (<event>)" for each parsed job (matrix-expanded, matching run creation) and triggering event.
// Job names that depend on run-context expressions cannot resolve here (no run context) and appear as authored; a glob pattern still matches them.
func deriveScopedStatusContexts(prefix, displayName string, content []byte, events []*jobparser.Event) []string {
parsed, err := jobparser.Parse(content)
if err != nil {
return nil
}
eventNames := make([]string, 0, len(events))
for _, e := range events {
// only events whose runs post a commit status can be a required check; workflow_dispatch, schedule, etc. post none.
if actions_module.ShouldEventCreateCommitStatus(e.Name) {
eventNames = append(eventNames, e.Name)
}
}
seen := make(container.Set[string])
contexts := make([]string, 0, len(parsed)*len(eventNames))
for _, sw := range parsed {
_, job := sw.Job()
if job == nil {
continue
}
jobName := util.EllipsisDisplayString(job.Name, 255) // run creation truncates job names the same way
for _, ev := range eventNames {
ctxName := actions_module.ScopedWorkflowStatusContextName(prefix, displayName, jobName, ev)
if seen.Contains(ctxName) {
continue
}
seen.Add(ctxName)
contexts = append(contexts, ctxName)
}
}
return contexts
}
func listSourceScopedWorkflowFiles(ctx *context.Context, repo *repo_model.Repository, configs map[string]*actions_model.ScopedWorkflowConfig) []scopedWorkflowInfo {
rendered := make(container.Set[string], len(configs))
files := make([]scopedWorkflowInfo, 0, len(configs))
// An empty source repo (or one that fails to parse) has no live workflow files, but a previously-saved config may still linger;
// fall through to surface those as orphan rows below so they remain clearable.
if !repo.IsEmpty {
_, parsed, err := actions_service.LoadParsedScopedWorkflows(ctx, repo)
if err != nil {
log.Error("scoped workflows settings: parse %s: %v", repo.RelativePath(), err)
} else {
for _, p := range parsed {
info := scopedWorkflowInfo{
EntryName: p.EntryName,
DisplayName: p.DisplayName,
Contexts: deriveScopedStatusContexts(repo.FullName(), p.DisplayName, p.Content, p.Events),
}
if cfg := configs[p.EntryName]; cfg != nil {
info.Required = cfg.Required
info.Patterns = strings.Join(cfg.Patterns, "\n")
}
rendered.Add(p.EntryName)
files = append(files, info)
}
}
}
// Surface configs whose workflow file no longer exists on the source default branch as orphan rows.
// A required orphan still gates merges (must-present), so the owner/admin must be able to see and clear it;
// otherwise the only escape would be removing the whole source registration.
orphans := make([]scopedWorkflowInfo, 0, len(configs))
for name, cfg := range configs {
if cfg == nil || rendered.Contains(name) {
continue
}
orphans = append(orphans, scopedWorkflowInfo{
EntryName: name,
DisplayName: name,
Required: cfg.Required,
Patterns: strings.Join(cfg.Patterns, "\n"),
Missing: true,
})
}
// map iteration order is random; sort orphans for a stable settings page
slices.SortFunc(orphans, func(a, b scopedWorkflowInfo) int { return strings.Compare(a.EntryName, b.EntryName) })
return append(files, orphans...)
}
func ScopedWorkflowAdd(ctx *context.Context) {
swCtx, err := getScopedWorkflowsCtx(ctx)
if err != nil {
ctx.ServerError("getScopedWorkflowsCtx", err)
return
}
if ctx.Written() {
return
}
repoName := ctx.FormString("repo_name")
var repo *repo_model.Repository
if swCtx.IsGlobal {
// instance-level: the source may be any repo on the instance, identified by owner/name
ownerName, name, ok := strings.Cut(repoName, "/")
if !ok {
ctx.JSONError(ctx.Tr("actions.scoped_workflows.source.not_found"))
return
}
repo, err = repo_model.GetRepositoryByOwnerAndName(ctx, ownerName, name)
} else {
// owner-level: resolve within the owner, which also enforces that the source is one of the owner's own repositories
repo, err = repo_model.GetRepositoryByName(ctx, swCtx.OwnerID, repoName)
}
if err != nil {
ctx.JSONError(ctx.Tr("actions.scoped_workflows.source.not_found"))
return
}
if err := actions_model.AddScopedWorkflowSource(ctx, swCtx.OwnerID, repo.ID); err != nil {
ctx.ServerError("AddScopedWorkflowSource", err)
return
}
ctx.Flash.Success(ctx.Tr("actions.scoped_workflows.source.add_success"))
ctx.JSONRedirect(swCtx.RedirectLink)
}
func ScopedWorkflowSetRequired(ctx *context.Context) {
swCtx, err := getScopedWorkflowsCtx(ctx)
if err != nil {
ctx.ServerError("getScopedWorkflowsCtx", err)
return
}
if ctx.Written() {
return
}
repoID := ctx.FormInt64("repo_id")
// the source must be registered for this owner
if _, err := actions_model.GetScopedWorkflowSource(ctx, swCtx.OwnerID, repoID); err != nil {
if errors.Is(err, util.ErrNotExist) {
ctx.JSONError(ctx.Tr("actions.scoped_workflows.source.not_found"))
} else {
ctx.ServerError("GetScopedWorkflowSource", err)
}
return
}
// Live workflow entry names on the source default branch, used to distinguish orphan configs (whose workflow file no longer exists) from live ones.
sourceRepo, err := repo_model.GetRepositoryByID(ctx, repoID)
if err != nil {
ctx.ServerError("GetRepositoryByID", err)
return
}
liveSet := make(container.Set[string])
if !sourceRepo.IsEmpty { // an empty source has no live workflows
_, parsed, err := actions_service.LoadParsedScopedWorkflows(ctx, sourceRepo)
if err != nil {
ctx.ServerError("LoadParsedScopedWorkflows", err)
return
}
for _, p := range parsed {
liveSet.Add(p.EntryName)
}
}
// Every workflow row submits its ID in workflow_ids and its patterns (one per line) in required_patterns[<id>];
// checked rows additionally submit their ID in required_workflow_ids.
// A required workflow must have at least one pattern.
requiredSet := make(container.Set[string])
for _, workflowID := range ctx.FormStrings("required_workflow_ids") {
requiredSet.Add(workflowID)
}
configs := make(map[string]*actions_model.ScopedWorkflowConfig)
for _, workflowID := range ctx.FormStrings("workflow_ids") {
patterns := parsePatternLines(ctx.FormString("required_patterns[" + workflowID + "]"))
required := requiredSet.Contains(workflowID)
if required && len(patterns) == 0 {
ctx.JSONError(ctx.Tr("actions.scoped_workflows.required.patterns_empty"))
return
}
// Keep a config only if it is required, or it is a still-existing.
// An orphan (file no longer in the source) that is not required is dropped.
if required || (liveSet.Contains(workflowID) && len(patterns) > 0) {
configs[workflowID] = &actions_model.ScopedWorkflowConfig{Required: required, Patterns: patterns}
}
}
if err := actions_model.SetScopedWorkflowSourceConfigs(ctx, swCtx.OwnerID, repoID, configs); err != nil {
ctx.ServerError("SetScopedWorkflowSourceConfigs", err)
return
}
ctx.Flash.Success(ctx.Tr("actions.scoped_workflows.required.update_success"))
ctx.JSONRedirect(swCtx.RedirectLink)
}
func ScopedWorkflowRemove(ctx *context.Context) {
swCtx, err := getScopedWorkflowsCtx(ctx)
if err != nil {
ctx.ServerError("getScopedWorkflowsCtx", err)
return
}
if ctx.Written() {
return
}
repoID := ctx.FormInt64("repo_id")
if err := actions_model.RemoveScopedWorkflowSource(ctx, swCtx.OwnerID, repoID); err != nil {
ctx.ServerError("RemoveScopedWorkflowSource", err)
return
}
ctx.Flash.Success(ctx.Tr("actions.scoped_workflows.source.remove_success"))
ctx.JSONRedirect(swCtx.RedirectLink)
}

View File

@@ -0,0 +1,75 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package actions
import (
"testing"
actions_module "gitea.dev/modules/actions"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestDeriveScopedStatusContexts(t *testing.T) {
t.Run("jobs x events; job name is its name: or its id", func(t *testing.T) {
content := []byte(`name: CI
on: [push, pull_request]
jobs:
lint:
runs-on: ubuntu-latest
steps:
- run: echo
build:
name: Build It
runs-on: ubuntu-latest
steps:
- run: echo
`)
events, err := actions_module.GetEventsFromContent(content)
require.NoError(t, err)
got := deriveScopedStatusContexts("org/src", "CI", content, events)
assert.ElementsMatch(t, []string{
"org/src: CI / lint (push)",
"org/src: CI / lint (pull_request)",
"org/src: CI / Build It (push)",
"org/src: CI / Build It (pull_request)",
}, got)
})
t.Run("only status-producing events; workflow_dispatch/schedule/workflow_call skipped", func(t *testing.T) {
content := []byte(`name: CI
on:
push:
workflow_dispatch:
workflow_call:
schedule:
- cron: "0 0 * * *"
jobs:
j:
runs-on: ubuntu-latest
steps:
- run: echo
`)
events, err := actions_module.GetEventsFromContent(content)
require.NoError(t, err)
got := deriveScopedStatusContexts("org/src", "CI", content, events)
assert.Equal(t, []string{"org/src: CI / j (push)"}, got) // only push posts a commit status
})
t.Run("a workflow_dispatch-only workflow has no expected contexts", func(t *testing.T) {
content := []byte(`name: Deploy
on: workflow_dispatch
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- run: echo
`)
events, err := actions_module.GetEventsFromContent(content)
require.NoError(t, err)
got := deriveScopedStatusContexts("org/src", "Deploy", content, events)
assert.Empty(t, got) // workflow_dispatch posts no commit status -> nothing to preview (and it cannot be a required check)
})
}

View File

@@ -500,6 +500,15 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) {
})
}
addSettingsScopedWorkflowsRoutes := func() {
m.Group("/scoped-workflows", func() {
m.Get("", shared_actions.ScopedWorkflows)
m.Post("/add", shared_actions.ScopedWorkflowAdd)
m.Post("/required", shared_actions.ScopedWorkflowSetRequired)
m.Post("/remove", shared_actions.ScopedWorkflowRemove)
})
}
// FIXME: not all routes need go through same middleware.
// Especially some AJAX requests, we can reduce middleware number to improve performance.
@@ -702,6 +711,7 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) {
addSettingsRunnersRoutes()
addSettingsSecretsRoutes()
addSettingsVariablesRoutes()
addSettingsScopedWorkflowsRoutes()
}, actions.MustEnableActions)
m.Get("/organization", user_setting.Organization)
@@ -865,6 +875,7 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) {
addSettingsRunnersRoutes()
m.Post("/runners/bulk", shared_actions.RunnerBulkActionPost)
addSettingsVariablesRoutes()
addSettingsScopedWorkflowsRoutes()
})
}, adminReq, ctxDataSet("EnableOAuth2", setting.OAuth2.Enabled, "EnablePackages", setting.Packages.Enabled))
// ***** END: Admin *****
@@ -1022,6 +1033,7 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) {
addSettingsRunnersRoutes()
addSettingsSecretsRoutes()
addSettingsVariablesRoutes()
addSettingsScopedWorkflowsRoutes()
}, actions.MustEnableActions)
m.Post("/rename", web.Bind(forms.RenameOrgForm{}), org.SettingsRenamePost)