mirror of
https://github.com/go-gitea/gitea.git
synced 2026-06-29 14:21:25 +00:00
## 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>
237 lines
8.4 KiB
Go
237 lines
8.4 KiB
Go
// Copyright 2023 The Gitea Authors. All rights reserved.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package actions
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
|
|
actions_model "gitea.dev/models/actions"
|
|
"gitea.dev/models/db"
|
|
git_model "gitea.dev/models/git"
|
|
repo_model "gitea.dev/models/repo"
|
|
user_model "gitea.dev/models/user"
|
|
actions_module "gitea.dev/modules/actions"
|
|
"gitea.dev/modules/commitstatus"
|
|
"gitea.dev/modules/log"
|
|
"gitea.dev/modules/util"
|
|
webhook_module "gitea.dev/modules/webhook"
|
|
commitstatus_service "gitea.dev/services/repository/commitstatus"
|
|
)
|
|
|
|
// CreateCommitStatusForRunJobs creates a commit status for the given job if it has a supported event and related commit.
|
|
// It won't return an error failed, but will log it, because it's not critical.
|
|
func CreateCommitStatusForRunJobs(ctx context.Context, run *actions_model.ActionRun, jobs ...*actions_model.ActionRunJob) {
|
|
// don't create commit status for cron job
|
|
if run.ScheduleID != 0 {
|
|
return
|
|
}
|
|
|
|
event, commitID, err := getCommitStatusEventNameAndCommitID(run)
|
|
if err != nil {
|
|
log.Error("GetCommitStatusEventNameAndSHA: %v", err)
|
|
}
|
|
if event == "" || commitID == "" {
|
|
return // unsupported event, or no commit id, or error occurs, do nothing
|
|
}
|
|
|
|
if err = run.LoadAttributes(ctx); err != nil {
|
|
log.Error("run.LoadAttributes: %v", err)
|
|
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, scopedPrefix, run, job); err != nil {
|
|
log.Error("Failed to create commit status for job %d: %v", job.ID, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func GetRunsFromCommitStatuses(ctx context.Context, statuses []*git_model.CommitStatus) ([]*actions_model.ActionRun, error) {
|
|
runMap := make(map[int64]*actions_model.ActionRun)
|
|
for _, status := range statuses {
|
|
runID, _, ok := status.ParseGiteaActionsTargetURL(ctx)
|
|
if !ok {
|
|
continue
|
|
}
|
|
_, ok = runMap[runID]
|
|
if !ok {
|
|
run, err := actions_model.GetRunByRepoAndID(ctx, status.RepoID, runID)
|
|
if err != nil {
|
|
if errors.Is(err, util.ErrNotExist) {
|
|
// the run may be deleted manually, just skip it
|
|
continue
|
|
}
|
|
return nil, fmt.Errorf("GetRunByRepoAndID: %w", err)
|
|
}
|
|
runMap[runID] = run
|
|
}
|
|
}
|
|
runs := make([]*actions_model.ActionRun, 0, len(runMap))
|
|
for _, run := range runMap {
|
|
runs = append(runs, run)
|
|
}
|
|
return runs, nil
|
|
}
|
|
|
|
func getCommitStatusEventNameAndCommitID(run *actions_model.ActionRun) (event, commitID string, _ error) {
|
|
switch run.Event {
|
|
case webhook_module.HookEventPush:
|
|
event = "push"
|
|
payload, err := run.GetPushEventPayload()
|
|
if err != nil {
|
|
return "", "", fmt.Errorf("GetPushEventPayload: %w", err)
|
|
}
|
|
if payload.HeadCommit == nil {
|
|
return "", "", errors.New("head commit is missing in event payload")
|
|
}
|
|
commitID = payload.HeadCommit.ID
|
|
case // pull_request
|
|
webhook_module.HookEventPullRequest,
|
|
webhook_module.HookEventPullRequestSync,
|
|
webhook_module.HookEventPullRequestAssign,
|
|
webhook_module.HookEventPullRequestLabel,
|
|
webhook_module.HookEventPullRequestReviewRequest,
|
|
webhook_module.HookEventPullRequestMilestone:
|
|
if run.TriggerEvent == actions_module.GithubEventPullRequestTarget {
|
|
event = "pull_request_target"
|
|
} else {
|
|
event = "pull_request"
|
|
}
|
|
payload, err := run.GetPullRequestEventPayload()
|
|
if err != nil {
|
|
return "", "", fmt.Errorf("GetPullRequestEventPayload: %w", err)
|
|
}
|
|
if payload.PullRequest == nil {
|
|
return "", "", errors.New("pull request is missing in event payload")
|
|
} else if payload.PullRequest.Head == nil {
|
|
return "", "", errors.New("head of pull request is missing in event payload")
|
|
}
|
|
commitID = payload.PullRequest.Head.Sha
|
|
case // pull_request_review events share the same PullRequestPayload as pull_request
|
|
webhook_module.HookEventPullRequestReviewApproved,
|
|
webhook_module.HookEventPullRequestReviewRejected,
|
|
webhook_module.HookEventPullRequestReviewComment:
|
|
event = run.TriggerEvent
|
|
payload, err := run.GetPullRequestEventPayload()
|
|
if err != nil {
|
|
return "", "", fmt.Errorf("GetPullRequestEventPayload: %w", err)
|
|
}
|
|
if payload.PullRequest == nil {
|
|
return "", "", errors.New("pull request is missing in event payload")
|
|
} else if payload.PullRequest.Head == nil {
|
|
return "", "", errors.New("head of pull request is missing in event payload")
|
|
}
|
|
commitID = payload.PullRequest.Head.Sha
|
|
case webhook_module.HookEventRelease:
|
|
event = string(run.Event)
|
|
commitID = run.CommitSHA
|
|
default: // do nothing, return empty
|
|
}
|
|
return event, commitID, nil
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
// 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
|
|
// (issue #35699).
|
|
ctxHash := git_model.HashCommitStatusContext(ctxName + "\x00" + run.WorkflowID)
|
|
// Pre-fix rows were hashed from Context alone. If a pre-existing row with
|
|
// the legacy hash is still the "latest" for this SHA, reuse that hash so
|
|
// the new row supersedes it; otherwise the old pending status would stay
|
|
// stuck forever (it lives in its own dedupe group). Only relevant for
|
|
// in-flight workflows at upgrade time.
|
|
legacyHash := git_model.HashCommitStatusContext(ctxName)
|
|
state := toCommitStatus(job.Status)
|
|
targetURL := fmt.Sprintf("%s/jobs/%d", run.Link(), job.ID)
|
|
description := toCommitStatusDescription(job)
|
|
|
|
statuses, err := git_model.GetLatestCommitStatus(ctx, repo.ID, commitID, db.ListOptionsAll)
|
|
if err != nil {
|
|
return fmt.Errorf("GetLatestCommitStatus: %w", err)
|
|
}
|
|
for _, v := range statuses {
|
|
if v.ContextHash == legacyHash && v.Context == ctxName {
|
|
ctxHash = legacyHash
|
|
break
|
|
}
|
|
}
|
|
for _, v := range statuses {
|
|
if v.ContextHash == ctxHash {
|
|
if v.State == state && v.TargetURL == targetURL && v.Description == description {
|
|
return nil
|
|
}
|
|
break
|
|
}
|
|
}
|
|
|
|
creator := user_model.NewActionsUser()
|
|
status := git_model.CommitStatus{
|
|
SHA: commitID,
|
|
TargetURL: targetURL,
|
|
Description: description,
|
|
Context: ctxName,
|
|
ContextHash: ctxHash,
|
|
State: state,
|
|
CreatorID: creator.ID,
|
|
}
|
|
|
|
return commitstatus_service.CreateCommitStatus(ctx, repo, creator, commitID, &status)
|
|
}
|
|
|
|
func toCommitStatusDescription(job *actions_model.ActionRunJob) string {
|
|
switch job.Status {
|
|
// TODO: if we want support description in different languages, we need to support i18n placeholders in it
|
|
case actions_model.StatusSuccess:
|
|
return fmt.Sprintf("Successful in %s", job.Duration())
|
|
case actions_model.StatusFailure:
|
|
return fmt.Sprintf("Failing after %s", job.Duration())
|
|
case actions_model.StatusCancelled:
|
|
return fmt.Sprintf("Canceled after %s", job.Duration())
|
|
case actions_model.StatusSkipped:
|
|
return "Skipped"
|
|
case actions_model.StatusRunning:
|
|
return "In progress"
|
|
case actions_model.StatusCancelling:
|
|
return "Canceling"
|
|
case actions_model.StatusWaiting:
|
|
return "Waiting to run"
|
|
case actions_model.StatusBlocked:
|
|
return "Blocked by required conditions"
|
|
default:
|
|
return fmt.Sprintf("Unknown status: %d", job.Status)
|
|
}
|
|
}
|
|
|
|
func toCommitStatus(status actions_model.Status) commitstatus.CommitStatusState {
|
|
switch status {
|
|
case actions_model.StatusSuccess:
|
|
return commitstatus.CommitStatusSuccess
|
|
case actions_model.StatusFailure, actions_model.StatusCancelled:
|
|
return commitstatus.CommitStatusFailure
|
|
case actions_model.StatusWaiting, actions_model.StatusBlocked, actions_model.StatusRunning, actions_model.StatusCancelling:
|
|
return commitstatus.CommitStatusPending
|
|
case actions_model.StatusSkipped:
|
|
return commitstatus.CommitStatusSkipped
|
|
default:
|
|
return commitstatus.CommitStatusError
|
|
}
|
|
}
|