// Copyright 2026 The Gitea Authors. All rights reserved. // SPDX-License-Identifier: MIT package actions import ( "context" "errors" "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" "gitea.dev/modules/git" "gitea.dev/modules/httplib" "gitea.dev/modules/json" "gitea.dev/modules/log" "gitea.dev/modules/setting" api "gitea.dev/modules/structs" "gitea.dev/services/convert" "xorm.io/builder" ) // 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 // cumulative cap a tiny set of files can drive exponential job-row insertion and exhaust the database. func checkRunJobLimit(ctx context.Context, runID, attemptID int64, adding int) error { existing, err := actions_model.CountRunJobsByRunAndAttemptID(ctx, runID, attemptID) if err != nil { return fmt.Errorf("count existing jobs of run %d attempt %d: %w", runID, attemptID, err) } if existing+int64(adding) > actions_model.MaxJobNumPerRun { return fmt.Errorf("workflow run exceeds the maximum of %d jobs", actions_model.MaxJobNumPerRun) } return nil } // 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 *model.ReusableWorkflowUses) (content []byte, sourceRepoID int64, sourceCommitSHA string, err error) { if err := run.LoadAttributes(ctx); err != nil { return nil, 0, "", err } 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 { return nil, 0, "", fmt.Errorf("look up caller source repo %d: %w", caller.WorkflowSourceRepoID, err) } sourceCommitSHA := resolveSameRepoWorkflowSourceCommit(run, caller) if sourceCommitSHA != caller.WorkflowSourceCommitSHA { log.Warn("run %d (pull_request_target) records workflow source commit %s, resolving %q at base commit %s instead", run.ID, caller.WorkflowSourceCommitSHA, ref.Path, sourceCommitSHA) } bytes, resolvedSHA, err := readWorkflowFromRepo(ctx, callerRepo, sourceCommitSHA, ref.Path) if err != nil { return nil, 0, "", err } return bytes, callerRepo.ID, resolvedSHA, nil 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) } ok, err := access_model.CanReadWorkflowCrossRepo(ctx, repo, run) if err != nil { return nil, 0, "", err } if !ok { return nil, 0, "", unavailable } bytes, resolvedSHA, err := readWorkflowFromRepo(ctx, repo, ref.Ref, ref.Path) if err != nil { return nil, 0, "", err } return bytes, repo.ID, resolvedSHA, nil } } // resolveSameRepoWorkflowSourceCommit returns the commit to read a same-repo reusable workflow from. // pull_request_target runs must resolve local `uses:` at the PR base commit, not a stored head SHA. func resolveSameRepoWorkflowSourceCommit(run *actions_model.ActionRun, caller *actions_model.ActionRunJob) string { // only a SHA copied from the run row can be the polluted head one; a SHA resolved from a `uses:` ref is right by construction if run.IsScopedRun || caller.WorkflowSourceRepoID != run.RepoID || caller.WorkflowSourceCommitSHA != run.WorkflowCommitSHA { return caller.WorkflowSourceCommitSHA } if baseSHA, ok := pullRequestTargetBaseSHA(run); ok && baseSHA != caller.WorkflowSourceCommitSHA { return baseSHA } return caller.WorkflowSourceCommitSHA } // readWorkflowFromRepo loads a workflow file from `repo` at `refOrSHA` and returns its content plus the resolved commit SHA. func readWorkflowFromRepo(ctx context.Context, repo *repo_model.Repository, refOrSHA, path string) ([]byte, string, error) { gitRepo, err := git.OpenRepository(ctx, repo) if err != nil { return nil, "", fmt.Errorf("open repo %s: %w", repo.FullName(), err) } defer gitRepo.Close() commit, err := gitRepo.GetCommit(ctx, refOrSHA) if err != nil { return nil, "", fmt.Errorf("get commit %q in %s: %w", refOrSHA, repo.FullName(), err) } str, err := commit.GetFileContent(ctx, gitRepo, path, 1024*1024) if err != nil { return nil, "", fmt.Errorf("read %s@%s:%s: %w", repo.FullName(), refOrSHA, path, err) } return []byte(str), commit.ID.String(), nil } // 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` 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)) depth := 0 current := caller for current.ParentJobID != 0 { next, err := actions_model.GetRunJobByRunAndID(ctx, current.RunID, current.ParentJobID) if err != nil { return fmt.Errorf("walk caller chain: %w", err) } current = next depth++ 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)) { return fmt.Errorf("reusable workflow call cycle detected: %q", current.CallUses) } } return nil } 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 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. // It expands only ONE level: a child that is itself a reusable caller is inserted Blocked and expanded later by a subsequent resolver pass. // It does NOT schedule a follow-up resolver pass; the caller of this function is responsible for emitting. // // All call sites (PrepareRunAndInsert, execRerunPlan, checkJobsOfCurrentRunAttempt, ApproveRuns) invoke this inside their enclosing write transaction, // because the caller row update and the child-row inserts must commit atomically. // Be aware this is not cheap inside a tx: it does a git read, YAML parsing, and `${{ }}` expression evaluation. // None of the call sites is hot: each caller is expanded once per attempt. func expandReusableWorkflowCaller(ctx context.Context, run *actions_model.ActionRun, attempt *actions_model.ActionRunAttempt, caller *actions_model.ActionRunJob, vars map[string]string) error { // Already expanded by an earlier call, skip if caller.IsExpanded { return nil } // 1. Cycle + depth check via the ParentJobID chain. if err := checkCallerChain(ctx, caller); err != nil { return err } // 2. Parse the caller's own job (Uses, With, RawSecrets) from its WorkflowPayload. parsedJob, err := caller.ParseJob() if err != nil { return fmt.Errorf("parse caller job %d: %w", caller.ID, err) } // 3. Resolve `uses` and load called-workflow source. ref, err := ResolveUses(ctx, parsedJob.Uses) if err != nil { return fmt.Errorf("resolve uses %q: %w", parsedJob.Uses, err) } content, contentSourceRepoID, contentSourceCommitSHA, err := loadReusableWorkflowSource(ctx, run, caller, ref) 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.ParseWorkflowCallConfig(content) if err != nil { return fmt.Errorf("parse called workflow spec: %w", err) } // 5. Resolve caller's `secrets:` and validate it against the callee's schema. inherit, secretsMap, err := jobparser.ParseCallerSecrets(parsedJob.RawSecrets) if err != nil { return fmt.Errorf("caller secrets %q: %w", caller.JobID, err) } // Under `secrets: inherit` the caller forwards all of its own secrets verbatim and does NOT name them individually, // 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 := wcSpec.ValidateSecrets(secretsMap); err != nil { return fmt.Errorf("caller %q secrets: %w", caller.JobID, err) } } switch { case inherit: caller.CallSecrets = jobparser.SecretsInherit case len(secretsMap) > 0: mapBytes, err := json.Marshal(secretsMap) if err != nil { return fmt.Errorf("marshal caller secret map: %w", err) } caller.CallSecrets = string(mapBytes) } caller.ReusableWorkflowContent = content // 6. Evaluate caller's `with:`, then match against the callee schema. workflowCallInputs := map[string]any{} 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) } parentInputs, err := getInputsForJob(ctx, run, caller) if err != nil { return err } callerGitCtx := GenerateGiteaContext(ctx, run, attempt, caller) 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) } } // 7. Build CallPayload (persisted in step 9). callPayload, err := (&api.WorkflowCallPayload{ Workflow: run.WorkflowID, Ref: run.Ref, Repository: convert.ToRepo(ctx, run.Repo, access_model.Permission{AccessMode: perm_model.AccessModeNone}), Sender: convert.ToUserWithAccessMode(ctx, run.TriggerUser, perm_model.AccessModeNone), Inputs: workflowCallInputs, }).JSONPayload() if err != nil { return fmt.Errorf("build call payload: %w", err) } // 8. Claim the expansion by flipping is_expanded false->true BEFORE inserting any children. // Two concurrent expanders serialize on this row: exactly one winner matches (n==1) and owns the expansion. // Children are only ever inserted by the claim winner, so no duplicate child rows can arise. caller.IsExpanded = true n, err := actions_model.UpdateRunJob(ctx, caller, builder.And( builder.Eq{"is_expanded": false}, builder.In("status", actions_model.StatusBlocked, actions_model.StatusWaiting), ), "is_expanded") if err != nil { caller.IsExpanded = false // the claim was not established return fmt.Errorf("claim caller %d expansion: %w", caller.ID, err) } if n == 0 { // Another writer won the expansion, or the caller has been moved to a terminal status (e.g. failed/cancelled). return nil } // 9. We own the expansion: insert the direct children. if err := insertCallerChildren(ctx, run, attempt, caller, content, contentSourceRepoID, contentSourceCommitSHA, vars, workflowCallInputs); err != nil { // On failure, undo the partial expansion so an error return always leaves the caller unexpanded and childless. return errors.Join(err, undoExpansion(ctx, caller)) } // 10. Persist the remaining caller metadata (the row is already ours via the claim above). caller.CallPayload = string(callPayload) if _, err := actions_model.UpdateRunJob(ctx, caller, nil, "call_secrets", "reusable_workflow_content", "call_payload"); err != nil { return errors.Join(fmt.Errorf("persist caller %d expansion metadata: %w", caller.ID, err), undoExpansion(ctx, caller)) } return nil } // 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 { event["inputs"] = inputs } gitCtx["event_name"] = "workflow_call" childWorkflows, err := jobparser.Parse(content, jobparser.WithVars(vars), jobparser.WithGitContext(gitCtx.ToGitHubContext()), jobparser.WithInputs(inputs), ) if err != nil { return fmt.Errorf("parse called workflow for caller %d: %w", caller.ID, err) } if len(childWorkflows) == 0 { return fmt.Errorf("called workflow for caller %d (uses %q) has no jobs", caller.ID, caller.CallUses) } if err := checkRunJobLimit(ctx, run.ID, attempt.ID, len(childWorkflows)); err != nil { return err } priorChildren, err := actions_model.GetPriorAttemptChildrenByParent(ctx, run.ID, attempt.ID, caller.AttemptJobID) if err != nil { return fmt.Errorf("lookup prior-attempt children of caller %d: %w", caller.ID, err) } for _, sw := range childWorkflows { jobID, parsedChild := sw.Job() if parsedChild == nil { continue } needs := parsedChild.Needs() isMatrixDeferred := jobparser.HasDeferredMatrix(parsedChild) if err := sw.SetJob(jobID, parsedChild.EraseNeeds()); err != nil { return err } payload, err := sw.Marshal() if err != nil { return fmt.Errorf("marshal child %q under caller %d: %w", jobID, caller.ID, err) } 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 if priorID, ok := priorAttemptJobID(priorChildren[jobID], parsedChild.Name, isMatrixDeferred); ok { attemptJobID = priorID } else { attemptJobID, err = actions_model.GetNextAttemptJobID(ctx, run.ID) if err != nil { return fmt.Errorf("alloc attempt_job_id for child %q: %w", jobID, err) } } child := &actions_model.ActionRunJob{ RunID: run.ID, RunAttemptID: attempt.ID, RepoID: run.RepoID, OwnerID: run.OwnerID, CommitSHA: run.CommitSHA, IsForkPullRequest: run.IsForkPullRequest, Name: parsedChild.Name, Attempt: attempt.Attempt, WorkflowPayload: payload, JobID: jobID, AttemptJobID: attemptJobID, Needs: needs, RunsOn: parsedChild.RunsOn(), ContinueOnError: parsedChild.GetContinueOnError(), MaxParallel: parseMaxParallel(jobID, parsedChild.Strategy.MaxParallelString), Status: actions_model.StatusBlocked, ParentJobID: caller.ID, WorkflowSourceRepoID: sourceRepoID, WorkflowSourceCommitSHA: sourceCommitSHA, IsMatrixDeferred: isMatrixDeferred, } if isMatrixDeferred { // Expansion overwrites WorkflowPayload; keep the raw payload so a rerun can re-derive the matrix. child.DeferredMatrixPayload = payload } if perms := ExtractJobPermissionsFromWorkflow(sw, parsedChild); perms != nil { child.TokenPermissions = new(repo_model.ClampActionsTokenPermissions(*perms, *callerPermissions)) } else { child.TokenPermissions = callerPermissions } if parsedChild.Uses != "" { child.IsReusableCaller = true child.CallUses = parsedChild.Uses } if err := db.Insert(ctx, child); err != nil { return fmt.Errorf("insert child %q under caller %d: %w", jobID, caller.ID, err) } } return nil } // 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 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. gsu := httplib.ParseGiteaSiteURL(ctx, uses) if gsu == nil { return nil, fmt.Errorf("unsupported reusable workflow URL %q: an absolute URL must point to this Gitea instance (%s)", uses, setting.AppURL) } // 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 := model.ParseReusableWorkflowUses(uses) if err != nil { return nil, err } 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 } // undoExpansion rolls back a partial expansion owned by the current transaction: // it removes the inserted children and releases the is_expanded claim itself. func undoExpansion(ctx context.Context, caller *actions_model.ActionRunJob) error { if err := actions_model.DeleteDirectChildJobsByParent(ctx, caller); err != nil { return fmt.Errorf("delete children of caller %d: %w", caller.ID, err) } caller.IsExpanded = false if _, err := actions_model.UpdateRunJob(ctx, caller, nil, "is_expanded"); err != nil { return fmt.Errorf("release caller %d expansion claim: %w", caller.ID, err) } return nil } // priorAttemptJobID returns the AttemptJobID a re-inserted child should reuse, // given the prior attempt's rows of the same JobID indexed by Name. func priorAttemptJobID(priorSameJobID map[string]*actions_model.ActionRunJob, name string, isMatrixDeferred bool) (int64, bool) { if isMatrixDeferred { for _, prior := range priorSameJobID { if len(prior.DeferredMatrixPayload) > 0 { return prior.AttemptJobID, true } } return 0, false } prior, ok := priorSameJobID[name] if !ok { return 0, false } return prior.AttemptJobID, true }