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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user