mirror of
https://github.com/go-gitea/gitea.git
synced 2026-06-14 03:29:55 +00:00
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:
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user