mirror of
https://github.com/go-gitea/gitea.git
synced 2026-09-25 22:23:42 +09:00
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>
489 lines
20 KiB
Go
489 lines
20 KiB
Go
// 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
|
|
}
|