mirror of
https://github.com/go-gitea/gitea.git
synced 2026-09-16 01:43:24 +09:00
feat(actions): support owner-level and global scoped workflows (#38154)
## Summary This PR adds **scoped workflows** to Gitea Actions. Workflows defined centrally in a "source" repository that automatically run on every repository in scope: an organization's repositories, or (for instance admins) every repository on the instance. Each scoped run executes in the consuming repository's own context (its runners, secrets, and branch) while its content is read from the source repository, so an org or instance can mandate shared CI across many repositories without copying workflow files into each one. An owner or instance admin registers source repositories on a settings page and can mark individual workflows as **required**. A required scoped workflow cannot be opted out by a consuming repository and gates its pull-request merges; an optional one can be disabled per repository. Scoped workflows live under a dedicated `SCOPED_WORKFLOW_DIRS` (default `.gitea/scoped_workflows`), kept separate from regular `WORKFLOW_DIRS`. ## Main changes ### Configuration New `SCOPED_WORKFLOW_DIRS` setting, validated to not overlap with `WORKFLOW_DIRS`. Default: `.gitea/scoped_workflows` ### Data model & migration - New `action_scoped_workflow_source` table mapping a registering owner (`owner_id`, where `0` = instance-level) to a source repository, with a per-workflow `WorkflowConfigs` map. - `ActionRun` gains `WorkflowRepoID` / `WorkflowCommitSHA` (the pinned content source) and an `IsScopedRun` flag. ### Detection & run creation On consumer events, scoped workflows from the effective sources (the owner's own sources plus instance-level ones) are matched and turned into runs that execute in the consumer's context, with content pinned to the source repo's default-branch commit. `on: workflow_run` and `on: schedule` are currently not supported. ### Opt-out A consuming repository can disable an optional scoped workflow (tracked separately from regular `DisabledWorkflows`); required scoped workflows can never be disabled, opted out, or bypassed. ### Commit status A scoped run's status context format is `"<source repo full name>: <workflow display name> / <job> (<event>)"` (for example: `my-org/scoped-workflows: db-tests / test-sqlite (pull_request)`), keeping it distinct from a same-named repo-level workflow and from other sources. ### Required status checks Admins mark workflows required and supply status-check patterns. `EffectiveRequiredContexts` appends those patterns to the branch protection's required contexts and they are matched must-present-and-pass. If the status checks from scoped workflows fail, the PR cannot be merged. NOTE: scoped workflows' required status checks patterns can protect any target branch that has a protection rule, even though the rule's "Status Check" is disabled. A target branch with no protection rule cannot be protected. <details> <summary>Screenshots</summary> <img width="1400" alt="image" src="https://github.com/user-attachments/assets/a5d1db33-15ec-487e-93be-2bc04b4e6643" /> </details> ### Reusable workflows (`uses:`) A scoped workflow's local `uses: ./...` resolves against the source repository. `uses:` directory validation honors the instance-configurable `WORKFLOW_DIRS` and `SCOPED_WORKFLOW_DIRS` (previously hardcoded to `.gitea`/`.github/workflows`). ### Manual dispatch `workflow_dispatch` is supported for scoped workflows (web and API), resolving inputs/content from the source repo. ### Performance A process-local LRU cache keyed by source repo ID for the per-source workflow parse, so instance-level and owner-level sources don't open the source repo and parse workflow files on every event. ### UI Org / user / admin pages to register and remove sources, search repositories, and mark workflows required with their status-check patterns. The repository Actions sidebar groups scoped workflows by source with owner/instance labels and required/disabled badges. <details> <summary>Screenshots</summary> Scoped workflows setting page: <img width="1600" alt="image" src="https://github.com/user-attachments/assets/9d19f667-97a5-4935-92b2-e53f105e3642" /> Consumer repo's Actions runs list: <img width="1600" alt="image" src="https://github.com/user-attachments/assets/a77241f9-0aa9-41aa-ba73-12a9a688cb64" /> - `Owner`: this is a owner-level scoped workflows source repo - `Global`: this is a global scoped workflows source repo - `Required`: this scoped workflow is required, repo admin cannot disable it </details> --- Docs: https://gitea.com/gitea/docs/pulls/447 --------- Co-authored-by: bircni <bircni@icloud.com>
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user