diff --git a/models/actions/run.go b/models/actions/run.go index 9ef3fdf5cc..bba6b4c34e 100644 --- a/models/actions/run.go +++ b/models/actions/run.go @@ -21,8 +21,6 @@ import ( "gitea.dev/modules/timeutil" "gitea.dev/modules/util" webhook_module "gitea.dev/modules/webhook" - - "xorm.io/builder" ) // ActionRun represents a run of a workflow file @@ -253,96 +251,6 @@ func UpdateRepoRunsNumbers(ctx context.Context, repoID int64) { } } -// CancelPreviousJobs cancels all previous jobs of the same repository, reference, workflow, and event. -// It's useful when a new run is triggered, and all previous runs needn't be continued anymore. -func CancelPreviousJobs(ctx context.Context, repoID int64, ref, workflowID string, event webhook_module.HookEventType) ([]*ActionRunJob, error) { - // Find all runs in the specified repository, reference, and workflow with non-final status - runs, total, err := db.FindAndCount[ActionRun](ctx, FindRunOptions{ - RepoID: repoID, - Ref: ref, - WorkflowID: workflowID, - TriggerEvent: event, - Status: []Status{StatusRunning, StatusWaiting, StatusBlocked, StatusCancelling}, - }) - if err != nil { - return nil, err - } - - // If there are no runs found, there's no need to proceed with cancellation, so return nil. - if total == 0 { - return nil, nil - } - - cancelledJobs := make([]*ActionRunJob, 0, total) - - // Iterate over each found run and cancel its associated jobs. - for _, run := range runs { - // Find all jobs associated with the current run. - jobs, err := db.Find[ActionRunJob](ctx, FindRunJobOptions{ - RunID: run.ID, - }) - if err != nil { - return cancelledJobs, err - } - - cjs, err := CancelJobs(ctx, jobs) - if err != nil { - return cancelledJobs, err - } - cancelledJobs = append(cancelledJobs, cjs...) - } - - // Return nil to indicate successful cancellation of all running and waiting jobs. - return cancelledJobs, nil -} - -func CancelJobs(ctx context.Context, jobs []*ActionRunJob) ([]*ActionRunJob, error) { - cancelledJobs := make([]*ActionRunJob, 0, len(jobs)) - // Iterate over each job and attempt to cancel it. - for _, job := range jobs { - // Skip jobs that are already in a terminal state (completed, cancelled, etc.). - status := job.Status - if status.IsDone() { - continue - } - - // If the job has no associated task (probably an error), set its status to 'Cancelled' and stop it. - if job.TaskID == 0 { - job.Status = StatusCancelled - job.Stopped = timeutil.TimeStampNow() - - // Update the job's status and stopped time in the database. - n, err := UpdateRunJob(ctx, job, builder.Eq{"task_id": 0}, "status", "stopped") - if err != nil { - return cancelledJobs, err - } - - // If the update affected 0 rows, it means the job has changed in the meantime - if n == 0 { - log.Error("Failed to cancel job %d because it has changed", job.ID) - continue - } - - cancelledJobs = append(cancelledJobs, job) - // Continue with the next job. - continue - } - - // If the job has an associated task, try to stop the task, effectively cancelling the job. - if err := StopTask(ctx, job.TaskID, StatusCancelling); err != nil { - return cancelledJobs, err - } - updatedJob, err := GetRunJobByRunAndID(ctx, job.RunID, job.ID) - if err != nil { - return cancelledJobs, fmt.Errorf("get job: %w", err) - } - cancelledJobs = append(cancelledJobs, updatedJob) - } - - // Return nil to indicate successful cancellation of all running and waiting jobs. - return cancelledJobs, nil -} - func GetRunByRepoAndID(ctx context.Context, repoID, runID int64) (*ActionRun, error) { var run ActionRun has, err := db.GetEngine(ctx).Where("id=? AND repo_id=?", runID, repoID).Get(&run) diff --git a/models/actions/run_job.go b/models/actions/run_job.go index 87423c2264..caf66ca451 100644 --- a/models/actions/run_job.go +++ b/models/actions/run_job.go @@ -12,8 +12,10 @@ import ( "gitea.dev/models/db" repo_model "gitea.dev/models/repo" "gitea.dev/modules/actions/jobparser" + "gitea.dev/modules/log" "gitea.dev/modules/timeutil" "gitea.dev/modules/util" + webhook_module "gitea.dev/modules/webhook" "xorm.io/builder" ) @@ -75,14 +77,55 @@ type ActionRunJob struct { // A value of 0 indicates a legacy job created before ActionRunAttempt existed. AttemptJobID int64 `xorm:"index NOT NULL DEFAULT 0"` + // WorkflowSourceRepoID + WorkflowSourceCommitSHA record the (repo, commit) this job's containing workflow file came from. + WorkflowSourceRepoID int64 `xorm:"NOT NULL DEFAULT 0"` + WorkflowSourceCommitSHA string `xorm:"VARCHAR(64) NOT NULL DEFAULT ''"` + + // IsReusableCaller marks this job as a reusable workflow caller. + // Caller jobs do not run on a runner; their status is derived from their child jobs. + IsReusableCaller bool `xorm:"index NOT NULL DEFAULT FALSE"` + // IsExpanded reports whether this job's lazy expansion (children-row insertion) is complete. + // For a reusable workflow caller, true means children rows exist and CallPayload is populated. + IsExpanded bool `xorm:"NOT NULL DEFAULT FALSE"` + // CallUses stores the raw "uses:" string of a reusable workflow caller job. + // Only set when IsReusableCaller is true. + CallUses string `xorm:"VARCHAR(512) NOT NULL DEFAULT ''"` + // ReusableWorkflowContent is the content of the reusable workflow specified by "uses:". + // Only set when IsReusableCaller is true. + ReusableWorkflowContent []byte `xorm:"LONGBLOB"` + // CallSecrets encodes the reusable workflow caller's "secrets:" section: + // - "" : no "secrets:" section (children only see auto-generated tokens). + // - "inherit" : the caller wrote "secrets: inherit". + // - JSON object : explicit mapping {alias: source_name}; names only, no values. + // Only set when IsReusableCaller is true. + CallSecrets string `xorm:"LONGTEXT"` + // CallPayload is the JSON-encoded WorkflowCallPayload exposed to children as gitea.event. + // Populated atomically with IsExpanded at the end of expandReusableWorkflowCaller. + // Only set when IsReusableCaller is true. + CallPayload string `xorm:"LONGTEXT"` + + // ParentJobID scopes `Needs` resolution: name lookups happen only among rows sharing the same ParentJobID. 0 for top-level rows. + ParentJobID int64 `xorm:"index NOT NULL DEFAULT 0"` + Started timeutil.TimeStamp Stopped timeutil.TimeStamp Created timeutil.TimeStamp `xorm:"created"` Updated timeutil.TimeStamp `xorm:"updated index"` } +// ActionRunAttemptJobIDIndex backs the run-wide AttemptJobID counter, keyed by ActionRun.ID. +// Use GetNextAttemptJobID to allocate the next ID for a run. +type ActionRunAttemptJobIDIndex db.ResourceIndex + +// GetNextAttemptJobID atomically allocates the next AttemptJobID for a job in the given run. +// AttemptJobIDs are unique within a single attempt and stable across attempts for the same logical job +func GetNextAttemptJobID(ctx context.Context, runID int64) (int64, error) { + return db.GetNextResourceIndex(ctx, "action_run_attempt_job_id_index", runID) +} + func init() { db.RegisterModel(new(ActionRunJob)) + db.RegisterModel(new(ActionRunAttemptJobIDIndex)) } func (job *ActionRunJob) Duration() time.Duration { @@ -218,6 +261,101 @@ func GetRunJobsByRunAndAttemptID(ctx context.Context, runID, runAttemptID int64) return jobs, nil } +// GetPriorAttemptChildrenByParent returns the children of the most recent prior attempt where +// the parent (identified by parentAttemptJobID) actually had children, indexed by child JobID then child Name. +// Returns (nil, nil) when no such attempt exists. +// The (JobID, Name) key disambiguates both reusable-workflow subtrees and matrix-expanded instances (whose Name carries the matrix suffix). +func GetPriorAttemptChildrenByParent(ctx context.Context, runID, currentAttemptID, parentAttemptJobID int64) (map[string]map[string]*ActionRunJob, error) { + // query every prior caller row sharing this AttemptJobID, newest first. + var priorCallers []*ActionRunJob + if err := db.GetEngine(ctx). + Where("run_id = ? AND attempt_job_id = ? AND run_attempt_id < ?", runID, parentAttemptJobID, currentAttemptID). + Desc("run_attempt_id"). + Find(&priorCallers); err != nil { + return nil, fmt.Errorf("find prior callers: %w", err) + } + if len(priorCallers) == 0 { + return nil, nil //nolint:nilnil // caller is brand new in this attempt + } + + // query for every child of every prior caller + callerIDs := make([]int64, len(priorCallers)) + for i, c := range priorCallers { + callerIDs[i] = c.ID + } + var allChildren []*ActionRunJob + if err := db.GetEngine(ctx). + Where("run_id = ?", runID). + In("parent_job_id", callerIDs). + Find(&allChildren); err != nil { + return nil, fmt.Errorf("find prior children: %w", err) + } + + childrenByCallerID := make(map[int64][]*ActionRunJob, len(callerIDs)) + for _, c := range allChildren { + childrenByCallerID[c.ParentJobID] = append(childrenByCallerID[c.ParentJobID], c) + } + + // Walk priorCallers in run_attempt_id-desc order and return the children of the first caller that actually had any. + // Skipped attempts (caller exists but no children) are bypassed. + for _, caller := range priorCallers { + children := childrenByCallerID[caller.ID] + if len(children) == 0 { + continue + } + out := make(map[string]map[string]*ActionRunJob) + for _, c := range children { + if out[c.JobID] == nil { + out[c.JobID] = make(map[string]*ActionRunJob) + } + out[c.JobID][c.Name] = c + } + return out, nil + } + + return nil, nil //nolint:nilnil // every prior attempt skipped this caller +} + +// GetDirectChildJobsByParent returns the direct child jobs of a parent job (e.g. a reusable workflow caller). +func GetDirectChildJobsByParent(ctx context.Context, parentJob *ActionRunJob) (ActionJobList, error) { + var jobs []*ActionRunJob + if err := db.GetEngine(ctx). + Where("run_id=? AND parent_job_id=?", parentJob.RunID, parentJob.ID). + OrderBy("id"). + Find(&jobs); err != nil { + return nil, err + } + return jobs, nil +} + +// CollectAllDescendantJobs returns every job in `allJobs` that lives under parent's subtree (recursively), excluding `parent` itself +func CollectAllDescendantJobs(parent *ActionRunJob, allJobs []*ActionRunJob) []*ActionRunJob { + parents := map[int64]bool{parent.ID: true} + for { + grew := false + for _, j := range allJobs { + if j.ParentJobID == 0 { + continue + } + if parents[j.ParentJobID] && !parents[j.ID] { + parents[j.ID] = true + grew = true + } + } + if !grew { + break + } + } + out := make([]*ActionRunJob, 0) + for _, j := range allJobs { + if j.ID == parent.ID || !parents[j.ID] { + continue + } + out = append(out, j) + } + return out +} + func UpdateRunJob(ctx context.Context, job *ActionRunJob, cond builder.Cond, cols ...string) (int64, error) { e := db.GetEngine(ctx) @@ -242,7 +380,8 @@ func UpdateRunJob(ctx context.Context, job *ActionRunJob, cond builder.Cond, col return affected, nil } - if slices.Contains(cols, "status") && job.Status.IsWaiting() { + // Reusable workflow caller jobs are never picked up by runners, so they don't need a task-version bump. + if statusUpdated && job.Status.IsWaiting() && !job.IsReusableCaller { // if the status of job changes to waiting again, increase tasks version. if err := IncreaseTaskVersion(ctx, job.OwnerID, job.RepoID); err != nil { return 0, err @@ -256,6 +395,15 @@ func UpdateRunJob(ctx context.Context, job *ActionRunJob, cond builder.Cond, col } } + if statusUpdated && job.ParentJobID > 0 { + // Reusable workflow caller's children cascade their status changes upward to the parent caller. + parent, err := GetRunJobByRunAndID(ctx, job.RunID, job.ParentJobID) + if err != nil { + return affected, fmt.Errorf("load parent caller %d: %w", job.ParentJobID, err) + } + return affected, RefreshReusableCallerStatus(ctx, parent) + } + { // Other goroutines may aggregate the status of the attempt/run and update it too. // So we need to load the current jobs before updating the aggregate state. @@ -308,6 +456,44 @@ func UpdateRunJob(ctx context.Context, job *ActionRunJob, cond builder.Cond, col return affected, nil } +// RefreshReusableCallerStatus recomputes a reusable workflow caller's Status, Started and Stopped from its current direct children and persists the change. +// No-op if caller is not a reusable caller. +// +// Concurrency: two sibling children finishing at roughly the same time can each invoke this for the same parent caller. +// No row-level lock is taken because AggregateJobStatus is a pure function of the children's statuses (order-independent), so racing callers arrive at the same Status. +func RefreshReusableCallerStatus(ctx context.Context, caller *ActionRunJob) error { + if !caller.IsReusableCaller { + return nil + } + children, err := GetDirectChildJobsByParent(ctx, caller) + if err != nil { + return err + } + + newStatus := AggregateJobStatus(children) + cols := make([]string, 0, 3) + if caller.Status != newStatus { + caller.Status = newStatus + cols = append(cols, "status") + } + if newStatus != StatusSkipped { + now := timeutil.TimeStampNow() + if caller.Started.IsZero() && newStatus == StatusRunning { + caller.Started = now + cols = append(cols, "started") + } + if caller.Stopped.IsZero() && newStatus.IsDone() { + caller.Stopped = now + cols = append(cols, "stopped") + } + } + if len(cols) == 0 { + return nil + } + _, err = UpdateRunJob(ctx, caller, nil, cols...) + return err +} + func AggregateJobStatus(jobs []*ActionRunJob) Status { allSuccessOrSkipped := len(jobs) != 0 allSkipped := len(jobs) != 0 @@ -346,6 +532,49 @@ func AggregateJobStatus(jobs []*ActionRunJob) Status { } } +// CancelPreviousJobs cancels all previous jobs of the same repository, reference, workflow, and event. +// It's useful when a new run is triggered, and all previous runs needn't be continued anymore. +func CancelPreviousJobs(ctx context.Context, repoID int64, ref, workflowID string, event webhook_module.HookEventType) ([]*ActionRunJob, error) { + // Find all runs in the specified repository, reference, and workflow with non-final status + runs, total, err := db.FindAndCount[ActionRun](ctx, FindRunOptions{ + RepoID: repoID, + Ref: ref, + WorkflowID: workflowID, + TriggerEvent: event, + Status: []Status{StatusRunning, StatusWaiting, StatusBlocked, StatusCancelling}, + }) + if err != nil { + return nil, err + } + + // If there are no runs found, there's no need to proceed with cancellation, so return nil. + if total == 0 { + return nil, nil + } + + cancelledJobs := make([]*ActionRunJob, 0, total) + + // Iterate over each found run and cancel its associated jobs. + for _, run := range runs { + // Find all jobs associated with the current run. + jobs, err := db.Find[ActionRunJob](ctx, FindRunJobOptions{ + RunID: run.ID, + }) + if err != nil { + return cancelledJobs, err + } + + cjs, err := CancelJobs(ctx, jobs) + if err != nil { + return cancelledJobs, err + } + cancelledJobs = append(cancelledJobs, cjs...) + } + + // Return nil to indicate successful cancellation of all running and waiting jobs. + return cancelledJobs, nil +} + func CancelPreviousJobsByJobConcurrency(ctx context.Context, job *ActionRunJob) (jobsToCancel []*ActionRunJob, _ error) { if job.RawConcurrency == "" { return nil, nil @@ -383,3 +612,84 @@ func CancelPreviousJobsByJobConcurrency(ctx context.Context, job *ActionRunJob) return CancelJobs(ctx, jobsToCancel) } + +func CancelJobs(ctx context.Context, jobs []*ActionRunJob) ([]*ActionRunJob, error) { + cancelledJobs := make([]*ActionRunJob, 0, len(jobs)) + + for _, job := range jobs { + if job.IsReusableCaller { + sub, err := cancelReusableCaller(ctx, job) + if err != nil { + return cancelledJobs, err + } + cancelledJobs = append(cancelledJobs, sub...) + continue + } + + c, err := cancelOneJob(ctx, job) + if err != nil { + return cancelledJobs, err + } + if c != nil { + cancelledJobs = append(cancelledJobs, c) + } + } + return cancelledJobs, nil +} + +// cancelOneJob cancels a single job and returns the post-cancel row +func cancelOneJob(ctx context.Context, job *ActionRunJob) (*ActionRunJob, error) { + if job.Status.IsDone() { + return nil, nil //nolint:nilnil // signal "nothing to cancel; not an error" + } + // No associated task: mark Cancelled directly. This includes reusable callers and jobs that never reached PickTask. + if job.TaskID == 0 { + job.Status = StatusCancelled + job.Stopped = timeutil.TimeStampNow() + n, err := UpdateRunJob(ctx, job, builder.Eq{"task_id": 0}, "status", "stopped") + if err != nil { + return nil, err + } + if n == 0 { + log.Error("Failed to cancel job %d because it has changed", job.ID) + return nil, nil //nolint:nilnil // signal "nothing to cancel; not an error" + } + return job, nil + } + // Has a task: stop the task and re-read the row. + if err := StopTask(ctx, job.TaskID, StatusCancelling); err != nil { + return nil, err + } + updated, err := GetRunJobByRunAndID(ctx, job.RunID, job.ID) + if err != nil { + return nil, fmt.Errorf("get job: %w", err) + } + return updated, nil +} + +// cancelReusableCaller cancels `caller` and all its child jobs +func cancelReusableCaller(ctx context.Context, caller *ActionRunJob) ([]*ActionRunJob, error) { + cancelledJobs := make([]*ActionRunJob, 0) + + if c, err := cancelOneJob(ctx, caller); err != nil { + return cancelledJobs, err + } else if c != nil { + cancelledJobs = append(cancelledJobs, c) + } + + attemptJobs, err := GetRunJobsByRunAndAttemptID(ctx, caller.RunID, caller.RunAttemptID) + if err != nil { + return cancelledJobs, err + } + + for _, c := range CollectAllDescendantJobs(caller, attemptJobs) { + cancelled, err := cancelOneJob(ctx, c) + if err != nil { + return cancelledJobs, err + } + if cancelled != nil { + cancelledJobs = append(cancelledJobs, cancelled) + } + } + return cancelledJobs, nil +} diff --git a/models/actions/run_job_test.go b/models/actions/run_job_test.go new file mode 100644 index 0000000000..a9e07ce0cf --- /dev/null +++ b/models/actions/run_job_test.go @@ -0,0 +1,133 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package actions + +import ( + "testing" + + "gitea.dev/models/db" + "gitea.dev/models/unittest" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGetPriorAttemptChildrenByParent(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + ctx := t.Context() + + // 3 attempts of one run: + // 1: caller expanded with 3 matrix instances of "work" + non-matrix sibling "summary". + // 2: caller skipped, no children rows. + // 3: placeholder "current" attempt for the walkback subtest. + + run := &ActionRun{ + Title: "prior-children-test", + RepoID: 4, + Index: 9501, + OwnerID: 1, + WorkflowID: "matrix.yaml", + TriggerUserID: 1, + Ref: "refs/heads/master", + CommitSHA: "c2d72f548424103f01ee1dc02889c1e2bff816b0", + Event: "push", + TriggerEvent: "push", + EventPayload: "{}", + Status: StatusSuccess, + } + require.NoError(t, db.Insert(ctx, run)) + + const callerAttemptJobID int64 = 9001 + insertAttempt := func(t *testing.T, num int64, status Status) *ActionRunAttempt { + t.Helper() + a := &ActionRunAttempt{ + RepoID: run.RepoID, + RunID: run.ID, + Attempt: num, + TriggerUserID: 1, + Status: status, + } + require.NoError(t, db.Insert(ctx, a)) + return a + } + insertCaller := func(t *testing.T, attemptID int64, status Status, expanded bool) *ActionRunJob { + t.Helper() + caller := &ActionRunJob{ + RunID: run.ID, + RunAttemptID: attemptID, + RepoID: run.RepoID, + OwnerID: run.OwnerID, + CommitSHA: run.CommitSHA, + Name: "caller", + JobID: "caller", + Attempt: 1, + Status: status, + AttemptJobID: callerAttemptJobID, + IsReusableCaller: true, + IsExpanded: expanded, + } + require.NoError(t, db.Insert(ctx, caller)) + return caller + } + insertChild := func(t *testing.T, attemptID, parentID, attemptJobID int64, name, jobID string) { + t.Helper() + require.NoError(t, db.Insert(ctx, &ActionRunJob{ + RunID: run.ID, + RunAttemptID: attemptID, + RepoID: run.RepoID, + OwnerID: run.OwnerID, + CommitSHA: run.CommitSHA, + Name: name, + JobID: jobID, + Attempt: 1, + Status: StatusSuccess, + AttemptJobID: attemptJobID, + ParentJobID: parentID, + })) + } + + attempt1 := insertAttempt(t, 1, StatusSuccess) + caller1 := insertCaller(t, attempt1.ID, StatusSuccess, true) + insertChild(t, attempt1.ID, caller1.ID, 101, "work (alpha)", "work") + insertChild(t, attempt1.ID, caller1.ID, 102, "work (beta)", "work") + insertChild(t, attempt1.ID, caller1.ID, 103, "work (gamma)", "work") + insertChild(t, attempt1.ID, caller1.ID, 104, "summary", "summary") + + attempt2 := insertAttempt(t, 2, StatusSkipped) + insertCaller(t, attempt2.ID, StatusSkipped, false) // no children intentionally + + // both subtests expect attempt 1's expansion, differing only in the "current" attempt id + assertAttempt1Children := func(t *testing.T, out map[string]map[string]*ActionRunJob) { + t.Helper() + // outer map keyed by JobID: "work" has 3 matrix instances, "summary" 1 + assert.Len(t, out, 2) + assert.Len(t, out["work"], 3, "matrix instances must each get their own inner-map entry") + assert.Len(t, out["summary"], 1) + + require.NotNil(t, out["work"]["work (alpha)"]) + require.NotNil(t, out["work"]["work (beta)"]) + require.NotNil(t, out["work"]["work (gamma)"]) + require.NotNil(t, out["summary"]["summary"]) + + assert.Equal(t, int64(101), out["work"]["work (alpha)"].AttemptJobID) + assert.Equal(t, int64(102), out["work"]["work (beta)"].AttemptJobID) + assert.Equal(t, int64(103), out["work"]["work (gamma)"].AttemptJobID) + assert.Equal(t, int64(104), out["summary"]["summary"].AttemptJobID) + } + + t.Run("matrix instances and non-matrix sibling are indexed by (JobID, Name)", func(t *testing.T) { + // "current" = attempt 2; prior = attempt 1, which is the immediately preceding attempt. + out, err := GetPriorAttemptChildrenByParent(ctx, run.ID, attempt2.ID, callerAttemptJobID) + require.NoError(t, err) + assertAttempt1Children(t, out) + }) + + t.Run("walkback past an attempt where the caller had no children", func(t *testing.T) { + attempt3 := insertAttempt(t, 3, StatusRunning) + // "current" = attempt 3; the immediately preceding attempt 2 has no children, so the lookup must walk further back to attempt 1. + out, err := GetPriorAttemptChildrenByParent(ctx, run.ID, attempt3.ID, callerAttemptJobID) + require.NoError(t, err) + assertAttempt1Children(t, out) + }) +} diff --git a/models/actions/task.go b/models/actions/task.go index 0332902ccc..36318c878a 100644 --- a/models/actions/task.go +++ b/models/actions/task.go @@ -249,7 +249,7 @@ func CreateTaskForRunner(ctx context.Context, runner *ActionRunner) (*ActionTask } var jobs []*ActionRunJob - if err := e.Where("task_id=? AND status=?", 0, StatusWaiting).And(jobCond).Asc("updated", "id").Find(&jobs); err != nil { + if err := e.Where("task_id=? AND status=? AND is_reusable_caller=?", 0, StatusWaiting, false).And(jobCond).Asc("updated", "id").Find(&jobs); err != nil { return nil, false, err } @@ -390,7 +390,7 @@ func UpdateTaskByState(ctx context.Context, runnerID int64, state *runnerv1.Task RepoID: task.RepoID, Status: task.Status, Stopped: task.Stopped, - }, nil); err != nil { + }, nil, "status", "stopped"); err != nil { return nil, err } } else { diff --git a/models/migrations/migrations.go b/models/migrations/migrations.go index 6b232545a0..904a3ffa20 100644 --- a/models/migrations/migrations.go +++ b/models/migrations/migrations.go @@ -412,6 +412,7 @@ func prepareMigrationTasks() []*migration { newMigration(332, "Add last_sync_unix to mirror", v1_27.AddLastSyncUnixToMirror), newMigration(333, "Add bypass allowlist to branch protection", v1_27.AddBranchProtectionBypassAllowlist), newMigration(334, "Add cancelling support to action runners", v1_27.AddCancellingSupportToActionRunner), + newMigration(335, "Add reusable workflow fields and action_run_attempt_job_id_index table for ActionRunJob", v1_27.AddReusableWorkflowFieldsToActionRunJob), } return preparedMigrations } diff --git a/models/migrations/v1_27/v335.go b/models/migrations/v1_27/v335.go new file mode 100644 index 0000000000..c14488e343 --- /dev/null +++ b/models/migrations/v1_27/v335.go @@ -0,0 +1,33 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package v1_27 + +import ( + "gitea.dev/models/db" + + "xorm.io/xorm" +) + +// AddReusableWorkflowFieldsToActionRunJob adds the ActionRunJob columns that describe the reusable workflow caller hierarchy, +// and the ActionRunAttemptJobIDIndex table backing run-wide AttemptJobID allocation. +func AddReusableWorkflowFieldsToActionRunJob(x db.EngineMigration) error { + type ActionRunJob struct { + WorkflowSourceRepoID int64 `xorm:"NOT NULL DEFAULT 0"` + WorkflowSourceCommitSHA string `xorm:"VARCHAR(64) NOT NULL DEFAULT ''"` + IsReusableCaller bool `xorm:"index NOT NULL DEFAULT FALSE"` + ParentJobID int64 `xorm:"index NOT NULL DEFAULT 0"` + CallUses string `xorm:"VARCHAR(512) NOT NULL DEFAULT ''"` + CallSecrets string `xorm:"LONGTEXT"` + CallPayload string `xorm:"LONGTEXT"` + IsExpanded bool `xorm:"NOT NULL DEFAULT FALSE"` + ReusableWorkflowContent []byte `xorm:"LONGBLOB"` + } + + type ActionRunAttemptJobIDIndex db.ResourceIndex + + if _, err := x.SyncWithOptions(xorm.SyncOptions{IgnoreDropIndices: true}, new(ActionRunJob)); err != nil { + return err + } + return x.Sync(new(ActionRunAttemptJobIDIndex)) +} diff --git a/models/perm/access/repo_permission.go b/models/perm/access/repo_permission.go index 2237b05b32..6e52177f0a 100644 --- a/models/perm/access/repo_permission.go +++ b/models/perm/access/repo_permission.go @@ -655,3 +655,37 @@ func CheckRepoUnitUser(ctx context.Context, repo *repo_model.Repository, user *u func PermissionNoAccess() Permission { return Permission{AccessMode: perm_model.AccessModeNone} } + +// CanReadWorkflowCrossRepo checks whether the run can read workflow files from targetRepo. +func CanReadWorkflowCrossRepo(ctx context.Context, targetRepo *repo_model.Repository, run *actions_model.ActionRun) (bool, error) { + if err := run.LoadRepo(ctx); err != nil { + return false, err + } + + // (1) Same owner: always allowed (fork-PR scrubbing handled inside). + if checkSameOwnerCrossRepoAccess(ctx, run.Repo, targetRepo, run.IsForkPullRequest) { + return true, nil + } + + // (2) Cross-owner: respect the target repo's collaborative-owner allowlist on its Actions unit. + // The caller (run.Repo) must itself be private. The collaborative-owner grant is owner-level, so without this + // guard a public caller owned by a grantee could pull a private reusable workflow and expose its definition and + // logs in a publicly visible run; requiring a private caller keeps private content flowing private -> private. + // This is intentionally stricter than GitHub, which gates on the target repo's access setting (introduced in #32562): + // https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/enabling-features-for-your-repository/managing-github-actions-settings-for-a-repository#allowing-access-to-components-in-a-private-repository + if run.Repo.IsPrivate { + if actionsUnit, err := targetRepo.GetUnit(ctx, unit.TypeActions); err == nil { + if actionsUnit.ActionsConfig().IsCollaborativeOwner(run.Repo.OwnerID) { + return true, nil + } + } + } + + // (3) Public target: the Actions user's individual permission gives read on any public repo, so `uses:` to a public reusable-workflow library is permitted by default. + // Matches GitHub's behavior: public reusable workflows are universally readable. + botPerm, err := GetIndividualUserRepoPermission(ctx, targetRepo, user_model.NewActionsUser()) + if err != nil { + return false, err + } + return botPerm.AccessMode >= perm_model.AccessModeRead, nil +} diff --git a/models/secret/main_test.go b/models/secret/main_test.go new file mode 100644 index 0000000000..bd74c3f5c9 --- /dev/null +++ b/models/secret/main_test.go @@ -0,0 +1,14 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package secret + +import ( + "testing" + + "gitea.dev/models/unittest" +) + +func TestMain(m *testing.M) { + unittest.MainTest(m) +} diff --git a/models/secret/secret.go b/models/secret/secret.go index 0080580039..8d4cfd3e64 100644 --- a/models/secret/secret.go +++ b/models/secret/secret.go @@ -11,6 +11,8 @@ import ( actions_model "gitea.dev/models/actions" "gitea.dev/models/db" actions_module "gitea.dev/modules/actions" + "gitea.dev/modules/actions/jobparser" + "gitea.dev/modules/json" "gitea.dev/modules/log" secret_module "gitea.dev/modules/secret" "gitea.dev/modules/setting" @@ -152,16 +154,16 @@ func UpdateSecret(ctx context.Context, secretID int64, data, description string) } func GetSecretsOfTask(ctx context.Context, task *actions_model.ActionTask) (map[string]string, error) { - secrets := map[string]string{} + baseSecrets := map[string]string{} - secrets["GITHUB_TOKEN"] = task.Token - secrets["GITEA_TOKEN"] = task.Token + baseSecrets["GITHUB_TOKEN"] = task.Token + baseSecrets["GITEA_TOKEN"] = task.Token if task.Job.Run.IsForkPullRequest && task.Job.Run.TriggerEvent != actions_module.GithubEventPullRequestTarget { // ignore secrets for fork pull request, except GITHUB_TOKEN and GITEA_TOKEN which are automatically generated. // for the tasks triggered by pull_request_target event, they could access the secrets because they will run in the context of the base branch // see the documentation: https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#pull_request_target - return secrets, nil + return baseSecrets, nil } ownerSecrets, err := db.Find[Secret](ctx, FindSecretsOptions{OwnerID: task.Job.Run.Repo.OwnerID}) @@ -181,10 +183,60 @@ func GetSecretsOfTask(ctx context.Context, task *actions_model.ActionTask) (map[ log.Error("Unable to decrypt Actions secret %v %q, maybe SECRET_KEY is wrong: %v", secret.ID, secret.Name, err) continue } - secrets[secret.Name] = v + baseSecrets[secret.Name] = v } - return secrets, nil + return getScopedSecretsForJob(ctx, task.Job, baseSecrets) +} + +// getScopedSecretsForJob walks up the caller chain (ParentJobID) and applies +// each caller's secrets policy: +// - "secrets: inherit" passes the parent scope's secrets through unchanged. +// - explicit mapping {alias: SOURCE} only forwards the named secrets, plus the auto-generated tokens. +// +// For top-level jobs (ParentJobID == 0) the base secrets are returned as-is. +func getScopedSecretsForJob(ctx context.Context, job *actions_model.ActionRunJob, baseSecrets map[string]string) (map[string]string, error) { + if job.ParentJobID == 0 { + return baseSecrets, nil + } + + caller, err := actions_model.GetRunJobByRunAndID(ctx, job.RunID, job.ParentJobID) + if err != nil { + return nil, fmt.Errorf("load caller job %d: %w", job.ParentJobID, err) + } + + parentScope, err := getScopedSecretsForJob(ctx, caller, baseSecrets) + if err != nil { + return nil, err + } + + if caller.CallSecrets == jobparser.SecretsInherit { + return parentScope, nil + } + + // Empty or explicit-mapping path: only auto-tokens + (any) mapped aliases are exposed. + scoped := map[string]string{ + "GITHUB_TOKEN": baseSecrets["GITHUB_TOKEN"], + "GITEA_TOKEN": baseSecrets["GITEA_TOKEN"], + } + if caller.CallSecrets == "" { + return scoped, nil + } + var mapping map[string]string + if err := json.Unmarshal([]byte(caller.CallSecrets), &mapping); err != nil { + return nil, fmt.Errorf("decode caller %d secret map: %w", caller.ID, err) + } + for alias, source := range mapping { + if v, ok := parentScope[source]; ok { + scoped[alias] = v + continue + } + // Secret names are case-insensitive in storage (uppercased). + if v, ok := parentScope[strings.ToUpper(source)]; ok { + scoped[alias] = v + } + } + return scoped, nil } func CountWrongRepoLevelSecrets(ctx context.Context) (int64, error) { diff --git a/models/secret/secret_test.go b/models/secret/secret_test.go new file mode 100644 index 0000000000..11dd0f750c --- /dev/null +++ b/models/secret/secret_test.go @@ -0,0 +1,174 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package secret + +import ( + "testing" + + actions_model "gitea.dev/models/actions" + "gitea.dev/models/db" + "gitea.dev/models/unittest" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGetScopedSecretsForJob(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + ctx := t.Context() + + base := map[string]string{ + "GITHUB_TOKEN": "tok", + "GITEA_TOKEN": "tok", + "PROD_API_KEY": "prod-secret", + "DEV_API_KEY": "dev-secret", + } + + // insertCaller create an ActionRunJob caller row with the given CallSecrets policy + insertCaller := func(t *testing.T, runID, parentJobID int64, callSecrets string) *actions_model.ActionRunJob { + t.Helper() + job := &actions_model.ActionRunJob{ + RunID: runID, + RepoID: 1, + IsReusableCaller: true, + ParentJobID: parentJobID, + CallSecrets: callSecrets, + Status: actions_model.StatusBlocked, + } + require.NoError(t, db.Insert(t.Context(), job)) + return job + } + + t.Run("TopLevelJob_ReturnsBaseUnchanged", func(t *testing.T) { + const runID = 9001 + leaf := &actions_model.ActionRunJob{RunID: runID, ParentJobID: 0} + + got, err := getScopedSecretsForJob(ctx, leaf, base) + require.NoError(t, err) + assert.Equal(t, base, got, "top-level jobs should see the full base scope") + }) + + t.Run("CallerInherit_PassesParentScopeThrough", func(t *testing.T) { + const runID = 9002 + caller := insertCaller(t, runID, 0, "inherit") + leaf := &actions_model.ActionRunJob{RunID: runID, ParentJobID: caller.ID} + + got, err := getScopedSecretsForJob(ctx, leaf, base) + require.NoError(t, err) + assert.Equal(t, base, got, "secrets: inherit forwards everything from parent scope") + }) + + t.Run("CallerEmptySecrets_ExposesOnlyAutoTokens", func(t *testing.T) { + const runID = 9003 + caller := insertCaller(t, runID, 0, "") + leaf := &actions_model.ActionRunJob{RunID: runID, ParentJobID: caller.ID} + + got, err := getScopedSecretsForJob(ctx, leaf, base) + require.NoError(t, err) + assert.Equal(t, map[string]string{ + "GITHUB_TOKEN": "tok", + "GITEA_TOKEN": "tok", + }, got) + }) + + t.Run("CallerMapping_OnlyMappedAliasesPlusTokens", func(t *testing.T) { + const runID = 9004 + // {alias: source} - the called workflow sees `secrets.MY_KEY` resolved to PROD_API_KEY's value. + caller := insertCaller(t, runID, 0, `{"MY_KEY":"PROD_API_KEY"}`) + leaf := &actions_model.ActionRunJob{RunID: runID, ParentJobID: caller.ID} + + got, err := getScopedSecretsForJob(ctx, leaf, base) + require.NoError(t, err) + assert.Equal(t, map[string]string{ + "GITHUB_TOKEN": "tok", + "GITEA_TOKEN": "tok", + "MY_KEY": "prod-secret", + // no "dev-secret" + }, got) + }) + + t.Run("CallerMapping_CaseInsensitiveSource", func(t *testing.T) { + const runID = 9005 + caller := insertCaller(t, runID, 0, `{"alias":"prod_api_key"}`) + leaf := &actions_model.ActionRunJob{RunID: runID, ParentJobID: caller.ID} + + got, err := getScopedSecretsForJob(ctx, leaf, base) + require.NoError(t, err) + assert.Equal(t, "prod-secret", got["alias"]) + }) + + t.Run("CallerMapping_UnknownSourceDropsAlias", func(t *testing.T) { + const runID = 9006 + // alias points at a non-existent secret name, so it must be dropped. + caller := insertCaller(t, runID, 0, `{"MAPPED_ALIAS":"DOES_NOT_EXIST"}`) + leaf := &actions_model.ActionRunJob{RunID: runID, ParentJobID: caller.ID} + + got, err := getScopedSecretsForJob(ctx, leaf, base) + require.NoError(t, err) + _, present := got["MAPPED_ALIAS"] + assert.False(t, present) + }) + + t.Run("Nested_InheritThenInherit_FullScope", func(t *testing.T) { + const runID = 9007 + outer := insertCaller(t, runID, 0, "inherit") + inner := insertCaller(t, runID, outer.ID, "inherit") + leaf := &actions_model.ActionRunJob{RunID: runID, ParentJobID: inner.ID} + + got, err := getScopedSecretsForJob(ctx, leaf, base) + require.NoError(t, err) + assert.Equal(t, base, got, "inherit-then-inherit should pass the full base scope through") + }) + + t.Run("Nested_InheritThenMapping_InnerNarrows", func(t *testing.T) { + const runID = 9008 + // inner mapping narrows the full scope it inherited from outer. + outer := insertCaller(t, runID, 0, "inherit") + inner := insertCaller(t, runID, outer.ID, `{"ALIAS_OUT":"PROD_API_KEY"}`) + leaf := &actions_model.ActionRunJob{RunID: runID, ParentJobID: inner.ID} + + got, err := getScopedSecretsForJob(ctx, leaf, base) + require.NoError(t, err) + assert.Equal(t, map[string]string{ + "GITHUB_TOKEN": "tok", + "GITEA_TOKEN": "tok", + "ALIAS_OUT": "prod-secret", + // no "dev-secret" + }, got) + }) + + t.Run("Nested_MappingThenInherit_OuterNarrows", func(t *testing.T) { + const runID = 9009 + // inner inherits outer's already-narrowed scope, so leaf sees only auto-tokens + OUTER_ALIAS. + outer := insertCaller(t, runID, 0, `{"OUTER_ALIAS":"PROD_API_KEY"}`) + inner := insertCaller(t, runID, outer.ID, "inherit") + leaf := &actions_model.ActionRunJob{RunID: runID, ParentJobID: inner.ID} + + got, err := getScopedSecretsForJob(ctx, leaf, base) + require.NoError(t, err) + assert.Equal(t, map[string]string{ + "GITHUB_TOKEN": "tok", + "GITEA_TOKEN": "tok", + "OUTER_ALIAS": "prod-secret", + // no "dev-secret" + }, got) + }) + + t.Run("Nested_MappingThenMapping_InnerSourceMustExistInOuterScope", func(t *testing.T) { + const runID = 9010 + // inner can rename ALIAS_A (in outer's scope) to ALIAS_C, but cannot forward DEV_API_KEY, which outer dropped. + outer := insertCaller(t, runID, 0, `{"ALIAS_A":"PROD_API_KEY"}`) + inner := insertCaller(t, runID, outer.ID, `{"ALIAS_B":"DEV_API_KEY","ALIAS_C":"ALIAS_A"}`) + leaf := &actions_model.ActionRunJob{RunID: runID, ParentJobID: inner.ID} + + got, err := getScopedSecretsForJob(ctx, leaf, base) + require.NoError(t, err) + assert.Equal(t, map[string]string{ + "GITHUB_TOKEN": "tok", + "GITEA_TOKEN": "tok", + "ALIAS_C": "prod-secret", + // no "dev-secret" + }, got) + }) +} diff --git a/modules/actions/jobparser/model.go b/modules/actions/jobparser/model.go index 96450849ab..c80626e4c0 100644 --- a/modules/actions/jobparser/model.go +++ b/modules/actions/jobparser/model.go @@ -8,6 +8,7 @@ import ( "errors" "fmt" + "gitea.com/gitea/runner/act/exprparser" "gitea.com/gitea/runner/act/model" "go.yaml.in/yaml/v4" ) @@ -466,6 +467,26 @@ func ParseRawOn(rawOn *yaml.Node) ([]*Event, error) { } } +func EvaluateJobIfExpression(jobID string, job *Job, gitCtx map[string]any, results map[string]*JobResult, vars map[string]string, inputs map[string]any) (bool, error) { + actJob := &model.Job{ + Strategy: &model.Strategy{ + FailFastString: job.Strategy.FailFastString, + MaxParallelString: job.Strategy.MaxParallelString, + RawMatrix: job.Strategy.RawMatrix, + }, + } + evaluator := NewExpressionEvaluator(NewInterpeter(jobID, actJob, nil, toGitContext(gitCtx), results, vars, inputs)) + expr, err := rewriteSubExpression(job.If.Value, false) + if err != nil { + return false, err + } + result, err := evaluator.evaluate(expr, exprparser.DefaultStatusCheckSuccess) + if err != nil { + return false, err + } + return exprparser.IsTruthy(result), nil +} + // parseMappingNode parse a mapping node and preserve order. func parseMappingNode[T any](node *yaml.Node) ([]string, []T, error) { if node.Kind != yaml.MappingNode { diff --git a/modules/actions/jobparser/uses.go b/modules/actions/jobparser/uses.go new file mode 100644 index 0000000000..d829537c69 --- /dev/null +++ b/modules/actions/jobparser/uses.go @@ -0,0 +1,75 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package jobparser + +import ( + "errors" + "fmt" + "path" + "regexp" + "strings" +) + +// UsesKind enumerates the supported forms of a reusable workflow "uses:" value. +type UsesKind int + +const ( + // UsesKindLocalSameRepo is "./.gitea/workflows/foo.yml" - a path inside the calling repository. + UsesKindLocalSameRepo UsesKind = iota + 1 + // UsesKindLocalCrossRepo is "owner/repo/.gitea/workflows/foo.yml@ref" - a workflow in another repo on the same instance. + UsesKindLocalCrossRepo +) + +// UsesRef is the parsed form of a reusable workflow "uses:" value. +type UsesRef struct { + Kind UsesKind + Owner string // empty for UsesKindLocalSameRepo + Repo string // empty for UsesKindLocalSameRepo + Path string // workflow file path inside the source repo + Ref string // git ref; empty for UsesKindLocalSameRepo +} + +var ( + reLocalSameRepo = regexp.MustCompile(`^\./\.(gitea|github)/workflows/([^@]+\.ya?ml)$`) + reLocalCrossRepo = regexp.MustCompile(`^([-.\w]+)/([-.\w]+)/\.(gitea|github)/workflows/([^@]+\.ya?ml)@(.+)$`) +) + +// ParseUses parses a reusable workflow "uses:" value. +// Only two forms are supported: +// - "./.gitea/workflows/foo.yml" (UsesKindLocalSameRepo, no @ref) +// - "OWNER/REPO/.gitea/workflows/foo.yml@REF" (UsesKindLocalCrossRepo) +func ParseUses(s string) (*UsesRef, error) { + s = strings.TrimSpace(s) + if s == "" { + return nil, errors.New("empty uses value") + } + + if strings.HasPrefix(s, "./") { + m := reLocalSameRepo.FindStringSubmatch(s) + if m == nil { + return nil, fmt.Errorf(`invalid local "uses:" %q (expect ./.gitea/workflows/.yml)`, s) + } + p := fmt.Sprintf(".%s/workflows/%s", m[1], m[2]) + if path.Clean(p) != p { + return nil, fmt.Errorf("invalid workflow path %q", s) + } + return &UsesRef{Kind: UsesKindLocalSameRepo, Path: p}, nil + } + + m := reLocalCrossRepo.FindStringSubmatch(s) + if m == nil { + return nil, fmt.Errorf(`invalid cross-repo "uses:" %q (expect owner/repo/.gitea/workflows/.yml@ref)`, s) + } + p := fmt.Sprintf(".%s/workflows/%s", m[3], m[4]) + if path.Clean(p) != p { + return nil, fmt.Errorf("invalid workflow path %q", s) + } + return &UsesRef{ + Kind: UsesKindLocalCrossRepo, + Owner: m[1], + Repo: m[2], + Path: p, + Ref: m[5], + }, nil +} diff --git a/modules/actions/jobparser/uses_test.go b/modules/actions/jobparser/uses_test.go new file mode 100644 index 0000000000..c6692054dc --- /dev/null +++ b/modules/actions/jobparser/uses_test.go @@ -0,0 +1,170 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package jobparser + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParseUses(t *testing.T) { + t.Run("LocalSameRepo", func(t *testing.T) { + cases := []struct { + name string + in string + want UsesRef + }{ + { + name: "gitea dir, .yml", + in: "./.gitea/workflows/build.yml", + want: UsesRef{Kind: UsesKindLocalSameRepo, Path: ".gitea/workflows/build.yml"}, + }, + { + name: "github dir, .yml", + in: "./.github/workflows/build.yml", + want: UsesRef{Kind: UsesKindLocalSameRepo, Path: ".github/workflows/build.yml"}, + }, + { + name: "gitea dir, .yaml", + in: "./.gitea/workflows/build.yaml", + want: UsesRef{Kind: UsesKindLocalSameRepo, Path: ".gitea/workflows/build.yaml"}, + }, + { + name: "filename containing dots is allowed", + in: "./.gitea/workflows/foo..bar.yml", + want: UsesRef{Kind: UsesKindLocalSameRepo, Path: ".gitea/workflows/foo..bar.yml"}, + }, + { + name: "nested subdirectory", + in: "./.gitea/workflows/sub/build.yml", + want: UsesRef{Kind: UsesKindLocalSameRepo, Path: ".gitea/workflows/sub/build.yml"}, + }, + { + name: "leading/trailing whitespace is trimmed", + in: " ./.gitea/workflows/build.yml ", + want: UsesRef{Kind: UsesKindLocalSameRepo, Path: ".gitea/workflows/build.yml"}, + }, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got, err := ParseUses(c.in) + require.NoError(t, err) + assert.Equal(t, c.want, *got) + }) + } + }) + + t.Run("LocalCrossRepo", func(t *testing.T) { + cases := []struct { + name string + in string + want UsesRef + }{ + { + name: "gitea dir, simple ref", + in: "owner/repo/.gitea/workflows/build.yml@v1", + want: UsesRef{ + Kind: UsesKindLocalCrossRepo, + Owner: "owner", + Repo: "repo", + Path: ".gitea/workflows/build.yml", + Ref: "v1", + }, + }, + { + name: "github dir, branch ref", + in: "owner/repo/.github/workflows/build.yml@main", + want: UsesRef{ + Kind: UsesKindLocalCrossRepo, + Owner: "owner", + Repo: "repo", + Path: ".github/workflows/build.yml", + Ref: "main", + }, + }, + { + name: ".yaml extension", + in: "owner/repo/.gitea/workflows/build.yaml@abc123", + want: UsesRef{ + Kind: UsesKindLocalCrossRepo, + Owner: "owner", + Repo: "repo", + Path: ".gitea/workflows/build.yaml", + Ref: "abc123", + }, + }, + { + name: "ref with slashes (refs/heads/feature)", + in: "owner/repo/.gitea/workflows/build.yml@refs/heads/feature", + want: UsesRef{ + Kind: UsesKindLocalCrossRepo, + Owner: "owner", + Repo: "repo", + Path: ".gitea/workflows/build.yml", + Ref: "refs/heads/feature", + }, + }, + { + name: "nested subdirectory under workflows", + in: "owner/repo/.gitea/workflows/sub/build.yml@v1", + want: UsesRef{ + Kind: UsesKindLocalCrossRepo, + Owner: "owner", + Repo: "repo", + Path: ".gitea/workflows/sub/build.yml", + Ref: "v1", + }, + }, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got, err := ParseUses(c.in) + require.NoError(t, err) + assert.Equal(t, c.want, *got) + }) + } + }) + + t.Run("Errors", func(t *testing.T) { + cases := []struct { + name string + in string + }{ + {name: "empty string", in: ""}, + {name: "whitespace only", in: " "}, + + // Same-repo malformed + {name: "same-repo with @ref", in: "./.gitea/workflows/build.yml@v1"}, + {name: "same-repo wrong directory", in: "./not-workflows/build.yml"}, + {name: "same-repo wrong extension", in: "./.gitea/workflows/build.txt"}, + {name: "same-repo missing extension", in: "./.gitea/workflows/build"}, + {name: "same-repo absolute path", in: "/.gitea/workflows/build.yml"}, + {name: "same-repo path traversal", in: "./.gitea/workflows/../escape.yml"}, + {name: "same-repo double slash", in: "./.gitea/workflows//build.yml"}, + {name: "same-repo redundant ./", in: "./.gitea/workflows/./build.yml"}, + {name: "same-repo no filename", in: "./.gitea/workflows/.yml"}, + + // Cross-repo malformed + {name: "cross-repo missing @ref", in: "owner/repo/.gitea/workflows/build.yml"}, + {name: "cross-repo empty ref", in: "owner/repo/.gitea/workflows/build.yml@"}, + {name: "cross-repo missing owner", in: "/repo/.gitea/workflows/build.yml@v1"}, + {name: "cross-repo missing repo", in: "owner//.gitea/workflows/build.yml@v1"}, + {name: "cross-repo wrong workflows dir", in: "owner/repo/workflows/build.yml@v1"}, + {name: "cross-repo wrong extension", in: "owner/repo/.gitea/workflows/build.txt@v1"}, + {name: "cross-repo path traversal", in: "owner/repo/.gitea/workflows/../escape.yml@v1"}, + {name: "cross-repo double slash in path", in: "owner/repo/.gitea/workflows//build.yml@v1"}, + // owner/repo with chars Gitea's name validators reject + {name: "cross-repo owner with space", in: "bad owner/repo/.gitea/workflows/build.yml@v1"}, + {name: "cross-repo repo with @", in: "owner/re@po/.gitea/workflows/build.yml@v1"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + _, err := ParseUses(c.in) + assert.Error(t, err) + }) + } + }) +} diff --git a/modules/actions/jobparser/workflow_call.go b/modules/actions/jobparser/workflow_call.go new file mode 100644 index 0000000000..7c534dca3c --- /dev/null +++ b/modules/actions/jobparser/workflow_call.go @@ -0,0 +1,401 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package jobparser + +import ( + "errors" + "fmt" + "regexp" + "strings" + + "gitea.dev/modules/container" + "gitea.dev/modules/util" + + "gitea.com/gitea/runner/act/exprparser" + "gitea.com/gitea/runner/act/model" + "go.yaml.in/yaml/v4" +) + +// InputType enumerates the allowed types for a workflow_call input. +type InputType string + +const ( + InputTypeString InputType = "string" + InputTypeBoolean InputType = "boolean" + InputTypeNumber InputType = "number" +) + +// InputSpec describes a single workflow_call input declaration. +type InputSpec struct { + Description string `yaml:"description"` + Required bool `yaml:"required"` + Default yaml.Node `yaml:"default"` + Type InputType `yaml:"type"` +} + +// SecretSpec describes a single workflow_call secret declaration. +type SecretSpec struct { + Description string `yaml:"description"` + Required bool `yaml:"required"` +} + +// OutputSpec describes a single workflow_call output declaration. +type OutputSpec struct { + Description string `yaml:"description"` + Value string `yaml:"value"` +} + +// WorkflowCallSpec is the parsed "on.workflow_call" schema of a called workflow. +type WorkflowCallSpec struct { + Inputs map[string]InputSpec + Secrets map[string]SecretSpec + Outputs map[string]OutputSpec +} + +// JobOutputs is the per-job-id outputs map used for evaluating workflow_call outputs. +type JobOutputs map[string]map[string]string + +// ParseWorkflowCallSpec extracts on.workflow_call.{inputs,secrets,outputs} from a workflow YAML. +// Returns an error if the workflow does not declare on.workflow_call at all. +func ParseWorkflowCallSpec(content []byte) (*WorkflowCallSpec, error) { + var doc struct { + On yaml.Node `yaml:"on"` + } + if err := yaml.Unmarshal(content, &doc); err != nil { + return nil, fmt.Errorf("parse workflow yaml: %w", err) + } + + wcNode, ok := findWorkflowCallNode(&doc.On) + if !ok { + return nil, errors.New("workflow does not declare on.workflow_call") + } + + spec := &WorkflowCallSpec{ + Inputs: map[string]InputSpec{}, + Secrets: map[string]SecretSpec{}, + Outputs: map[string]OutputSpec{}, + } + + if wcNode == nil || wcNode.Kind != yaml.MappingNode { + return spec, nil + } + + for i := 0; i+1 < len(wcNode.Content); i += 2 { + key := wcNode.Content[i] + val := wcNode.Content[i+1] + switch key.Value { + case "inputs": + if err := decodeWorkflowCallMapping(val, spec.Inputs); err != nil { + return nil, fmt.Errorf("parse workflow_call.inputs: %w", err) + } + case "secrets": + if err := decodeWorkflowCallMapping(val, spec.Secrets); err != nil { + return nil, fmt.Errorf("parse workflow_call.secrets: %w", err) + } + case "outputs": + if err := decodeWorkflowCallMapping(val, spec.Outputs); err != nil { + return nil, fmt.Errorf("parse workflow_call.outputs: %w", err) + } + } + } + + for name, in := range spec.Inputs { + if in.Type == "" { + return nil, fmt.Errorf("workflow_call input %q is missing required field \"type\"", name) + } + switch in.Type { + case InputTypeString, InputTypeBoolean, InputTypeNumber: + default: + return nil, fmt.Errorf("workflow_call input %q has unsupported type %q", name, in.Type) + } + } + + return spec, nil +} + +// findWorkflowCallNode walks the "on:" node and returns the value mapping (or nil) for "workflow_call". +// "ok" is true when the workflow declares workflow_call (even with an empty body). +func findWorkflowCallNode(on *yaml.Node) (val *yaml.Node, ok bool) { + if on == nil || on.Kind == 0 { + return nil, false + } + switch on.Kind { + case yaml.ScalarNode: + return nil, on.Value == "workflow_call" + case yaml.SequenceNode: + for _, item := range on.Content { + if item.Kind == yaml.ScalarNode && item.Value == "workflow_call" { + return nil, true + } + } + return nil, false + case yaml.MappingNode: + for i := 0; i+1 < len(on.Content); i += 2 { + k := on.Content[i] + v := on.Content[i+1] + if k.Value != "workflow_call" { + continue + } + if v.Kind == yaml.MappingNode { + return v, true + } + return nil, true + } + } + return nil, false +} + +func decodeWorkflowCallMapping[T any](node *yaml.Node, dst map[string]T) error { + if node == nil || node.Kind != yaml.MappingNode { + return nil + } + for i := 0; i+1 < len(node.Content); i += 2 { + name := node.Content[i].Value + var v T + if err := node.Content[i+1].Decode(&v); err != nil { + return fmt.Errorf("%q: %w", name, err) + } + dst[name] = v + } + return nil +} + +// EvaluateCallerWith evaluates the caller-side expressions in `job.With` against the provided contexts +func EvaluateCallerWith( + jobID string, + job *Job, + gitCtx map[string]any, + results map[string]*JobResult, + vars map[string]string, + inputs map[string]any, +) (map[string]any, error) { + actJob := &model.Job{Strategy: &model.Strategy{ + FailFastString: job.Strategy.FailFastString, + MaxParallelString: job.Strategy.MaxParallelString, + RawMatrix: job.Strategy.RawMatrix, + }} + + var matrix map[string]any + matrixes, err := actJob.GetMatrixes() + if err != nil { + return nil, fmt.Errorf("get caller %q matrix: %w", jobID, err) + } + if len(matrixes) > 0 { + matrix = matrixes[0] + } + + evaluator := NewExpressionEvaluator(NewInterpeter(jobID, actJob, matrix, toGitContext(gitCtx), results, vars, inputs)) + + out := make(map[string]any, len(job.With)) + for k, raw := range job.With { + var evaluated any + switch v := raw.(type) { + case string: + node := yaml.Node{} + if err := node.Encode(v); err != nil { + return nil, fmt.Errorf("encode caller %q with[%q]: %w", jobID, k, err) + } + if err := evaluator.EvaluateYamlNode(&node); err != nil { + return nil, fmt.Errorf("evaluate caller %q with[%q]: %w", jobID, k, err) + } + if err := node.Decode(&evaluated); err != nil { + return nil, fmt.Errorf("decode caller %q with[%q]: %w", jobID, k, err) + } + default: + evaluated = v + } + out[k] = evaluated + } + return out, nil +} + +// MatchCallerInputsAgainstSpec checks the caller's already-evaluated `with:` values against the callee's declared `on.workflow_call.inputs` schema +func MatchCallerInputsAgainstSpec(spec *WorkflowCallSpec, evaluated map[string]any) (map[string]any, error) { + resolved := make(map[string]any, len(spec.Inputs)) + + // fill defaults first + for name, in := range spec.Inputs { + if in.Default.IsZero() { + continue + } + var defaultVal any + if err := in.Default.Decode(&defaultVal); err != nil { + return nil, fmt.Errorf("decode workflow_call input %q default: %w", name, err) + } + v, err := parseWorkflowCallInput(name, in.Type, defaultVal) + if err != nil { + return nil, err + } + resolved[name] = v + } + + for k, raw := range evaluated { + inputSpec, ok := spec.Inputs[k] + if !ok { + // ignore unknown "with:" keys + continue + } + converted, err := parseWorkflowCallInput(k, inputSpec.Type, raw) + if err != nil { + return nil, err + } + resolved[k] = converted + } + + for name, in := range spec.Inputs { + if !in.Required { + continue + } + // resolved[name] is set when caller provided it OR when spec has a non-zero default - both satisfy "required". + if _, ok := resolved[name]; ok { + continue + } + return nil, fmt.Errorf("workflow_call input %q is required", name) + } + + return resolved, nil +} + +func parseWorkflowCallInput(name string, typ InputType, v any) (any, error) { + switch typ { + case InputTypeString: + return toString(v), nil + case InputTypeBoolean: + // strict type matching: a boolean input only accepts a native bool, not a "true"/"false" string + if b, ok := v.(bool); ok { + return b, nil + } + return false, fmt.Errorf("workflow_call input %q expects boolean", name) + case InputTypeNumber: + // strict type matching: a number input rejects "123"/"3.14" strings. + if _, isString := v.(string); isString { + return 0.0, fmt.Errorf("workflow_call input %q expects number", name) + } + return util.ToFloat64(v) + default: + return nil, fmt.Errorf("workflow_call input %q has unsupported type %q", name, typ) + } +} + +// SecretsInherit is the literal keyword used in a caller's `secrets: inherit` directive +const SecretsInherit = "inherit" + +// callerSecretValueRegexp matches the `${{ secrets.NAME }}` form expected for each value in a caller's `secrets:` mapping. +var callerSecretValueRegexp = regexp.MustCompile(`^\s*\$\{\{\s*secrets\.([A-Za-z_][A-Za-z0-9_]*)\s*\}\}\s*$`) + +// ParseCallerSecrets decodes a caller's "secrets:" YAML node into one of two forms: +// - inherit == true: the caller wrote `secrets: inherit`; mapping is nil +// - inherit == false, mapping == {alias: source_name}: explicit mapping. Each value must be of the form `${{ secrets.NAME }}`. +// +// Both alias and source name are upper-cased: secret names are case-insensitive (matching GitHub), +// and Gitea stores secrets upper-cased, so this keeps lookups and schema validation consistent. +func ParseCallerSecrets(node yaml.Node) (inherit bool, mapping map[string]string, err error) { + if node.IsZero() { + return false, nil, nil + } + if node.Kind == yaml.ScalarNode && strings.TrimSpace(node.Value) == SecretsInherit { + return true, nil, nil + } + if node.Kind != yaml.MappingNode { + return false, nil, errors.New("invalid secrets: section, expected mapping or 'inherit'") + } + out := make(map[string]string, len(node.Content)/2) + for i := 0; i+1 < len(node.Content); i += 2 { + k := node.Content[i] + v := node.Content[i+1] + var sv string + if err := v.Decode(&sv); err != nil { + return false, nil, fmt.Errorf("decode secret %q: %w", k.Value, err) + } + matches := callerSecretValueRegexp.FindStringSubmatch(sv) + if len(matches) != 2 { + return false, nil, fmt.Errorf("caller secret %q value must be of the form ${{ secrets.NAME }}", k.Value) + } + out[strings.ToUpper(k.Value)] = strings.ToUpper(matches[1]) + } + return false, out, nil +} + +// ValidateCallerSecrets checks a caller's parsed explicit-mapping `secrets:` against the called workflow's declared `on.workflow_call.secrets` schema. +func ValidateCallerSecrets(spec *WorkflowCallSpec, mapping map[string]string) error { + if spec == nil { + return errors.New("ValidateCallerSecrets: nil workflow_call spec") + } + // Secret names are case-insensitive, so compare declared names and caller aliases upper-cased. + declaredNames := make(container.Set[string], len(spec.Secrets)) + for name := range spec.Secrets { + declaredNames.Add(strings.ToUpper(name)) + } + provided := make(container.Set[string], len(mapping)) + for alias := range mapping { + up := strings.ToUpper(alias) + provided.Add(up) + if !declaredNames.Contains(up) { + return fmt.Errorf("caller secret %q is not declared in the called workflow's on.workflow_call.secrets", alias) + } + } + for name, sec := range spec.Secrets { + if sec.Required && !provided.Contains(strings.ToUpper(name)) { + return fmt.Errorf("required secret %q is not provided by the caller", name) + } + } + return nil +} + +// EvaluateWorkflowCallOutputs evaluates a called workflow's "on.workflow_call.outputs..value" expressions against the provided contexts. +func EvaluateWorkflowCallOutputs(spec *WorkflowCallSpec, gitCtx *model.GithubContext, vars map[string]string, inputs map[string]any, jobOutputs JobOutputs) (map[string]string, error) { + if spec == nil || len(spec.Outputs) == 0 { + return map[string]string{}, nil + } + + jobsCtx := make(map[string]*model.WorkflowCallResult, len(jobOutputs)) + for jobID, outputs := range jobOutputs { + jobsCtx[jobID] = &model.WorkflowCallResult{Outputs: outputs} + } + + // See `on.workflow_call.outputs..value` in https://docs.github.com/en/actions/reference/workflows-and-actions/contexts#context-availability + env := &exprparser.EvaluationEnvironment{ + Github: gitCtx, + Jobs: &jobsCtx, + Vars: vars, + Inputs: inputs, + } + interpreter := exprparser.NewInterpeter(env, exprparser.Config{}) + + out := make(map[string]string, len(spec.Outputs)) + for name, o := range spec.Outputs { + v, err := evaluateWorkflowCallOutputValue(interpreter, o.Value) + if err != nil { + return nil, fmt.Errorf("workflow_call output %q: %w", name, err) + } + out[name] = v + } + return out, nil +} + +func evaluateWorkflowCallOutputValue(interpreter exprparser.Interpreter, value string) (string, error) { + if !strings.Contains(value, "${{") || !strings.Contains(value, "}}") { + return value, nil + } + expr, err := rewriteSubExpression(value, true) + if err != nil { + return "", err + } + evaluated, err := interpreter.Evaluate(expr, exprparser.DefaultStatusCheckNone) + if err != nil { + return "", err + } + return toString(evaluated), nil +} + +func toString(v any) string { + switch s := v.(type) { + case string: + return s + case nil: + return "" + default: + return fmt.Sprintf("%v", s) + } +} diff --git a/modules/actions/jobparser/workflow_call_test.go b/modules/actions/jobparser/workflow_call_test.go new file mode 100644 index 0000000000..6777d8173a --- /dev/null +++ b/modules/actions/jobparser/workflow_call_test.go @@ -0,0 +1,471 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package jobparser + +import ( + "maps" + "testing" + + "gitea.com/gitea/runner/act/model" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.yaml.in/yaml/v4" +) + +func TestParseWorkflowCallSpec(t *testing.T) { + t.Run("malformed YAML surfaces a parse error", func(t *testing.T) { + // Mismatched flow-sequence brackets — yaml.Unmarshal must reject this. + _, err := ParseWorkflowCallSpec([]byte(`name: bad +on: [workflow_call +jobs: + noop: { } +`)) + require.Error(t, err) + }) + + t.Run("workflow without on.workflow_call is rejected", func(t *testing.T) { + notCallable := []byte(`name: ordinary +on: push +jobs: + noop: + runs-on: ubuntu-latest + steps: + - run: echo +`) + _, err := ParseWorkflowCallSpec(notCallable) + require.Error(t, err) + assert.Contains(t, err.Error(), "does not declare on.workflow_call") + }) + + t.Run("input missing the required type field is rejected", func(t *testing.T) { + content := callableWorkflow(t, `inputs: + x: + description: missing type +`) + _, err := ParseWorkflowCallSpec(content) + require.Error(t, err) + assert.Contains(t, err.Error(), `missing required field "type"`) + }) + + t.Run("inputs/secrets/outputs are decoded", func(t *testing.T) { + content := callableWorkflow(t, `inputs: + env: + type: string + required: true + secrets: + DEPLOY_KEY: + required: true + outputs: + sha: + value: ${{ jobs.build.outputs.commit }} +`) + spec, err := ParseWorkflowCallSpec(content) + require.NoError(t, err) + assert.Equal(t, InputTypeString, spec.Inputs["env"].Type) + assert.True(t, spec.Inputs["env"].Required) + assert.True(t, spec.Secrets["DEPLOY_KEY"].Required) + assert.Equal(t, "${{ jobs.build.outputs.commit }}", spec.Outputs["sha"].Value) + }) +} + +func TestEvaluateCallerWith(t *testing.T) { + t.Run("empty with: returns empty map", func(t *testing.T) { + out, err := EvaluateCallerWith("caller", &Job{}, nil, callerResults("caller", nil, nil), nil, nil) + require.NoError(t, err) + assert.Empty(t, out) + }) + + t.Run("non-string raw values pass through unchanged", func(t *testing.T) { + job := &Job{With: map[string]any{ + "already_bool": true, + "already_int": 42, + "already_slice": []any{"a", "b"}, + }} + out, err := EvaluateCallerWith("caller", job, nil, callerResults("caller", nil, nil), nil, nil) + require.NoError(t, err) + assert.Equal(t, true, out["already_bool"]) + assert.Equal(t, 42, out["already_int"]) + assert.Equal(t, []any{"a", "b"}, out["already_slice"]) + }) + + t.Run("expressions resolve against vars/inputs/results", func(t *testing.T) { + job := &Job{With: map[string]any{ + "env_name": "${{ vars.ENV }}", + "from_inputs": "${{ inputs.PARENT_VAR }}", + "from_needs": "${{ needs.upstream.outputs.commit }}", + }} + gitCtx := map[string]any{"event": map[string]any{}} + results := callerResults("caller", []string{"upstream"}, map[string]*JobResult{ + "upstream": {Result: "success", Outputs: map[string]string{"commit": "abc123"}}, + }) + vars := map[string]string{"ENV": "staging"} + inputs := map[string]any{"PARENT_VAR": "from-parent"} + out, err := EvaluateCallerWith("caller", job, gitCtx, results, vars, inputs) + require.NoError(t, err) + assert.Equal(t, "staging", out["env_name"]) + assert.Equal(t, "from-parent", out["from_inputs"]) + assert.Equal(t, "abc123", out["from_needs"]) + }) + + t.Run("matrix.X resolves to this caller row's matrix instance", func(t *testing.T) { + var rawMatrix yaml.Node + require.NoError(t, rawMatrix.Encode(map[string][]any{"target": {"staging"}})) + job := &Job{ + With: map[string]any{"env": "${{ matrix.target }}"}, + Strategy: Strategy{RawMatrix: rawMatrix}, + } + out, err := EvaluateCallerWith("caller", job, nil, callerResults("caller", nil, nil), nil, nil) + require.NoError(t, err) + assert.Equal(t, "staging", out["env"]) + }) +} + +func TestMatchCallerInputsAgainstSpec(t *testing.T) { + // mustParseSpec wraps ParseWorkflowCallSpec for test brevity. + mustParseSpec := func(t *testing.T, content []byte) *WorkflowCallSpec { + t.Helper() + spec, err := ParseWorkflowCallSpec(content) + require.NoError(t, err) + return spec + } + + t.Run("default is filled when caller does not provide the input", func(t *testing.T) { + spec := mustParseSpec(t, callableWorkflow(t, `inputs: + greeting: + type: string + default: hi +`)) + out, err := MatchCallerInputsAgainstSpec(spec, nil) + require.NoError(t, err) + assert.Equal(t, map[string]any{"greeting": "hi"}, out) + }) + + t.Run("caller-provided value wins over default", func(t *testing.T) { + spec := mustParseSpec(t, callableWorkflow(t, `inputs: + greeting: + type: string + default: hi +`)) + out, err := MatchCallerInputsAgainstSpec(spec, map[string]any{"greeting": "hello"}) + require.NoError(t, err) + assert.Equal(t, map[string]any{"greeting": "hello"}, out) + }) + + t.Run("required input must be provided", func(t *testing.T) { + spec := mustParseSpec(t, callableWorkflow(t, `inputs: + target: + type: string + required: true +`)) + _, err := MatchCallerInputsAgainstSpec(spec, nil) + require.Error(t, err) + assert.Contains(t, err.Error(), `"target" is required`) + }) + + t.Run("required input is satisfied by a default value", func(t *testing.T) { + spec := mustParseSpec(t, callableWorkflow(t, `inputs: + target: + type: string + required: true + default: prod +`)) + out, err := MatchCallerInputsAgainstSpec(spec, nil) + require.NoError(t, err) + assert.Equal(t, map[string]any{"target": "prod"}, out) + }) + + t.Run("boolean inputs accept native bool values and bool defaults", func(t *testing.T) { + spec := mustParseSpec(t, callableWorkflow(t, `inputs: + flag1: + type: boolean + flag2: + type: boolean + default: true + flag3: + type: boolean +`)) + out, err := MatchCallerInputsAgainstSpec(spec, map[string]any{ + "flag1": true, + "flag3": false, + }) + require.NoError(t, err) + assert.Equal(t, true, out["flag1"]) + assert.Equal(t, true, out["flag2"]) // from default + assert.Equal(t, false, out["flag3"]) + }) + + t.Run("boolean input rejects strings", func(t *testing.T) { + spec := mustParseSpec(t, callableWorkflow(t, `inputs: + flag: + type: boolean +`)) + _, err := MatchCallerInputsAgainstSpec(spec, map[string]any{"flag": "true"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "expects boolean") + }) + + t.Run("number inputs accept native numeric values and number defaults", func(t *testing.T) { + spec := mustParseSpec(t, callableWorkflow(t, `inputs: + count: + type: number + ratio: + type: number + default: 0.5 +`)) + out, err := MatchCallerInputsAgainstSpec(spec, map[string]any{"count": 42}) + require.NoError(t, err) + assert.InDelta(t, 42.0, out["count"], 0) + assert.InDelta(t, 0.5, out["ratio"], 0) + }) + + t.Run("number input rejects strings", func(t *testing.T) { + spec := mustParseSpec(t, callableWorkflow(t, `inputs: + count: + type: number +`)) + _, err := MatchCallerInputsAgainstSpec(spec, map[string]any{"count": "42"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "expects number") + }) + + t.Run("unknown caller-with key is silently dropped", func(t *testing.T) { + spec := mustParseSpec(t, callableWorkflow(t, `inputs: + known: + type: string + default: ok +`)) + out, err := MatchCallerInputsAgainstSpec(spec, map[string]any{ + "known": "yes", + "unknown": "ignored", + }) + require.NoError(t, err) + assert.Equal(t, map[string]any{"known": "yes"}, out) + }) +} + +func TestParseCallerSecrets(t *testing.T) { + // secretYAMLNode unmarshals raw YAML text into a yaml.Node so tests can hand it to ParseCallerSecrets. + secretYAMLNode := func(t *testing.T, s string) yaml.Node { + t.Helper() + var node yaml.Node + require.NoError(t, yaml.Unmarshal([]byte(s), &node)) + // yaml.Unmarshal wraps content in a DocumentNode; the meaningful node is the first child. + if node.Kind == yaml.DocumentNode && len(node.Content) > 0 { + return *node.Content[0] + } + return node + } + + t.Run("zero node returns no inherit, no mapping", func(t *testing.T) { + inherit, mapping, err := ParseCallerSecrets(yaml.Node{}) + require.NoError(t, err) + assert.False(t, inherit) + assert.Nil(t, mapping) + }) + + t.Run("\"inherit\" scalar sets inherit=true", func(t *testing.T) { + inherit, mapping, err := ParseCallerSecrets(secretYAMLNode(t, `inherit`)) + require.NoError(t, err) + assert.True(t, inherit) + assert.Nil(t, mapping) + }) + + t.Run("non-inherit scalar is rejected", func(t *testing.T) { + _, _, err := ParseCallerSecrets(secretYAMLNode(t, `something-else`)) + require.Error(t, err) + assert.Contains(t, err.Error(), "expected mapping or 'inherit'") + }) + + t.Run("mapping of secrets-style references is parsed", func(t *testing.T) { + inherit, mapping, err := ParseCallerSecrets(secretYAMLNode(t, ` +DEPLOY_KEY: ${{ secrets.GITEA_DEPLOY_KEY }} +DB_PASS: ${{ secrets.PROD_DB_PASS }} +`)) + require.NoError(t, err) + assert.False(t, inherit) + assert.Equal(t, map[string]string{ + "DEPLOY_KEY": "GITEA_DEPLOY_KEY", + "DB_PASS": "PROD_DB_PASS", + }, mapping) + }) + + t.Run("alias and source names are upper-cased", func(t *testing.T) { + inherit, mapping, err := ParseCallerSecrets(secretYAMLNode(t, ` +deploy_key: ${{ secrets.gitea_deploy_key }} +`)) + require.NoError(t, err) + assert.False(t, inherit) + assert.Equal(t, map[string]string{"DEPLOY_KEY": "GITEA_DEPLOY_KEY"}, mapping) + }) + + t.Run("mapping value not in ${{ secrets.NAME }} form is rejected", func(t *testing.T) { + // plain string + _, _, err := ParseCallerSecrets(secretYAMLNode(t, `KEY: not-an-expression`)) + require.Error(t, err) + assert.Contains(t, err.Error(), `must be of the form ${{ secrets.NAME }}`) + + // expression but referencing the wrong context (vars instead of secrets) + _, _, err = ParseCallerSecrets(secretYAMLNode(t, `KEY: ${{ vars.NAME }}`)) + require.Error(t, err) + assert.Contains(t, err.Error(), `must be of the form ${{ secrets.NAME }}`) + }) +} + +func TestValidateCallerSecrets(t *testing.T) { + specWith := func(secrets map[string]SecretSpec) *WorkflowCallSpec { + return &WorkflowCallSpec{Secrets: secrets} + } + + t.Run("explicit mapping with all required + only declared aliases is accepted", func(t *testing.T) { + spec := specWith(map[string]SecretSpec{ + "DEPLOY_KEY": {Required: true}, + "OPTIONAL": {}, + }) + mapping := map[string]string{ + "DEPLOY_KEY": "PROD_DEPLOY_KEY", + "OPTIONAL": "SOMETHING_ELSE", + } + require.NoError(t, ValidateCallerSecrets(spec, mapping)) + }) + + t.Run("alias not in callee schema is rejected", func(t *testing.T) { + spec := specWith(map[string]SecretSpec{"DEPLOY_KEY": {}}) + mapping := map[string]string{ + "DEPLOY_KEY": "PROD_DEPLOY_KEY", + "EXTRA": "SOMETHING_NOT_DECLARED", + } + err := ValidateCallerSecrets(spec, mapping) + require.Error(t, err) + assert.Contains(t, err.Error(), `caller secret "EXTRA"`) + assert.Contains(t, err.Error(), `not declared`) + }) + + t.Run("missing required secret is rejected", func(t *testing.T) { + spec := specWith(map[string]SecretSpec{ + "MUST_HAVE": {Required: true}, + "OPTIONAL": {}, + }) + mapping := map[string]string{"OPTIONAL": "X"} + err := ValidateCallerSecrets(spec, mapping) + require.Error(t, err) + assert.Contains(t, err.Error(), `required secret "MUST_HAVE"`) + assert.Contains(t, err.Error(), `not provided`) + }) + + t.Run("callee with no secrets schema accepts an empty mapping", func(t *testing.T) { + spec := specWith(map[string]SecretSpec{}) + require.NoError(t, ValidateCallerSecrets(spec, nil)) + require.NoError(t, ValidateCallerSecrets(spec, map[string]string{})) + }) + + t.Run("callee with no secrets schema rejects a non-empty mapping", func(t *testing.T) { + spec := specWith(map[string]SecretSpec{}) + err := ValidateCallerSecrets(spec, map[string]string{"X": "Y"}) + require.Error(t, err) + assert.Contains(t, err.Error(), `caller secret "X"`) + }) + + t.Run("name matching is case-insensitive", func(t *testing.T) { + // declared name and caller alias differ only in case; both should match. + spec := specWith(map[string]SecretSpec{"deploy_key": {Required: true}}) + mapping := map[string]string{"DEPLOY_KEY": "PROD_DEPLOY_KEY"} + require.NoError(t, ValidateCallerSecrets(spec, mapping)) + }) + + t.Run("nil spec is rejected", func(t *testing.T) { + err := ValidateCallerSecrets(nil, map[string]string{"X": "Y"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "nil workflow_call spec") + }) +} + +func TestEvaluateWorkflowCallOutputs(t *testing.T) { + t.Run("nil spec returns empty map", func(t *testing.T) { + out, err := EvaluateWorkflowCallOutputs(nil, &model.GithubContext{}, nil, nil, nil) + require.NoError(t, err) + assert.Empty(t, out) + }) + + t.Run("spec with no outputs returns empty map", func(t *testing.T) { + spec := &WorkflowCallSpec{Outputs: map[string]OutputSpec{}} + out, err := EvaluateWorkflowCallOutputs(spec, &model.GithubContext{}, nil, nil, nil) + require.NoError(t, err) + assert.Empty(t, out) + }) + + t.Run("plain string value passes through unchanged", func(t *testing.T) { + spec := &WorkflowCallSpec{Outputs: map[string]OutputSpec{ + "name": {Value: "static-value"}, + }} + out, err := EvaluateWorkflowCallOutputs(spec, &model.GithubContext{}, nil, nil, nil) + require.NoError(t, err) + assert.Equal(t, map[string]string{"name": "static-value"}, out) + }) + + t.Run("output references jobs..outputs.", func(t *testing.T) { + spec := &WorkflowCallSpec{Outputs: map[string]OutputSpec{ + "sha": {Value: "${{ jobs.build.outputs.commit }}"}, + }} + jobOutputs := JobOutputs{ + "build": {"commit": "deadbeef"}, + } + out, err := EvaluateWorkflowCallOutputs(spec, &model.GithubContext{}, nil, nil, jobOutputs) + require.NoError(t, err) + assert.Equal(t, "deadbeef", out["sha"]) + }) + + t.Run("output references inputs.", func(t *testing.T) { + spec := &WorkflowCallSpec{Outputs: map[string]OutputSpec{ + "target": {Value: "${{ inputs.env_name }}"}, + }} + inputs := map[string]any{"env_name": "staging"} + out, err := EvaluateWorkflowCallOutputs(spec, &model.GithubContext{}, nil, inputs, nil) + require.NoError(t, err) + assert.Equal(t, "staging", out["target"]) + }) + + t.Run("multiple outputs are all evaluated", func(t *testing.T) { + spec := &WorkflowCallSpec{Outputs: map[string]OutputSpec{ + "static": {Value: "static-value"}, + "dynamic": {Value: "${{ vars.SUFFIX }}"}, + }} + vars := map[string]string{"SUFFIX": "abc"} + out, err := EvaluateWorkflowCallOutputs(spec, &model.GithubContext{}, vars, nil, nil) + require.NoError(t, err) + assert.Equal(t, "static-value", out["static"]) + assert.Equal(t, "abc", out["dynamic"]) + }) + + t.Run("expression referencing an undefined symbol surfaces an error", func(t *testing.T) { + spec := &WorkflowCallSpec{Outputs: map[string]OutputSpec{ + "bad": {Value: "${{ this.is.not.valid() }}"}, + }} + _, err := EvaluateWorkflowCallOutputs(spec, &model.GithubContext{}, nil, nil, nil) + require.Error(t, err) + assert.Contains(t, err.Error(), `output "bad"`) + }) +} + +// callableWorkflow returns a minimal valid called-workflow YAML with on.workflow_call. +func callableWorkflow(t *testing.T, body string) []byte { + t.Helper() + return []byte(`name: callable +on: + workflow_call: + ` + body + ` +jobs: + noop: + runs-on: ubuntu-latest + steps: + - run: "echo" +`) +} + +// callerResults returns the minimum results map shape that NewInterpeter expects +func callerResults(callerJobID string, callerNeeds []string, deps map[string]*JobResult) map[string]*JobResult { + out := make(map[string]*JobResult, len(deps)+1) + maps.Copy(out, deps) + out[callerJobID] = &JobResult{Needs: callerNeeds} + return out +} diff --git a/modules/structs/hook.go b/modules/structs/hook.go index 35f997c80c..0301b08e1d 100644 --- a/modules/structs/hook.go +++ b/modules/structs/hook.go @@ -575,6 +575,20 @@ func (p *WorkflowDispatchPayload) JSONPayload() ([]byte, error) { return json.MarshalIndent(p, "", " ") } +// WorkflowCallPayload is persisted on a reusable workflow caller job's CallPayload field. +type WorkflowCallPayload struct { + Workflow string `json:"workflow"` + Ref string `json:"ref"` + Inputs map[string]any `json:"inputs"` + Repository *Repository `json:"repository"` + Sender *User `json:"sender"` +} + +// JSONPayload implements Payload +func (p *WorkflowCallPayload) JSONPayload() ([]byte, error) { + return json.MarshalIndent(p, "", " ") +} + // CommitStatusPayload represents a payload information of commit status event. type CommitStatusPayload struct { // TODO: add Branches per https://docs.github.com/en/webhooks/webhook-events-and-payloads#status diff --git a/options/locale/locale_en-US.json b/options/locale/locale_en-US.json index ec564d3e26..fdef581358 100644 --- a/options/locale/locale_en-US.json +++ b/options/locale/locale_en-US.json @@ -3793,6 +3793,8 @@ "actions.runs.view_workflow_file": "View workflow file", "actions.runs.summary": "Summary", "actions.runs.all_jobs": "All jobs", + "actions.runs.expand_caller_jobs": "Show jobs of this reusable workflow caller", + "actions.runs.collapse_caller_jobs": "Hide jobs of this reusable workflow caller", "actions.runs.attempt": "Attempt", "actions.runs.latest": "Latest", "actions.runs.latest_attempt": "Latest attempt", diff --git a/routers/web/devtest/mock_actions.go b/routers/web/devtest/mock_actions.go index c8aa906807..69062ff6e7 100644 --- a/routers/web/devtest/mock_actions.go +++ b/routers/web/devtest/mock_actions.go @@ -271,6 +271,75 @@ func MockActionsRunsJobs(ctx *context.Context) { } } + if runID == 40 { + // Reusable workflow caller demo: same-repo caller (with a nested same-repo caller inside), + // alongside a flat cross-repo caller. + // Layout: + // prepare (regular, top-level) + // local_caller (caller, same-repo, expanded) + // ├ lib_step (regular) + // └ inner_caller (caller, same-repo nested, expanded) + // └ deep_job (regular) + // cross_caller (caller, cross-repo, expanded) + // └ external_job (regular) + // final (regular, needs local_caller + cross_caller) + const ( + prepareID = int64(400) + localCallerID = int64(401) + libStepID = int64(402) + innerCallerID = int64(403) + deepJobID = int64(404) + crossCallerID = int64(405) + externalJobID = int64(406) + finalID = int64(407) + ) + + resp.State.Run.Jobs = []*actions.ViewJob{ + { + ID: prepareID, Link: jobLink(prepareID), JobID: "prepare", Name: "prepare", + Status: actions_model.StatusSuccess.String(), Duration: "30s", + }, + { + ID: localCallerID, Link: jobLink(localCallerID), JobID: "local_caller", Name: "local caller", + Status: actions_model.StatusRunning.String(), Duration: "5m", + Needs: []string{"prepare"}, + IsReusableCaller: true, CallUses: "./.gitea/workflows/lib.yml", + }, + { + ID: libStepID, Link: jobLink(libStepID), JobID: "lib_step", Name: "lib step", + Status: actions_model.StatusSuccess.String(), Duration: "1m", + ParentJobID: localCallerID, + }, + { + ID: innerCallerID, Link: jobLink(innerCallerID), JobID: "inner_caller", Name: "inner caller (nested)", + Status: actions_model.StatusRunning.String(), Duration: "4m", + ParentJobID: localCallerID, + IsReusableCaller: true, CallUses: "./.gitea/workflows/inner.yml", + }, + { + ID: deepJobID, Link: jobLink(deepJobID), JobID: "deep_job", Name: "deep job", + Status: actions_model.StatusRunning.String(), Duration: "2m", + ParentJobID: innerCallerID, + }, + { + ID: crossCallerID, Link: jobLink(crossCallerID), JobID: "cross_caller", Name: "cross-repo caller", + Status: actions_model.StatusWaiting.String(), Duration: "0s", + Needs: []string{"prepare"}, + IsReusableCaller: true, CallUses: "user2/lib-repo/.gitea/workflows/external.yml@main", + }, + { + ID: externalJobID, Link: jobLink(externalJobID), JobID: "external_job", Name: "external job", + Status: actions_model.StatusWaiting.String(), Duration: "0s", + ParentJobID: crossCallerID, + }, + { + ID: finalID, Link: jobLink(finalID), JobID: "final", Name: "final", + Status: actions_model.StatusBlocked.String(), Duration: "0s", + Needs: []string{"local_caller", "cross_caller"}, + }, + } + } + fillViewRunResponseCurrentJob(ctx, resp) ctx.JSON(http.StatusOK, resp) } diff --git a/routers/web/repo/actions/view.go b/routers/web/repo/actions/view.go index d95b8679b8..da99744449 100644 --- a/routers/web/repo/actions/view.go +++ b/routers/web/repo/actions/view.go @@ -331,6 +331,12 @@ type ViewJob struct { CanRerun bool `json:"canRerun"` Duration string `json:"duration"` Needs []string `json:"needs,omitempty"` + + ParentJobID int64 `json:"parentJobID"` + + // Reusable workflow caller fields. Zero/empty for non-caller jobs. + IsReusableCaller bool `json:"isReusableCaller"` + CallUses string `json:"callUses,omitempty"` } type ViewRunAttempt struct { @@ -458,6 +464,10 @@ func fillViewRunResponseSummary(ctx *context_module.Context, resp *ViewResponse, CanRerun: resp.State.Run.CanRerun, Duration: v.Duration().String(), Needs: v.Needs, + + IsReusableCaller: v.IsReusableCaller, + ParentJobID: v.ParentJobID, + CallUses: v.CallUses, }) } diff --git a/services/actions/approve.go b/services/actions/approve.go index a17bfbb26a..31d77f01ae 100644 --- a/services/actions/approve.go +++ b/services/actions/approve.go @@ -5,16 +5,22 @@ package actions import ( "context" + "errors" + "fmt" actions_model "gitea.dev/models/actions" "gitea.dev/models/db" repo_model "gitea.dev/models/repo" user_model "gitea.dev/models/user" + "gitea.dev/modules/container" + "gitea.dev/modules/log" ) func ApproveRuns(ctx context.Context, repo *repo_model.Repository, doer *user_model.User, runIDs []int64) error { updatedJobs := make([]*actions_model.ActionRunJob, 0) cancelledConcurrencyJobs := make([]*actions_model.ActionRunJob, 0) + // Track runs whose reusable callers were just expanded so we can re-emit after the tx commits. + expandedCallerRunIDs := make(container.Set[int64]) err := db.WithTx(ctx, func(ctx context.Context) (err error) { for _, runID := range runIDs { @@ -31,6 +37,7 @@ func ApproveRuns(ctx context.Context, repo *repo_model.Repository, doer *user_mo if err != nil { return err } + for _, job := range jobs { // Skip jobs with `needs`: they stay blocked until their dependencies finish, // at which point job_emitter will evaluate and start them. @@ -43,14 +50,38 @@ func ApproveRuns(ctx context.Context, repo *repo_model.Repository, doer *user_mo return err } cancelledConcurrencyJobs = append(cancelledConcurrencyJobs, jobsToCancel...) - if job.Status == actions_model.StatusWaiting { - n, err := actions_model.UpdateRunJob(ctx, job, nil, "status") + if job.Status != actions_model.StatusWaiting { + continue + } + n, err := actions_model.UpdateRunJob(ctx, job, nil, "status") + if err != nil { + return err + } + if n == 0 { + continue + } + updatedJobs = append(updatedJobs, job) + + // A top-level reusable caller was just unblocked by approval, expand it + if job.IsReusableCaller && !job.IsExpanded { + attempt, has, err := run.GetLatestAttempt(ctx) + if err != nil { + return fmt.Errorf("get latest attempt of run %d: %w", run.ID, err) + } + if !has { + return errors.New("run has no attempt") + } + vars, err := actions_model.GetVariablesOfRun(ctx, run) if err != nil { return err } - if n > 0 { - updatedJobs = append(updatedJobs, job) + if err := expandReusableWorkflowCaller(ctx, run, attempt, job, vars); err != nil { + return fmt.Errorf("expand caller %d on approval: %w", job.ID, err) } + if err := actions_model.RefreshReusableCallerStatus(ctx, job); err != nil { + return fmt.Errorf("refresh caller %d status after approval-time expansion: %w", job.ID, err) + } + expandedCallerRunIDs.Add(run.ID) } } } @@ -60,6 +91,13 @@ func ApproveRuns(ctx context.Context, repo *repo_model.Repository, doer *user_mo return err } + // Re-emit AFTER the tx commits so the newly inserted callee rows transition Blocked -> Waiting. + for runID := range expandedCallerRunIDs { + if err := EmitJobsIfReadyByRun(runID); err != nil { + log.Error("emit run %d after approval-time caller expansion: %v", runID, err) + } + } + NotifyWorkflowJobsAndRunsStatusUpdate(ctx, updatedJobs) NotifyWorkflowJobsAndRunsStatusUpdate(ctx, cancelledConcurrencyJobs) diff --git a/services/actions/concurrency.go b/services/actions/concurrency.go index f4560ac8f1..0433278b75 100644 --- a/services/actions/concurrency.go +++ b/services/actions/concurrency.go @@ -9,8 +9,6 @@ import ( actions_model "gitea.dev/models/actions" "gitea.dev/modules/actions/jobparser" - "gitea.dev/modules/json" - api "gitea.dev/modules/structs" act_model "gitea.com/gitea/runner/act/model" "go.yaml.in/yaml/v4" @@ -29,7 +27,7 @@ func EvaluateRunConcurrencyFillModel(ctx context.Context, run *actions_model.Act jobResults := map[string]*jobparser.JobResult{"": {}} if inputs == nil { var err error - inputs, err = getInputsFromRun(run) + inputs, err = getWorkflowDispatchInputsFromRun(run) if err != nil { return fmt.Errorf("get inputs: %w", err) } @@ -43,25 +41,6 @@ func EvaluateRunConcurrencyFillModel(ctx context.Context, run *actions_model.Act return nil } -func findJobNeedsAndFillJobResults(ctx context.Context, job *actions_model.ActionRunJob) (map[string]*jobparser.JobResult, error) { - taskNeeds, err := FindTaskNeeds(ctx, job) - if err != nil { - return nil, fmt.Errorf("find task needs: %w", err) - } - jobResults := make(map[string]*jobparser.JobResult, len(taskNeeds)) - for jobID, taskNeed := range taskNeeds { - jobResult := &jobparser.JobResult{ - Result: taskNeed.Result.String(), - Outputs: taskNeed.Outputs, - } - jobResults[jobID] = jobResult - } - jobResults[job.JobID] = &jobparser.JobResult{ - Needs: job.Needs, - } - return jobResults, nil -} - // EvaluateJobConcurrencyFillModel evaluates the expressions in a job-level concurrency, // and fills the job's model fields with `concurrency.group` and `concurrency.cancel-in-progress`. // Job-level concurrency may depend on other job's outputs (via `needs`): `concurrency.group: my-group-${{ needs.job1.outputs.out1 }}` @@ -86,7 +65,7 @@ func EvaluateJobConcurrencyFillModel(ctx context.Context, run *actions_model.Act if inputs == nil { var err error - inputs, err = getInputsFromRun(run) + inputs, err = getInputsForJob(ctx, run, actionRunJob) if err != nil { return fmt.Errorf("get inputs: %w", err) } @@ -104,14 +83,3 @@ func EvaluateJobConcurrencyFillModel(ctx context.Context, run *actions_model.Act actionRunJob.IsConcurrencyEvaluated = true return nil } - -func getInputsFromRun(run *actions_model.ActionRun) (map[string]any, error) { - if run.Event != "workflow_dispatch" { - return map[string]any{}, nil - } - var payload api.WorkflowDispatchPayload - if err := json.Unmarshal([]byte(run.EventPayload), &payload); err != nil { - return nil, err - } - return payload.Inputs, nil -} diff --git a/services/actions/context.go b/services/actions/context.go index ba2a3cbd5d..b92e179f94 100644 --- a/services/actions/context.go +++ b/services/actions/context.go @@ -11,11 +11,14 @@ import ( actions_model "gitea.dev/models/actions" "gitea.dev/models/db" actions_module "gitea.dev/modules/actions" + "gitea.dev/modules/actions/jobparser" "gitea.dev/modules/container" "gitea.dev/modules/git" "gitea.dev/modules/json" + "gitea.dev/modules/log" "gitea.dev/modules/optional" "gitea.dev/modules/setting" + api "gitea.dev/modules/structs" "gitea.dev/modules/util" "gitea.com/gitea/runner/act/model" @@ -96,6 +99,31 @@ func GenerateGiteaContext(ctx context.Context, run *actions_model.ActionRun, att if job != nil { gitContext["job"] = job.JobID gitContext["run_attempt"] = strconv.FormatInt(job.Attempt, 10) + + if job.ParentJobID > 0 { + // Inject the caller's resolved workflow_call inputs into gitea.event.inputs. + // The rest of gitea.event stays as the caller's actual trigger event (push/pull_request/etc.) + // to match GitHub's semantics (see https://docs.github.com/en/actions/reference/workflows-and-actions/reusing-workflow-configurations#github-context). + // FIXME: If the run is triggered by "workflow_dispatch", the original inputs of "workflow_dispatch" will be overridden. + // If necessary, the caller can send these values to the called workflow via `with:`. + caller, err := actions_model.GetRunJobByRunAndID(ctx, job.RunID, job.ParentJobID) + if err != nil { + log.Error("GenerateGiteaContext: load caller job %d of job %d: %v", job.ParentJobID, job.ID, err) + } else if caller.CallPayload != "" { + var cp api.WorkflowCallPayload + if err := json.Unmarshal([]byte(caller.CallPayload), &cp); err != nil { + log.Error("GenerateGiteaContext: decode CallPayload of caller %d: %v", caller.ID, err) + } else if cp.Inputs != nil { + event["inputs"] = cp.Inputs + } + } + + // Override gitea.event_name to "workflow_call", so that the runner-side `getEvaluatorInputs` can get inputs from event["inputs"]. + // https://gitea.com/gitea/runner/src/commit/0b9f251b6abb30d5f292a49cfe0c611f7c26d857/act/runner/expression.go#L509 + // FIXME: The trade-off is that `${{ gitea.event_name }}` inside a reusable workflow's child job reads "workflow_call" + // instead of the caller's real trigger event name (push/pull_request/etc.) This is a small deviation from GitHub spec. + gitContext["event_name"] = "workflow_call" + } } if attempt == nil { @@ -125,7 +153,8 @@ type TaskNeed struct { Outputs map[string]string } -// FindTaskNeeds finds the `needs` for the task by the task's job +// FindTaskNeeds finds the `needs` for the task by the task's job. +// Lookup is scoped to the same ParentJobID. func FindTaskNeeds(ctx context.Context, job *actions_model.ActionRunJob) (map[string]*TaskNeed, error) { if len(job.Needs) == 0 { return nil, nil //nolint:nilnil // return nil when the job has no needs @@ -144,8 +173,16 @@ func FindTaskNeeds(ctx context.Context, job *actions_model.ActionRunJob) (map[st } jobIDJobs := make(map[string][]*actions_model.ActionRunJob) - for _, job := range jobs { - jobIDJobs[job.JobID] = append(jobIDJobs[job.JobID], job) + // childrenByParent indexes every job by its ParentJobID + childrenByParent := make(map[int64][]*actions_model.ActionRunJob) + for _, candidate := range jobs { + if candidate.ParentJobID != 0 { + childrenByParent[candidate.ParentJobID] = append(childrenByParent[candidate.ParentJobID], candidate) + } + // `needs` references are scope-bound: only candidates in the same caller scope match. + if candidate.ParentJobID == job.ParentJobID { + jobIDJobs[candidate.JobID] = append(jobIDJobs[candidate.JobID], candidate) + } } ret := make(map[string]*TaskNeed, len(needs)) @@ -154,19 +191,19 @@ func FindTaskNeeds(ctx context.Context, job *actions_model.ActionRunJob) (map[st continue } var jobOutputs map[string]string - for _, job := range jobsWithSameID { - taskID := job.EffectiveTaskID() - if taskID == 0 || !job.Status.IsDone() { - // it shouldn't happen + for _, candidate := range jobsWithSameID { + if !candidate.Status.IsDone() { continue } - got, err := actions_model.FindTaskOutputByTaskID(ctx, taskID) - if err != nil { - return nil, fmt.Errorf("FindTaskOutputByTaskID: %w", err) + var outputs map[string]string + var err error + if candidate.IsReusableCaller { + outputs, err = computeReusableCallerOutputs(ctx, candidate, childrenByParent) + } else { + outputs, err = loadJobTaskOutputs(ctx, candidate) } - outputs := make(map[string]string, len(got)) - for _, v := range got { - outputs[v.OutputKey] = v.OutputValue + if err != nil { + return nil, err } if len(jobOutputs) == 0 { jobOutputs = outputs @@ -182,6 +219,86 @@ func FindTaskNeeds(ctx context.Context, job *actions_model.ActionRunJob) (map[st return ret, nil } +// computeReusableCallerOutputs returns the workflow_call outputs of a reusable caller by recursing into its child subtree. +func computeReusableCallerOutputs(ctx context.Context, caller *actions_model.ActionRunJob, childrenByParent map[int64][]*actions_model.ActionRunJob) (map[string]string, error) { + if !caller.IsExpanded { + // A caller that was never expanded (e.g. Skipped because its `if:` was false) has no workflow_call outputs, return early. + return map[string]string{}, nil + } + + directChildren := childrenByParent[caller.ID] + + if err := caller.LoadRun(ctx); err != nil { + return nil, err + } + wcSpec, err := jobparser.ParseWorkflowCallSpec(caller.ReusableWorkflowContent) + if err != nil { + return nil, err + } + if len(wcSpec.Outputs) == 0 { + return map[string]string{}, nil + } + + // Per-job outputs over the children of this caller. + jobOutputs := make(jobparser.JobOutputs, len(directChildren)) + for _, child := range directChildren { + var outs map[string]string + switch { + case child.IsReusableCaller: + outs, err = computeReusableCallerOutputs(ctx, child, childrenByParent) + default: + outs, err = loadJobTaskOutputs(ctx, child) + } + if err != nil { + return nil, err + } + if existing, ok := jobOutputs[child.JobID]; ok { + jobOutputs[child.JobID] = mergeTwoOutputs(outs, existing) + } else { + jobOutputs[child.JobID] = outs + } + } + + // build contexts for evaluating outputs + if err := caller.Run.LoadAttributes(ctx); err != nil { + return nil, err + } + gitCtx := GenerateGiteaContext(ctx, caller.Run, nil, caller) + vars, err := actions_model.GetVariablesOfRun(ctx, caller.Run) + if err != nil { + return nil, err + } + inputs := map[string]any{} + if caller.CallPayload != "" { + var p api.WorkflowCallPayload + if err := json.Unmarshal([]byte(caller.CallPayload), &p); err != nil { + return nil, fmt.Errorf("decode caller payload: %w", err) + } + if p.Inputs != nil { + inputs = p.Inputs + } + } + + return jobparser.EvaluateWorkflowCallOutputs(wcSpec, gitCtx.ToGitHubContext(), vars, inputs, jobOutputs) +} + +// loadJobTaskOutputs returns the task-output map of `job`. +func loadJobTaskOutputs(ctx context.Context, job *actions_model.ActionRunJob) (map[string]string, error) { + tid := job.EffectiveTaskID() + if tid == 0 { + return map[string]string{}, nil + } + rows, err := actions_model.FindTaskOutputByTaskID(ctx, tid) + if err != nil { + return nil, fmt.Errorf("FindTaskOutputByTaskID: %w", err) + } + out := make(map[string]string, len(rows)) + for _, r := range rows { + out[r.OutputKey] = r.OutputValue + } + return out, nil +} + // mergeTwoOutputs merges two outputs from two different ActionRunJobs // Values with the same output name may be overridden. The user should ensure the output names are unique. // See https://docs.github.com/en/actions/writing-workflows/workflow-syntax-for-github-actions#using-job-outputs-in-a-matrix-job diff --git a/services/actions/context_test.go b/services/actions/context_test.go index 40ad0c7eb7..252399a37d 100644 --- a/services/actions/context_test.go +++ b/services/actions/context_test.go @@ -8,7 +8,10 @@ import ( "testing" actions_model "gitea.dev/models/actions" + "gitea.dev/models/db" "gitea.dev/models/unittest" + "gitea.dev/modules/json" + api "gitea.dev/modules/structs" act_model "gitea.com/gitea/runner/act/model" "github.com/stretchr/testify/assert" @@ -16,10 +19,8 @@ import ( ) func TestEvaluateRunConcurrency_RunIDFallback(t *testing.T) { - // Unit-level check that EvaluateRunConcurrencyFillModel resolves - // github.run_id from run.ID. The full-flow regression — that run.ID is - // non-zero by the time evaluation happens — is in - // TestPrepareRunAndInsert_ExpressionsSeeRunID. + // Unit-level check that EvaluateRunConcurrencyFillModel resolves github.run_id from run.ID. + // The full-flow regression (run.ID non-zero by evaluation time) is TestPrepareRunAndInsert_ExpressionsSeeRunID. assert.NoError(t, unittest.PrepareTestDatabase()) ctx := t.Context() @@ -43,10 +44,8 @@ func TestEvaluateRunConcurrency_RunIDFallback(t *testing.T) { } func TestPrepareRunAndInsert_ExpressionsSeeRunID(t *testing.T) { - // Regression for the cross-branch concurrency leak: github.run_id must - // be available during BOTH jobparser.Parse (run-name) and workflow-level - // concurrency evaluation. Re-ordering db.Insert relative to either step - // would leave run.ID at 0 and break this test. + // Regression for the cross-branch concurrency leak: github.run_id must be available during both + // jobparser.Parse (run-name) and concurrency evaluation; inserting run after either leaves run.ID at 0. assert.NoError(t, unittest.PrepareTestDatabase()) ctx := t.Context() @@ -90,6 +89,219 @@ jobs: assert.NotEmpty(t, persisted.RawConcurrency) } +func TestComputeReusableCallerOutputs(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + ctx := t.Context() + + var nextRunIndex int64 = 9001 + insertRun := func(t *testing.T, workflowID string) *actions_model.ActionRun { + t.Helper() + run := &actions_model.ActionRun{ + Title: "reusable-out", + RepoID: 4, + Index: nextRunIndex, + OwnerID: 1, + WorkflowID: workflowID, + TriggerUserID: 1, + Ref: "refs/heads/master", + CommitSHA: "c2d72f548424103f01ee1dc02889c1e2bff816b0", + Event: "push", + TriggerEvent: "push", + EventPayload: "{}", + Status: actions_model.StatusSuccess, + } + nextRunIndex++ + require.NoError(t, db.Insert(ctx, run)) + return run + } + + insertCaller := func(t *testing.T, run *actions_model.ActionRun, jobID string, parentID int64, content, callPayload string) *actions_model.ActionRunJob { + t.Helper() + job := &actions_model.ActionRunJob{ + RunID: run.ID, + RepoID: run.RepoID, + OwnerID: run.OwnerID, + CommitSHA: run.CommitSHA, + Name: jobID, + JobID: jobID, + Attempt: 1, + Status: actions_model.StatusSuccess, + ParentJobID: parentID, + IsReusableCaller: true, + IsExpanded: true, + ReusableWorkflowContent: []byte(content), + CallPayload: callPayload, + } + require.NoError(t, db.Insert(ctx, job)) + return job + } + + // Each call to insertChildJobAndTask with non-empty outputs allocates a fresh TaskID + // so its action_task_output rows stay isolated per subtest. + var nextTaskID int64 = 90001 + insertChildJobAndTask := func(t *testing.T, run *actions_model.ActionRun, jobID string, parentID int64, outputs map[string]string) *actions_model.ActionRunJob { + t.Helper() + var taskID int64 + if len(outputs) > 0 { + taskID = nextTaskID + nextTaskID++ + } + job := &actions_model.ActionRunJob{ + RunID: run.ID, + RepoID: run.RepoID, + OwnerID: run.OwnerID, + CommitSHA: run.CommitSHA, + Name: jobID, + JobID: jobID, + Attempt: 1, + Status: actions_model.StatusSuccess, + ParentJobID: parentID, + TaskID: taskID, + } + require.NoError(t, db.Insert(ctx, job)) + for k, v := range outputs { + require.NoError(t, db.Insert(ctx, &actions_model.ActionTaskOutput{ + TaskID: taskID, + OutputKey: k, + OutputValue: v, + })) + } + return job + } + + // childrenByParentOfRun returns the run's jobs indexed by ParentJobID, the shape computeReusableCallerOutputs expects. + childrenByParentOfRun := func(t *testing.T, runID int64) map[int64][]*actions_model.ActionRunJob { + t.Helper() + all, err := db.Find[actions_model.ActionRunJob](ctx, actions_model.FindRunJobOptions{RunID: runID}) + require.NoError(t, err) + index := make(map[int64][]*actions_model.ActionRunJob) + for _, j := range all { + if j.ParentJobID != 0 { + index[j.ParentJobID] = append(index[j.ParentJobID], j) + } + } + return index + } + + t.Run("returns empty when callee declares no outputs", func(t *testing.T) { + run := insertRun(t, "no-outputs.yaml") + caller := insertCaller(t, run, "caller", 0, `on: + workflow_call: + outputs: {} +`, "") + out, err := computeReusableCallerOutputs(ctx, caller, childrenByParentOfRun(t, run.ID)) + require.NoError(t, err) + assert.Empty(t, out) + }) + + t.Run("unexpanded (skipped) caller yields empty outputs without error", func(t *testing.T) { + run := insertRun(t, "skipped-caller.yaml") + // A reusable caller skipped before expansion: IsExpanded=false, empty ReusableWorkflowContent, no children. + caller := &actions_model.ActionRunJob{ + RunID: run.ID, + RepoID: run.RepoID, + OwnerID: run.OwnerID, + CommitSHA: run.CommitSHA, + Name: "caller", + JobID: "caller", + Attempt: 1, + Status: actions_model.StatusSkipped, + IsReusableCaller: true, + IsExpanded: false, + } + require.NoError(t, db.Insert(ctx, caller)) + out, err := computeReusableCallerOutputs(ctx, caller, childrenByParentOfRun(t, run.ID)) + require.NoError(t, err) + assert.Empty(t, out) + }) + + t.Run("literal output value passes through", func(t *testing.T) { + run := insertRun(t, "literal-out.yaml") + caller := insertCaller(t, run, "caller", 0, `on: + workflow_call: + outputs: + hello: + value: world +`, "") + out, err := computeReusableCallerOutputs(ctx, caller, childrenByParentOfRun(t, run.ID)) + require.NoError(t, err) + assert.Equal(t, map[string]string{"hello": "world"}, out) + }) + + t.Run("output expression reads child task outputs", func(t *testing.T) { + run := insertRun(t, "child-out.yaml") + caller := insertCaller(t, run, "caller", 0, `on: + workflow_call: + outputs: + result: + value: ${{ jobs.child.outputs.foo }} +`, "") + insertChildJobAndTask(t, run, "child", caller.ID, map[string]string{"foo": "bar"}) + + out, err := computeReusableCallerOutputs(ctx, caller, childrenByParentOfRun(t, run.ID)) + require.NoError(t, err) + assert.Equal(t, map[string]string{"result": "bar"}, out) + }) + + t.Run("CallPayload inputs reachable in output expression", func(t *testing.T) { + run := insertRun(t, "payload-out.yaml") + payload, err := json.Marshal(api.WorkflowCallPayload{ + Inputs: map[string]any{"env": "staging"}, + }) + require.NoError(t, err) + caller := insertCaller(t, run, "caller", 0, `on: + workflow_call: + inputs: + env: + type: string + outputs: + env: + value: ${{ inputs.env }} +`, string(payload)) + + out, err := computeReusableCallerOutputs(ctx, caller, childrenByParentOfRun(t, run.ID)) + require.NoError(t, err) + assert.Equal(t, map[string]string{"env": "staging"}, out) + }) + + t.Run("nested caller outputs propagate to outer", func(t *testing.T) { + run := insertRun(t, "nested-out.yaml") + outer := insertCaller(t, run, "outer", 0, `on: + workflow_call: + outputs: + bubbled: + value: ${{ jobs.inner.outputs.up }} +`, "") + inner := insertCaller(t, run, "inner", outer.ID, `on: + workflow_call: + outputs: + up: + value: ${{ jobs.leaf.outputs.foo }} +`, "") + insertChildJobAndTask(t, run, "leaf", inner.ID, map[string]string{"foo": "bubble-value"}) + + out, err := computeReusableCallerOutputs(ctx, outer, childrenByParentOfRun(t, run.ID)) + require.NoError(t, err) + assert.Equal(t, map[string]string{"bubbled": "bubble-value"}, out) + }) + + t.Run("matrix children with same JobID prefer non-empty values", func(t *testing.T) { + run := insertRun(t, "matrix-out.yaml") + caller := insertCaller(t, run, "caller", 0, `on: + workflow_call: + outputs: + foo: + value: ${{ jobs.matrix.outputs.foo }} +`, "") + insertChildJobAndTask(t, run, "matrix", caller.ID, map[string]string{"foo": ""}) + insertChildJobAndTask(t, run, "matrix", caller.ID, map[string]string{"foo": "filled"}) + + out, err := computeReusableCallerOutputs(ctx, caller, childrenByParentOfRun(t, run.ID)) + require.NoError(t, err) + assert.Equal(t, map[string]string{"foo": "filled"}, out) + }) +} + func TestFindTaskNeeds(t *testing.T) { assert.NoError(t, unittest.PrepareTestDatabase()) diff --git a/services/actions/helper.go b/services/actions/helper.go new file mode 100644 index 0000000000..37dbb326ed --- /dev/null +++ b/services/actions/helper.go @@ -0,0 +1,92 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package actions + +import ( + "context" + "fmt" + + actions_model "gitea.dev/models/actions" + "gitea.dev/modules/actions/jobparser" + "gitea.dev/modules/json" + api "gitea.dev/modules/structs" +) + +func getWorkflowDispatchInputsFromRun(run *actions_model.ActionRun) (map[string]any, error) { + if run.Event != "workflow_dispatch" { + return map[string]any{}, nil + } + var payload api.WorkflowDispatchPayload + if err := json.Unmarshal([]byte(run.EventPayload), &payload); err != nil { + return nil, err + } + return payload.Inputs, nil +} + +// getInputsForJob returns the `inputs.*` top-level expression context for a job's evaluation. +// - For top-level jobs, it falls back to the run's dispatch inputs (empty for non-dispatch events) +// - For reusable workflow children (and nested callers), this is the direct parent caller's CallPayload.Inputs +func getInputsForJob(ctx context.Context, run *actions_model.ActionRun, job *actions_model.ActionRunJob) (map[string]any, error) { + if job.ParentJobID == 0 { + return getWorkflowDispatchInputsFromRun(run) + } + + caller, err := actions_model.GetRunJobByRunAndID(ctx, run.ID, job.ParentJobID) + if err != nil { + return nil, fmt.Errorf("load caller job %d: %w", job.ParentJobID, err) + } + if caller.CallPayload == "" { + // should not happen - a child job cannot reach this point if its caller's CallPayload hasn't been evaluated + return map[string]any{}, nil + } + var p api.WorkflowCallPayload + if err := json.Unmarshal([]byte(caller.CallPayload), &p); err != nil { + return nil, fmt.Errorf("decode caller %d payload: %w", caller.ID, err) + } + if p.Inputs == nil { + return map[string]any{}, nil + } + return p.Inputs, nil +} + +// evaluateJobIf evaluates a job's `if:` +func evaluateJobIf(ctx context.Context, run *actions_model.ActionRun, attempt *actions_model.ActionRunAttempt, job *actions_model.ActionRunJob, vars map[string]string, allNeedsSucceed bool) (bool, error) { + parsedJob, err := job.ParseJob() + if err != nil { + return false, err + } + // Empty `if:` reduces to implicit `success()` - true iff every need finished as Success. + if len(parsedJob.If.Value) == 0 { + return allNeedsSucceed, nil + } + jobResults, err := findJobNeedsAndFillJobResults(ctx, job) + if err != nil { + return false, err + } + inputs, err := getInputsForJob(ctx, run, job) + if err != nil { + return false, err + } + gitCtx := GenerateGiteaContext(ctx, run, attempt, job) + return jobparser.EvaluateJobIfExpression(job.JobID, parsedJob, gitCtx, jobResults, vars, inputs) +} + +func findJobNeedsAndFillJobResults(ctx context.Context, job *actions_model.ActionRunJob) (map[string]*jobparser.JobResult, error) { + taskNeeds, err := FindTaskNeeds(ctx, job) + if err != nil { + return nil, fmt.Errorf("find task needs: %w", err) + } + jobResults := make(map[string]*jobparser.JobResult, len(taskNeeds)) + for jobID, taskNeed := range taskNeeds { + jobResult := &jobparser.JobResult{ + Result: taskNeed.Result.String(), + Outputs: taskNeed.Outputs, + } + jobResults[jobID] = jobResult + } + jobResults[job.JobID] = &jobparser.JobResult{ + Needs: job.Needs, + } + return jobResults, nil +} diff --git a/services/actions/job_emitter.go b/services/actions/job_emitter.go index 338ec08e12..9c9a408db0 100644 --- a/services/actions/job_emitter.go +++ b/services/actions/job_emitter.go @@ -69,39 +69,48 @@ func checkJobsByRunID(ctx context.Context, runID int64) error { if err != nil { return fmt.Errorf("get action run: %w", err) } - var jobs, updatedJobs, cancelledJobs []*actions_model.ActionRunJob + var result jobsCheckResult if err := db.WithTx(ctx, func(ctx context.Context) error { // check jobs of the current run - if js, ujs, cjs, err := checkJobsOfCurrentRunAttempt(ctx, run); err != nil { + r, err := checkJobsOfCurrentRunAttempt(ctx, run) + if err != nil { return err - } else { - jobs = append(jobs, js...) - updatedJobs = append(updatedJobs, ujs...) - cancelledJobs = append(cancelledJobs, cjs...) } - if js, ujs, cjs, err := checkRunConcurrency(ctx, run); err != nil { + result.merge(r) + + r, err = checkRunConcurrency(ctx, run) + if err != nil { return err - } else { - jobs = append(jobs, js...) - updatedJobs = append(updatedJobs, ujs...) - cancelledJobs = append(cancelledJobs, cjs...) } + result.merge(r) return nil }); err != nil { return err } - NotifyWorkflowJobsAndRunsStatusUpdate(ctx, cancelledJobs) - EmitJobsIfReadyByJobs(cancelledJobs) - if err := createCommitStatusesForJobsByRun(ctx, jobs); err != nil { + // Re-emit AFTER the transaction commits; doing this inside WithTx would deadlock under + // immediate-mode queues (the inline handler reopens checkJobsByRunID and asks for a + // nested writer transaction while the outer one is still open). + emitted := make(container.Set[int64]) + for _, rid := range result.RunIDsToReEmit { + if !emitted.Add(rid) { + continue + } + if err := EmitJobsIfReadyByRun(rid); err != nil { + log.Error("re-emit run %d after caller expansion: %v", rid, err) + } + } + NotifyWorkflowJobsAndRunsStatusUpdate(ctx, result.CancelledJobs) + EmitJobsIfReadyByJobs(result.CancelledJobs) + if err := createCommitStatusesForJobsByRun(ctx, result.Jobs); err != nil { return err } - NotifyWorkflowJobsStatusUpdate(ctx, updatedJobs...) + NotifyWorkflowJobsStatusUpdate(ctx, result.UpdatedJobs...) runJobs := make(map[int64][]*actions_model.ActionRunJob) - for _, job := range jobs { + for _, job := range result.Jobs { runJobs[job.RunID] = append(runJobs[job.RunID], job) } runUpdatedJobs := make(map[int64][]*actions_model.ActionRunJob) - for _, uj := range updatedJobs { + for _, uj := range result.UpdatedJobs { runUpdatedJobs[uj.RunID] = append(runUpdatedJobs[uj.RunID], uj) } for runID, js := range runJobs { @@ -158,20 +167,22 @@ func findBlockedRunIDByConcurrency(ctx context.Context, repoID int64, concurrenc return 0, nil } -func checkBlockedConcurrentRun(ctx context.Context, repoID, runID int64) (jobs, updatedJobs, cancelledJobs []*actions_model.ActionRunJob, err error) { +func checkBlockedConcurrentRun(ctx context.Context, repoID, runID int64) (*jobsCheckResult, error) { concurrentRun, err := actions_model.GetRunByRepoAndID(ctx, repoID, runID) if err != nil { - return nil, nil, nil, fmt.Errorf("get run %d: %w", runID, err) + return nil, fmt.Errorf("get run %d: %w", runID, err) } if concurrentRun.NeedApproval { - return nil, nil, nil, nil + return &jobsCheckResult{}, nil } return checkJobsOfCurrentRunAttempt(ctx, concurrentRun) } // checkRunConcurrency rechecks runs blocked by concurrency that may become unblocked after the current run releases a workflow-level or job-level concurrency group. -func checkRunConcurrency(ctx context.Context, run *actions_model.ActionRun) (jobs, updatedJobs, cancelledJobs []*actions_model.ActionRunJob, err error) { +// RunIDsToReEmit propagates from inner checkJobsOfCurrentRunAttempt calls; see that function's doc. +func checkRunConcurrency(ctx context.Context, run *actions_model.ActionRun) (*jobsCheckResult, error) { + result := &jobsCheckResult{} checkedConcurrencyGroup := make(container.Set[string]) collect := func(concurrencyGroup string) error { @@ -180,13 +191,11 @@ func checkRunConcurrency(ctx context.Context, run *actions_model.ActionRun) (job return fmt.Errorf("find blocked run by concurrency: %w", err) } if concurrentRunID > 0 { - js, ujs, cjs, err := checkBlockedConcurrentRun(ctx, run.RepoID, concurrentRunID) + r, err := checkBlockedConcurrentRun(ctx, run.RepoID, concurrentRunID) if err != nil { return err } - jobs = append(jobs, js...) - updatedJobs = append(updatedJobs, ujs...) - cancelledJobs = append(cancelledJobs, cjs...) + result.merge(r) } checkedConcurrencyGroup.Add(concurrencyGroup) return nil @@ -195,18 +204,18 @@ func checkRunConcurrency(ctx context.Context, run *actions_model.ActionRun) (job // check run (workflow-level) concurrency runConcurrencyGroup, _, err := run.GetEffectiveConcurrency(ctx) if err != nil { - return nil, nil, nil, fmt.Errorf("GetEffectiveConcurrency: %w", err) + return nil, fmt.Errorf("GetEffectiveConcurrency: %w", err) } if runConcurrencyGroup != "" { if err := collect(runConcurrencyGroup); err != nil { - return nil, nil, nil, err + return nil, err } } // check job concurrency runJobs, err := actions_model.GetLatestAttemptJobsByRepoAndRunID(ctx, run.RepoID, run.ID) if err != nil { - return nil, nil, nil, fmt.Errorf("find run %d jobs: %w", run.ID, err) + return nil, fmt.Errorf("find run %d jobs: %w", run.ID, err) } for _, job := range runJobs { if !job.Status.IsDone() { @@ -216,42 +225,47 @@ func checkRunConcurrency(ctx context.Context, run *actions_model.ActionRun) (job continue } if err := collect(job.ConcurrencyGroup); err != nil { - return nil, nil, nil, err + return nil, err } } - return jobs, updatedJobs, cancelledJobs, nil + return result, nil } // checkJobsOfCurrentRunAttempt resolves blocked jobs of the run's latest attempt. -func checkJobsOfCurrentRunAttempt(ctx context.Context, run *actions_model.ActionRun) (jobs, updatedJobs, cancelledJobs []*actions_model.ActionRunJob, err error) { - jobs, err = actions_model.GetRunJobsByRunAndAttemptID(ctx, run.ID, run.LatestAttemptID) +func checkJobsOfCurrentRunAttempt(ctx context.Context, run *actions_model.ActionRun) (*jobsCheckResult, error) { + jobs, err := actions_model.GetRunJobsByRunAndAttemptID(ctx, run.ID, run.LatestAttemptID) if err != nil { - return nil, nil, nil, err + return nil, err } + result := &jobsCheckResult{Jobs: jobs} + + var attempt *actions_model.ActionRunAttempt + if run.LatestAttemptID > 0 { + attempt, err = actions_model.GetRunAttemptByRepoAndID(ctx, run.RepoID, run.LatestAttemptID) + if err != nil { + return nil, err + } + } + // The resolver below only considers needs and job-level concurrency, so a run blocked // solely by run-level concurrency would have its jobs unblocked here. checkRunConcurrency // re-evaluates when the holding run finishes. - if run.Status.IsBlocked() { - attempt, has, err := run.GetLatestAttempt(ctx) + if run.Status.IsBlocked() && attempt != nil { + shouldBlock, err := shouldBlockRunByConcurrency(ctx, attempt) if err != nil { - return nil, nil, nil, fmt.Errorf("GetLatestAttempt: %w", err) + return nil, fmt.Errorf("shouldBlockRunByConcurrency: %w", err) } - if has { - shouldBlock, err := shouldBlockRunByConcurrency(ctx, attempt) - if err != nil { - return nil, nil, nil, fmt.Errorf("shouldBlockRunByConcurrency: %w", err) - } - if shouldBlock { - return jobs, nil, nil, nil - } + if shouldBlock { + return result, nil } } vars, err := actions_model.GetVariablesOfRun(ctx, run) if err != nil { - return nil, nil, nil, err + return nil, err } resolver := newJobStatusResolver(jobs, vars) + expandedAnyCaller := false if err = db.WithTx(ctx, func(ctx context.Context) error { for _, job := range jobs { job.Run = run @@ -259,22 +273,47 @@ func checkJobsOfCurrentRunAttempt(ctx context.Context, run *actions_model.Action updates := resolver.Resolve(ctx) for _, job := range jobs { - if status, ok := updates[job.ID]; ok { - job.Status = status - if n, err := actions_model.UpdateRunJob(ctx, job, builder.Eq{"status": actions_model.StatusBlocked}, "status"); err != nil { - return err - } else if n != 1 { - return fmt.Errorf("no affected for updating blocked job %v", job.ID) - } - updatedJobs = append(updatedJobs, job) + status, ok := updates[job.ID] + if !ok { + continue } + + if job.IsReusableCaller { + switch status { + case actions_model.StatusWaiting: + if err := expandReusableWorkflowCaller(ctx, run, attempt, job, vars); err != nil { + return fmt.Errorf("trigger caller-ready %d: %w", job.ID, err) + } + // expandReusableWorkflowCaller inserts children as Blocked. They need a follow-up resolver pass. + expandedAnyCaller = true + case actions_model.StatusSkipped: + job.Status = actions_model.StatusSkipped + if _, err := actions_model.UpdateRunJob(ctx, job, nil, "status"); err != nil { + return err + } + } + continue + } + + // Non-caller: standard status update. + job.Status = status + if n, err := actions_model.UpdateRunJob(ctx, job, builder.Eq{"status": actions_model.StatusBlocked}, "status"); err != nil { + return err + } else if n != 1 { + return fmt.Errorf("no affected for updating blocked job %v", job.ID) + } + result.UpdatedJobs = append(result.UpdatedJobs, job) } return nil }); err != nil { - return nil, nil, nil, err + return nil, err } - return jobs, updatedJobs, resolver.cancelledJobs, nil + if expandedAnyCaller { + result.RunIDsToReEmit = append(result.RunIDsToReEmit, run.ID) + } + result.CancelledJobs = resolver.cancelledJobs + return result, nil } type jobStatusResolver struct { @@ -286,10 +325,17 @@ type jobStatusResolver struct { } func newJobStatusResolver(jobs actions_model.ActionJobList, vars map[string]string) *jobStatusResolver { - idToJobs := make(map[string][]*actions_model.ActionRunJob, len(jobs)) + // Scope-aware: needs are resolved within the same ParentJobID scope so the same + // JobID in different reusable workflow calls does not cross-link. + scopedIDToJobs := make(map[int64]map[string][]*actions_model.ActionRunJob) jobMap := make(map[int64]*actions_model.ActionRunJob) for _, job := range jobs { - idToJobs[job.JobID] = append(idToJobs[job.JobID], job) + scope := scopedIDToJobs[job.ParentJobID] + if scope == nil { + scope = make(map[string][]*actions_model.ActionRunJob) + scopedIDToJobs[job.ParentJobID] = scope + } + scope[job.JobID] = append(scope[job.JobID], job) jobMap[job.ID] = job } @@ -297,8 +343,9 @@ func newJobStatusResolver(jobs actions_model.ActionJobList, vars map[string]stri needs := make(map[int64][]int64, len(jobs)) for _, job := range jobs { statuses[job.ID] = job.Status + scope := scopedIDToJobs[job.ParentJobID] for _, need := range job.Needs { - for _, v := range idToJobs[need] { + for _, v := range scope[need] { needs[job.ID] = append(needs[job.ID], v.ID) } } @@ -340,14 +387,6 @@ func (r *jobStatusResolver) resolveCheckNeeds(id int64) (allDone, allSucceed boo return allDone, allSucceed } -func (r *jobStatusResolver) resolveJobHasIfCondition(actionRunJob *actions_model.ActionRunJob) (hasIf bool) { - // FIXME evaluate this on the server side - if job, err := actionRunJob.ParseJob(); err == nil { - return len(job.If.Value) > 0 - } - return hasIf -} - func (r *jobStatusResolver) resolve(ctx context.Context) map[int64]actions_model.Status { ret := map[int64]actions_model.Status{} for id, status := range r.statuses { @@ -355,6 +394,12 @@ func (r *jobStatusResolver) resolve(ctx context.Context) map[int64]actions_model if status != actions_model.StatusBlocked { continue } + // A child of a caller cannot start until the caller has become "ready" (children inserted, CallPayload populated). + if actionRunJob.ParentJobID > 0 { + if parent, ok := r.jobMap[actionRunJob.ParentJobID]; ok && !parent.IsExpanded { + continue + } + } allDone, allSucceed := r.resolveCheckNeeds(id) if !allDone { continue @@ -365,18 +410,16 @@ func (r *jobStatusResolver) resolve(ctx context.Context) map[int64]actions_model if err != nil { // The err can be caused by different cases: database error, or syntax error, or the needed jobs haven't completed // At the moment there is no way to distinguish them. - // Actually, for most cases, the error is caused by "syntax error" / "the needed jobs haven't completed (skipped?)" // TODO: if workflow or concurrency expression has syntax error, there should be a user error message, need to show it to end users log.Debug("updateConcurrencyEvaluationForJobWithNeeds failed, this job will stay blocked: job: %d, err: %v", id, err) continue } - shouldStartJob := true - if !allSucceed { - // Not all dependent jobs completed successfully: - // * if the job has "if" condition, it can be started, then the act_runner will evaluate the "if" condition. - // * otherwise, the job should be skipped. - shouldStartJob = r.resolveJobHasIfCondition(actionRunJob) + shouldStartJob, err := evaluateJobIf(ctx, actionRunJob.Run, nil, actionRunJob, r.vars, allSucceed) + if err != nil { + // TODO: surface deterministic expression errors to users by failing the job with a message. + log.Error("evaluateJobIf failed, job will stay blocked: job: %d, err: %v", id, err) + continue } newStatus := util.Iif(shouldStartJob, actions_model.StatusWaiting, actions_model.StatusSkipped) @@ -420,3 +463,23 @@ func updateConcurrencyEvaluationForJobWithNeeds(ctx context.Context, actionRunJo } return nil } + +// jobsCheckResult bundles the output of the per-run job-check helpers. +type jobsCheckResult struct { + // Jobs are all jobs of the run's latest attempt that were inspected. + Jobs []*actions_model.ActionRunJob + // UpdatedJobs are jobs whose status was transitioned out of Blocked in this pass. + UpdatedJobs []*actions_model.ActionRunJob + // CancelledJobs are jobs cancelled by job-level concurrency while preparing to start. + CancelledJobs []*actions_model.ActionRunJob + // RunIDsToReEmit are runs whose newly expanded reusable workflow callers need another resolver pass. + RunIDsToReEmit []int64 +} + +// merge appends another result's contents into r in place. +func (r *jobsCheckResult) merge(other *jobsCheckResult) { + r.Jobs = append(r.Jobs, other.Jobs...) + r.UpdatedJobs = append(r.UpdatedJobs, other.UpdatedJobs...) + r.CancelledJobs = append(r.CancelledJobs, other.CancelledJobs...) + r.RunIDsToReEmit = append(r.RunIDsToReEmit, other.RunIDsToReEmit...) +} diff --git a/services/actions/job_emitter_test.go b/services/actions/job_emitter_test.go index 5b3c5c0bba..892ba6c2c5 100644 --- a/services/actions/job_emitter_test.go +++ b/services/actions/job_emitter_test.go @@ -4,11 +4,14 @@ package actions import ( + "fmt" "testing" actions_model "gitea.dev/models/actions" "gitea.dev/models/db" + repo_model "gitea.dev/models/repo" "gitea.dev/models/unittest" + user_model "gitea.dev/models/user" "github.com/stretchr/testify/assert" ) @@ -129,10 +132,48 @@ jobs: want: map[int64]actions_model.Status{2: actions_model.StatusSkipped}, }, } - for _, tt := range tests { + assert.NoError(t, unittest.PrepareTestDatabase()) + ctx := t.Context() + stubRun := &actions_model.ActionRun{TriggerUser: &user_model.User{}, Repo: &repo_model.Repository{}} + for i, tt := range tests { t.Run(tt.name, func(t *testing.T) { + // Each subtest gets a unique RunID / RunAttemptID so jobs from different subtests don't bleed into each other's FindTaskNeeds queries + runID := int64(9001 + i) + attemptID := int64(9001 + i) + + // Insert each test job (letting the DB assign IDs) and remember the testID -> dbID mapping so we can translate the expected map. + idMap := make(map[int64]int64, len(tt.jobs)) + for _, j := range tt.jobs { + origID := j.ID + j.ID = 0 + j.RunID = runID + j.RunAttemptID = attemptID + j.Run = stubRun + + // The resolver evaluates Blocked jobs via evaluateJobIf, which needs a valid YAML payload; + // supply a minimal one when the case didn't. + if j.Status == actions_model.StatusBlocked && len(j.WorkflowPayload) == 0 { + j.WorkflowPayload = fmt.Appendf(nil, `name: test +on: push +jobs: + %s: + runs-on: ubuntu-latest + steps: + - run: echo +`, j.JobID) + } + + assert.NoError(t, db.Insert(ctx, j)) + idMap[origID] = j.ID + } + + want := make(map[int64]actions_model.Status, len(tt.want)) + for k, v := range tt.want { + want[idMap[k]] = v + } + r := newJobStatusResolver(tt.jobs, nil) - assert.Equal(t, tt.want, r.Resolve(t.Context())) + assert.Equal(t, want, r.Resolve(ctx)) }) } } @@ -221,11 +262,11 @@ func Test_checkRunConcurrency_NoDuplicateConcurrencyGroupCheck(t *testing.T) { assert.NoError(t, db.Insert(ctx, jobBBlocked)) runA, _, _ = db.GetByID[actions_model.ActionRun](t.Context(), runA.ID) - jobs, _, _, err := checkRunConcurrency(ctx, runA) + result, err := checkRunConcurrency(ctx, runA) assert.NoError(t, err) - if assert.Len(t, jobs, 1) { - assert.Equal(t, jobBBlocked.ID, jobs[0].ID) + if assert.Len(t, result.Jobs, 1) { + assert.Equal(t, jobBBlocked.ID, result.Jobs[0].ID) } } @@ -286,9 +327,9 @@ jobs: } assert.NoError(t, db.Insert(ctx, blockedJob)) - _, updated, _, err := checkJobsOfCurrentRunAttempt(ctx, blockedRun) + result, err := checkJobsOfCurrentRunAttempt(ctx, blockedRun) assert.NoError(t, err) - assert.Empty(t, updated) + assert.Empty(t, result.UpdatedJobs) refreshed := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: blockedJob.ID}) assert.Equal(t, actions_model.StatusBlocked, refreshed.Status) diff --git a/services/actions/rerun.go b/services/actions/rerun.go index 4c9ec37724..1ef84608a0 100644 --- a/services/actions/rerun.go +++ b/services/actions/rerun.go @@ -14,6 +14,7 @@ import ( "gitea.dev/models/unit" user_model "gitea.dev/models/user" "gitea.dev/modules/container" + "gitea.dev/modules/log" "gitea.dev/modules/setting" "gitea.dev/modules/util" @@ -42,6 +43,7 @@ func GetFailedJobsForRerun(allJobs []*actions_model.ActionRunJob) []*actions_mod // rather than one big outer transaction: // - execRerunPlan performs slow work (loading variables, YAML unmarshal, concurrency expression evaluation) // before opening its own transaction, so the tx stays focused on inserts/updates. +// (Exception: reusable workflow caller expansion runs inside the tx, see expandReusableWorkflowCaller's doc.) // - The legacy backfill is idempotent-friendly: if it succeeds but a later stage fails, a subsequent rerun // will observe run.LatestAttemptID != 0 and skip the backfill, continuing naturally. No data corruption // or stuck state results from partial progress. @@ -112,8 +114,19 @@ type rerunPlan struct { run *actions_model.ActionRun templateAttempt *actions_model.ActionRunAttempt templateJobs actions_model.ActionJobList - rerunJobIDs container.Set[string] triggerUser *user_model.User + + // rerunAttemptJobIDs holds the AttemptJobIDs of jobs that will actually be re-run in the new attempt. + // If a job here is a reusable caller, the whole subtree under it will be re-run. + rerunAttemptJobIDs container.Set[int64] + + // ancestorAttemptJobIDs holds the AttemptJobIDs of reusable caller jobs that have only some of their descendants being re-run: + // the caller itself is NOT re-run as a whole, it stays pass-through and its non-rerun children stay pass-through too. + ancestorAttemptJobIDs container.Set[int64] + + // skipCloneTemplateJobIDs holds the template-attempt DB row IDs of descendants of any reusable caller in rerunAttemptJobIDs. + // These jobs should not be cloned, since the caller's lazy expansion will re-insert them fresh. + skipCloneTemplateJobIDs container.Set[int64] } // buildRerunPlan constructs a rerunPlan for the given workflow run without writing to the database. @@ -151,6 +164,7 @@ func buildRerunPlan(ctx context.Context, run *actions_model.ActionRun, triggerUs if err := plan.expandRerunJobIDs(jobsToRerun); err != nil { return nil, err } + plan.skipCloneTemplateJobIDs = plan.collectResetCallerDescendants() return plan, nil } @@ -188,6 +202,7 @@ func execRerunPlan(ctx context.Context, plan *rerunPlan) (*actions_model.ActionR var newJobs, newJobsToRerun actions_model.ActionJobList var cancelledConcurrencyJobs []*actions_model.ActionRunJob + var hasWaitingCallerJobs bool err = db.WithTx(ctx, func(ctx context.Context) error { newAttemptStatus, jobsToCancel, err := PrepareToStartRunWithConcurrency(ctx, newAttempt) @@ -212,10 +227,30 @@ func execRerunPlan(ctx context.Context, plan *rerunPlan) (*actions_model.ActionR hasWaitingJobs := false newJobs = make(actions_model.ActionJobList, 0, len(plan.templateJobs)) - newJobsToRerun = make(actions_model.ActionJobList, 0, len(plan.rerunJobIDs)) + newJobsToRerun = make(actions_model.ActionJobList, 0, len(plan.rerunAttemptJobIDs)) + + // templateIDToNewID maps each template-attempt job's DB ID to its newly-inserted clone's DB ID + templateIDToNewID := make(map[int64]int64, len(plan.templateJobs)) + for _, templateJob := range plan.templateJobs { + // descendants of a reset reusable caller are not cloned at all, the caller will re-insert them + if plan.skipCloneTemplateJobIDs.Contains(templateJob.ID) { + continue + } + newJob := cloneRunJobForAttempt(templateJob, newAttempt) - if plan.rerunJobIDs.Contains(templateJob.JobID) { + + // Remap ParentJobID from template attempts's DB ID -> new attempt's DB ID. + if templateJob.ParentJobID != 0 { + newParentID, ok := templateIDToNewID[templateJob.ParentJobID] + if !ok { + return fmt.Errorf("clone order violation: parent job %d not yet cloned for child %d", + templateJob.ParentJobID, templateJob.ID) + } + newJob.ParentJobID = newParentID + } + + if plan.rerunAttemptJobIDs.Contains(templateJob.AttemptJobID) { shouldBlockJob := shouldBlock || plan.hasRerunDependency(templateJob) newJob.Status = util.Iif(shouldBlockJob, actions_model.StatusBlocked, actions_model.StatusWaiting) @@ -227,6 +262,11 @@ func execRerunPlan(ctx context.Context, plan *rerunPlan) (*actions_model.ActionR newJob.ConcurrencyCancel = false newJob.IsConcurrencyEvaluated = false + if templateJob.IsReusableCaller { + newJob.IsExpanded = false + newJob.CallPayload = "" + } + if newJob.RawConcurrency != "" && !shouldBlockJob { if err := EvaluateJobConcurrencyFillModel(ctx, plan.run, newAttempt, newJob, vars, nil); err != nil { return fmt.Errorf("evaluate job concurrency: %w", err) @@ -242,17 +282,45 @@ func execRerunPlan(ctx context.Context, plan *rerunPlan) (*actions_model.ActionR } else { newJob.TaskID = 0 newJob.SourceTaskID = templateJob.EffectiveTaskID() - newJob.Started = templateJob.Started - newJob.Stopped = templateJob.Stopped + + isAncestor := plan.ancestorAttemptJobIDs.Contains(templateJob.AttemptJobID) + newJob.Started = util.Iif(isAncestor, 0, templateJob.Started) + newJob.Stopped = util.Iif(isAncestor, 0, templateJob.Stopped) } if err := db.Insert(ctx, newJob); err != nil { return err } - hasWaitingJobs = hasWaitingJobs || newJob.Status == actions_model.StatusWaiting + templateIDToNewID[templateJob.ID] = newJob.ID + + // expand reusable caller + if newJob.IsReusableCaller && newJob.Status == actions_model.StatusWaiting && !newJob.IsExpanded { + if err := expandReusableWorkflowCaller(ctx, plan.run, newAttempt, newJob, vars); err != nil { + return fmt.Errorf("inline trigger caller %d ready: %w", newJob.ID, err) + } + // refresh the caller status + if err := actions_model.RefreshReusableCallerStatus(ctx, newJob); err != nil { + return fmt.Errorf("refresh caller %d status: %w", newJob.ID, err) + } + hasWaitingCallerJobs = true + } + + // A reusable caller is never dispatched to a runner, so it must not drive the task-version bump. + hasWaitingJobs = hasWaitingJobs || (newJob.Status == actions_model.StatusWaiting && !newJob.IsReusableCaller) newJobs = append(newJobs, newJob) } + // Refresh each ancestor's status from its now-fresh children. + // `newJobs` is appended top-down (caller before its children), so we walk it in reverse to refresh the deepest ancestor first. + for _, ancestor := range slices.Backward(newJobs) { + if !ancestor.IsReusableCaller || !plan.ancestorAttemptJobIDs.Contains(ancestor.AttemptJobID) { + continue + } + if err := actions_model.RefreshReusableCallerStatus(ctx, ancestor); err != nil { + return fmt.Errorf("refresh ancestor caller %d status: %w", ancestor.ID, err) + } + } + newAttempt.Status = actions_model.AggregateJobStatus(newJobsToRerun) if err := actions_model.UpdateRunAttempt(ctx, newAttempt, "status"); err != nil { return err @@ -280,60 +348,149 @@ func execRerunPlan(ctx context.Context, plan *rerunPlan) (*actions_model.ActionR CreateCommitStatusForRunJobs(ctx, plan.run, newJobs...) NotifyWorkflowJobsAndRunsStatusUpdate(ctx, newJobsToRerun) + // Post-commit kick for expanded callers: let job_emitter resolve its child jobs + if hasWaitingCallerJobs { + if err := EmitJobsIfReadyByRun(plan.run.ID); err != nil { + log.Error("emit run %d after rerun: %v", plan.run.ID, err) + } + } + return newAttempt, nil } +// expandRerunJobIDs computes rerunAttemptJobIDs and ancestorAttemptJobIDs from the user-selected jobsToRerun. func (p *rerunPlan) expandRerunJobIDs(jobsToRerun []*actions_model.ActionRunJob) error { - templateJobIDs := make(container.Set[string]) - for _, job := range p.templateJobs { - templateJobIDs.Add(job.JobID) - } - + // Empty jobsToRerun: rerun the whole latest attempt if len(jobsToRerun) == 0 { - p.rerunJobIDs = templateJobIDs + all := make(container.Set[int64], len(p.templateJobs)) + for _, job := range p.templateJobs { + all.Add(job.AttemptJobID) + } + p.rerunAttemptJobIDs = all + p.ancestorAttemptJobIDs = make(container.Set[int64]) return nil } - rerunJobIDs := make(container.Set[string]) + byID := make(map[int64]*actions_model.ActionRunJob, len(p.templateJobs)) + byAttemptJobID := make(map[int64]*actions_model.ActionRunJob, len(p.templateJobs)) + for _, job := range p.templateJobs { + byID[job.ID] = job + byAttemptJobID[job.AttemptJobID] = job + } + for _, job := range jobsToRerun { - if !templateJobIDs.Contains(job.JobID) { + if _, ok := byID[job.ID]; !ok { return util.NewInvalidArgumentErrorf("job %q does not exist in the latest attempt", job.JobID) } - rerunJobIDs.Add(job.JobID) } - for { - found := false - for _, job := range p.templateJobs { - if rerunJobIDs.Contains(job.JobID) { + rerunSet := make(container.Set[int64]) + ancestorSet := make(container.Set[int64]) + queue := make([]*actions_model.ActionRunJob, 0, len(jobsToRerun)) + + for _, job := range jobsToRerun { + j := byID[job.ID] + rerunSet.Add(j.AttemptJobID) + queue = append(queue, j) + } + + for len(queue) > 0 { + cur := queue[0] + queue = queue[1:] + + // same-scope downstream: siblings whose Needs reference cur.JobID join the rerun set + for _, candidate := range p.templateJobs { + if candidate.ParentJobID != cur.ParentJobID { continue } - for _, need := range job.Needs { - if rerunJobIDs.Contains(need) { - found = true - rerunJobIDs.Add(job.JobID) - break - } + if rerunSet.Contains(candidate.AttemptJobID) || ancestorSet.Contains(candidate.AttemptJobID) { + continue } + if !slices.Contains(candidate.Needs, cur.JobID) { + continue + } + rerunSet.Add(candidate.AttemptJobID) + queue = append(queue, candidate) } - if !found { - break + + // escalate to parent caller as an ancestor so its own siblings get checked next round + if cur.ParentJobID == 0 { + continue + } + parent, ok := byID[cur.ParentJobID] + if !ok { + continue + } + if rerunSet.Contains(parent.AttemptJobID) || ancestorSet.Contains(parent.AttemptJobID) { + continue + } + ancestorSet.Add(parent.AttemptJobID) + queue = append(queue, parent) + } + + // remove entries whose parent-caller chain already has a rerunSet member + for atID := range ancestorSet { + cur := byAttemptJobID[atID] + for cur.ParentJobID != 0 { + parent, ok := byID[cur.ParentJobID] + if !ok { + break + } + if rerunSet.Contains(parent.AttemptJobID) { + delete(ancestorSet, atID) + break + } + cur = parent } } - p.rerunJobIDs = rerunJobIDs + p.rerunAttemptJobIDs = rerunSet + p.ancestorAttemptJobIDs = ancestorSet return nil } +// hasRerunDependency reports whether `job` has a needs-reference that points to a job which is itself being rerun (in rerunAttemptJobIDs) +// or is an ancestor caller whose subtree is being rerun (in ancestorAttemptJobIDs). +// Either case means `job` should start in Blocked status. func (p *rerunPlan) hasRerunDependency(job *actions_model.ActionRunJob) bool { - for _, need := range job.Needs { - if p.rerunJobIDs.Contains(need) { + if len(job.Needs) == 0 { + return false + } + needSet := container.SetOf(job.Needs...) + for _, sibling := range p.templateJobs { + if sibling.ParentJobID != job.ParentJobID { + continue + } + if !needSet.Contains(sibling.JobID) { + continue + } + if p.rerunAttemptJobIDs.Contains(sibling.AttemptJobID) || p.ancestorAttemptJobIDs.Contains(sibling.AttemptJobID) { return true } } return false } +// collectResetCallerDescendants walks p.templateJobs and returns the DB IDs of every transitive descendant of any reusable caller whose AttemptJobID is in p.rerunAttemptJobIDs. +// These descendants must NOT be cloned by execRerunPlan: the reset caller will re-insert them with template-matched AttemptJobIDs. +func (p *rerunPlan) collectResetCallerDescendants() container.Set[int64] { + out := make(container.Set[int64]) + for _, tj := range p.templateJobs { + if !tj.IsReusableCaller || !p.rerunAttemptJobIDs.Contains(tj.AttemptJobID) { + continue + } + // If this caller's row ID is already in `out`, it means an outer caller has already covered its whole subtree. + // Skip the redundant walk. + if out.Contains(tj.ID) { + continue + } + for _, child := range actions_model.CollectAllDescendantJobs(tj, p.templateJobs) { + out.Add(child.ID) + } + } + return out +} + func cloneRunJobForAttempt(templateJob *actions_model.ActionRunJob, attempt *actions_model.ActionRunAttempt) *actions_model.ActionRunJob { return &actions_model.ActionRunJob{ RunID: templateJob.RunID, @@ -355,6 +512,17 @@ func cloneRunJobForAttempt(templateJob *actions_model.ActionRunJob, attempt *act ConcurrencyGroup: templateJob.ConcurrencyGroup, ConcurrencyCancel: templateJob.ConcurrencyCancel, TokenPermissions: templateJob.TokenPermissions, + + // reusable workflow fields + IsReusableCaller: templateJob.IsReusableCaller, + CallUses: templateJob.CallUses, + ReusableWorkflowContent: slices.Clone(templateJob.ReusableWorkflowContent), + CallSecrets: templateJob.CallSecrets, + CallPayload: templateJob.CallPayload, + IsExpanded: templateJob.IsExpanded, + ParentJobID: templateJob.ParentJobID, // remapped by execRerunPlan + WorkflowSourceRepoID: templateJob.WorkflowSourceRepoID, + WorkflowSourceCommitSHA: templateJob.WorkflowSourceCommitSHA, } } diff --git a/services/actions/rerun_test.go b/services/actions/rerun_test.go index 61132885f9..baa678a440 100644 --- a/services/actions/rerun_test.go +++ b/services/actions/rerun_test.go @@ -8,6 +8,7 @@ import ( actions_model "gitea.dev/models/actions" user_model "gitea.dev/models/user" + "gitea.dev/modules/container" "gitea.dev/modules/util" "github.com/stretchr/testify/assert" @@ -100,3 +101,242 @@ func TestRerunValidation(t *testing.T) { assert.ErrorIs(t, err, util.ErrInvalidArgument) }) } + +func TestRerunPlan(t *testing.T) { + // "verify" appears in two scopes (inner caller under deploy, and top-level) so scope-blind matching would fail here. + + // build id=101, attemptJobID=1 + // test id=102, attemptJobID=2, needs=[build] + // deploy id=103, attemptJobID=3, caller + // ├── validate id=104, attemptJobID=4, parent=103 + // ├── push id=105, attemptJobID=5, parent=103, needs=[validate] + // ├── verify id=106, attemptJobID=6, parent=103, caller, needs=[push] + // │ ├── smoke-test id=107, attemptJobID=7, parent=106 + // │ └── cleanup id=108, attemptJobID=8, parent=106, needs=[smoke-test] + // └── finish-deploy id=109, attemptJobID=9, parent=103, needs=[verify] + // verify id=110, attemptJobID=10, needs=[deploy] (top-level, same JobID) + + buildJob := templateJob(101, 1, "build", 0, false) + testJob := templateJob(102, 2, "test", 0, false, "build") + deployJob := templateJob(103, 3, "deploy", 0, true) + validateJob := templateJob(104, 4, "validate", 103, false) + pushJob := templateJob(105, 5, "push", 103, false, "validate") + verifyInnerJob := templateJob(106, 6, "verify", 103, true, "push") + smokeTestJob := templateJob(107, 7, "smoke-test", 106, false) + cleanupJob := templateJob(108, 8, "cleanup", 106, false, "smoke-test") + finishDeployJob := templateJob(109, 9, "finish-deploy", 103, false, "verify") + verifyTopJob := templateJob(110, 10, "verify", 0, false, "deploy") + + jobs := []*actions_model.ActionRunJob{ + buildJob, testJob, deployJob, validateJob, pushJob, + verifyInnerJob, smokeTestJob, cleanupJob, + finishDeployJob, verifyTopJob, + } + + t.Run("ExpandRerunJobIDs", func(t *testing.T) { + t.Run("empty jobsToRerun reruns every template job, no ancestors", func(t *testing.T) { + plan := &rerunPlan{templateJobs: jobs} + require.NoError(t, plan.expandRerunJobIDs(nil)) + + assert.ElementsMatch(t, attemptJobIDsOf(jobs...), plan.rerunAttemptJobIDs.Values()) + assert.Empty(t, plan.ancestorAttemptJobIDs) + }) + + t.Run("same-scope downstream BFS pulls in dependents", func(t *testing.T) { + // a -> b -> c (chain), d unrelated. + a := templateJob(101, 1, "a", 0, false) + b := templateJob(102, 2, "b", 0, false, "a") + c := templateJob(103, 3, "c", 0, false, "b") + d := templateJob(104, 4, "d", 0, false) + plan := &rerunPlan{templateJobs: []*actions_model.ActionRunJob{a, b, c, d}} + require.NoError(t, plan.expandRerunJobIDs([]*actions_model.ActionRunJob{a})) + + assert.ElementsMatch(t, attemptJobIDsOf(a, b, c), plan.rerunAttemptJobIDs.Values()) + assert.Empty(t, plan.ancestorAttemptJobIDs) + }) + + t.Run("rerun a deep child escalates across reusable scopes", func(t *testing.T) { + plan := &rerunPlan{templateJobs: jobs} + require.NoError(t, plan.expandRerunJobIDs([]*actions_model.ActionRunJob{smokeTestJob})) + + // rerun: smoke-test (selected), cleanup (same-scope downstream), + // finish-deploy (deploy-scope sibling of inner verify ancestor), + // top-level verify (top-scope sibling of deploy ancestor). + assert.ElementsMatch(t, + attemptJobIDsOf(smokeTestJob, cleanupJob, finishDeployJob, verifyTopJob), + plan.rerunAttemptJobIDs.Values()) + + // ancestors: inner verify and deploy + assert.ElementsMatch(t, attemptJobIDsOf(verifyInnerJob, deployJob), plan.ancestorAttemptJobIDs.Values()) + }) + + t.Run("rerun a top-level caller resets only itself and same-scope dependents", func(t *testing.T) { + plan := &rerunPlan{templateJobs: jobs} + require.NoError(t, plan.expandRerunJobIDs([]*actions_model.ActionRunJob{deployJob})) + + // rerun: deploy (selected) + top-level verify (needs:[deploy]). + assert.ElementsMatch(t, attemptJobIDsOf(deployJob, verifyTopJob), plan.rerunAttemptJobIDs.Values()) + // deploy is top-level so no ancestors. + assert.Empty(t, plan.ancestorAttemptJobIDs) + }) + + t.Run("rerun a nested caller escalates one level", func(t *testing.T) { + plan := &rerunPlan{templateJobs: jobs} + require.NoError(t, plan.expandRerunJobIDs([]*actions_model.ActionRunJob{verifyInnerJob})) + + // inner verify (selected) -> finish-deploy (deploy-scope dep) -> top-level verify (top-scope dep of deploy). + assert.ElementsMatch(t, + attemptJobIDsOf(verifyInnerJob, finishDeployJob, verifyTopJob), + plan.rerunAttemptJobIDs.Values()) + // deploy is the only ancestor (one level up from inner verify). + assert.ElementsMatch(t, attemptJobIDsOf(deployJob), plan.ancestorAttemptJobIDs.Values()) + }) + + t.Run("selecting one same-name job leaves the other-scope same-name job alone", func(t *testing.T) { + // Selecting the top-level "verify" must not pull in the same-named inner one or its descendants. + plan := &rerunPlan{templateJobs: jobs} + require.NoError(t, plan.expandRerunJobIDs([]*actions_model.ActionRunJob{verifyTopJob})) + + // Only the top-level verify is rerun. + assert.ElementsMatch(t, attemptJobIDsOf(verifyTopJob), plan.rerunAttemptJobIDs.Values()) + assert.Empty(t, plan.ancestorAttemptJobIDs) + }) + + t.Run("a caller is rerun when a sibling it needs is selected", func(t *testing.T) { + plan := &rerunPlan{templateJobs: jobs} + require.NoError(t, plan.expandRerunJobIDs([]*actions_model.ActionRunJob{pushJob})) + + assert.ElementsMatch(t, + attemptJobIDsOf(pushJob, verifyInnerJob, finishDeployJob, verifyTopJob), + plan.rerunAttemptJobIDs.Values()) + assert.ElementsMatch(t, attemptJobIDsOf(deployJob), plan.ancestorAttemptJobIDs.Values()) + + // Confirm the downstream effect: verify(inner) is a reset caller, so its children's DB row IDs are marked for skip-clone. + assert.ElementsMatch(t, rowIDsOf(smokeTestJob, cleanupJob), plan.collectResetCallerDescendants().Values()) + }) + + t.Run("multiple selections converge", func(t *testing.T) { + plan := &rerunPlan{templateJobs: jobs} + require.NoError(t, plan.expandRerunJobIDs([]*actions_model.ActionRunJob{deployJob, smokeTestJob})) + + assert.ElementsMatch(t, attemptJobIDsOf(deployJob, smokeTestJob, cleanupJob, finishDeployJob, verifyTopJob), plan.rerunAttemptJobIDs.Values()) + assert.Empty(t, plan.ancestorAttemptJobIDs) + assert.ElementsMatch(t, + rowIDsOf(validateJob, pushJob, verifyInnerJob, smokeTestJob, cleanupJob, finishDeployJob), + plan.collectResetCallerDescendants().Values()) + }) + }) + + t.Run("CollectResetCallerDescendants", func(t *testing.T) { + planWith := func(rerunJobs ...*actions_model.ActionRunJob) *rerunPlan { + set := make(container.Set[int64]) + for _, j := range rerunJobs { + set.Add(j.AttemptJobID) + } + return &rerunPlan{templateJobs: jobs, rerunAttemptJobIDs: set} + } + + t.Run("non-caller in reset set is ignored", func(t *testing.T) { + assert.Empty(t, planWith(smokeTestJob).collectResetCallerDescendants()) + }) + + t.Run("caller in reset set returns transitive descendants", func(t *testing.T) { + out := planWith(deployJob).collectResetCallerDescendants() + assert.ElementsMatch(t, + rowIDsOf(validateJob, pushJob, verifyInnerJob, smokeTestJob, cleanupJob, finishDeployJob), + out.Values()) + }) + + t.Run("multiple reset callers union their descendants", func(t *testing.T) { + out := planWith(deployJob, verifyInnerJob).collectResetCallerDescendants() + assert.ElementsMatch(t, + rowIDsOf(validateJob, pushJob, verifyInnerJob, smokeTestJob, cleanupJob, finishDeployJob), + out.Values()) + }) + + t.Run("nested-only reset returns just the nested subtree", func(t *testing.T) { + out := planWith(verifyInnerJob).collectResetCallerDescendants() + assert.ElementsMatch(t, rowIDsOf(smokeTestJob, cleanupJob), out.Values()) + }) + }) + + t.Run("HasRerunDependency", func(t *testing.T) { + t.Run("no needs returns false", func(t *testing.T) { + plan := &rerunPlan{ + templateJobs: []*actions_model.ActionRunJob{buildJob}, + rerunAttemptJobIDs: make(container.Set[int64]), + ancestorAttemptJobIDs: make(container.Set[int64]), + } + assert.False(t, plan.hasRerunDependency(buildJob)) + }) + + t.Run("dependency in rerun set returns true", func(t *testing.T) { + plan := &rerunPlan{ + templateJobs: jobs, + rerunAttemptJobIDs: container.SetOf(smokeTestJob.AttemptJobID), + ancestorAttemptJobIDs: make(container.Set[int64]), + } + // cleanup `needs: [smoke-test]`, both in inner verify scope. + assert.True(t, plan.hasRerunDependency(cleanupJob)) + }) + + t.Run("dependency in ancestor set returns true", func(t *testing.T) { + plan := &rerunPlan{ + templateJobs: jobs, + rerunAttemptJobIDs: container.SetOf(attemptJobIDsOf(smokeTestJob, cleanupJob)...), + ancestorAttemptJobIDs: container.SetOf(verifyInnerJob.AttemptJobID), + } + assert.True(t, plan.hasRerunDependency(finishDeployJob)) + }) + + t.Run("dependency on unrelated sibling returns false", func(t *testing.T) { + plan := &rerunPlan{ + templateJobs: jobs, + rerunAttemptJobIDs: container.SetOf(smokeTestJob.AttemptJobID), + ancestorAttemptJobIDs: make(container.Set[int64]), + } + assert.False(t, plan.hasRerunDependency(pushJob)) + }) + + t.Run("scope-bound: same JobID in another scope does not match", func(t *testing.T) { + plan := &rerunPlan{ + templateJobs: jobs, + rerunAttemptJobIDs: container.SetOf(verifyTopJob.AttemptJobID), + ancestorAttemptJobIDs: make(container.Set[int64]), + } + assert.False(t, plan.hasRerunDependency(finishDeployJob)) + + // Sanity: swap to the inner verify and the same target now sees it. + plan.rerunAttemptJobIDs = container.SetOf(verifyInnerJob.AttemptJobID) + assert.True(t, plan.hasRerunDependency(finishDeployJob)) + }) + }) +} + +// templateJob is a small constructor for fixture jobs used by the rerunPlan unit tests. +func templateJob(id, attemptJobID int64, jobID string, parentID int64, isCaller bool, needs ...string) *actions_model.ActionRunJob { + return &actions_model.ActionRunJob{ + ID: id, + AttemptJobID: attemptJobID, + JobID: jobID, + ParentJobID: parentID, + IsReusableCaller: isCaller, + Needs: needs, + } +} + +func attemptJobIDsOf(jobs ...*actions_model.ActionRunJob) []int64 { + out := make([]int64, len(jobs)) + for i, j := range jobs { + out[i] = j.AttemptJobID + } + return out +} + +func rowIDsOf(jobs ...*actions_model.ActionRunJob) []int64 { + out := make([]int64, len(jobs)) + for i, j := range jobs { + out[i] = j.ID + } + return out +} diff --git a/services/actions/reusable_workflow.go b/services/actions/reusable_workflow.go new file mode 100644 index 0000000000..9b5ecdef6f --- /dev/null +++ b/services/actions/reusable_workflow.go @@ -0,0 +1,342 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package actions + +import ( + "context" + "fmt" + + 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/modules/actions/jobparser" + "gitea.dev/modules/container" + "gitea.dev/modules/gitrepo" + "gitea.dev/modules/json" + 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 + +// 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) { + if err := run.LoadAttributes(ctx); err != nil { + return nil, 0, "", err + } + + switch ref.Kind { + case jobparser.UsesKindLocalSameRepo: + // `./` is 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) + } + bytes, resolvedSHA, err := readWorkflowFromRepo(ctx, callerRepo, caller.WorkflowSourceCommitSHA, ref.Path) + if err != nil { + return nil, 0, "", err + } + return bytes, callerRepo.ID, resolvedSHA, nil + + case jobparser.UsesKindLocalCrossRepo: + repo, err := repo_model.GetRepositoryByOwnerAndName(ctx, ref.Owner, ref.Repo) + 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, "", 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) + if err != nil { + return nil, 0, "", err + } + return bytes, repo.ID, resolvedSHA, nil + } + return nil, 0, "", fmt.Errorf("unsupported uses kind %d", ref.Kind) +} + +// 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 := gitrepo.OpenRepository(ctx, repo) + if err != nil { + return nil, "", fmt.Errorf("open repo %s: %w", repo.FullName(), err) + } + defer gitRepo.Close() + + commit, err := gitRepo.GetCommit(refOrSHA) + if err != nil { + return nil, "", fmt.Errorf("get commit %q in %s: %w", refOrSHA, repo.FullName(), err) + } + str, err := commit.GetFileContent(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` +// +// Cycle detection is intentionally *syntactic* (string equality on CallUses), 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 := make(container.Set[string]) + visited.Add(caller.CallUses) + + 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 != "" { + if visited.Contains(current.CallUses) { + return fmt.Errorf("reusable workflow call cycle detected: %q", current.CallUses) + } + visited.Add(current.CallUses) + } + } + return nil +} + +// 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. Load called-workflow source. + ref, err := jobparser.ParseUses(parsedJob.Uses) + if err != nil { + return fmt.Errorf("parse uses %q: %w", parsedJob.Uses, err) + } + content, contentSourceRepoID, contentSourceCommitSHA, err := loadReusableWorkflowSource(ctx, run, caller, ref) + if err != nil { + return err + } + + // 4. Parse the called workflow's spec (used by both secret validation and input evaluation). + wcSpec, err := jobparser.ParseWorkflowCallSpec(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 := jobparser.ValidateCallerSecrets(wcSpec, 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 { + 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) + 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) + 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. Insert direct children of this caller. + existingChildren, err := actions_model.GetDirectChildJobsByParent(ctx, caller) + if err != nil { + return fmt.Errorf("get existing children of caller %d: %w", caller.ID, err) + } + if len(existingChildren) > 0 { + // Should not happen - child jobs cannot be expanded before the caller gets ready + return fmt.Errorf("invariant violation: caller %d has %d pre-existing children", caller.ID, len(existingChildren)) + } + if err := insertCallerChildren(ctx, run, attempt, caller, content, contentSourceRepoID, contentSourceCommitSHA, vars, workflowCallInputs); err != nil { + return err + } + + // 9. Update caller-related cols. + caller.CallPayload = string(callPayload) + caller.IsExpanded = true + n, err := actions_model.UpdateRunJob(ctx, caller, + builder.Eq{"is_expanded": false}, + "call_secrets", "reusable_workflow_content", "call_payload", "is_expanded") + if err != nil { + return fmt.Errorf("commit caller %d expansion: %w", caller.ID, err) + } + if n == 0 { + return fmt.Errorf("caller %d already expanded by another writer", caller.ID) + } + 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 { + // 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) + } + + 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() + 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 = util.EllipsisDisplayString(parsedChild.Name, 255) + + // AttemptJobID: prefer a prior-attempt match by (JobID, Name) and fall back to a fresh allocator value for newly-appearing logical jobs. + // The two-level key disambiguates matrix instances (same JobID, different Names) and distinct jobs that legally share the same Name (different JobIDs). + var attemptJobID int64 + if priorChild, ok := priorChildren[jobID][parsedChild.Name]; ok { + attemptJobID = priorChild.AttemptJobID + } 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(), + Status: actions_model.StatusBlocked, + ParentJobID: caller.ID, + WorkflowSourceRepoID: sourceRepoID, + WorkflowSourceCommitSHA: sourceCommitSHA, + } + if perms := ExtractJobPermissionsFromWorkflow(sw, parsedChild); perms != nil { + child.TokenPermissions = perms + } + 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 +} diff --git a/services/actions/reusable_workflow_test.go b/services/actions/reusable_workflow_test.go new file mode 100644 index 0000000000..cadb26a851 --- /dev/null +++ b/services/actions/reusable_workflow_test.go @@ -0,0 +1,134 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package actions + +import ( + "fmt" + "testing" + + actions_model "gitea.dev/models/actions" + "gitea.dev/models/db" + "gitea.dev/models/unittest" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCheckCallerChain_Cycle(t *testing.T) { + t.Run("DirectCycle", func(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + // A -> A: leaf's CallUses matches its direct parent's. + chain := buildCallerChain(t, + "./.gitea/workflows/a.yml", + "./.gitea/workflows/a.yml", + ) + err := checkCallerChain(t.Context(), chain[len(chain)-1]) + assert.ErrorContains(t, err, "cycle detected") + }) + + t.Run("IndirectCycle", func(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + // A -> B -> A: leaf's CallUses matches its grandparent's. + chain := buildCallerChain(t, + "./.gitea/workflows/a.yml", + "./.gitea/workflows/b.yml", + "./.gitea/workflows/a.yml", + ) + err := checkCallerChain(t.Context(), chain[len(chain)-1]) + assert.ErrorContains(t, err, "cycle detected") + }) + + t.Run("NoCycle", func(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + // Sanity: linear chain with distinct CallUses must not trip cycle detection. + chain := buildCallerChain(t, + "./.gitea/workflows/a.yml", + "./.gitea/workflows/b.yml", + "./.gitea/workflows/c.yml", + ) + require.NoError(t, checkCallerChain(t.Context(), chain[len(chain)-1])) + }) +} + +func TestCheckCallerChain_DepthLimit(t *testing.T) { + // top + MaxReusableCallLevels nested callers is the longest accepted; one more exceeds the limit. + makeDistinctUses := func(n int) []string { + out := make([]string, n) + for i := range out { + out[i] = fmt.Sprintf("./.gitea/workflows/level%d.yml", i) + } + return out + } + + t.Run("ExactlyAtLimit", func(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + chain := buildCallerChain(t, makeDistinctUses(MaxReusableCallLevels+1)...) + require.NoError(t, checkCallerChain(t.Context(), chain[len(chain)-1])) + }) + + t.Run("OneOverLimit", func(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + chain := buildCallerChain(t, makeDistinctUses(MaxReusableCallLevels+2)...) + err := checkCallerChain(t.Context(), chain[len(chain)-1]) + assert.ErrorContains(t, err, "exceeds the maximum nesting level") + }) +} + +// buildCallerChain inserts a linear chain of reusable caller jobs in a single run+attempt. +// callerUses[0] is the top-level caller (ParentJobID=0); each subsequent caller is inserted as a child of the previous one. +// Returns the inserted jobs in order (index 0 = top, last = leaf). +func buildCallerChain(t *testing.T, callerUses ...string) []*actions_model.ActionRunJob { + t.Helper() + require.NotEmpty(t, callerUses) + ctx := t.Context() + + run := &actions_model.ActionRun{ + Title: "caller-chain-test", + RepoID: 4, + OwnerID: 1, + Index: 9601, + WorkflowID: "test.yaml", + TriggerUserID: 1, + Ref: "refs/heads/master", + CommitSHA: "c2d72f548424103f01ee1dc02889c1e2bff816b0", + Event: "push", + TriggerEvent: "push", + EventPayload: "{}", + Status: actions_model.StatusRunning, + } + require.NoError(t, db.Insert(ctx, run)) + + attempt := &actions_model.ActionRunAttempt{ + RepoID: run.RepoID, + RunID: run.ID, + Attempt: 1, + TriggerUserID: 1, + Status: actions_model.StatusRunning, + } + require.NoError(t, db.Insert(ctx, attempt)) + + jobs := make([]*actions_model.ActionRunJob, 0, len(callerUses)) + parentID := int64(0) + for i, uses := range callerUses { + job := &actions_model.ActionRunJob{ + RunID: run.ID, + RunAttemptID: attempt.ID, + RepoID: run.RepoID, + OwnerID: run.OwnerID, + CommitSHA: run.CommitSHA, + Name: fmt.Sprintf("caller-%d", i), + JobID: fmt.Sprintf("caller-%d", i), + Attempt: 1, + Status: actions_model.StatusBlocked, + AttemptJobID: int64(i + 1), + IsReusableCaller: true, + CallUses: uses, + ParentJobID: parentID, + } + require.NoError(t, db.Insert(ctx, job)) + jobs = append(jobs, job) + parentID = job.ID + } + return jobs +} diff --git a/services/actions/run.go b/services/actions/run.go index a8637cb396..82335af861 100644 --- a/services/actions/run.go +++ b/services/actions/run.go @@ -10,6 +10,7 @@ import ( actions_model "gitea.dev/models/actions" "gitea.dev/models/db" "gitea.dev/modules/actions/jobparser" + "gitea.dev/modules/log" "gitea.dev/modules/util" act_model "gitea.com/gitea/runner/act/model" @@ -55,6 +56,7 @@ func PrepareRunAndInsert(ctx context.Context, content []byte, run *actions_model // The title will be cut off at 255 characters if it's longer than 255 characters. func InsertRun(ctx context.Context, run *actions_model.ActionRun, content []byte, vars map[string]string, inputs map[string]any, wfRawConcurrency *act_model.RawConcurrency) error { var cancelledConcurrencyJobs []*actions_model.ActionRunJob + var hasWaitingCallerJobs bool if err := db.WithTx(ctx, func(ctx context.Context) error { index, err := db.GetNextResourceIndex(ctx, "action_run_index", run.RepoID) if err != nil { @@ -128,7 +130,7 @@ func InsertRun(ctx context.Context, run *actions_model.ActionRun, content []byte runJobs := make([]*actions_model.ActionRunJob, 0, len(jobs)) var hasWaitingJobs bool - for i, v := range jobs { + for _, v := range jobs { id, job := v.Job() needs := job.Needs() if err := v.SetJob(id, job.EraseNeeds()); err != nil { @@ -136,30 +138,43 @@ func InsertRun(ctx context.Context, run *actions_model.ActionRun, content []byte } payload, _ := v.Marshal() + isReusableWorkflowCaller := job.Uses != "" shouldBlockJob := runAttempt.Status == actions_model.StatusBlocked || len(needs) > 0 || run.NeedApproval + attemptJobID, err := actions_model.GetNextAttemptJobID(ctx, run.ID) + if err != nil { + return fmt.Errorf("alloc attempt_job_id: %w", err) + } + job.Name = util.EllipsisDisplayString(job.Name, 255) runJob := &actions_model.ActionRunJob{ - RunID: run.ID, - RunAttemptID: runAttempt.ID, - RepoID: run.RepoID, - OwnerID: run.OwnerID, - CommitSHA: run.CommitSHA, - IsForkPullRequest: run.IsForkPullRequest, - Name: job.Name, - Attempt: runAttempt.Attempt, - WorkflowPayload: payload, - JobID: id, - AttemptJobID: int64(i + 1), - Needs: needs, - RunsOn: job.RunsOn(), - Status: util.Iif(shouldBlockJob, actions_model.StatusBlocked, actions_model.StatusWaiting), + RunID: run.ID, + RunAttemptID: runAttempt.ID, + RepoID: run.RepoID, + OwnerID: run.OwnerID, + CommitSHA: run.CommitSHA, + IsForkPullRequest: run.IsForkPullRequest, + Name: job.Name, + Attempt: runAttempt.Attempt, + WorkflowPayload: payload, + JobID: id, + AttemptJobID: attemptJobID, + Needs: needs, + RunsOn: job.RunsOn(), + Status: util.Iif(shouldBlockJob, actions_model.StatusBlocked, actions_model.StatusWaiting), + WorkflowSourceRepoID: run.RepoID, + WorkflowSourceCommitSHA: run.CommitSHA, } // Parse workflow/job permissions (no clamping here) if perms := ExtractJobPermissionsFromWorkflow(v, job); perms != nil { runJob.TokenPermissions = perms } + if isReusableWorkflowCaller { + runJob.IsReusableCaller = true + runJob.CallUses = job.Uses + } + // check job concurrency if job.RawConcurrency != nil { rawConcurrency, err := yaml.Marshal(job.RawConcurrency) @@ -188,11 +203,24 @@ func InsertRun(ctx context.Context, run *actions_model.ActionRun, content []byte } } - hasWaitingJobs = hasWaitingJobs || runJob.Status == actions_model.StatusWaiting + // A reusable caller is never dispatched to a runner, so it must not drive the task-version bump. + hasWaitingJobs = hasWaitingJobs || (runJob.Status == actions_model.StatusWaiting && !isReusableWorkflowCaller) if err := db.Insert(ctx, runJob); err != nil { return err } + // expand reusable caller + if isReusableWorkflowCaller && runJob.Status == actions_model.StatusWaiting { + if err := expandReusableWorkflowCaller(ctx, run, runAttempt, runJob, vars); err != nil { + return fmt.Errorf("inline trigger caller %d ready: %w", runJob.ID, err) + } + // refresh the caller status + if err := actions_model.RefreshReusableCallerStatus(ctx, runJob); err != nil { + return fmt.Errorf("refresh caller %d status: %w", runJob.ID, err) + } + hasWaitingCallerJobs = true + } + runJobs = append(runJobs, runJob) } @@ -216,5 +244,12 @@ func InsertRun(ctx context.Context, run *actions_model.ActionRun, content []byte NotifyWorkflowJobsAndRunsStatusUpdate(ctx, cancelledConcurrencyJobs) EmitJobsIfReadyByJobs(cancelledConcurrencyJobs) + // Post-commit kick for expanded callers: let job_emitter resolve its child jobs + if hasWaitingCallerJobs { + if err := EmitJobsIfReadyByRun(run.ID); err != nil { + log.Error("emit run %d after InsertRun: %v", run.ID, err) + } + } + return nil } diff --git a/templates/devtest/repo-action-view.tmpl b/templates/devtest/repo-action-view.tmpl index 2971039fc9..4e06f72cdd 100644 --- a/templates/devtest/repo-action-view.tmpl +++ b/templates/devtest/repo-action-view.tmpl @@ -5,6 +5,7 @@ Run:CanApprove Run:CanRerunLatest Run:PreviousAttempt + Run:ReusableCaller {{template "repo/actions/view_component" (dict "JobID" (or .JobID 0) diff --git a/templates/repo/actions/view_component.tmpl b/templates/repo/actions/view_component.tmpl index b370ca59f1..2ed47ad9df 100644 --- a/templates/repo/actions/view_component.tmpl +++ b/templates/repo/actions/view_component.tmpl @@ -15,6 +15,8 @@ data-locale-runs-pushed-by="{{ctx.Locale.Tr "actions.runs.pushed_by"}}" data-locale-summary="{{ctx.Locale.Tr "actions.runs.summary"}}" data-locale-all-jobs="{{ctx.Locale.Tr "actions.runs.all_jobs"}}" + data-locale-expand-caller-jobs="{{ctx.Locale.Tr "actions.runs.expand_caller_jobs"}}" + data-locale-collapse-caller-jobs="{{ctx.Locale.Tr "actions.runs.collapse_caller_jobs"}}" data-locale-triggered-via="{{ctx.Locale.Tr "actions.runs.triggered_via"}}" data-locale-total-duration="{{ctx.Locale.Tr "actions.runs.total_duration"}}" data-locale-run-details="{{ctx.Locale.Tr "actions.runs.run_details"}}" diff --git a/tests/integration/actions_reusable_workflow_test.go b/tests/integration/actions_reusable_workflow_test.go new file mode 100644 index 0000000000..39e14b6d7d --- /dev/null +++ b/tests/integration/actions_reusable_workflow_test.go @@ -0,0 +1,782 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "encoding/base64" + "fmt" + "net/http" + "net/url" + "testing" + "time" + + runnerv1 "gitea.dev/actions-proto-go/runner/v1" + actions_model "gitea.dev/models/actions" + auth_model "gitea.dev/models/auth" + repo_model "gitea.dev/models/repo" + "gitea.dev/models/unittest" + user_model "gitea.dev/models/user" + "gitea.dev/modules/gitrepo" + "gitea.dev/modules/json" + api "gitea.dev/modules/structs" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestActionsReusableWorkflow(t *testing.T) { + onGiteaRun(t, func(t *testing.T, u *url.URL) { + user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + user2Session := loginUser(t, user2.Name) + user2Token := getTokenForLoggedInUser(t, user2Session, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser) + user4 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4}) + user4Session := loginUser(t, user4.Name) + user4Token := getTokenForLoggedInUser(t, user4Session, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser) + + t.Run("Same-repo reusable workflow", func(t *testing.T) { + apiRepo := createActionsTestRepo(t, user2Token, "workflow-call-test", false) + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: apiRepo.ID}) + + defaultRunner := newMockRunner() + defaultRunner.registerAsRepoRunner(t, repo.OwnerName, repo.Name, "mock-default-runner", []string{"ubuntu-latest"}, false) + customRunner := newMockRunner() + customRunner.registerAsRepoRunner(t, repo.OwnerName, repo.Name, "mock-custom-runner", []string{"custom-os"}, false) + + // add a variable for test + req := NewRequestWithJSON(t, "POST", + fmt.Sprintf("/api/v1/repos/%s/%s/actions/variables/myvar", repo.OwnerName, repo.Name), &api.CreateVariableOption{ + Value: "abcdef", + }). + AddTokenAuth(user2Token) + MakeRequest(t, req, http.StatusCreated) + // add a secret for test + req = NewRequestWithJSON(t, "PUT", fmt.Sprintf("/api/v1/repos/%s/%s/actions/secrets/mysecret", repo.OwnerName, repo.Name), api.CreateOrUpdateSecretOption{ + Data: "secRET-t0Ken", + }).AddTokenAuth(user2Token) + MakeRequest(t, req, http.StatusCreated) + + createRepoWorkflowFile(t, user2, user2Token, repo, ".gitea/workflows/reusable1.yaml", + `name: Reusable1 +on: + workflow_call: + inputs: + str_input: + type: string + num_input: + type: number + bool_input: + type: boolean + parent_var: + type: string + needs_out: + type: string + secrets: + PARENT_TOKEN: + outputs: + r1_out: + value: ${{ jobs.reusable1_job2.outputs.r1j2_out }} + +jobs: + reusable1_job1: + runs-on: ubuntu-latest + steps: + - run: echo 'reusable1_job1' + + reusable1_job2: + needs: [reusable1_job1] + outputs: + r1j2_out: ${{ steps.gen_r1j2_output.outputs.out }} + runs-on: custom-os + steps: + - id: gen_r1j2_output + run: | + echo "out=r1j2_out_data" >> "$GITHUB_OUTPUT" + + reusable1_job3: + needs: [reusable1_job2] + uses: ./.gitea/workflows/reusable2.yaml + with: + msg: ${{ inputs.str_input }} +`) + + createRepoWorkflowFile(t, user2, user2Token, repo, ".gitea/workflows/reusable2.yaml", + `name: Reusable2 +on: + workflow_call: + inputs: + msg: + type: string + +jobs: + reusable2_job1: + runs-on: ubuntu-latest + steps: + - run: echo ${{ inputs.msg }} +`) + + createRepoWorkflowFile(t, user2, user2Token, repo, ".gitea/workflows/caller.yaml", + `name: Caller +on: + push: + paths: + - '.gitea/workflows/caller.yaml' +jobs: + caller_job1: + runs-on: ubuntu-latest + outputs: + prepared: ${{ steps.gen_output.outputs.pd }} + steps: + - id: gen_output + run: | + echo "pd=prepared_data" >> "$GITHUB_OUTPUT" + + caller_job2: + needs: [caller_job1] + uses: './.gitea/workflows/reusable1.yaml' + with: + str_input: 'from_caller_job2' + num_input: ${{ 2.3e2 }} + bool_input: ${{ gitea.event_name == 'push' }} + parent_var: ${{ vars.myvar }} + needs_out: ${{ needs.caller_job1.outputs.prepared }} + secrets: + PARENT_TOKEN: ${{ secrets.mysecret }} + + caller_job3: + needs: [caller_job2] + runs-on: ubuntu-latest + steps: + - run: | + echo ${{ needs.caller_job1.outputs.r1_out }} +`) + + var ( + runID int64 + callerJob2ID, callerJob2AttemptJobID int64 + callerJob3AttemptJobID int64 + r1Job2ID, r1Job2AttemptJobID int64 + r1Job3ID, r1Job3AttemptJobID int64 + r2Job1AttemptJobID int64 + ) + + t.Run("Check initialized jobs", func(t *testing.T) { + // run + assert.Equal(t, 1, unittest.GetCount(t, &actions_model.ActionRun{RepoID: repo.ID})) + run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{RepoID: repo.ID}) + runID = run.ID + + // caller_job1 + assert.Equal(t, 3, unittest.GetCount(t, &actions_model.ActionRunJob{RunID: runID})) + callerJob1 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: runID, JobID: "caller_job1"}) + assert.Equal(t, actions_model.StatusWaiting, callerJob1.Status) + assert.False(t, callerJob1.IsReusableCaller) + + // caller_job2 + callerJob2 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: runID, JobID: "caller_job2"}) + callerJob2ID = callerJob2.ID + callerJob2AttemptJobID = callerJob2.AttemptJobID + assert.Equal(t, actions_model.StatusBlocked, callerJob2.Status) + assert.True(t, callerJob2.IsReusableCaller) + + // caller_job3 + callerJob3 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: runID, JobID: "caller_job3"}) + callerJob3AttemptJobID = callerJob3.AttemptJobID + assert.Equal(t, actions_model.StatusBlocked, callerJob3.Status) + assert.False(t, callerJob3.IsReusableCaller) + }) + + t.Run("First run", func(t *testing.T) { + callerJob1Task := defaultRunner.fetchTask(t) // for caller_job1 + _, callerJob1, _ := getTaskAndJobAndRunByTaskID(t, callerJob1Task.Id) + assert.Equal(t, "caller_job1", callerJob1.JobID) + defaultRunner.fetchNoTask(t) + defaultRunner.execTask(t, callerJob1Task, &mockTaskOutcome{ + result: runnerv1.Result_RESULT_SUCCESS, + outputs: map[string]string{ + "prepared": "prepared_data", + }, + }) + + r1Job1Task := defaultRunner.fetchTask(t) // for reusable1_job1 + _, r1Job1, _ := getTaskAndJobAndRunByTaskID(t, r1Job1Task.Id) + assert.Equal(t, "reusable1_job1", r1Job1.JobID) + assert.Equal(t, callerJob2ID, r1Job1.ParentJobID) + payload := getWorkflowCallPayloadFromTask(t, r1Job1Task) + if assert.Len(t, payload.Inputs, 5) { + assert.Equal(t, "from_caller_job2", payload.Inputs["str_input"]) + assert.EqualValues(t, 230, payload.Inputs["num_input"]) + assert.Equal(t, true, payload.Inputs["bool_input"]) + assert.Equal(t, "abcdef", payload.Inputs["parent_var"]) + assert.Equal(t, "prepared_data", payload.Inputs["needs_out"]) + } + if assert.Len(t, r1Job1Task.Secrets, 3) { + assert.Contains(t, r1Job1Task.Secrets, "GITEA_TOKEN") + assert.Contains(t, r1Job1Task.Secrets, "GITHUB_TOKEN") + assert.Equal(t, "secRET-t0Ken", r1Job1Task.Secrets["PARENT_TOKEN"]) + } + customRunner.fetchNoTask(t) + defaultRunner.execTask(t, r1Job1Task, &mockTaskOutcome{ + result: runnerv1.Result_RESULT_SUCCESS, + }) + + // reusable1_job3 (a nested caller) needs reusable1_job2, so it stays Blocked until r1j2 succeeds. + r1Job3Pre := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: runID, JobID: "reusable1_job3"}) + assert.Equal(t, actions_model.StatusBlocked, r1Job3Pre.Status) + assert.False(t, r1Job3Pre.IsExpanded) + assert.Equal(t, 0, unittest.GetCount(t, &actions_model.ActionRunJob{RunID: runID, JobID: "reusable2_job1"})) + + r1Job2Task := customRunner.fetchTask(t) // for reusable1_job2 + _, r1Job2, _ := getTaskAndJobAndRunByTaskID(t, r1Job2Task.Id) + assert.Equal(t, "reusable1_job2", r1Job2.JobID) + r1Job2ID = r1Job2.ID + r1Job2AttemptJobID = r1Job2.AttemptJobID + if assert.Len(t, r1Job2Task.Needs, 1) { + assert.Contains(t, r1Job2Task.Needs, "reusable1_job1") + assert.Equal(t, runnerv1.Result_RESULT_SUCCESS, r1Job2Task.Needs["reusable1_job1"].Result) + } + customRunner.execTask(t, r1Job2Task, &mockTaskOutcome{ + result: runnerv1.Result_RESULT_SUCCESS, + outputs: map[string]string{ + "r1j2_out": "r1j2_out_data", + }, + }) + + // Now reusable1_job3 expands and reusable2_job1 becomes runnable. + r1Job3 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: runID, JobID: "reusable1_job3"}) + assert.True(t, r1Job3.IsReusableCaller) + assert.True(t, r1Job3.IsExpanded) + assert.Equal(t, callerJob2ID, r1Job3.ParentJobID) + r1Job3ID = r1Job3.ID + r1Job3AttemptJobID = r1Job3.AttemptJobID + r2Job1 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: runID, JobID: "reusable2_job1"}) + assert.Equal(t, r1Job3ID, r2Job1.ParentJobID) + r2Job1AttemptJobID = r2Job1.AttemptJobID + + r2Job1Task := defaultRunner.fetchTask(t) // for reusable2_job1 + _, fetchedR2Job1, _ := getTaskAndJobAndRunByTaskID(t, r2Job1Task.Id) + assert.Equal(t, "reusable2_job1", fetchedR2Job1.JobID) + assert.Equal(t, r1Job3ID, fetchedR2Job1.ParentJobID) + r2Job1Payload := getWorkflowCallPayloadFromTask(t, r2Job1Task) + if assert.Len(t, r2Job1Payload.Inputs, 1) { + assert.Equal(t, "from_caller_job2", r2Job1Payload.Inputs["msg"]) + } + defaultRunner.execTask(t, r2Job1Task, &mockTaskOutcome{ + result: runnerv1.Result_RESULT_SUCCESS, + }) + + callerJob2 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: callerJob2ID}) + assert.Equal(t, actions_model.StatusSuccess, callerJob2.Status) + + callerJob3Task := defaultRunner.fetchTask(t) // for caller_job3 + _, callerJob3, _ := getTaskAndJobAndRunByTaskID(t, callerJob3Task.Id) + assert.Equal(t, "caller_job3", callerJob3.JobID) + if assert.Len(t, callerJob3Task.Needs, 1) { + assert.Contains(t, callerJob3Task.Needs, "caller_job2") + assert.Equal(t, runnerv1.Result_RESULT_SUCCESS, callerJob3Task.Needs["caller_job2"].Result) + if assert.Len(t, callerJob3Task.Needs["caller_job2"].Outputs, 1) { + assert.Equal(t, "r1j2_out_data", callerJob3Task.Needs["caller_job2"].Outputs["r1_out"]) + } + } + defaultRunner.execTask(t, callerJob3Task, &mockTaskOutcome{ + result: runnerv1.Result_RESULT_SUCCESS, + }) + callerRun := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: runID}) + assert.Equal(t, actions_model.StatusSuccess, callerRun.Status) + }) + + t.Run("Rerun 'reusable1_job2'", func(t *testing.T) { + req = NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/jobs/%d/rerun", repo.OwnerName, repo.Name, runID, r1Job2ID)) + user2Session.MakeRequest(t, req, http.StatusOK) + + run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: runID}) + assert.Equal(t, actions_model.StatusWaiting, run.Status) + attempt2 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunAttempt{RunID: runID, Attempt: 2}) + assert.Equal(t, actions_model.StatusWaiting, attempt2.Status) + callerJob2 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: runID, RunAttemptID: attempt2.ID, AttemptJobID: callerJob2AttemptJobID}) + assert.Equal(t, actions_model.StatusWaiting, callerJob2.Status) + callerJob3 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: runID, RunAttemptID: attempt2.ID, AttemptJobID: callerJob3AttemptJobID}) + assert.Equal(t, actions_model.StatusBlocked, callerJob3.Status) + + // reusable1_job3 needs reusable1_job2, so rerunning r1j2 pulls r1j3 (and its subtree) into the rerun set + r1Job3Attempt2 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: runID, RunAttemptID: attempt2.ID, AttemptJobID: r1Job3AttemptJobID}) + assert.Equal(t, actions_model.StatusBlocked, r1Job3Attempt2.Status) + assert.True(t, r1Job3Attempt2.IsReusableCaller) + assert.False(t, r1Job3Attempt2.IsExpanded) + assert.Equal(t, 0, unittest.GetCount(t, &actions_model.ActionRunJob{RunID: runID, RunAttemptID: attempt2.ID, JobID: "reusable2_job1"})) + + defaultRunner.fetchNoTask(t) + r1Job2Task := customRunner.fetchTask(t) + _, r1Job2, _ := getTaskAndJobAndRunByTaskID(t, r1Job2Task.Id) + assert.Equal(t, "reusable1_job2", r1Job2.JobID) + assert.Equal(t, callerJob2.ID, r1Job2.ParentJobID) + assert.Equal(t, r1Job2AttemptJobID, r1Job2.AttemptJobID) + assert.Equal(t, actions_model.StatusRunning, r1Job2.Status) + run = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: runID}) + assert.Equal(t, actions_model.StatusRunning, run.Status) + customRunner.execTask(t, r1Job2Task, &mockTaskOutcome{ + result: runnerv1.Result_RESULT_SUCCESS, + outputs: map[string]string{ + "r1j2_out": "r1j2_out_data_updated", + }, + }) + + // r1j3 expands again. Its child reuses the AttemptJobID from attempt 1 + r1Job3Attempt2 = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: runID, RunAttemptID: attempt2.ID, AttemptJobID: r1Job3AttemptJobID}) + assert.True(t, r1Job3Attempt2.IsExpanded) + r2Job1Attempt2 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: runID, RunAttemptID: attempt2.ID, JobID: "reusable2_job1"}) + assert.Equal(t, r2Job1AttemptJobID, r2Job1Attempt2.AttemptJobID) + assert.Equal(t, r1Job3Attempt2.ID, r2Job1Attempt2.ParentJobID) + + r2Job1Task := defaultRunner.fetchTask(t) + _, fetchedR2Job1, _ := getTaskAndJobAndRunByTaskID(t, r2Job1Task.Id) + assert.Equal(t, "reusable2_job1", fetchedR2Job1.JobID) + defaultRunner.execTask(t, r2Job1Task, &mockTaskOutcome{ + result: runnerv1.Result_RESULT_SUCCESS, + }) + + callerJob2 = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: callerJob2.ID}) + assert.Equal(t, actions_model.StatusSuccess, callerJob2.Status) + + callerJob3Task := defaultRunner.fetchTask(t) + _, callerJob3, _ = getTaskAndJobAndRunByTaskID(t, callerJob3Task.Id) + assert.Equal(t, "caller_job3", callerJob3.JobID) + if assert.Len(t, callerJob3Task.Needs, 1) { + assert.Contains(t, callerJob3Task.Needs, "caller_job2") + assert.Equal(t, runnerv1.Result_RESULT_SUCCESS, callerJob3Task.Needs["caller_job2"].Result) + if assert.Len(t, callerJob3Task.Needs["caller_job2"].Outputs, 1) { + assert.Equal(t, "r1j2_out_data_updated", callerJob3Task.Needs["caller_job2"].Outputs["r1_out"]) + } + } + defaultRunner.execTask(t, callerJob3Task, &mockTaskOutcome{ + result: runnerv1.Result_RESULT_SUCCESS, + }) + attempt2 = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunAttempt{RunID: runID, Attempt: 2}) + assert.Equal(t, actions_model.StatusSuccess, attempt2.Status) + run = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: runID}) + assert.Equal(t, actions_model.StatusSuccess, run.Status) + }) + }) + + t.Run("Cross-repo reusable workflow with collaborative owner", func(t *testing.T) { + // libRepo: private, owned by user2. + libAPIRepo := createActionsTestRepo(t, user2Token, "reusable-lib-private", true) + libRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: libAPIRepo.ID}) + createRepoWorkflowFile(t, user2, user2Token, libRepo, ".gitea/workflows/reusable_lib.yaml", + `name: ReusableLib +on: + workflow_call: + inputs: + from: + type: string + +jobs: + lib_job: + runs-on: ubuntu-latest + steps: + - run: echo hello-${{ inputs.from }} +`) + + // consumerRepo: private, owned by user4. + consumerAPIRepo := createActionsTestRepo(t, user4Token, "workflow-call-cross-repo", true) + consumerRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: consumerAPIRepo.ID}) + + runner := newMockRunner() + runner.registerAsRepoRunner(t, consumerRepo.OwnerName, consumerRepo.Name, "mock-cross-runner", []string{"ubuntu-latest"}, false) + + createRepoWorkflowFile(t, user4, user4Token, consumerRepo, ".gitea/workflows/cross-caller.yaml", + `name: CrossCaller +on: push +jobs: + cross_job: + uses: user2/reusable-lib-private/.gitea/workflows/reusable_lib.yaml@main + with: + from: 'consumer' +`) + + // Phase 1: no grant. The cross-repo read check fails, and NO ActionRun row gets persisted. + assert.Equal(t, 0, unittest.GetCount(t, &actions_model.ActionRun{RepoID: consumerRepo.ID})) + runner.fetchNoTask(t) + + // Phase 2: user2 (libRepo owner) adds user4 (consumer owner) as a Collaborative Owner of libRepo. + addCollabReq := NewRequestWithValues(t, "POST", + fmt.Sprintf("/%s/%s/settings/actions/general/collaborative_owner/add", libRepo.OwnerName, libRepo.Name), + map[string]string{"collaborative_owner": user4.Name}) + user2Session.MakeRequest(t, addCollabReq, http.StatusOK) + + // Phase 3: trigger the workflow again + createRepoWorkflowFile(t, user4, user4Token, consumerRepo, "marker.txt", "trigger after grant") + + run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{RepoID: consumerRepo.ID}) + crossJob := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: run.ID, JobID: "cross_job"}) + assert.True(t, crossJob.IsReusableCaller) + assert.True(t, crossJob.IsExpanded) + assert.Equal(t, actions_model.StatusWaiting, crossJob.Status) + + libJobTask := runner.fetchTask(t) + _, fetchedLibJob, _ := getTaskAndJobAndRunByTaskID(t, libJobTask.Id) + assert.Equal(t, "lib_job", fetchedLibJob.JobID) + assert.Equal(t, crossJob.ID, fetchedLibJob.ParentJobID) + assert.Equal(t, consumerRepo.ID, fetchedLibJob.RepoID) + payload := getWorkflowCallPayloadFromTask(t, libJobTask) + if assert.Len(t, payload.Inputs, 1) { + assert.Equal(t, "consumer", payload.Inputs["from"]) + } + crossJob = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: crossJob.ID}) + assert.Equal(t, actions_model.StatusRunning, crossJob.Status) + runner.execTask(t, libJobTask, &mockTaskOutcome{result: runnerv1.Result_RESULT_SUCCESS}) + + crossJob = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: crossJob.ID}) + assert.Equal(t, actions_model.StatusSuccess, crossJob.Status) + run = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: run.ID}) + assert.Equal(t, actions_model.StatusSuccess, run.Status) + }) + + t.Run("Public caller denied private target even with collaborative owner", func(t *testing.T) { + // Isolates the run.Repo.IsPrivate gate: a public caller must be denied a private target even with a + // collaborative-owner grant, since allowing it would expose private workflow content in a public run. + + // libRepo: private, owned by user2. + libAPIRepo := createActionsTestRepo(t, user2Token, "reusable-lib-public-denied", true) + libRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: libAPIRepo.ID}) + createRepoWorkflowFile(t, user2, user2Token, libRepo, ".gitea/workflows/reusable_lib.yaml", + `name: ReusableLib +on: + workflow_call: + +jobs: + lib_job: + runs-on: ubuntu-latest + steps: + - run: echo hello +`) + + // Grant first: user2 adds user4 as a collaborative owner of the private libRepo, so the grant is + // satisfied and the public-caller gate is the only thing that can deny access. + addCollabReq := NewRequestWithValues(t, "POST", + fmt.Sprintf("/%s/%s/settings/actions/general/collaborative_owner/add", libRepo.OwnerName, libRepo.Name), + map[string]string{"collaborative_owner": user4.Name}) + user2Session.MakeRequest(t, addCollabReq, http.StatusOK) + + // consumerRepo: public, owned by user4. + consumerAPIRepo := createActionsTestRepo(t, user4Token, "workflow-call-public-denied", false) + consumerRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: consumerAPIRepo.ID}) + + runner := newMockRunner() + runner.registerAsRepoRunner(t, consumerRepo.OwnerName, consumerRepo.Name, "mock-public-denied-runner", []string{"ubuntu-latest"}, false) + + createRepoWorkflowFile(t, user4, user4Token, consumerRepo, ".gitea/workflows/cross-caller.yaml", + `name: CrossCaller +on: push +jobs: + cross_job: + uses: user2/reusable-lib-public-denied/.gitea/workflows/reusable_lib.yaml@main +`) + + // Denied: the cross-repo read check fails for the public caller, so NO ActionRun is persisted and no task is dispatched. + assert.Equal(t, 0, unittest.GetCount(t, &actions_model.ActionRun{RepoID: consumerRepo.ID})) + runner.fetchNoTask(t) + }) + + t.Run("Cross-repo callee with same-repo nested uses", func(t *testing.T) { + // A same-repo `uses: ./...` inside a cross-repo reusable callee must resolve relative to the callee's own repo (matching GitHub's behavior), not the original triggering repo. + + // Place a util.yaml with a distinguishable job name in BOTH repos to detect mis-resolution. + + libAPIRepo := createActionsTestRepo(t, user2Token, "reusable-lib-nested", false) + libRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: libAPIRepo.ID}) + createRepoWorkflowFile(t, user2, user2Token, libRepo, ".gitea/workflows/util.yaml", + `name: UtilLib +on: + workflow_call: + +jobs: + util_lib_job: + runs-on: ubuntu-latest + steps: + - run: echo from-lib +`) + createRepoWorkflowFile(t, user2, user2Token, libRepo, ".gitea/workflows/lib.yaml", + `name: LibNested +on: + workflow_call: + +jobs: + call_util_in_lib: + uses: ./.gitea/workflows/util.yaml +`) + + consumerAPIRepo := createActionsTestRepo(t, user4Token, "consumer-nested-uses", false) + consumerRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: consumerAPIRepo.ID}) + + // A *different* util.yaml in the consumer repo: if `./` mis-resolves we'd see this job's name. + createRepoWorkflowFile(t, user4, user4Token, consumerRepo, ".gitea/workflows/util.yaml", + `name: UtilConsumer +on: + workflow_call: + +jobs: + util_consumer_job: + runs-on: ubuntu-latest + steps: + - run: echo from-consumer +`) + + runner := newMockRunner() + runner.registerAsRepoRunner(t, consumerRepo.OwnerName, consumerRepo.Name, "mock-nested-runner", []string{"ubuntu-latest"}, false) + + createRepoWorkflowFile(t, user4, user4Token, consumerRepo, ".gitea/workflows/caller.yaml", + `name: NestedCaller +on: push +jobs: + cross_job: + uses: user2/reusable-lib-nested/.gitea/workflows/lib.yaml@main +`) + + run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{RepoID: consumerRepo.ID}) + crossJob := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: run.ID, JobID: "cross_job"}) + assert.True(t, crossJob.IsReusableCaller) + assert.True(t, crossJob.IsExpanded) + + // cross_job's children come from libRepo/lib.yaml - their source must be libRepo + libRepo's commit. + libHead, err := gitrepo.GetBranchCommitID(t.Context(), libRepo, libRepo.DefaultBranch) + require.NoError(t, err) + callUtilJob := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: run.ID, JobID: "call_util_in_lib", ParentJobID: crossJob.ID}) + assert.True(t, callUtilJob.IsReusableCaller) + assert.Equal(t, libRepo.ID, callUtilJob.WorkflowSourceRepoID) + assert.Equal(t, libHead, callUtilJob.WorkflowSourceCommitSHA) + + // call_util_in_lib has `uses: ./.gitea/workflows/util.yaml`, so its children should come from libRepo/util.yaml + assert.True(t, callUtilJob.IsExpanded) + unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: run.ID, JobID: "util_lib_job", ParentJobID: callUtilJob.ID}) + unittest.AssertNotExistsBean(t, &actions_model.ActionRunJob{RunID: run.ID, JobID: "util_consumer_job"}) + }) + + t.Run("Missing callee file", func(t *testing.T) { + // A caller workflow references a callee path that does not exist in the repo. + + apiRepo := createActionsTestRepo(t, user2Token, "caller-missing-callee", false) + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: apiRepo.ID}) + + createRepoWorkflowFile(t, user2, user2Token, repo, ".gitea/workflows/caller.yaml", + `name: Caller +on: push +jobs: + plain_job: + runs-on: ubuntu-latest + steps: + - run: echo 'job' + call_missing: + uses: ./.gitea/workflows/does-not-exist.yml +`) + + assert.Equal(t, 0, unittest.GetCount(t, &actions_model.ActionRun{RepoID: repo.ID})) + }) + + t.Run("Fork PR with secrets: inherit does not leak base repo secrets", func(t *testing.T) { + // user2 owns the base repo, configures a secret, and registers a reusable workflow that declares a required secret. + // The caller workflow uses `secrets: inherit`. + + apiBaseRepo := createActionsTestRepo(t, user2Token, "fork-pr-inherit-test", false) + baseRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: apiBaseRepo.ID}) + user2APICtx := NewAPITestContext(t, baseRepo.OwnerName, baseRepo.Name, auth_model.AccessTokenScopeWriteRepository) + defer doAPIDeleteRepository(user2APICtx)(t) + + // Real secret that must never reach a fork PR task. + req := NewRequestWithJSON(t, "PUT", + fmt.Sprintf("/api/v1/repos/%s/%s/actions/secrets/leaked_secret", baseRepo.OwnerName, baseRepo.Name), + api.CreateOrUpdateSecretOption{Data: "MUST-NOT-LEAK"}).AddTokenAuth(user2Token) + MakeRequest(t, req, http.StatusCreated) + + runner := newMockRunner() + runner.registerAsRepoRunner(t, baseRepo.OwnerName, baseRepo.Name, "mock-fork-runner", []string{"ubuntu-latest"}, false) + + createRepoWorkflowFile(t, user2, user2Token, baseRepo, ".gitea/workflows/reusable.yaml", + `name: Reusable +on: + workflow_call: + secrets: + leaked_secret: + +jobs: + callee: + runs-on: ubuntu-latest + steps: + - run: echo +`) + createRepoWorkflowFile(t, user2, user2Token, baseRepo, ".gitea/workflows/caller.yaml", + `name: Caller +on: pull_request +jobs: + call_reusable: + uses: ./.gitea/workflows/reusable.yaml + secrets: inherit +`) + + // user4 forks + req = NewRequestWithJSON(t, "POST", + fmt.Sprintf("/api/v1/repos/%s/%s/forks", baseRepo.OwnerName, baseRepo.Name), + &api.CreateForkOption{Name: new("fork-pr-inherit-test-fork")}).AddTokenAuth(user4Token) + resp := MakeRequest(t, req, http.StatusAccepted) + apiForkRepo := DecodeJSON(t, resp, &api.Repository{}) + forkRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: apiForkRepo.ID}) + user4APICtx := NewAPITestContext(t, user4.Name, forkRepo.Name, auth_model.AccessTokenScopeWriteRepository) + defer doAPIDeleteRepository(user4APICtx)(t) + + // user4 pushes a change on the fork and opens a PR to base + doAPICreateFile(user4APICtx, "user4-fix.txt", &api.CreateFileOptions{ + FileOptions: api.FileOptions{ + NewBranchName: "user4/branch", + Message: "create user4-fix.txt", + Author: api.Identity{Name: user4.Name, Email: user4.Email}, + Committer: api.Identity{Name: user4.Name, Email: user4.Email}, + Dates: api.CommitDateOptions{Author: time.Now(), Committer: time.Now()}, + }, + ContentBase64: base64.StdEncoding.EncodeToString([]byte("fix")), + })(t) + doAPICreatePullRequest(user4APICtx, baseRepo.OwnerName, baseRepo.Name, baseRepo.DefaultBranch, user4.Name+":user4/branch")(t) + + // Approve the fork PR run. + forkRun := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{RepoID: baseRepo.ID, TriggerUserID: user4.ID}) + assert.True(t, forkRun.IsForkPullRequest) + req = NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/approve", baseRepo.OwnerName, baseRepo.Name, forkRun.ID)) + user2Session.MakeRequest(t, req, http.StatusOK) + + task := runner.fetchTask(t) + _, taskJob, taskRun := getTaskAndJobAndRunByTaskID(t, task.Id) + assert.Equal(t, "callee", taskJob.JobID) + assert.Equal(t, forkRun.ID, taskRun.ID) + + // Only the auto-issued tokens should be present. The user-defined `leaked_secret` must not appear. + assert.Contains(t, task.Secrets, "GITEA_TOKEN") + assert.Contains(t, task.Secrets, "GITHUB_TOKEN") + assert.NotContains(t, task.Secrets, "leaked_secret") + for name, value := range task.Secrets { + assert.NotEqual(t, "MUST-NOT-LEAK", value, "secret %q leaked the base repo's secret value into a fork PR task", name) + } + + runner.execTask(t, task, &mockTaskOutcome{result: runnerv1.Result_RESULT_SUCCESS}) + }) + + t.Run("Caller alternates expanding across attempts", func(t *testing.T) { + apiRepo := createActionsTestRepo(t, user2Token, "caller-walkback-test", false) + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: apiRepo.ID}) + + runner := newMockRunner() + runner.registerAsRepoRunner(t, repo.OwnerName, repo.Name, "mock-walkback-runner", []string{"ubuntu-latest"}, false) + + // Scenario: + // attempt 1: gate succeeds -> caller expands -> inner runs (records inner.AttemptJobID = N) + // attempt 2: rerun gate, mock Failure -> caller is Skipped without expanding (no children inserted) + // attempt 3: rerun gate, mock Success -> caller expands again -> inner.AttemptJobID must equal N + + createRepoWorkflowFile(t, user2, user2Token, repo, ".gitea/workflows/lib.yaml", + `name: Lib +on: + workflow_call: + +jobs: + inner: + runs-on: ubuntu-latest + steps: + - run: echo inner +`) + createRepoWorkflowFile(t, user2, user2Token, repo, ".gitea/workflows/main.yaml", + `name: Main +on: + push: + paths: + - '.gitea/workflows/main.yaml' +jobs: + gate: + runs-on: ubuntu-latest + steps: + - run: echo gate + + caller: + needs: [gate] + uses: ./.gitea/workflows/lib.yaml +`) + + run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{RepoID: repo.ID}) + runID := run.ID + + latestAttempt := func() *actions_model.ActionRunAttempt { + r := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: runID}) + return unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunAttempt{ID: r.LatestAttemptID}) + } + jobInLatest := func(jobID string) *actions_model.ActionRunJob { + a := latestAttempt() + return unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: runID, RunAttemptID: a.ID, JobID: jobID}) + } + + // attempt 1: gate Success -> caller expands -> inner runs + gate1Task := runner.fetchTask(t) + _, gate1, _ := getTaskAndJobAndRunByTaskID(t, gate1Task.Id) + assert.Equal(t, "gate", gate1.JobID) + runner.execTask(t, gate1Task, &mockTaskOutcome{result: runnerv1.Result_RESULT_SUCCESS}) + + inner1Task := runner.fetchTask(t) + _, inner1, _ := getTaskAndJobAndRunByTaskID(t, inner1Task.Id) + assert.Equal(t, "inner", inner1.JobID) + innerAttemptJobID := inner1.AttemptJobID + callerAttempt1 := jobInLatest("caller") + assert.True(t, callerAttempt1.IsExpanded) + assert.Equal(t, callerAttempt1.ID, inner1.ParentJobID) + runner.execTask(t, inner1Task, &mockTaskOutcome{result: runnerv1.Result_RESULT_SUCCESS}) + + run = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: runID}) + assert.Equal(t, actions_model.StatusSuccess, run.Status) + + // attempt 2: rerun gate, mock Failure -> caller stays unexpanded (Skipped) + gateLatest := jobInLatest("gate") + req := NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/jobs/%d/rerun", repo.OwnerName, repo.Name, runID, gateLatest.ID)) + user2Session.MakeRequest(t, req, http.StatusOK) + + gate2Task := runner.fetchTask(t) + _, gate2, _ := getTaskAndJobAndRunByTaskID(t, gate2Task.Id) + assert.Equal(t, "gate", gate2.JobID) + runner.execTask(t, gate2Task, &mockTaskOutcome{result: runnerv1.Result_RESULT_FAILURE}) + + runner.fetchNoTask(t) // no inner because caller did not expand + attempt2 := latestAttempt() + assert.Equal(t, actions_model.StatusFailure, attempt2.Status) + callerAttempt2 := jobInLatest("caller") + assert.Equal(t, actions_model.StatusSkipped, callerAttempt2.Status) + assert.False(t, callerAttempt2.IsExpanded) + assert.Equal(t, 0, unittest.GetCount(t, &actions_model.ActionRunJob{RunID: runID, RunAttemptID: attempt2.ID, JobID: "inner"})) + + // attempt 3: rerun gate, mock Success -> caller expands and inner reuses attempt 1's AttemptJobID + gateLatest = jobInLatest("gate") + req = NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/jobs/%d/rerun", repo.OwnerName, repo.Name, runID, gateLatest.ID)) + user2Session.MakeRequest(t, req, http.StatusOK) + + gate3Task := runner.fetchTask(t) + runner.execTask(t, gate3Task, &mockTaskOutcome{result: runnerv1.Result_RESULT_SUCCESS}) + + inner3Task := runner.fetchTask(t) + _, inner3, _ := getTaskAndJobAndRunByTaskID(t, inner3Task.Id) + assert.Equal(t, "inner", inner3.JobID) + assert.Equal(t, innerAttemptJobID, inner3.AttemptJobID) + runner.execTask(t, inner3Task, &mockTaskOutcome{result: runnerv1.Result_RESULT_SUCCESS}) + + run = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: runID}) + assert.Equal(t, actions_model.StatusSuccess, run.Status) + }) + }) +} + +// token must belong to u (the commit identity) and have write access to repo. Reuse the caller's +// existing token rather than logging in per call, which would re-run bcrypt password verification each time. +func createRepoWorkflowFile(t *testing.T, u *user_model.User, token string, repo *repo_model.Repository, treePath, content string) { + opts := getWorkflowCreateFileOptions(u, repo.DefaultBranch, "create "+treePath, content) + createWorkflowFile(t, token, repo.OwnerName, repo.Name, treePath, opts) +} + +func getWorkflowCallPayloadFromTask(t *testing.T, runnerTask *runnerv1.Task) *api.WorkflowCallPayload { + eventJSON, err := runnerTask.GetContext().Fields["event"].GetStructValue().MarshalJSON() + assert.NoError(t, err) + var payload api.WorkflowCallPayload + assert.NoError(t, json.Unmarshal(eventJSON, &payload)) + return &payload +} diff --git a/web_src/js/components/ActionRunJobView.vue b/web_src/js/components/ActionRunJobView.vue index de3cd3a2e4..835518ecb8 100644 --- a/web_src/js/components/ActionRunJobView.vue +++ b/web_src/js/components/ActionRunJobView.vue @@ -1,7 +1,8 @@