mirror of
https://github.com/go-gitea/gitea.git
synced 2026-09-26 06:33:42 +09:00
feat(actions): update actionslib, support self:, misc fixes (#39358)
Updates actionslib to https://gitea.com/gitea/actionslib/releases/tag/v1.2.1, moves workflow parsing into it and aligns behaviour with GitHub. 1. `uses:` supports `self:` (Gitea-only feature) and `$/` paths. 1. `strategy`, `matrix`, `max-parallel` and `fail-fast` accept expressions, including over `needs`. A job whose `name`, `runs-on` or `continue-on-error` reads `needs` is resolved once they finish. 1. A job `if:` may only read `github`, `needs`, `vars` and `inputs` and is decided before the matrix, as on github.com. 1. Matrix `fail-fast` cancels the other combinations, and `always()` jobs keep running when a run is cancelled. 1. Invalid workflow files, including a malformed `on:` and unknown or cyclic `needs`, show up on push as failed runs with the error. 1. A job whose `if:` or `concurrency:` fails to evaluate is skipped or failed with the error, instead of staying blocked. 1. Reusable workflows: a missing and an unreadable repository fail alike, public callers cannot use private workflows, nested jobs cannot exceed the caller's token permissions. 1. Runner labels match case-insensitively, and `runs-on` accepts an array from an expression. Runner PR: https://gitea.com/gitea/runner/pulls/1247 Docs PR: https://gitea.com/gitea/docs/pulls/553 Fixes: https://github.com/go-gitea/gitea/issues/38990 Fixes: https://github.com/go-gitea/gitea/issues/39382 Fixes: https://github.com/go-gitea/gitea/issues/32364 Fixes: https://github.com/go-gitea/gitea/issues/36077 Fixes: https://github.com/go-gitea/gitea/issues/23277 Fixes: https://github.com/go-gitea/gitea/issues/29020 Co-authored-by: Claude (Opus 5) <noreply@anthropic.com> Co-authored-by: Zettat123 <zettat123@gmail.com>
This commit is contained in:
@@ -9,11 +9,13 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"gitea.dev/actionslib/pkg/model"
|
||||
actions_model "gitea.dev/models/actions"
|
||||
"gitea.dev/models/db"
|
||||
perm_model "gitea.dev/models/perm"
|
||||
access_model "gitea.dev/models/perm/access"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/unit"
|
||||
actions_module "gitea.dev/modules/actions"
|
||||
"gitea.dev/modules/actions/jobparser"
|
||||
"gitea.dev/modules/container"
|
||||
@@ -23,15 +25,13 @@ import (
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/setting"
|
||||
api "gitea.dev/modules/structs"
|
||||
"gitea.dev/modules/util"
|
||||
"gitea.dev/services/convert"
|
||||
|
||||
"xorm.io/builder"
|
||||
)
|
||||
|
||||
// MaxReusableCallLevels caps how deep a reusable workflow can nest:
|
||||
// a top-level caller may have at most MaxReusableCallLevels nested callers below it.
|
||||
const MaxReusableCallLevels = 9
|
||||
// MaxReusableCallLevels allows nine calls across ten workflows, including the top-level workflow.
|
||||
const MaxReusableCallLevels = 8
|
||||
|
||||
// checkRunJobLimit rejects an expansion that would push the attempt over actions_model.MaxJobNumPerRun.
|
||||
// checkCallerChain bounds nesting *depth*, but a reusable graph also fans out in *breadth*: without a
|
||||
@@ -49,13 +49,13 @@ func checkRunJobLimit(ctx context.Context, runID, attemptID int64, adding int) e
|
||||
|
||||
// loadReusableWorkflowSource resolves the workflow file referenced by a caller's `uses:` and returns its raw bytes,
|
||||
// along with the (repo_id, commit_sha) the file was loaded from.
|
||||
func loadReusableWorkflowSource(ctx context.Context, run *actions_model.ActionRun, caller *actions_model.ActionRunJob, ref *jobparser.UsesRef) (content []byte, sourceRepoID int64, sourceCommitSHA string, err error) {
|
||||
func loadReusableWorkflowSource(ctx context.Context, run *actions_model.ActionRun, caller *actions_model.ActionRunJob, ref *model.ReusableWorkflowUses) (content []byte, sourceRepoID int64, sourceCommitSHA string, err error) {
|
||||
if err := run.LoadAttributes(ctx); err != nil {
|
||||
return nil, 0, "", err
|
||||
}
|
||||
|
||||
switch ref.Kind {
|
||||
case jobparser.UsesKindLocalSameRepo:
|
||||
switch {
|
||||
case ref.IsLocal():
|
||||
// `./` and `$/` are resolved against the workflow file containing the `uses:` - i.e. the caller's own source repo + commit.
|
||||
callerRepo, err := repo_model.GetRepositoryByID(ctx, caller.WorkflowSourceRepoID)
|
||||
if err != nil {
|
||||
@@ -71,8 +71,12 @@ func loadReusableWorkflowSource(ctx context.Context, run *actions_model.ActionRu
|
||||
}
|
||||
return bytes, callerRepo.ID, resolvedSHA, nil
|
||||
|
||||
case jobparser.UsesKindLocalCrossRepo:
|
||||
default:
|
||||
unavailable := fmt.Errorf("reusable workflow repository %s/%s does not exist or is not readable", ref.Owner, ref.Repo) // the same for both, so a run cannot tell whether a private one exists
|
||||
repo, err := repo_model.GetRepositoryByOwnerAndName(ctx, ref.Owner, ref.Repo)
|
||||
if repo_model.IsErrRepoNotExist(err) {
|
||||
return nil, 0, "", unavailable
|
||||
}
|
||||
if err != nil {
|
||||
return nil, 0, "", fmt.Errorf("look up cross-repo workflow source %q: %w", ref.Owner+"/"+ref.Repo, err)
|
||||
}
|
||||
@@ -81,12 +85,7 @@ 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.FullName())
|
||||
}
|
||||
return nil, 0, "", fmt.Errorf("no permission to read reusable workflow from %s/%s", ref.Owner, ref.Repo)
|
||||
return nil, 0, "", unavailable
|
||||
}
|
||||
bytes, resolvedSHA, err := readWorkflowFromRepo(ctx, repo, ref.Ref, ref.Path)
|
||||
if err != nil {
|
||||
@@ -94,7 +93,6 @@ func loadReusableWorkflowSource(ctx context.Context, run *actions_model.ActionRu
|
||||
}
|
||||
return bytes, repo.ID, resolvedSHA, nil
|
||||
}
|
||||
return nil, 0, "", fmt.Errorf("unsupported uses kind %d", ref.Kind)
|
||||
}
|
||||
|
||||
// resolveSameRepoWorkflowSourceCommit returns the commit to read a same-repo reusable workflow from.
|
||||
@@ -132,16 +130,12 @@ func readWorkflowFromRepo(ctx context.Context, repo *repo_model.Repository, refO
|
||||
// checkCallerChain walks `caller`'s ancestor chain (via ParentJobID) and:
|
||||
// - rejects cycles (caller.CallUses appearing in any ancestor's CallUses)
|
||||
// - enforces MaxReusableCallLevels on the number of ancestors above `caller`
|
||||
//
|
||||
// Cycle detection is intentionally *syntactic* (string equality on canonicalCallUses), not semantic.
|
||||
// So `owner/repo/lib.yml@v1` and `owner/repo/lib.yml@refs/heads/v1` resolving to the same commit are NOT treated as the same node.
|
||||
// Going semantic (Owner, Repo, Path, ResolvedSHA tuples) would require extra git reads.
|
||||
func checkCallerChain(ctx context.Context, caller *actions_model.ActionRunJob) error {
|
||||
if caller.ParentJobID == 0 {
|
||||
return nil // top-level caller: depth 0, no ancestors to walk
|
||||
}
|
||||
|
||||
visited := container.SetOf(canonicalCallUses(caller.CallUses))
|
||||
visited := container.SetOf(canonicalCallUses(caller))
|
||||
|
||||
depth := 0
|
||||
current := caller
|
||||
@@ -155,19 +149,41 @@ func checkCallerChain(ctx context.Context, caller *actions_model.ActionRunJob) e
|
||||
if depth > MaxReusableCallLevels {
|
||||
return fmt.Errorf("reusable workflow call exceeds the maximum nesting level of %d at %q", MaxReusableCallLevels, caller.CallUses)
|
||||
}
|
||||
if current.IsReusableCaller && current.CallUses != "" && !visited.Add(canonicalCallUses(current.CallUses)) {
|
||||
if current.IsReusableCaller && current.CallUses != "" && !visited.Add(canonicalCallUses(current)) {
|
||||
return fmt.Errorf("reusable workflow call cycle detected: %q", current.CallUses)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// canonicalCallUses folds the two same-repo prefixes into one key, because `$/x.yml` and `./x.yml` name the same file.
|
||||
func canonicalCallUses(uses string) string {
|
||||
if ref, err := jobparser.ParseUses(uses); err == nil && ref.Kind == jobparser.UsesKindLocalSameRepo {
|
||||
return "./" + ref.Path
|
||||
func checkResolvedCallerCycle(ctx context.Context, caller *actions_model.ActionRunJob, sourceRepoID int64, sourceCommitSHA, path string) error {
|
||||
for current := caller; current.ParentJobID != 0; {
|
||||
parent, err := actions_model.GetRunJobByRunAndID(ctx, current.RunID, current.ParentJobID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("walk caller chain: %w", err)
|
||||
}
|
||||
ref, err := ResolveUses(ctx, parent.CallUses)
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolve ancestor uses %q: %w", parent.CallUses, err)
|
||||
}
|
||||
if current.WorkflowSourceRepoID == sourceRepoID && current.WorkflowSourceCommitSHA == sourceCommitSHA && ref.Path == path {
|
||||
return fmt.Errorf("reusable workflow call cycle detected: %q", caller.CallUses)
|
||||
}
|
||||
current = parent
|
||||
}
|
||||
return uses
|
||||
return nil
|
||||
}
|
||||
|
||||
// canonicalCallUses keys a call by its parsed form, so the `$/` and `self:` spellings match the plain ones.
|
||||
func canonicalCallUses(job *actions_model.ActionRunJob) string {
|
||||
ref, err := model.ParseReusableWorkflowUses(job.CallUses)
|
||||
if err != nil {
|
||||
return job.CallUses
|
||||
}
|
||||
if ref.IsLocal() {
|
||||
return fmt.Sprintf("./%s@%d:%s", ref.Path, job.WorkflowSourceRepoID, job.WorkflowSourceCommitSHA)
|
||||
}
|
||||
return ref.Owner + "/" + ref.Repo + "/" + ref.Path + "@" + ref.Ref
|
||||
}
|
||||
|
||||
// expandReusableWorkflowCaller loads and parses the target reusable workflow and inserts the caller's direct child jobs.
|
||||
@@ -204,9 +220,12 @@ func expandReusableWorkflowCaller(ctx context.Context, run *actions_model.Action
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := checkResolvedCallerCycle(ctx, caller, contentSourceRepoID, contentSourceCommitSHA, ref.Path); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 4. Parse the called workflow's spec (used by both secret validation and input evaluation).
|
||||
wcSpec, err := jobparser.ParseWorkflowCallSpec(content)
|
||||
wcSpec, err := jobparser.ParseWorkflowCallConfig(content)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse called workflow spec: %w", err)
|
||||
}
|
||||
@@ -220,7 +239,7 @@ func expandReusableWorkflowCaller(ctx context.Context, run *actions_model.Action
|
||||
// so required-secret presence cannot be verified at expansion time and a missing required secret will surface at job runtime.
|
||||
// This matches GitHub Actions' behavior.
|
||||
if !inherit {
|
||||
if err := jobparser.ValidateCallerSecrets(wcSpec, secretsMap); err != nil {
|
||||
if err := wcSpec.ValidateSecrets(secretsMap); err != nil {
|
||||
return fmt.Errorf("caller %q secrets: %w", caller.JobID, err)
|
||||
}
|
||||
}
|
||||
@@ -238,7 +257,7 @@ func expandReusableWorkflowCaller(ctx context.Context, run *actions_model.Action
|
||||
|
||||
// 6. Evaluate caller's `with:`, then match against the callee schema.
|
||||
workflowCallInputs := map[string]any{}
|
||||
if len(wcSpec.Inputs) > 0 {
|
||||
if len(wcSpec.Inputs) > 0 || parsedJob.With.Kind != 0 {
|
||||
jobResults, err := findJobNeedsAndFillJobResults(ctx, caller)
|
||||
if err != nil {
|
||||
return fmt.Errorf("find caller needs: %w", err)
|
||||
@@ -248,14 +267,7 @@ func expandReusableWorkflowCaller(ctx context.Context, run *actions_model.Action
|
||||
return err
|
||||
}
|
||||
callerGitCtx := GenerateGiteaContext(ctx, run, attempt, caller)
|
||||
evaluated, err := jobparser.EvaluateCallerWith(
|
||||
caller.JobID, parsedJob,
|
||||
callerGitCtx, jobResults, vars, parentInputs,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("evaluate caller with: %w", err)
|
||||
}
|
||||
workflowCallInputs, err = jobparser.MatchCallerInputsAgainstSpec(wcSpec, evaluated)
|
||||
workflowCallInputs, err = jobparser.ResolveCallerInputs(caller.JobID, parsedJob, wcSpec, callerGitCtx, jobResults, vars, parentInputs)
|
||||
if err != nil {
|
||||
return fmt.Errorf("caller %q inputs: %w", caller.JobID, err)
|
||||
}
|
||||
@@ -306,6 +318,23 @@ func expandReusableWorkflowCaller(ctx context.Context, run *actions_model.Action
|
||||
|
||||
// insertCallerChildren parses the called workflow with the caller's resolved inputs and inserts each parsed job.
|
||||
func insertCallerChildren(ctx context.Context, run *actions_model.ActionRun, attempt *actions_model.ActionRunAttempt, caller *actions_model.ActionRunJob, content []byte, sourceRepoID int64, sourceCommitSHA string, vars map[string]string, inputs map[string]any) error {
|
||||
callerPermissions := caller.TokenPermissions
|
||||
if callerPermissions == nil {
|
||||
actionsUnit, err := run.Repo.GetUnit(ctx, unit.TypeActions)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load caller repository Actions settings: %w", err)
|
||||
}
|
||||
if config := actionsUnit.ActionsConfig(); config.OverrideOwnerConfig {
|
||||
callerPermissions = new(config.GetDefaultTokenPermissions())
|
||||
} else {
|
||||
ownerConfig, err := actions_model.GetOwnerActionsConfig(ctx, run.OwnerID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load caller owner Actions settings: %w", err)
|
||||
}
|
||||
callerPermissions = new(ownerConfig.GetDefaultTokenPermissions())
|
||||
}
|
||||
}
|
||||
|
||||
// Parse the called workflow with the caller's `inputs`
|
||||
gitCtx := GenerateGiteaContext(ctx, run, attempt, nil)
|
||||
if event, ok := gitCtx["event"].(map[string]any); ok {
|
||||
@@ -349,7 +378,7 @@ func insertCallerChildren(ctx context.Context, run *actions_model.ActionRun, att
|
||||
return fmt.Errorf("marshal child %q under caller %d: %w", jobID, caller.ID, err)
|
||||
}
|
||||
|
||||
parsedChild.Name = util.EllipsisDisplayString(parsedChild.Name, 255)
|
||||
parsedChild.Name = parsedChild.DisplayName()
|
||||
|
||||
// AttemptJobID: prefer a prior-attempt match and fall back to a fresh allocator value for newly-appearing logical jobs.
|
||||
var attemptJobID int64
|
||||
@@ -388,7 +417,9 @@ func insertCallerChildren(ctx context.Context, run *actions_model.ActionRun, att
|
||||
child.DeferredMatrixPayload = payload
|
||||
}
|
||||
if perms := ExtractJobPermissionsFromWorkflow(sw, parsedChild); perms != nil {
|
||||
child.TokenPermissions = perms
|
||||
child.TokenPermissions = new(repo_model.ClampActionsTokenPermissions(*perms, *callerPermissions))
|
||||
} else {
|
||||
child.TokenPermissions = callerPermissions
|
||||
}
|
||||
if parsedChild.Uses != "" {
|
||||
child.IsReusableCaller = true
|
||||
@@ -403,8 +434,8 @@ func insertCallerChildren(ctx context.Context, run *actions_model.ActionRun, att
|
||||
|
||||
// ResolveUses normalizes and parses a reusable workflow `uses:` value.
|
||||
// It first rewrites an absolute URL pointing to this instance into the cross-repo form (rejecting external URLs),
|
||||
// then validates the syntax via jobparser.ParseUses.
|
||||
func ResolveUses(ctx context.Context, uses string) (*jobparser.UsesRef, error) {
|
||||
// then validates the syntax via model.ParseReusableWorkflowUses.
|
||||
func ResolveUses(ctx context.Context, uses string) (*model.ReusableWorkflowUses, error) {
|
||||
// Rewrite a local-instance URL to the equivalent cross-repo form "owner/repo/.gitea/workflows/file.yml@ref".
|
||||
if strings.HasPrefix(uses, "http://") || strings.HasPrefix(uses, "https://") {
|
||||
// ParseGiteaSiteURL returns nil for URLs that do not belong to this instance.
|
||||
@@ -415,11 +446,10 @@ 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, "/")
|
||||
}
|
||||
ref, err := jobparser.ParseUses(uses)
|
||||
ref, err := model.ParseReusableWorkflowUses(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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user