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

## Summary

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

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

## Main changes

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

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

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

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

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

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

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

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

<details>
  <summary>Screenshots</summary>

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

</details>


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

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

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

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

<details>
  <summary>Screenshots</summary>

Scoped workflows setting page:

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


Consumer repo's Actions runs list:

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

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

</details>

---

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

---------

Co-authored-by: bircni <bircni@icloud.com>
This commit is contained in:
Zettat123
2026-06-28 03:31:35 -06:00
committed by GitHub
parent c9920b7bd0
commit f46c9a9769
71 changed files with 3399 additions and 249 deletions
+15 -13
View File
@@ -7,8 +7,6 @@ import (
"context"
"errors"
"fmt"
"path"
"strings"
actions_model "gitea.dev/models/actions"
"gitea.dev/models/db"
@@ -16,7 +14,6 @@ import (
repo_model "gitea.dev/models/repo"
user_model "gitea.dev/models/user"
actions_module "gitea.dev/modules/actions"
"gitea.dev/modules/actions/jobparser"
"gitea.dev/modules/commitstatus"
"gitea.dev/modules/log"
"gitea.dev/modules/util"
@@ -45,8 +42,14 @@ func CreateCommitStatusForRunJobs(ctx context.Context, run *actions_model.Action
return
}
// Compute the scoped source-repo prefix once per run; it is identical for every job.
var scopedPrefix string
if run.IsScopedRun {
scopedPrefix = actions_model.ScopedStatusContextPrefix(ctx, run.WorkflowRepoID)
}
for _, job := range jobs {
if err = createCommitStatus(ctx, run.Repo, event, commitID, run, job); err != nil {
if err = createCommitStatus(ctx, run.Repo, event, commitID, scopedPrefix, run, job); err != nil {
log.Error("Failed to create commit status for job %d: %v", job.ID, err)
}
}
@@ -136,16 +139,15 @@ func getCommitStatusEventNameAndCommitID(run *actions_model.ActionRun) (event, c
return event, commitID, nil
}
func createCommitStatus(ctx context.Context, repo *repo_model.Repository, event, commitID string, run *actions_model.ActionRun, job *actions_model.ActionRunJob) error {
// TODO: store workflow name as a field in ActionRun to avoid parsing
runName := path.Base(run.WorkflowID)
// fall back to the file name when the workflow has no non-blank `name:`
if wfs, err := jobparser.Parse(job.WorkflowPayload); err == nil && len(wfs) > 0 {
if name := strings.TrimSpace(wfs[0].Name); name != "" {
runName = name
}
func createCommitStatus(ctx context.Context, repo *repo_model.Repository, event, commitID, scopedPrefix string, run *actions_model.ActionRun, job *actions_model.ActionRunJob) error {
displayName := actions_module.WorkflowDisplayName(run.WorkflowID, job.WorkflowPayload)
ctxName := actions_module.WorkflowStatusContextName(displayName, job.Name, event) // git_model.NewCommitStatus also trims spaces
if run.IsScopedRun {
// A scoped run is prefixed with its source repo (set off by a colon) so it stays distinct from a same-named repo-level workflow.
// scopedPrefix is computed once per run by the caller. The settings page derives the same string to preview expected checks.
ctxName = actions_module.ScopedWorkflowStatusContextName(scopedPrefix, displayName, job.Name, event)
}
ctxName := strings.TrimSpace(fmt.Sprintf("%s / %s (%s)", runName, job.Name, event)) // git_model.NewCommitStatus also trims spaces
// Mix the workflow file path into the hash so two workflow files that
// share the same `name:` and job name produce distinct commit statuses
// even though they render identically — matching GitHub's behavior
+69 -8
View File
@@ -72,7 +72,7 @@ func TestCreateCommitStatus_Dedupe(t *testing.T) {
expectedContext := "status-dedupe-test.yaml / status-dedupe-job (push)"
expectedTargetURL := run.Link() + "/jobs/99002"
require.NoError(t, createCommitStatus(t.Context(), repo, "push", commit.ID.String(), run, job))
require.NoError(t, createCommitStatus(t.Context(), repo, "push", commit.ID.String(), "", run, job))
statuses := findCommitStatusesForContext(t, repo.ID, commit.ID.String(), expectedContext)
require.Len(t, statuses, 1)
@@ -81,7 +81,7 @@ func TestCreateCommitStatus_Dedupe(t *testing.T) {
assert.Equal(t, expectedTargetURL, statuses[0].TargetURL)
job.Status = actions_model.StatusRunning
require.NoError(t, createCommitStatus(t.Context(), repo, "push", commit.ID.String(), run, job))
require.NoError(t, createCommitStatus(t.Context(), repo, "push", commit.ID.String(), "", run, job))
statuses = findCommitStatusesForContext(t, repo.ID, commit.ID.String(), expectedContext)
require.Len(t, statuses, 2)
@@ -90,12 +90,12 @@ func TestCreateCommitStatus_Dedupe(t *testing.T) {
assert.Equal(t, "In progress", statuses[1].Description)
assert.Equal(t, expectedTargetURL, statuses[1].TargetURL)
require.NoError(t, createCommitStatus(t.Context(), repo, "push", commit.ID.String(), run, job))
require.NoError(t, createCommitStatus(t.Context(), repo, "push", commit.ID.String(), "", run, job))
statuses = findCommitStatusesForContext(t, repo.ID, commit.ID.String(), expectedContext)
assert.Len(t, statuses, 2)
job.Status = actions_model.StatusSuccess
require.NoError(t, createCommitStatus(t.Context(), repo, "push", commit.ID.String(), run, job))
require.NoError(t, createCommitStatus(t.Context(), repo, "push", commit.ID.String(), "", run, job))
statuses = findCommitStatusesForContext(t, repo.ID, commit.ID.String(), expectedContext)
require.Len(t, statuses, 3)
assert.Equal(t, commitstatus.CommitStatusSuccess, statuses[2].State)
@@ -126,7 +126,7 @@ func TestGetCommitActionsStatusMap(t *testing.T) {
RunID: run.ID, RepoID: repo.ID, OwnerID: repo.OwnerID, Name: tc.jobName, Status: tc.status,
}
require.NoError(t, db.Insert(t.Context(), job))
require.NoError(t, createCommitStatus(t.Context(), repo, "push", branch.CommitID, run, job))
require.NoError(t, createCommitStatus(t.Context(), repo, "push", branch.CommitID, "", run, job))
}
statuses, err := git_model.GetLatestCommitStatus(t.Context(), repo.ID, branch.CommitID, db.ListOptionsAll)
@@ -185,7 +185,7 @@ jobs:
WorkflowPayload: payload,
}
require.NoError(t, db.Insert(t.Context(), job))
require.NoError(t, createCommitStatus(t.Context(), repo, "pull_request", branch.CommitID, run, job))
require.NoError(t, createCommitStatus(t.Context(), repo, "pull_request", branch.CommitID, "", run, job))
}
statuses, err := git_model.GetLatestCommitStatus(t.Context(), repo.ID, branch.CommitID, db.ListOptionsAll)
@@ -242,7 +242,7 @@ func TestCreateCommitStatus_LegacyHashRecovery(t *testing.T) {
Name: "my-job", Status: actions_model.StatusSuccess,
}
require.NoError(t, db.Insert(t.Context(), job))
require.NoError(t, createCommitStatus(t.Context(), repo, "push", branch.CommitID, run, job))
require.NoError(t, createCommitStatus(t.Context(), repo, "push", branch.CommitID, "", run, job))
latest, err := git_model.GetLatestCommitStatus(t.Context(), repo.ID, branch.CommitID, db.ListOptionsAll)
require.NoError(t, err)
@@ -292,7 +292,7 @@ func TestCreateCommitStatus_UnnamedWorkflowUsesFileName(t *testing.T) {
`),
}
require.NoError(t, db.Insert(t.Context(), job))
require.NoError(t, createCommitStatus(t.Context(), repo, "push", branch.CommitID, run, job))
require.NoError(t, createCommitStatus(t.Context(), repo, "push", branch.CommitID, "", run, job))
statuses := findCommitStatusesForContext(t, repo.ID, branch.CommitID, tc.workflowID+" / my-test (push)")
require.Len(t, statuses, 1)
@@ -300,6 +300,67 @@ func TestCreateCommitStatus_UnnamedWorkflowUsesFileName(t *testing.T) {
}
}
// TestCreateCommitStatus_ScopedSourcePrefix: a scoped run's commit status Context is prefixed with the source repo's full name,
// so it is distinct (display AND hash) from a same-named repo-level workflow.
func TestCreateCommitStatus_ScopedSourcePrefix(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
consumer := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 4})
source := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
branch := unittest.AssertExistsAndLoadBean(t, &git_model.Branch{RepoID: consumer.ID, Name: consumer.DefaultBranch})
payload := []byte(`name: ci
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- run: echo hi
`)
// A repo-level run and a scoped run share the same workflow name and job name;
// only the scoped one points its content source at another repo (WorkflowRepoID=source.ID, IsScopedRun=true).
for _, spec := range []struct {
runID, jobID int64
scoped bool
}{
{99501, 99511, false},
{99502, 99512, true},
} {
workflowRepoID := consumer.ID
if spec.scoped {
workflowRepoID = source.ID
}
run := &actions_model.ActionRun{
ID: spec.runID, Index: spec.runID, RepoID: consumer.ID, Repo: consumer, OwnerID: consumer.OwnerID, TriggerUserID: consumer.OwnerID,
WorkflowID: "ci.yaml", CommitSHA: branch.CommitID,
WorkflowRepoID: workflowRepoID, WorkflowCommitSHA: branch.CommitID, IsScopedRun: spec.scoped,
}
require.NoError(t, db.Insert(t.Context(), run))
job := &actions_model.ActionRunJob{
ID: spec.jobID, RunID: run.ID, RepoID: consumer.ID, OwnerID: consumer.OwnerID,
Name: "build", Status: actions_model.StatusWaiting, WorkflowPayload: payload,
}
require.NoError(t, db.Insert(t.Context(), job))
// mirror CreateCommitStatusForRunJobs: compute the scoped prefix once per run
scopedPrefix := ""
if run.IsScopedRun {
scopedPrefix = actions_model.ScopedStatusContextPrefix(t.Context(), run.WorkflowRepoID)
}
require.NoError(t, createCommitStatus(t.Context(), consumer, "push", branch.CommitID, scopedPrefix, run, job))
}
// repo-level Context is the bare "<display name> / <job> (<event>)"; the scoped one is the same but sets off the source repo with a colon,
// so the two stay distinct (and have different hashes) despite the same `name:`.
repoStatuses := findCommitStatusesForContext(t, consumer.ID, branch.CommitID, "ci / build (push)")
require.Len(t, repoStatuses, 1)
scopedStatuses := findCommitStatusesForContext(t, consumer.ID, branch.CommitID, source.FullName()+": ci / build (push)")
require.Len(t, scopedStatuses, 1)
assert.NotEqual(t, repoStatuses[0].ContextHash, scopedStatuses[0].ContextHash,
"scoped status must not collide with the same-named repo-level workflow")
}
func findCommitStatusesForContext(t *testing.T, repoID int64, sha, context string) []*git_model.CommitStatus {
t.Helper()
+12 -10
View File
@@ -63,16 +63,18 @@ jobs:
`)
run := &actions_model.ActionRun{
Title: "before parse",
RepoID: 4,
OwnerID: 1,
WorkflowID: "expr-runid.yaml",
TriggerUserID: 1,
Ref: "refs/heads/master",
CommitSHA: "c2d72f548424103f01ee1dc02889c1e2bff816b0",
Event: "push",
TriggerEvent: "push",
EventPayload: "{}",
Title: "before parse",
RepoID: 4,
OwnerID: 1,
WorkflowID: "expr-runid.yaml",
TriggerUserID: 1,
Ref: "refs/heads/master",
CommitSHA: "c2d72f548424103f01ee1dc02889c1e2bff816b0",
Event: "push",
TriggerEvent: "push",
EventPayload: "{}",
WorkflowRepoID: 4,
WorkflowCommitSHA: "c2d72f548424103f01ee1dc02889c1e2bff816b0",
}
require.NoError(t, PrepareRunAndInsert(ctx, content, run, nil))
require.Positive(t, run.ID)
+7 -12
View File
@@ -16,7 +16,6 @@ import (
repo_model "gitea.dev/models/repo"
user_model "gitea.dev/models/user"
"gitea.dev/modules/git"
"gitea.dev/modules/gitrepo"
"gitea.dev/modules/log"
"gitea.dev/modules/repository"
"gitea.dev/modules/setting"
@@ -802,21 +801,17 @@ func (n *actionsNotifier) WorkflowRunStatusUpdate(ctx context.Context, repo *rep
status := convert.ToWorkflowRunAction(run.Status)
gitRepo, err := gitrepo.OpenRepository(ctx, repo)
convertedWorkflow, err := convert.ResolveActionWorkflowForRun(ctx, repo, run)
if err != nil {
log.Error("OpenRepository: %v", err)
if errors.Is(err, util.ErrNotExist) {
// The workflow definition is gone (e.g. a scoped source repo/file was deleted, or the file no longer exists at the recorded commit), skip
log.Debug("WorkflowRunStatusUpdate: workflow %q for run %d not found: %v", run.WorkflowID, run.ID, err)
return
}
log.Error("WorkflowRunStatusUpdate resolve workflow: %v", err)
return
}
defer gitRepo.Close()
convertedWorkflow, err := convert.GetActionWorkflowByRef(ctx, gitRepo, repo, run.WorkflowID, git.RefName(run.Ref))
if err != nil && errors.Is(err, util.ErrNotExist) {
convertedWorkflow, err = convert.GetActionWorkflow(ctx, gitRepo, repo, run.WorkflowID)
}
if err != nil {
log.Error("GetActionWorkflow: %v", err)
return
}
run.Repo = repo
convertedRun, err := convert.ToActionWorkflowRun(ctx, run, nil, false)
if err != nil {
+167 -40
View File
@@ -19,6 +19,7 @@ import (
unit_model "gitea.dev/models/unit"
user_model "gitea.dev/models/user"
actions_module "gitea.dev/modules/actions"
"gitea.dev/modules/container"
"gitea.dev/modules/git"
"gitea.dev/modules/gitrepo"
"gitea.dev/modules/json"
@@ -239,7 +240,11 @@ func notify(ctx context.Context, input *notifyInput) error {
}
}
return handleWorkflows(ctx, detectedWorkflows, commit, input, ref)
if err := handleWorkflows(ctx, detectedWorkflows, commit, input, ref); err != nil {
return err
}
return detectAndHandleScopedWorkflows(ctx, input, ref, gitRepo, commit)
}
func skipWorkflows(ctx context.Context, input *notifyInput, commit *git.Commit) bool {
@@ -303,51 +308,63 @@ func handleWorkflows(
return fmt.Errorf("json.Marshal: %w", err)
}
isForkPullRequest := false
if pr := input.PullRequest; pr != nil {
switch pr.Flow {
case issues_model.PullRequestFlowGithub:
isForkPullRequest = pr.IsFromFork()
case issues_model.PullRequestFlowAGit:
// There is no fork concept in agit flow, anyone with read permission can push refs/for/<target-branch>/<topic-branch> to the repo.
// So we can treat it as a fork pull request because it may be from an untrusted user
isForkPullRequest = true
default:
// unknown flow, assume it's a fork pull request to be safe
isForkPullRequest = true
}
}
isForkPullRequest := isForkPullRequestInput(input)
for _, dwf := range detectedWorkflows {
run := &actions_model.ActionRun{
Title: commit.MessageTitle(),
RepoID: input.Repo.ID,
Repo: input.Repo,
OwnerID: input.Repo.OwnerID,
WorkflowID: dwf.EntryName,
TriggerUserID: input.Doer.ID,
TriggerUser: input.Doer,
Ref: ref.String(),
CommitSHA: commit.ID.String(),
IsForkPullRequest: isForkPullRequest,
Event: input.Event,
EventPayload: string(p),
TriggerEvent: dwf.TriggerEvent.Name,
Status: actions_model.StatusWaiting,
}
need, err := ifNeedApproval(ctx, run, input.Repo, input.Doer)
if err != nil {
log.Error("check if need approval for repo %d with user %d: %v", input.Repo.ID, input.Doer.ID, err)
// repo-level run: the workflow content is this repo at this commit
if err := buildApproveAndInsertRun(ctx, input, ref, commit, string(p), isForkPullRequest, dwf, input.Repo.ID, commit.ID.String(), false); err != nil {
log.Error("repo %s: %v", input.Repo.RelativePath(), err)
continue
}
}
return nil
}
run.NeedApproval = need
// buildApproveAndInsertRun assembles an ActionRun for a detected workflow, runs the
// fork-PR approval gate, and inserts it. Repo-level and scoped runs share this path so
// run construction and the approval flow have a single implementation that can't drift.
// workflowRepoID/workflowCommitSHA point at the repo+commit the workflow content comes
// from (the repo itself for repo-level runs, the source repo for scoped runs).
func buildApproveAndInsertRun(
ctx context.Context,
input *notifyInput,
ref git.RefName,
commit *git.Commit,
payload string,
isForkPullRequest bool,
dwf *actions_module.DetectedWorkflow,
workflowRepoID int64,
workflowCommitSHA string,
isScopedRun bool,
) error {
run := &actions_model.ActionRun{
Title: commit.MessageTitle(),
RepoID: input.Repo.ID,
Repo: input.Repo,
OwnerID: input.Repo.OwnerID,
WorkflowID: dwf.EntryName,
TriggerUserID: input.Doer.ID,
TriggerUser: input.Doer,
Ref: ref.String(),
CommitSHA: commit.ID.String(),
IsForkPullRequest: isForkPullRequest,
Event: input.Event,
EventPayload: payload,
TriggerEvent: dwf.TriggerEvent.Name,
Status: actions_model.StatusWaiting,
WorkflowRepoID: workflowRepoID,
WorkflowCommitSHA: workflowCommitSHA,
IsScopedRun: isScopedRun,
}
if err := PrepareRunAndInsert(ctx, dwf.Content, run, nil); err != nil {
log.Error("PrepareRunAndInsert: %v", err)
continue
}
need, err := ifNeedApproval(ctx, run, input.Repo, input.Doer)
if err != nil {
return fmt.Errorf("check if need approval for user %d: %w", input.Doer.ID, err)
}
run.NeedApproval = need
if err := PrepareRunAndInsert(ctx, dwf.Content, run, nil); err != nil {
return fmt.Errorf("PrepareRunAndInsert: %w", err)
}
return nil
}
@@ -551,3 +568,113 @@ func DetectAndHandleSchedules(ctx context.Context, repo *repo_model.Repository)
return handleSchedules(ctx, scheduleWorkflows, commit, notifyInput, git.RefNameFromBranch(repo.DefaultBranch))
}
// isForkPullRequestInput reports whether the run should be treated as a fork pull request.
func isForkPullRequestInput(input *notifyInput) bool {
pr := input.PullRequest
if pr == nil {
return false
}
switch pr.Flow {
case issues_model.PullRequestFlowGithub:
return pr.IsFromFork()
case issues_model.PullRequestFlowAGit:
// There is no fork concept in agit flow, anyone with read permission can push refs/for/<target-branch>/<topic-branch> to the repo.
// So we can treat it as a fork pull request because it may be from an untrusted user
return true
default:
// unknown flow, assume it's a fork pull request to be safe
return true
}
}
// detectAndHandleScopedWorkflows detects scoped workflows registered for the consuming repo
func detectAndHandleScopedWorkflows(
ctx context.Context,
input *notifyInput,
ref git.RefName,
consumerGitRepo *git.Repository,
consumerCommit *git.Commit,
) error {
// TODO: support workflow_run and schedule
if input.Event == webhook_module.HookEventWorkflowRun || input.Event == webhook_module.HookEventSchedule {
return nil
}
sources, err := actions_model.GetEffectiveScopedWorkflowSources(ctx, input.Repo.OwnerID)
if err != nil {
return fmt.Errorf("GetEffectiveScopedWorkflowSources: %w", err)
}
if len(sources) == 0 {
return nil
}
p, err := json.Marshal(input.Payload)
if err != nil {
return fmt.Errorf("json.Marshal: %w", err)
}
isForkPullRequest := isForkPullRequestInput(input)
actionsConfig := input.Repo.MustGetUnit(ctx, unit_model.TypeActions).ActionsConfig()
// The same source repo may be registered at both the owner and instance level; dedup
// the IDs and batch-load them in one query instead of one round-trip per source.
seen := make(container.Set[int64], len(sources))
for _, source := range sources {
seen.Add(source.SourceRepoID)
}
sourceRepoIDs := seen.Values()
sourceRepos, err := repo_model.GetRepositoriesMapByIDs(ctx, sourceRepoIDs)
if err != nil {
return fmt.Errorf("GetRepositoriesMapByIDs: %w", err)
}
for _, sourceRepoID := range sourceRepoIDs {
sourceRepo := sourceRepos[sourceRepoID]
if sourceRepo == nil {
// don't abort the other effective sources for this event
log.Error("scoped workflows: source repo %d for consumer %s not found", sourceRepoID, input.Repo.RelativePath())
continue
}
if sourceRepo.IsEmpty {
continue
}
sourceCommitSHA, detected, err := detectScopedWorkflowsForSource(ctx, input, consumerGitRepo, consumerCommit, sourceRepo)
if err != nil {
log.Error("scoped workflows: source %d for consumer %s: %v", sourceRepoID, input.Repo.RelativePath(), err)
continue
}
for _, dwf := range detected {
// A consuming repo can opt out of a non-required scoped workflow.
// A required workflow (marked required at any effective level) can never be opted out.
if actions_model.ScopedWorkflowOptedOut(actionsConfig, sources, sourceRepo.ID, dwf.EntryName) {
continue
}
if err := buildApproveAndInsertRun(ctx, input, ref, consumerCommit, string(p), isForkPullRequest, dwf, sourceRepo.ID, sourceCommitSHA, true); err != nil {
log.Error("scoped workflows: source %s workflow %s: %v", sourceRepo.RelativePath(), dwf.EntryName, err)
continue
}
}
}
return nil
}
// detectScopedWorkflowsForSource detects the scoped workflows from the source repo at its default branch
func detectScopedWorkflowsForSource(
ctx context.Context,
input *notifyInput,
consumerGitRepo *git.Repository,
consumerCommit *git.Commit,
sourceRepo *repo_model.Repository,
) (sourceCommitSHA string, detected []*actions_module.DetectedWorkflow, err error) {
// scoped workflow content is always taken from the source repo's default branch; the parse is cached per (source, default-branch SHA) and reused across consuming repos/events
sourceCommitSHA, parsed, err := LoadParsedScopedWorkflows(ctx, sourceRepo)
if err != nil {
return "", nil, err
}
return sourceCommitSHA, actions_module.MatchScopedWorkflows(parsed, consumerGitRepo, consumerCommit, input.Event, input.Payload), nil
}
+10 -1
View File
@@ -88,7 +88,16 @@ func validateRerun(ctx context.Context, run *actions_model.ActionRun, repo *repo
}
cfgUnit := repo.MustGetUnit(ctx, unit.TypeActions)
cfg := cfgUnit.ActionsConfig()
if cfg.IsWorkflowDisabled(run.WorkflowID) {
if run.IsScopedRun {
// a required scoped workflow can never be opted out, so a stale disabled flag must not block rerun
optedOut, err := actions_model.IsScopedWorkflowOptedOut(ctx, cfg, repo.OwnerID, run.WorkflowRepoID, run.WorkflowID)
if err != nil {
return err
}
if optedOut {
return util.NewInvalidArgumentErrorf("scoped workflow %s is disabled", run.WorkflowID)
}
} else if cfg.IsWorkflowDisabled(run.WorkflowID) {
return util.NewInvalidArgumentErrorf("workflow %s is disabled", run.WorkflowID)
}
+15 -1
View File
@@ -13,6 +13,7 @@ import (
perm_model "gitea.dev/models/perm"
access_model "gitea.dev/models/perm/access"
repo_model "gitea.dev/models/repo"
actions_module "gitea.dev/modules/actions"
"gitea.dev/modules/actions/jobparser"
"gitea.dev/modules/container"
"gitea.dev/modules/gitrepo"
@@ -60,6 +61,11 @@ func loadReusableWorkflowSource(ctx context.Context, run *actions_model.ActionRu
return nil, 0, "", err
}
if !ok {
if run.IsScopedRun {
// A scoped workflow's cross-repo "uses:" is resolved with the consuming repo's read permission,
// so the referenced repo must be readable by every consumer. Make that explicit in the failure.
return nil, 0, "", fmt.Errorf("no permission to read reusable workflow %s/%s: a scoped workflow's cross-repo \"uses:\" is resolved with the consuming repository %q read permission", ref.Owner, ref.Repo, run.Repo.RelativePath())
}
return nil, 0, "", fmt.Errorf("no permission to read reusable workflow from %s/%s", ref.Owner, ref.Repo)
}
bytes, resolvedSHA, err := readWorkflowFromRepo(ctx, repo, ref.Ref, ref.Path)
@@ -359,5 +365,13 @@ func ResolveUses(ctx context.Context, uses string) (*jobparser.UsesRef, error) {
// RoutePath is the instance-relative path (AppSubURL already stripped), e.g. "/owner/repo/.gitea/workflows/file.yml@ref".
uses = strings.TrimPrefix(gsu.RoutePath, "/")
}
return jobparser.ParseUses(uses)
ref, err := jobparser.ParseUses(uses)
if err != nil {
return nil, err
}
// jobparser only validates syntax; enforce the (instance-configurable) directory allowlist here.
if !actions_module.IsWorkflowOrScopedWorkflow(ref.Path) {
return nil, fmt.Errorf(`"uses:" path %q must be under a configured workflow directory (WORKFLOW_DIRS or SCOPED_WORKFLOW_DIRS)`, ref.Path)
}
return ref, nil
}
@@ -139,6 +139,8 @@ func buildCallerChain(t *testing.T, callerUses ...string) []*actions_model.Actio
func TestResolveUses(t *testing.T) {
defer test.MockVariableValue(&setting.AppURL, "https://gitea.example.com/sub/")()
defer test.MockVariableValue(&setting.AppSubURL, "/sub")()
defer test.MockVariableValue(&setting.Actions.WorkflowDirs, []string{".gitea/workflows", ".github/workflows"})()
defer test.MockVariableValue(&setting.Actions.ScopedWorkflowDirs, []string{".gitea/scoped_workflows"})()
ctx := t.Context()
t.Run("LocalForms", func(t *testing.T) {
@@ -152,6 +154,34 @@ func TestResolveUses(t *testing.T) {
assert.Equal(t, jobparser.UsesRef{Kind: jobparser.UsesKindLocalCrossRepo, Owner: "owner", Repo: "repo", Path: ".gitea/workflows/build.yml", Ref: "v1"}, *ref)
})
t.Run("DirectoryAllowlist", func(t *testing.T) {
// SCOPED_WORKFLOW_DIRS is allowed (local and cross-repo).
ref, err := ResolveUses(ctx, "./.gitea/scoped_workflows/lib.yml")
require.NoError(t, err)
assert.Equal(t, ".gitea/scoped_workflows/lib.yml", ref.Path)
ref, err = ResolveUses(ctx, "owner/repo/.gitea/scoped_workflows/lib.yml@v1")
require.NoError(t, err)
assert.Equal(t, ".gitea/scoped_workflows/lib.yml", ref.Path)
// A directory that is neither WORKFLOW_DIRS nor SCOPED_WORKFLOW_DIRS parses but is rejected by the allowlist.
_, err = ResolveUses(ctx, "./not-workflows/build.yml")
require.Error(t, err)
_, err = ResolveUses(ctx, "owner/repo/lib/build.yml@v1")
require.Error(t, err)
})
t.Run("ConfigurableWorkflowDirs", func(t *testing.T) {
// A non-default WORKFLOW_DIRS is honored (the hardcoded ".gitea/workflows" is no longer special).
defer test.MockVariableValue(&setting.Actions.WorkflowDirs, []string{".gitea/ci"})()
ref, err := ResolveUses(ctx, "./.gitea/ci/build.yml")
require.NoError(t, err)
assert.Equal(t, ".gitea/ci/build.yml", ref.Path)
_, err = ResolveUses(ctx, "./.gitea/workflows/build.yml") // no longer a configured dir
require.Error(t, err)
})
t.Run("LocalInstanceURL", func(t *testing.T) {
// An absolute URL on this instance (incl. AppSubURL) resolves to the equivalent cross-repo ref.
ref, err := ResolveUses(ctx, "https://gitea.example.com/sub/owner/repo/.gitea/workflows/ci.yml@refs/heads/main")
+6 -2
View File
@@ -21,6 +21,10 @@ import (
// It parses the workflow content, evaluates concurrency if needed, and inserts the run and its jobs into the database.
// The title will be cut off at 255 characters if it's longer than 255 characters.
func PrepareRunAndInsert(ctx context.Context, content []byte, run *actions_model.ActionRun, inputsWithDefaults map[string]any) error {
if run.WorkflowRepoID == 0 {
return fmt.Errorf("WorkflowRepoID must be set before insert (repo %d, workflow %q)", run.RepoID, run.WorkflowID)
}
if err := run.LoadAttributes(ctx); err != nil {
return fmt.Errorf("LoadAttributes: %w", err)
}
@@ -162,8 +166,8 @@ func InsertRun(ctx context.Context, run *actions_model.ActionRun, content []byte
Needs: needs,
RunsOn: job.RunsOn(),
Status: util.Iif(shouldBlockJob, actions_model.StatusBlocked, actions_model.StatusWaiting),
WorkflowSourceRepoID: run.RepoID,
WorkflowSourceCommitSHA: run.CommitSHA,
WorkflowSourceRepoID: run.WorkflowRepoID,
WorkflowSourceCommitSHA: run.WorkflowCommitSHA,
ContinueOnError: job.GetContinueOnError(),
}
// Parse workflow/job permissions (no clamping here)
+3
View File
@@ -118,6 +118,9 @@ func CreateScheduleTask(ctx context.Context, spec *actions_model.ActionScheduleS
TriggerEvent: string(webhook_module.HookEventSchedule),
ScheduleID: cron.ID,
Status: actions_model.StatusWaiting,
// schedule runs the repo's own workflow at the recorded commit
WorkflowRepoID: cron.RepoID,
WorkflowCommitSHA: cron.CommitSHA,
}
// FIXME cron.Content might be outdated if the workflow file has been changed.
+84
View File
@@ -0,0 +1,84 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package actions
import (
"context"
"fmt"
git_model "gitea.dev/models/git"
repo_model "gitea.dev/models/repo"
actions_module "gitea.dev/modules/actions"
"gitea.dev/modules/gitrepo"
"gitea.dev/modules/log"
lru "github.com/hashicorp/golang-lru/v2"
)
// cachedScopedWorkflows is one source repo's parsed scoped workflows together with the default-branch SHA they were parsed at.
type cachedScopedWorkflows struct {
sha string
parsed []*actions_module.ParsedScopedWorkflow
}
// scopedWorkflowCache caches each scoped-workflow source repo's parsed workflows, keyed by source repo ID.
// There is exactly one entry per source: a default-branch update is detected by SHA mismatch and overwrites the entry, so stale parses never accumulate.
var scopedWorkflowCache *lru.Cache[int64, *cachedScopedWorkflows]
const defaultScopedWorkflowCacheSize = 1024
func init() {
c, err := lru.New[int64, *cachedScopedWorkflows](defaultScopedWorkflowCacheSize)
if err != nil {
log.Fatal("failed to new scopedWorkflowCache, err: %v", err)
}
scopedWorkflowCache = c
}
// LoadParsedScopedWorkflows returns the source repo's parsed scoped workflows at its current default-branch HEAD.
func LoadParsedScopedWorkflows(ctx context.Context, sourceRepo *repo_model.Repository) (sha string, parsed []*actions_module.ParsedScopedWorkflow, err error) {
branch, err := git_model.GetBranch(ctx, sourceRepo.ID, sourceRepo.DefaultBranch)
if err != nil {
return "", nil, fmt.Errorf("get source default branch: %w", err)
}
sha = branch.CommitID
if v, ok := scopedWorkflowCache.Get(sourceRepo.ID); ok && v.sha == sha {
// cache hit at the current default-branch HEAD
return sha, v.parsed, nil
}
// cache miss: open the source repo at the exact SHA we keyed on
sourceGitRepo, err := gitrepo.OpenRepository(ctx, sourceRepo)
if err != nil {
return "", nil, fmt.Errorf("open source repo: %w", err)
}
defer sourceGitRepo.Close()
sourceCommit, err := sourceGitRepo.GetCommit(sha)
if err != nil {
return "", nil, fmt.Errorf("get source commit %s: %w", sha, err)
}
parsed, err = actions_module.ParseScopedWorkflows(sourceCommit)
if err != nil {
return "", nil, err
}
// overwrite this source's single entry (a stale entry from a previous HEAD is replaced, not accumulated)
scopedWorkflowCache.Add(sourceRepo.ID, &cachedScopedWorkflows{sha: sha, parsed: parsed})
return sha, parsed, nil
}
// ScopedWorkflowContent returns one scoped workflow's raw content (by entry name) at the source repo's current default-branch HEAD, or nil if no such workflow exists there.
func ScopedWorkflowContent(ctx context.Context, sourceRepo *repo_model.Repository, entryName string) ([]byte, error) {
_, parsed, err := LoadParsedScopedWorkflows(ctx, sourceRepo)
if err != nil {
return nil, err
}
for _, p := range parsed {
if p.EntryName == entryName {
return p.Content, nil
}
}
return nil, nil
}
+79 -28
View File
@@ -43,7 +43,9 @@ func EnableOrDisableWorkflow(ctx *context.APIContext, workflowID string, isEnabl
return repo_model.UpdateRepoUnitConfig(ctx, cfgUnit)
}
func DispatchActionWorkflow(ctx reqctx.RequestContext, doer *user_model.User, repo *repo_model.Repository, gitRepo *git.Repository, workflowID, ref string, processInputs func(model *model.WorkflowDispatch, inputs map[string]any) error) (runID int64, _ error) {
// DispatchActionWorkflow manually triggers a workflow_dispatch run.
// scopedWorkflowSourceRepoID selects the workflow source: 0 means a repo-level workflow in this repo; a non-zero value is the source repo of a scoped workflow.
func DispatchActionWorkflow(ctx reqctx.RequestContext, doer *user_model.User, repo *repo_model.Repository, gitRepo *git.Repository, workflowID, ref string, scopedWorkflowSourceRepoID int64, processInputs func(model *model.WorkflowDispatch, inputs map[string]any) error) (runID int64, _ error) {
if workflowID == "" {
return 0, util.ErrorWrapTranslatable(
util.NewNotExistErrorf("workflowID is empty"),
@@ -58,10 +60,20 @@ func DispatchActionWorkflow(ctx reqctx.RequestContext, doer *user_model.User, re
)
}
// can not rerun job when workflow is disabled
isScoped := scopedWorkflowSourceRepoID > 0
cfgUnit := repo.MustGetUnit(ctx, unit.TypeActions)
cfg := cfgUnit.ActionsConfig()
if cfg.IsWorkflowDisabled(workflowID) {
var workflowDisabled bool
if isScoped {
var err error
if workflowDisabled, err = actions_model.IsScopedWorkflowOptedOut(ctx, cfg, repo.OwnerID, scopedWorkflowSourceRepoID, workflowID); err != nil {
return 0, err
}
} else {
workflowDisabled = cfg.IsWorkflowDisabled(workflowID)
}
if workflowDisabled {
return 0, util.ErrorWrapTranslatable(
util.NewPermissionDeniedErrorf("workflow is disabled"),
"actions.workflow.disabled",
@@ -87,15 +99,6 @@ func DispatchActionWorkflow(ctx reqctx.RequestContext, doer *user_model.User, re
)
}
// get workflow entry from runTargetCommit
_, entries, err := actions.ListWorkflows(runTargetCommit)
if err != nil {
return 0, err
}
// find workflow from commit
var entry *git.TreeEntry
run := &actions_model.ActionRun{
Title: runTargetCommit.MessageTitle(),
RepoID: repo.ID,
@@ -110,24 +113,13 @@ func DispatchActionWorkflow(ctx reqctx.RequestContext, doer *user_model.User, re
Event: "workflow_dispatch",
TriggerEvent: "workflow_dispatch",
Status: actions_model.StatusWaiting,
// local dispatch: own repo at the target commit; the scoped path overrides these below
WorkflowRepoID: repo.ID,
WorkflowCommitSHA: runTargetCommit.ID.String(),
}
for _, e := range entries {
if e.Name() != workflowID {
continue
}
entry = e
break
}
if entry == nil {
return 0, util.ErrorWrapTranslatable(
util.NewNotExistErrorf("workflow %q doesn't exist", workflowID),
"actions.workflow.not_found", workflowID,
)
}
content, err := actions.GetContentFromEntry(entry)
// resolve the workflow content and record its source on the run (scoped runs read from the source repo)
content, err := resolveDispatchWorkflowContent(ctx, repo, runTargetCommit, workflowID, scopedWorkflowSourceRepoID, isScoped, run)
if err != nil {
return 0, err
}
@@ -176,3 +168,62 @@ func DispatchActionWorkflow(ctx reqctx.RequestContext, doer *user_model.User, re
}
return run.ID, nil
}
// resolveDispatchWorkflowContent returns the YAML for a dispatched workflow and records its source on the run.
// - Repo-level: from the consumer's runTargetCommit.
// - Scoped: from the source repo's default branch.
func resolveDispatchWorkflowContent(ctx reqctx.RequestContext, repo *repo_model.Repository, runTargetCommit *git.Commit, workflowID string, sourceRepoID int64, isScoped bool, run *actions_model.ActionRun) ([]byte, error) {
if isScoped {
return resolveScopedDispatchContent(ctx, repo, sourceRepoID, workflowID, run)
}
_, entries, err := actions.ListWorkflows(runTargetCommit)
if err != nil {
return nil, err
}
for _, e := range entries {
if e.Name() == workflowID {
return actions.GetContentFromEntry(e)
}
}
return nil, util.ErrorWrapTranslatable(
util.NewNotExistErrorf("workflow %q doesn't exist", workflowID),
"actions.workflow.not_found", workflowID,
)
}
func resolveScopedDispatchContent(ctx reqctx.RequestContext, repo *repo_model.Repository, sourceRepoID int64, workflowID string, run *actions_model.ActionRun) ([]byte, error) {
// the source must be an effective scoped source for this consumer repo
effective, err := actions_model.IsScopedWorkflowSourceEffective(ctx, repo.OwnerID, sourceRepoID)
if err != nil {
return nil, err
}
if !effective {
return nil, util.ErrorWrapTranslatable(
util.NewNotExistErrorf("scoped workflow source %d is not effective for this repository", sourceRepoID),
"actions.workflow.not_found", workflowID,
)
}
sourceRepo, err := repo_model.GetRepositoryByID(ctx, sourceRepoID)
if err != nil {
return nil, err
}
sha, parsed, err := LoadParsedScopedWorkflows(ctx, sourceRepo)
if err != nil {
return nil, err
}
for _, p := range parsed {
if p.EntryName == workflowID {
run.WorkflowRepoID = sourceRepo.ID
run.WorkflowCommitSHA = sha
run.IsScopedRun = true
return p.Content, nil
}
}
return nil, util.ErrorWrapTranslatable(
util.NewNotExistErrorf("scoped workflow %q doesn't exist", workflowID),
"actions.workflow.not_found", workflowID,
)
}
+60
View File
@@ -7,6 +7,7 @@ package convert
import (
"bytes"
"context"
"errors"
"fmt"
"net/url"
"path"
@@ -29,6 +30,7 @@ import (
"gitea.dev/modules/actions"
"gitea.dev/modules/container"
"gitea.dev/modules/git"
"gitea.dev/modules/gitrepo"
"gitea.dev/modules/httplib"
"gitea.dev/modules/log"
"gitea.dev/modules/setting"
@@ -641,6 +643,64 @@ func getActionWorkflowFromCommit(ctx context.Context, repo *repo_model.Repositor
return nil, util.NewNotExistErrorf("workflow %q not found", workflowID)
}
// GetScopedActionWorkflow resolves a scoped workflow definition (under SCOPED_WORKFLOW_DIRS) from the source repo at commitSHA.
func GetScopedActionWorkflow(ctx context.Context, sourceGitRepo *git.Repository, sourceRepo *repo_model.Repository, workflowID, commitSHA string) (*api.ActionWorkflow, error) {
commit, err := sourceGitRepo.GetCommit(commitSHA)
if err != nil {
return nil, err
}
folder, entries, err := actions.ListScopedWorkflows(commit)
if err != nil {
return nil, err
}
for _, entry := range entries {
if entry.Name() == workflowID {
// An empty ref pins HTMLURL to commit (the run's WorkflowCommitSHA) rather than the moving default branch.
wf := getActionWorkflowEntry(ctx, sourceRepo, commit, git.RefName(""), folder, entry)
// TODO: a scoped workflow has no repo-level representation on the source: the workflow API scans WORKFLOW_DIRS (not SCOPED_WORKFLOW_DIRS),
// and the badge only reflects the source's repo-level runs, so neither link resolves a scoped workflow.
// Blank them for now and populate once a scoped-aware workflow/badge endpoint exists.
wf.URL = ""
wf.BadgeURL = ""
return wf, nil
}
}
return nil, util.NewNotExistErrorf("scoped workflow %q not found", workflowID)
}
// ResolveActionWorkflowForRun returns the api.ActionWorkflow describing a run's workflow definition.
// For a scoped run the definition lives in the source repo (run.WorkflowRepoID @ run.WorkflowCommitSHA) under SCOPED_WORKFLOW_DIRS,
// not in the consuming repo, so it is resolved against the source repo.
func ResolveActionWorkflowForRun(ctx context.Context, repo *repo_model.Repository, run *actions_model.ActionRun) (*api.ActionWorkflow, error) {
if run.IsScopedRun {
sourceRepo, err := repo_model.GetRepositoryByID(ctx, run.WorkflowRepoID)
if err != nil {
return nil, err
}
sourceGitRepo, err := gitrepo.OpenRepository(ctx, sourceRepo)
if err != nil {
return nil, err
}
defer sourceGitRepo.Close()
return GetScopedActionWorkflow(ctx, sourceGitRepo, sourceRepo, run.WorkflowID, run.WorkflowCommitSHA)
}
gitRepo, err := gitrepo.OpenRepository(ctx, repo)
if err != nil {
return nil, err
}
defer gitRepo.Close()
convertedWorkflow, err := GetActionWorkflowByRef(ctx, gitRepo, repo, run.WorkflowID, git.RefName(run.Ref))
if err != nil && errors.Is(err, util.ErrNotExist) {
convertedWorkflow, err = GetActionWorkflow(ctx, gitRepo, repo, run.WorkflowID)
}
return convertedWorkflow, err
}
// ToActionArtifact convert a actions_model.ActionArtifact to an api.ActionArtifact
func ToActionArtifact(repo *repo_model.Repository, art *actions_model.ActionArtifact) (*api.ActionArtifact, error) {
url := fmt.Sprintf("%s/actions/artifacts/%d", repo.APIURL(), art.ID)
+1
View File
@@ -39,6 +39,7 @@ func deleteOrganization(ctx context.Context, org *org_model.Organization) error
&user_model.Blocking{BlockerID: org.ID},
&actions_model.ActionRunner{OwnerID: org.ID},
&actions_model.ActionRunnerToken{OwnerID: org.ID},
&actions_model.ActionScopedWorkflowSource{OwnerID: org.ID},
); err != nil {
return fmt.Errorf("DeleteBeans: %w", err)
}
+70 -5
View File
@@ -8,11 +8,15 @@ import (
"context"
"errors"
"fmt"
"slices"
actions_model "gitea.dev/models/actions"
"gitea.dev/models/db"
git_model "gitea.dev/models/git"
issues_model "gitea.dev/models/issues"
repo_model "gitea.dev/models/repo"
"gitea.dev/modules/commitstatus"
"gitea.dev/modules/container"
"gitea.dev/modules/gitrepo"
"gitea.dev/modules/glob"
"gitea.dev/modules/log"
@@ -69,11 +73,25 @@ func MergeRequiredContextsCommitStatus(commitStatuses []*git_model.CommitStatus,
func IsPullCommitStatusPass(ctx context.Context, pr *issues_model.PullRequest) (bool, error) {
pb, err := git_model.GetFirstMatchProtectedBranchRule(ctx, pr.BaseRepoID, pr.BaseBranch)
if err != nil {
return false, fmt.Errorf("GetLatestCommitStatus: %w", err)
return false, fmt.Errorf("GetFirstMatchProtectedBranchRule: %w", err)
}
if pb == nil || !pb.EnableStatusCheck {
if pb == nil {
return true, nil
}
if !pb.EnableStatusCheck {
// The branch's own status check is off, but required scoped checks (mandated by the owner or instance admin) still gate the merge.
if err := pr.LoadBaseRepo(ctx); err != nil {
return false, err
}
required, err := EffectiveRequiredContexts(ctx, pr.BaseRepo, pb)
if err != nil {
return false, err
}
if len(required) == 0 {
// With none in effect there is nothing to enforce, so don't block
return true, nil
}
}
state, err := GetPullRequestCommitStatusState(ctx, pr)
if err != nil {
@@ -130,10 +148,57 @@ func GetPullRequestCommitStatusState(ctx context.Context, pr *issues_model.PullR
if err != nil {
return "", fmt.Errorf("LoadProtectedBranch: %w", err)
}
var requiredContexts []string
if pb != nil {
requiredContexts = pb.StatusCheckContexts
requiredContexts, err := EffectiveRequiredContexts(ctx, pr.BaseRepo, pb)
if err != nil {
return "", err
}
return MergeRequiredContextsCommitStatus(commitStatuses, requiredContexts), nil
}
// EffectiveRequiredContexts returns the required status-check contexts for a PR head:
// 1. every required scoped workflow's status-check patterns effective for the repo (always)
// 2. the branch protection's own configured contexts, only when its status check is enabled
func EffectiveRequiredContexts(ctx context.Context, repo *repo_model.Repository, pb *git_model.ProtectedBranch) ([]string, error) {
if pb == nil {
return nil, nil
}
sources, err := actions_model.GetEffectiveScopedWorkflowSources(ctx, repo.OwnerID)
if err != nil {
return nil, fmt.Errorf("GetEffectiveScopedWorkflowSources: %w", err)
}
// Every required scoped workflow's admin-authored status-check patterns, matched must-present-and-pass:
// a required scoped check that posts no matching status blocks the merge.
seen := make(container.Set[string])
var scoped []string
for _, source := range sources {
for _, cfg := range source.WorkflowConfigs {
if !cfg.Required {
continue
}
for _, p := range cfg.Patterns {
if seen.Add(p) {
scoped = append(scoped, p)
}
}
}
}
slices.Sort(scoped) // sort for stable output
// With the branch protection's own status check disabled, only the required scoped checks (mandated by the owner or instance admin) gate the merge.
if !pb.EnableStatusCheck {
return scoped, nil
}
// Status check enabled: the rule's configured contexts, then the scoped patterns not already among them.
required := slices.Clone(pb.StatusCheckContexts)
for _, p := range scoped {
if !slices.Contains(pb.StatusCheckContexts, p) {
required = append(required, p)
}
}
return required, nil
}
+64
View File
@@ -7,10 +7,15 @@ package pull
import (
"testing"
actions_model "gitea.dev/models/actions"
"gitea.dev/models/db"
git_model "gitea.dev/models/git"
repo_model "gitea.dev/models/repo"
"gitea.dev/models/unittest"
"gitea.dev/modules/commitstatus"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestMergeRequiredContextsCommitStatus(t *testing.T) {
@@ -90,3 +95,62 @@ func TestMergeRequiredContextsCommitStatus(t *testing.T) {
assert.Equal(t, c.expected, MergeRequiredContextsCommitStatus(c.commitStatuses, c.requiredContexts), "case %d", i)
}
}
// TestEffectiveRequiredContexts: every required scoped workflow's stored status-check patterns are appended to the
// branch protection's configured contexts unconditionally (must-present; the matching is done downstream).
func TestEffectiveRequiredContexts(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
consumer := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 4}) // owned by user5
pbOn := &git_model.ProtectedBranch{EnableStatusCheck: true, StatusCheckContexts: []string{"configured/check"}}
t.Run("nil protected branch: nil", func(t *testing.T) {
got, err := EffectiveRequiredContexts(t.Context(), consumer, nil)
require.NoError(t, err)
assert.Nil(t, got)
})
t.Run("status checks disabled, no required scoped: nothing required", func(t *testing.T) {
pbOff := &git_model.ProtectedBranch{EnableStatusCheck: false, StatusCheckContexts: []string{"configured/check"}}
got, err := EffectiveRequiredContexts(t.Context(), consumer, pbOff)
require.NoError(t, err)
assert.Empty(t, got) // the rule's own status check is off and no required scoped workflow applies -> nothing gates
})
t.Run("owner with no scoped sources: configured contexts unchanged", func(t *testing.T) {
noSourceRepo := &repo_model.Repository{ID: consumer.ID, OwnerID: 99999}
got, err := EffectiveRequiredContexts(t.Context(), noSourceRepo, pbOn)
require.NoError(t, err)
assert.Equal(t, []string{"configured/check"}, got)
})
t.Run("required workflow patterns appended", func(t *testing.T) {
require.NoError(t, db.Insert(t.Context(), &actions_model.ActionScopedWorkflowSource{
OwnerID: consumer.OwnerID,
SourceRepoID: 1,
WorkflowConfigs: map[string]*actions_model.ScopedWorkflowConfig{
"ci.yaml": {Required: true, Patterns: []string{"org/src: ci.yaml / build (pull_request)", "org/src: ci.yaml / lint (pull_request)"}},
"old.yaml": {Required: false, Patterns: []string{"org/src: old.yaml / *"}}, // kept as history, must NOT be enforced
},
}))
// No status is passed/needed: required patterns are enforced even though nothing has posted them yet (must-present).
got, err := EffectiveRequiredContexts(t.Context(), consumer, pbOn)
require.NoError(t, err)
assert.ElementsMatch(t, []string{
"configured/check",
"org/src: ci.yaml / build (pull_request)",
"org/src: ci.yaml / lint (pull_request)",
}, got)
assert.NotContains(t, got, "org/src: old.yaml / *", "a non-required (history) config must not be enforced")
})
t.Run("status checks disabled, with required scoped: only the scoped patterns gate", func(t *testing.T) {
pbOff := &git_model.ProtectedBranch{EnableStatusCheck: false, StatusCheckContexts: []string{"configured/check"}}
got, err := EffectiveRequiredContexts(t.Context(), consumer, pbOff)
require.NoError(t, err)
// "configured/check" is dropped (the rule's own status check is off); only the required scoped patterns remain.
assert.ElementsMatch(t, []string{
"org/src: ci.yaml / build (pull_request)",
"org/src: ci.yaml / lint (pull_request)",
}, got)
})
}
+1
View File
@@ -179,6 +179,7 @@ func DeleteRepositoryDirectly(ctx context.Context, repoID int64, ignoreOrgTeams
&actions_model.ActionArtifact{RepoID: repoID},
&actions_model.ActionRunJobSummary{RepoID: repoID},
&actions_model.ActionRunnerToken{RepoID: repoID},
&actions_model.ActionScopedWorkflowSource{SourceRepoID: repoID},
&issues_model.IssuePin{RepoID: repoID},
); err != nil {
return fmt.Errorf("deleteBeans: %w", err)
+1
View File
@@ -95,6 +95,7 @@ func deleteUser(ctx context.Context, u *user_model.User, purge bool) (err error)
&user_model.Blocking{BlockerID: u.ID},
&user_model.Blocking{BlockeeID: u.ID},
&actions_model.ActionRunnerToken{OwnerID: u.ID},
&actions_model.ActionScopedWorkflowSource{OwnerID: u.ID},
); err != nil {
return fmt.Errorf("deleteBeans: %w", err)
}
+7 -13
View File
@@ -17,7 +17,6 @@ import (
repo_model "gitea.dev/models/repo"
user_model "gitea.dev/models/user"
"gitea.dev/modules/git"
"gitea.dev/modules/gitrepo"
"gitea.dev/modules/httplib"
"gitea.dev/modules/log"
"gitea.dev/modules/repository"
@@ -1029,19 +1028,14 @@ func (*webhookNotifier) WorkflowRunStatusUpdate(ctx context.Context, repo *repo_
status := convert.ToWorkflowRunAction(run.Status)
gitRepo, err := gitrepo.OpenRepository(ctx, repo)
// Resolve the workflow definition from its source repo.
convertedWorkflow, err := convert.ResolveActionWorkflowForRun(ctx, repo, run)
if err != nil {
log.Error("OpenRepository: %v", err)
return
}
defer gitRepo.Close()
convertedWorkflow, err := convert.GetActionWorkflowByRef(ctx, gitRepo, repo, run.WorkflowID, git.RefName(run.Ref))
if err != nil && errors.Is(err, util.ErrNotExist) {
convertedWorkflow, err = convert.GetActionWorkflow(ctx, gitRepo, repo, run.WorkflowID)
}
if err != nil {
log.Error("GetActionWorkflow: %v", err)
if errors.Is(err, util.ErrNotExist) {
log.Debug("WorkflowRunStatusUpdate: workflow %q for run %d not found: %v", run.WorkflowID, run.ID, err)
return
}
log.Error("ResolveActionWorkflowForRun: %v", err)
return
}