feat(actions)!: improve support for reusable workflows (#37478)

## Summary

This PR improves reusable workflow support for Gitea Actions. The
parsing of the called workflow now happens on Gitea side, not on the
runner. When the caller becomes ready, Gitea fetches the called workflow
source, parses it, and inserts each child job into the database as a
`ActionRunJob` linked to the caller via `ParentCallJobID`. As a result,
every callee job is dispatched as its own task and its logs surface as
an independent job entry in the UI, rather than being inlined into the
caller's "Set up job" step.

This PR supports two kinds of `uses` : 
- same-repo call: `uses: ./.gitea/workflows/foo.yaml`
- cross-repo call: `uses: OWNER/REPO/.gitea/workflows/foo.yaml@REF`

## **⚠️ BREAKING ⚠️**
External reusable workflows (`uses:
https://other-gitea-instance/OWNER/REPO/.gitea/workflows/test.yaml@REF`)
are no longer supported. To keep using them, clone the repositories to
the local instance.

## Main changes

### Execution model

- Each caller job carries `IsReusableCaller=true` and won't be fetched
by runners.
- `ParentCallJobID` can link a called job to its caller.
- Caller status is derived from its direct children.


### Workflow syntax

- `jobparser` now supports parsing `on: workflow_call` trigger with
`inputs:`, `outputs:`, and `secrets:` declarations.
- **Max nesting depth**: capped at `MaxReusableCallLevels = 9`, which
means a top-level caller may have at most 9 nested callers below it.
- **Cycle prevention**: at expansion time, `checkCallerChain` walks the
caller's ancestor chain via `ParentCallJobID` and rejects if the same
`uses:` string appears anywhere upstream (`reusable workflow call cycle
detected`). This catches both direct (`A -> A`) and indirect (`A -> B ->
A`) cycles.

### Cross-repo access

- To share reusable workflows from private repos, use `Collaborative
Owners` introduced by #32562

### Rerun semantics

- `expandRerunJobIDs` partitions the latest attempt's jobs into:
- a **rerun set**: jobs being rerun + downstream siblings within the
same scope.
- an **ancestor set**: reusable callers whose only *some* descendants
are being rerun (the caller itself is not).
- Cloning behavior for callers in `execRerunPlan`:
- **Caller is fully rerun** (caller's `AttemptJobID` in `rerunSet`):
none of its descendants are cloned. The caller is cloned with
`IsCallerExpanded=false`, and re-expansion (which reinserts the children
fresh) happens later when the resolver brings the caller to `Waiting`
again.
- **Caller is in ancestor set** (only some descendants rerun): the
caller is pass-through (`Status` will be updated by its fresh children).
Its non-rerun descendants are also pass-through clones (point
`SourceTaskID` at the original task). Their `ParentCallJobID` is
remapped to the new attempt's caller row.

### UI

- Job list in `RepoActionView.vue` is now tree-shaped: callers indent
their children. Callers default to collapsed.
- New caller detail page using `WorkflowGraph` to show direct children
only; the run summary's `WorkflowGraph` shows top-level callers and
their immediate descendants.

### Known trade-offs

- **Caller expansion runs inside the enclosing write transaction.**
`expandReusableWorkflowCaller` performs a git read of the called
workflow while holding the row locks that update the caller and insert
its children. This is intentional: the caller-row update and child-row
inserts must commit atomically. None of the call sites is hot (each
caller is expanded once per attempt), so the trade-off is acceptable.

- **A malformed `if:` expression on a job leaves it `Blocked`
silently.** `evaluateJobIf` now runs server-side as part of resolver
passes; deterministic expression errors (typos, undefined context
fields) are logged but do not surface in the UI. This is the same
behavior the resolver already had for concurrency-expression errors.
Distinguishing transient DB errors from user-authored expression errors
and writing the latter back as `StatusFailure` is a follow-up.


#### Screenshots

<img width="1600" alt="image"
src="https://github.com/user-attachments/assets/bfaa9b7a-07e9-4127-8de9-a81f86e82828"
/>

<img width="1600" alt="image"
src="https://github.com/user-attachments/assets/8af109b3-ef28-4b53-aaad-d4632b923224"
/>


## References

-
https://docs.github.com/en/actions/how-tos/reuse-automations/reuse-workflows
-
https://docs.github.com/en/actions/reference/workflows-and-actions/reusing-workflow-configurations

---

Replace #36388

---------

Signed-off-by: Zettat123 <zettat123@gmail.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: silverwind <me@silverwind.io>
Co-authored-by: Claude (Opus 4.7) <noreply@anthropic.com>
This commit is contained in:
Zettat123
2026-05-30 08:31:14 +02:00
committed by GitHub
co-authored by GitHub Copilot Autofix powered by AI silverwind Claude
parent 2960d6889c
commit 0359746abe
41 changed files with 4692 additions and 363 deletions
-92
View File
@@ -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)
+311 -1
View File
@@ -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
}
+133
View File
@@ -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)
})
}
+2 -2
View File
@@ -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 {
+1
View File
@@ -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
}
+33
View File
@@ -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))
}
+34
View File
@@ -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
}
+14
View File
@@ -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)
}
+58 -6
View File
@@ -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) {
+174
View File
@@ -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)
})
}
+21
View File
@@ -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 {
+75
View File
@@ -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/<file>.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/<file>.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
}
+170
View File
@@ -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)
})
}
})
}
+401
View File
@@ -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.<name>.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.<output_id>.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)
}
}
@@ -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.<id>.outputs.<name>", 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.<name>", 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
}
+14
View File
@@ -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
+2
View File
@@ -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",
+69
View File
@@ -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)
}
+10
View File
@@ -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,
})
}
+42 -4
View File
@@ -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)
+2 -34
View File
@@ -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
}
+130 -13
View File
@@ -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
+220 -8
View File
@@ -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())
+92
View File
@@ -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
}
+136 -73
View File
@@ -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...)
}
+48 -7
View File
@@ -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)
+198 -30
View File
@@ -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,
}
}
+240
View File
@@ -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
}
+342
View File
@@ -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
}
+134
View File
@@ -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
}
+51 -16
View File
@@ -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
}
+1
View File
@@ -5,6 +5,7 @@
<a href="/devtest/repo-action-view/runs/20">Run:CanApprove</a>
<a href="/devtest/repo-action-view/runs/30">Run:CanRerunLatest</a>
<a href="/devtest/repo-action-view/runs/10/attempts/2">Run:PreviousAttempt</a>
<a href="/devtest/repo-action-view/runs/40">Run:ReusableCaller</a>
</div>
{{template "repo/actions/view_component" (dict
"JobID" (or .JobID 0)
@@ -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"}}"
@@ -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
}
+64 -9
View File
@@ -1,7 +1,8 @@
<script setup lang="ts">
import {nextTick, onBeforeUnmount, onMounted, ref, toRefs, watch} from 'vue';
import {computed, nextTick, onBeforeUnmount, onMounted, ref, toRefs, watch} from 'vue';
import {SvgIcon} from '../svg.ts';
import ActionStatusIcon from './ActionStatusIcon.vue';
import WorkflowGraph from './WorkflowGraph.vue';
import {addDelegatedEventListener, createElementFromAttrs, toggleElem} from '../utils/dom.ts';
import {formatDatetime, formatDatetimeISO} from '../utils/time.ts';
import {POST} from '../modules/fetch.ts';
@@ -9,13 +10,14 @@ import {copyToClipboardWithFeedback} from '../modules/clipboard.ts';
import type {IntervalId} from '../types.ts';
import {toggleFullScreen} from '../utils.ts';
import {localUserSettings} from '../modules/user-settings.ts';
import type {ActionsArtifact, ActionsRun, ActionsStatus} from '../modules/gitea-actions.ts';
import type {ActionsArtifact, ActionsJob, ActionsRun, ActionsStatus} from '../modules/gitea-actions.ts';
import {
type ActionRunViewStore,
collectCallerChildJobs,
createLogLineMessage,
type LogLine,
type LogLineCommand,
parseLogLineCommand
parseLogLineCommand,
} from './ActionRunView.ts';
function isLogElementInViewport(el: Element, {extraViewPortHeight}={extraViewPortHeight: 0}): boolean {
@@ -116,6 +118,15 @@ const currentJob = ref<CurrentJob>({
const stepsContainer = ref<HTMLElement | null>(null);
const jobStepLogs = ref<Array<StepContainerElement | undefined>>([]);
// Reusable workflow caller view: when the selected job is a caller node, the right pane
// shows the children list rather than step logs (callers don't run on a runner).
const selectedJob = computed<ActionsJob | undefined>(() => (run.value.jobs || []).find((it) => it.id === props.jobId));
const isCallerJob = computed(() => Boolean(selectedJob.value?.isReusableCaller));
const callerChildJobs = computed<ActionsJob[]>(() => {
if (!isCallerJob.value) return [];
return collectCallerChildJobs(run.value.jobs || [], props.jobId);
});
watch(optionAlwaysAutoScroll, () => {
saveLocaleStorageOptions();
});
@@ -417,11 +428,17 @@ async function hashChangeListener() {
<template>
<div class="job-info-header">
<div class="job-info-header-left gt-ellipsis">
<h3 class="job-info-header-title gt-ellipsis">
{{ currentJob.title }}
</h3>
<div class="job-info-header-title-row">
<h3 class="job-info-header-title gt-ellipsis">
{{ isCallerJob ? selectedJob?.name : currentJob.title }}
</h3>
<span v-if="isCallerJob && selectedJob?.callUses" class="ui label job-info-header-uses">
<span>uses:</span>
<span class="gt-ellipsis">{{ selectedJob.callUses }}</span>
</span>
</div>
<p class="job-info-header-detail">
{{ currentJob.detail }}
{{ isCallerJob && selectedJob ? locale.status[selectedJob.status] : currentJob.detail }}
</p>
</div>
<div class="job-info-header-right">
@@ -460,8 +477,22 @@ async function hashChangeListener() {
</div>
</div>
</div>
<!-- Caller (reusable workflow) view: render the direct children's dependency graph,
mirroring the run summary's WorkflowGraph but scoped to this caller's subtree.
The caller's name + uses path + status all live in job-info-header above. -->
<div class="caller-children-container" v-if="isCallerJob">
<WorkflowGraph
v-if="callerChildJobs.length > 0"
:store="store"
:jobs="callerChildJobs"
:run-link="run.link"
:workflow-id="`${run.workflowID}#caller-${props.jobId}`"
:locale="locale"
/>
</div>
<!-- always create the node because we have our own event listeners on it, don't use "v-if" -->
<div class="job-step-container" ref="stepsContainer" v-show="currentJob.steps.length">
<div class="job-step-container" ref="stepsContainer" v-show="!isCallerJob && currentJob.steps.length">
<div class="job-step-section" v-for="(jobStep, stepIdx) in currentJob.steps" :key="stepIdx">
<div
class="job-step-summary"
@@ -547,7 +578,8 @@ async function hashChangeListener() {
border-radius: 3px;
}
.job-info-header:has(+ .job-step-container) {
.job-info-header:has(+ .job-step-container),
.job-info-header:has(+ .caller-children-container) {
border-radius: var(--border-radius) var(--border-radius) 0 0;
}
@@ -564,6 +596,29 @@ async function hashChangeListener() {
.job-info-header-left {
flex: 1;
min-width: 0;
}
.job-info-header-title-row {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.job-info-header-uses {
display: inline-flex !important;
align-items: baseline;
gap: 4px;
min-width: 0;
}
.caller-children-container {
flex: 1;
display: flex;
flex-direction: column;
border-top: 1px solid var(--color-console-border);
color: var(--color-console-fg);
}
.job-step-container {
@@ -18,6 +18,10 @@ const {currentRun: run} = toRefs(props.store.viewData);
const isRerun = computed(() => run.value.runAttempt > 1);
// The summary's dependency graph is the workflow's top-level shape: a reusable caller
// renders as a single node, its expanded children belong to the caller's own detail page.
const topLevelJobs = computed(() => (run.value.jobs || []).filter((j) => !j.parentJobID));
const triggerUser = computed(() => {
const currentAttempt = run.value.attempts.find((attempt) => attempt.current);
if (currentAttempt) {
@@ -54,9 +58,9 @@ onBeforeUnmount(() => {
</div>
</div>
<WorkflowGraph
v-if="run.jobs.length > 0"
v-if="topLevelJobs.length > 0"
:store="store"
:jobs="run.jobs"
:jobs="topLevelJobs"
:run-link="run.link"
:workflow-id="run.workflowID"
:locale="locale"
+24 -2
View File
@@ -88,6 +88,28 @@ export function createLogLineMessage(line: LogLine, cmd: LogLineCommand | null)
return logMsg;
}
// buildJobsByParentJobID groups jobs by their parentJobID (0 = top level).
// Useful for rendering the reusable-workflow caller/child tree in the sidebar.
export function buildJobsByParentJobID(jobs: ActionsJob[]): Map<number, ActionsJob[]> {
const childrenByParent = new Map<number, ActionsJob[]>();
for (const job of jobs) {
const parentID = job.parentJobID || 0;
const existing = childrenByParent.get(parentID);
if (existing) {
existing.push(job);
} else {
childrenByParent.set(parentID, [job]);
}
}
return childrenByParent;
}
// collectCallerChildJobs returns the direct children of a caller job.
export function collectCallerChildJobs(jobs: ActionsJob[], callerJobID: number): ActionsJob[] {
if (!callerJobID) return [];
return buildJobsByParentJobID(jobs).get(callerJobID) || [];
}
export function createEmptyActionsRun(): ActionsRun {
return {
repoId: 0,
@@ -161,7 +183,7 @@ export function createActionRunViewStore(viewUrl: string) {
}
};
return reactive({
return {
viewData,
async startPollingCurrentRun() {
@@ -178,7 +200,7 @@ export function createActionRunViewStore(viewUrl: string) {
clearInterval(intervalID);
intervalID = null;
},
});
};
}
export type ActionRunViewStore = ReturnType<typeof createActionRunViewStore>;
+114 -12
View File
@@ -1,12 +1,12 @@
<script setup lang="ts">
import {SvgIcon} from '../svg.ts';
import ActionStatusIcon from './ActionStatusIcon.vue';
import {toRefs} from 'vue';
import {computed, ref, toRefs} from 'vue';
import {POST, DELETE} from '../modules/fetch.ts';
import ActionRunSummaryView from './ActionRunSummaryView.vue';
import ActionRunJobView from './ActionRunJobView.vue';
import type {ActionsRunAttempt} from '../modules/gitea-actions.ts';
import {createActionRunViewStore} from './ActionRunView.ts';
import type {ActionsJob, ActionsRunAttempt} from '../modules/gitea-actions.ts';
import {buildJobsByParentJobID, createActionRunViewStore} from './ActionRunView.ts';
import {buildArtifactTooltipHtml} from './ActionRunArtifacts.ts';
defineOptions({
@@ -23,6 +23,62 @@ const locale = props.locale;
const store = createActionRunViewStore(props.actionsViewUrl);
const {currentRun: run, runArtifacts: artifacts} = toRefs(store.viewData);
type JobListItem = {
job: ActionsJob;
depth: number;
hasChildren: boolean;
};
// Caller jobs default to collapsed. Membership in this set means "user has manually expanded this caller"
const expandedJobIDs = ref(new Set<number>());
function toggleExpandedJob(jobID: number) {
const next = new Set(expandedJobIDs.value);
if (next.has(jobID)) {
next.delete(jobID);
} else {
next.add(jobID);
}
expandedJobIDs.value = next;
}
// When a child job is currently selected, force-expand the chain of caller ancestors
const forcedExpandedJobIDs = computed(() => {
const expanded = new Set<number>();
if (!props.jobId) return expanded;
const jobsByID = new Map((run.value.jobs || []).map((job) => [job.id, job]));
let cur = jobsByID.get(props.jobId);
while (cur?.parentJobID) {
expanded.add(cur.parentJobID);
cur = jobsByID.get(cur.parentJobID);
}
return expanded;
});
function isJobCollapsed(jobID: number) {
return !expandedJobIDs.value.has(jobID) && !forcedExpandedJobIDs.value.has(jobID);
}
const visibleJobListItems = computed<JobListItem[]>(() => {
const jobs = [...(run.value.jobs || [])].sort((a, b) => a.id - b.id);
const childrenByParent = buildJobsByParentJobID(jobs);
const result: JobListItem[] = [];
const stack: Array<{job: ActionsJob; depth: number}> = [];
const top = childrenByParent.get(0) || [];
for (let i = top.length - 1; i >= 0; i--) stack.push({job: top[i], depth: 0});
while (stack.length > 0) {
const {job, depth} = stack.pop()!;
const children = childrenByParent.get(job.id) || [];
const hasChildren = children.length > 0;
result.push({job, depth, hasChildren});
if (hasChildren && isJobCollapsed(job.id)) continue;
for (let i = children.length - 1; i >= 0; i--) stack.push({job: children[i], depth: depth + 1});
}
return result;
});
function formatAttemptTitle(attempt: ActionsRunAttempt) {
return attempt.latest ? `${locale.latestAttempt} #${attempt.attempt}` : `${locale.attempt} #${attempt.attempt}`;
}
@@ -153,13 +209,31 @@ async function deleteArtifact(name: string) {
<div class="ui divider"/>
<div class="left-list-header">{{ locale.allJobs }}</div>
<div class="flex-items-block action-view-sidebar-list">
<div class="item" v-for="job in run.jobs" :key="job.id" :class="props.jobId === job.id ? 'selected' : ''">
<a class="flex-text-block tw-flex-1 silenced" :href="job.link">
<ActionStatusIcon :locale-status="locale.status[job.status]" :status="job.status" icon-variant="circle-fill"/>
<span class="tw-flex-1 gt-ellipsis">{{ job.name }}</span>
<SvgIcon name="octicon-sync" role="button" :data-tooltip-content="locale.rerun" class="job-rerun-button tw-cursor-pointer link-action interact-fg" :data-url="`${run.link}/jobs/${job.id}/rerun`" v-if="job.canRerun"/>
<span class="job-duration">{{ job.duration }}</span>
<div
class="item job-brief-item"
:class="{'selected': props.jobId === item.job.id}"
:style="{paddingLeft: `${10 + item.depth * 16}px`}"
v-for="item in visibleJobListItems"
:key="item.job.id"
>
<a class="tw-contents silenced" :href="item.job.link">
<ActionStatusIcon :locale-status="locale.status[item.job.status]" :status="item.job.status" icon-variant="circle-fill"/>
<span class="tw-min-w-0 gt-ellipsis">{{ item.job.name }}</span>
<SvgIcon name="octicon-sync" role="button" :data-tooltip-content="locale.rerun" class="job-rerun-button tw-cursor-pointer link-action interact-fg" :data-url="`${run.link}/jobs/${item.job.id}/rerun`" v-if="item.job.canRerun"/>
<span class="job-duration">{{ item.job.duration }}</span>
</a>
<button
v-if="item.hasChildren"
type="button"
class="job-brief-toggle"
:class="{'collapsed': isJobCollapsed(item.job.id)}"
@click="toggleExpandedJob(item.job.id)"
:title="isJobCollapsed(item.job.id) ? locale.expandCallerJobs : locale.collapseCallerJobs"
:aria-label="isJobCollapsed(item.job.id) ? locale.expandCallerJobs : locale.collapseCallerJobs"
:aria-expanded="!isJobCollapsed(item.job.id)"
>
<SvgIcon name="octicon-chevron-down" :size="14"/>
</button>
</div>
</div>
@@ -332,19 +406,47 @@ async function deleteArtifact(name: string) {
background-color: var(--color-active);
}
/* the re-run button replaces the duration on hover/focus */
.job-brief-toggle {
border: none;
padding: 0;
background: transparent;
cursor: pointer;
color: inherit;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
/* the icon is always chevron-down; flip to chevron-up when expanded */
transition: transform 0.15s ease;
/* sit right after the job name; rerun/duration float to the right via auto-margin */
order: 1;
}
.job-brief-toggle:not(.collapsed) {
transform: rotate(180deg);
}
/* push rerun/duration to the right edge; only one is visible at a time (hover swap),
the visible one absorbs the free space via auto-margin */
.action-view-sidebar-list > .item .job-rerun-button,
.action-view-sidebar-list > .item .job-duration {
order: 2;
margin-left: auto;
}
/* the re-run button replaces the duration on hover or job-link focus */
.action-view-sidebar-list > .item .job-rerun-button {
display: none;
}
.action-view-sidebar-list > .item:hover .job-rerun-button,
.action-view-sidebar-list > .item:focus-within .job-rerun-button {
.action-view-sidebar-list > .item:has(a:focus) .job-rerun-button {
display: inline-flex;
}
/* only swap out the duration when a re-run button exists to take its place */
.action-view-sidebar-list > .item:hover .job-rerun-button ~ .job-duration,
.action-view-sidebar-list > .item:focus-within .job-rerun-button ~ .job-duration {
.action-view-sidebar-list > .item:has(a:focus) .job-rerun-button ~ .job-duration {
display: none;
}
+65 -52
View File
@@ -115,7 +115,8 @@ const jobsWithLayout = computed<JobNode[]>(() => {
let maxJobsPerLevel = 0;
props.jobs.forEach(job => {
const level = levels.get(job.name) || levels.get(job.jobId) || 0;
// `?? 0`, not `|| 0`: a root job's level is 0, which `||` would wrongly discard.
const level = levels.get(scopedKey(job)) ?? 0;
if (!jobsByLevel[level]) {
jobsByLevel[level] = [];
@@ -164,75 +165,87 @@ const jobsWithLayout = computed<JobNode[]>(() => {
}
});
// scopedKey identifies a job within its reusable-workflow call scope so that the same
// JobID in different reusable calls does not collide.
function scopedKey(job: {parentJobID: number; jobId: string}): string {
return `${job.parentJobID || 0}:${job.jobId}`;
}
function buildDirectNeedsMap(jobs: ActionsJob[]): Map<string, string[]> {
const directNeedsByJobId = new Map<string, string[]>();
const dependentsByJobId = new Map<string, Set<string>>();
// The map keys/values are scoped keys, not bare jobIds, so we keep edge construction
// accurate when reusable workflows reuse common job names like "build" / "test".
const directNeedsByScopedKey = new Map<string, string[]>();
const dependentsByScopedKey = new Map<string, Set<string>>();
for (const job of jobs) {
const needs = job.needs || [];
directNeedsByJobId.set(job.jobId, needs);
const fromKey = scopedKey(job);
const needKeys = (job.needs || []).map((n) => `${job.parentJobID || 0}:${n}`);
directNeedsByScopedKey.set(fromKey, needKeys);
for (const need of needs) {
if (!dependentsByJobId.has(need)) {
dependentsByJobId.set(need, new Set());
for (const needKey of needKeys) {
if (!dependentsByScopedKey.has(needKey)) {
dependentsByScopedKey.set(needKey, new Set());
}
dependentsByJobId.get(need)!.add(job.jobId);
dependentsByScopedKey.get(needKey)!.add(fromKey);
}
}
const reachabilityCache = new Map<string, boolean>();
function canReach(fromJobId: string, toJobId: string): boolean {
const cacheKey = `${fromJobId}->${toJobId}`;
function canReach(fromKey: string, toKey: string): boolean {
const cacheKey = `${fromKey}->${toKey}`;
if (reachabilityCache.has(cacheKey)) {
return reachabilityCache.get(cacheKey)!;
}
const visited = new Set<string>();
const stack = [...(dependentsByJobId.get(fromJobId) || [])];
const stack = [...(dependentsByScopedKey.get(fromKey) || [])];
while (stack.length > 0) {
const current = stack.pop()!;
if (current === toJobId) {
if (current === toKey) {
reachabilityCache.set(cacheKey, true);
return true;
}
if (visited.has(current)) continue;
visited.add(current);
stack.push(...(dependentsByJobId.get(current) || []));
stack.push(...(dependentsByScopedKey.get(current) || []));
}
reachabilityCache.set(cacheKey, false);
return false;
}
const reducedNeedsByJobId = new Map<string, string[]>();
for (const [jobId, needs] of directNeedsByJobId.entries()) {
reducedNeedsByJobId.set(jobId, needs.filter((need) => {
const reducedNeedsByScopedKey = new Map<string, string[]>();
for (const [fromKey, needs] of directNeedsByScopedKey.entries()) {
reducedNeedsByScopedKey.set(fromKey, needs.filter((need) => {
return !needs.some((otherNeed) => otherNeed !== need && canReach(need, otherNeed));
}));
}
return reducedNeedsByJobId;
return reducedNeedsByScopedKey;
}
const directNeedsByJobId = computed(() => buildDirectNeedsMap(props.jobs));
const directNeedsByScopedKey = computed(() => buildDirectNeedsMap(props.jobs));
const edges = computed<Edge[]>(() => {
const edgesList: Edge[] = [];
const jobsByJobId = new Map<string, ActionsJob[]>();
// Store every job per scoped key, not just one: matrix-expanded jobs share same jobId
const jobsByScopedKey = new Map<string, ActionsJob[]>();
for (const job of props.jobs) {
if (!jobsByJobId.has(job.jobId)) {
jobsByJobId.set(job.jobId, []);
const key = scopedKey(job);
const existing = jobsByScopedKey.get(key);
if (existing) {
existing.push(job);
} else {
jobsByScopedKey.set(key, [job]);
}
jobsByJobId.get(job.jobId)!.push(job);
}
for (const job of props.jobs) {
for (const need of directNeedsByJobId.value.get(job.jobId) || []) {
const upstreamJobs = jobsByJobId.get(need) || [];
for (const upstreamJob of upstreamJobs) {
for (const needKey of directNeedsByScopedKey.value.get(scopedKey(job)) || []) {
for (const upstreamJob of jobsByScopedKey.get(needKey) || []) {
edgesList.push({
fromId: upstreamJob.id,
toId: job.id,
@@ -469,10 +482,11 @@ const nodesWithOutgoingEdge = computed(() => {
function computeJobLevels(jobs: ActionsJob[]): Map<string, number> {
const jobMap = new Map<string, ActionsJob>()
// Scope-aware: each job is keyed by `${parentJobID}:${jobId}` so the same JobID
// in different reusable workflow calls does not cross-link in the level graph.
const jobMap = new Map<string, ActionsJob>();
jobs.forEach(job => {
jobMap.set(job.name, job);
if (job.jobId) jobMap.set(job.jobId, job);
jobMap.set(scopedKey(job), job);
});
const levels = new Map<string, number>();
@@ -480,60 +494,59 @@ function computeJobLevels(jobs: ActionsJob[]): Map<string, number> {
const recursionStack = new Set<string>();
const MAX_DEPTH = 100;
function dfs(jobNameOrId: string, depth: number = 0): number {
function dfs(scoped: string, depth: number = 0): number {
if (depth > MAX_DEPTH) {
console.error(`Max recursion depth (${MAX_DEPTH}) reached for: ${jobNameOrId}`);
console.error(`Max recursion depth (${MAX_DEPTH}) reached for: ${scoped}`);
return 0;
}
if (recursionStack.has(jobNameOrId)) {
console.error(`Cycle detected involving: ${jobNameOrId}`);
if (recursionStack.has(scoped)) {
console.error(`Cycle detected involving: ${scoped}`);
return 0;
}
if (visited.has(jobNameOrId)) {
return levels.get(jobNameOrId) || 0;
if (visited.has(scoped)) {
return levels.get(scoped) || 0;
}
recursionStack.add(jobNameOrId);
visited.add(jobNameOrId);
recursionStack.add(scoped);
visited.add(scoped);
const job = jobMap.get(jobNameOrId);
const job = jobMap.get(scoped);
if (!job) {
recursionStack.delete(jobNameOrId);
recursionStack.delete(scoped);
return 0;
}
if (!job.needs?.length) {
levels.set(job.jobId, 0);
recursionStack.delete(jobNameOrId);
levels.set(scoped, 0);
recursionStack.delete(scoped);
return 0;
}
let maxLevel = -1;
for (const need of job.needs) {
const needJob = jobMap.get(need);
const needScoped = `${job.parentJobID || 0}:${need}`;
const needJob = jobMap.get(needScoped);
if (!needJob) continue;
const needLevel = dfs(need, depth + 1);
const needLevel = dfs(needScoped, depth + 1);
maxLevel = Math.max(maxLevel, needLevel);
}
const level = maxLevel + 1
levels.set(job.name, level);
if (job.jobId && job.jobId !== job.name) {
levels.set(job.jobId, level);
}
const level = maxLevel + 1;
levels.set(scoped, level);
recursionStack.delete(jobNameOrId);
recursionStack.delete(scoped);
return level;
}
jobs.forEach(job => {
if (!visited.has(job.name) && !visited.has(job.jobId)) {
dfs(job.name);
const sk = scopedKey(job);
if (!visited.has(sk)) {
dfs(sk);
}
})
});
return levels;
}
+2
View File
@@ -27,6 +27,8 @@ export function initRepositoryActionView() {
pushedBy: el.getAttribute('data-locale-runs-pushed-by'),
summary: el.getAttribute('data-locale-summary'),
allJobs: el.getAttribute('data-locale-all-jobs'),
expandCallerJobs: el.getAttribute('data-locale-expand-caller-jobs'),
collapseCallerJobs: el.getAttribute('data-locale-collapse-caller-jobs'),
triggeredVia: el.getAttribute('data-locale-triggered-via'),
totalDuration: el.getAttribute('data-locale-total-duration'),
artifactsTitle: el.getAttribute('data-locale-artifacts-title'),
+4
View File
@@ -62,6 +62,10 @@ export type ActionsJob = {
canRerun: boolean;
needs?: string[];
duration: string;
isReusableCaller: boolean;
parentJobID: number; // 0 for top-level jobs.
callUses?: string;
};
export type ActionsArtifact = {