diff --git a/modules/actions/scoped_workflows.go b/modules/actions/scoped_workflows.go index 0789015ac18..5d0e91cce8e 100644 --- a/modules/actions/scoped_workflows.go +++ b/modules/actions/scoped_workflows.go @@ -59,6 +59,7 @@ func ParseScopedWorkflows(sourceCommit *git.Commit) ([]*ParsedScopedWorkflow, er // It returns the workflows whose `on:` matches, and those that matched the event but were excluded by a branch/paths filter (filtered). func MatchScopedWorkflows( parsed []*ParsedScopedWorkflow, + sourceCommitSHA string, consumerGitRepo *git.Repository, consumerCommit *git.Commit, triggedEvent webhook_module.HookEventType, @@ -71,9 +72,10 @@ func MatchScopedWorkflows( continue } dwf := &DetectedWorkflow{ - EntryName: p.EntryName, - TriggerEvent: evt, - Content: p.Content, + EntryName: p.EntryName, + TriggerEvent: evt, + Content: p.Content, + SourceCommitSHA: sourceCommitSHA, } switch detectWorkflowMatch(consumerGitRepo, consumerCommit, triggedEvent, payload, evt) { case detectMatched: diff --git a/modules/actions/workflows.go b/modules/actions/workflows.go index dc7c55b9c04..a4765d33b85 100644 --- a/modules/actions/workflows.go +++ b/modules/actions/workflows.go @@ -28,6 +28,8 @@ type DetectedWorkflow struct { EntryName string TriggerEvent *jobparser.Event Content []byte + // SourceCommitSHA is the commit Content was read from, and must always be filled in together with Content. + SourceCommitSHA string } type detectResult int @@ -203,17 +205,19 @@ func DetectWorkflows( if evt.IsSchedule() { if detectSchedule { dwf := &DetectedWorkflow{ - EntryName: entry.Name(), - TriggerEvent: evt, - Content: content, + EntryName: entry.Name(), + TriggerEvent: evt, + Content: content, + SourceCommitSHA: commit.ID.String(), } schedules = append(schedules, dwf) } } else { dwf := &DetectedWorkflow{ - EntryName: entry.Name(), - TriggerEvent: evt, - Content: content, + EntryName: entry.Name(), + TriggerEvent: evt, + Content: content, + SourceCommitSHA: commit.ID.String(), } switch detectWorkflowMatch(gitRepo, commit, triggedEvent, payload, evt) { case detectMatched: @@ -252,9 +256,10 @@ func DetectScheduledWorkflows(gitRepo *git.Repository, commit *git.Commit) ([]*D if evt.IsSchedule() { log.Trace("detect scheduled workflow: %q", entry.Name()) dwf := &DetectedWorkflow{ - EntryName: entry.Name(), - TriggerEvent: evt, - Content: content, + EntryName: entry.Name(), + TriggerEvent: evt, + Content: content, + SourceCommitSHA: commit.ID.String(), } wfs = append(wfs, dwf) } diff --git a/services/actions/helper.go b/services/actions/helper.go index 37dbb326ed4..c109555d01b 100644 --- a/services/actions/helper.go +++ b/services/actions/helper.go @@ -8,8 +8,10 @@ import ( "fmt" actions_model "gitea.dev/models/actions" + actions_module "gitea.dev/modules/actions" "gitea.dev/modules/actions/jobparser" "gitea.dev/modules/json" + "gitea.dev/modules/log" api "gitea.dev/modules/structs" ) @@ -50,6 +52,22 @@ func getInputsForJob(ctx context.Context, run *actions_model.ActionRun, job *act return p.Inputs, nil } +// pullRequestTargetBaseSHA returns the base branch commit of a pull_request_target run, and whether the run is one. +func pullRequestTargetBaseSHA(run *actions_model.ActionRun) (string, bool) { + if run.TriggerEvent != actions_module.GithubEventPullRequestTarget { + return "", false + } + payload, err := run.GetPullRequestEventPayload() + if err != nil { + log.Error("run %d: get pull request event payload: %v", run.ID, err) + return "", false + } + if payload.PullRequest == nil || payload.PullRequest.Base == nil || payload.PullRequest.Base.Sha == "" { + return "", false + } + return payload.PullRequest.Base.Sha, true +} + // 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() diff --git a/services/actions/helper_test.go b/services/actions/helper_test.go new file mode 100644 index 00000000000..87c5962413f --- /dev/null +++ b/services/actions/helper_test.go @@ -0,0 +1,73 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package actions + +import ( + "testing" + + actions_model "gitea.dev/models/actions" + actions_module "gitea.dev/modules/actions" + "gitea.dev/modules/json" + api "gitea.dev/modules/structs" + webhook_module "gitea.dev/modules/webhook" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPullRequestTargetBaseSHA(t *testing.T) { + prPayload := func(baseSHA string) string { + payload, err := json.Marshal(api.PullRequestPayload{ + PullRequest: &api.PullRequest{ + Base: &api.PRBranchInfo{Sha: baseSHA}, + }, + }) + require.NoError(t, err) + return string(payload) + } + + t.Run("pull_request_target with base SHA", func(t *testing.T) { + run := &actions_model.ActionRun{ + Event: webhook_module.HookEventPullRequest, + TriggerEvent: actions_module.GithubEventPullRequestTarget, + EventPayload: prPayload("base-sha"), + } + got, ok := pullRequestTargetBaseSHA(run) + assert.True(t, ok) + assert.Equal(t, "base-sha", got) + }) + + t.Run("non pull_request_target trigger", func(t *testing.T) { + run := &actions_model.ActionRun{ + Event: webhook_module.HookEventPullRequest, + TriggerEvent: actions_module.GithubEventPullRequest, + EventPayload: prPayload("base-sha"), + } + got, ok := pullRequestTargetBaseSHA(run) + assert.False(t, ok) + assert.Empty(t, got) + }) + + t.Run("missing base SHA", func(t *testing.T) { + run := &actions_model.ActionRun{ + Event: webhook_module.HookEventPullRequest, + TriggerEvent: actions_module.GithubEventPullRequestTarget, + EventPayload: prPayload(""), + } + got, ok := pullRequestTargetBaseSHA(run) + assert.False(t, ok) + assert.Empty(t, got) + }) + + t.Run("invalid payload", func(t *testing.T) { + run := &actions_model.ActionRun{ + Event: webhook_module.HookEventPullRequest, + TriggerEvent: actions_module.GithubEventPullRequestTarget, + EventPayload: "{", + } + got, ok := pullRequestTargetBaseSHA(run) + assert.False(t, ok) + assert.Empty(t, got) + }) +} diff --git a/services/actions/notifier_helper.go b/services/actions/notifier_helper.go index 9e558e2d2f9..02940c7e819 100644 --- a/services/actions/notifier_helper.go +++ b/services/actions/notifier_helper.go @@ -338,8 +338,8 @@ func handleWorkflows( isForkPullRequest := isForkPullRequestInput(input) for _, dwf := range detectedWorkflows { - // repo-level run: the workflow content is this repo at this commit - if err := buildApproveAndInsertRun(ctx, input, ref, commit, string(p), isForkPullRequest, dwf, input.Repo.ID, commit.ID.String(), false); err != nil { + // repo-level run: the workflow content is this repo at dwf.SourceCommitSHA + if err := buildApproveAndInsertRun(ctx, input, ref, commit, string(p), isForkPullRequest, dwf, input.Repo.ID, false); err != nil { log.Error("repo %s: %v", input.Repo.RelativePath(), err) continue } @@ -350,7 +350,7 @@ func handleWorkflows( // buildApproveAndInsertRun assembles an ActionRun for a detected workflow, runs the // fork-PR approval gate, and inserts it. Repo-level and scoped runs share this path so // run construction and the approval flow have a single implementation that can't drift. -// workflowRepoID/workflowCommitSHA point at the repo+commit the workflow content comes +// workflowRepoID and dwf.SourceCommitSHA point at the repo+commit the workflow content comes // from (the repo itself for repo-level runs, the source repo for scoped runs). func buildApproveAndInsertRun( ctx context.Context, @@ -361,9 +361,12 @@ func buildApproveAndInsertRun( isForkPullRequest bool, dwf *actions_module.DetectedWorkflow, workflowRepoID int64, - workflowCommitSHA string, isScopedRun bool, ) error { + if dwf.SourceCommitSHA == "" { + // unreachable in the normal flow; catches a test case that builds a DetectedWorkflow without it + setting.PanicInDevOrTesting("workflow %q has no source commit", dwf.EntryName) + } run := &actions_model.ActionRun{ Title: commit.MessageTitle(), RepoID: input.Repo.ID, @@ -380,7 +383,7 @@ func buildApproveAndInsertRun( TriggerEvent: dwf.TriggerEvent.Name, Status: actions_model.StatusWaiting, WorkflowRepoID: workflowRepoID, - WorkflowCommitSHA: workflowCommitSHA, + WorkflowCommitSHA: dwf.SourceCommitSHA, IsScopedRun: isScopedRun, } @@ -695,7 +698,7 @@ func detectAndHandleScopedWorkflows( continue } - sourceCommitSHA, detected, filtered, err := detectScopedWorkflowsForSource(ctx, input, consumerGitRepo, consumerCommit, sourceRepo) + detected, filtered, err := detectScopedWorkflowsForSource(ctx, input, consumerGitRepo, consumerCommit, sourceRepo) if err != nil { log.Error("scoped workflows: source %d for consumer %s: %v", sourceRepoID, input.Repo.RelativePath(), err) continue @@ -708,7 +711,7 @@ func detectAndHandleScopedWorkflows( continue } - if err := buildApproveAndInsertRun(ctx, input, ref, consumerCommit, string(p), isForkPullRequest, dwf, sourceRepo.ID, sourceCommitSHA, true); err != nil { + if err := buildApproveAndInsertRun(ctx, input, ref, consumerCommit, string(p), isForkPullRequest, dwf, sourceRepo.ID, true); err != nil { log.Error("scoped workflows: source %s workflow %s: %v", sourceRepo.RelativePath(), dwf.EntryName, err) continue } @@ -741,12 +744,12 @@ func detectScopedWorkflowsForSource( consumerGitRepo *git.Repository, consumerCommit *git.Commit, sourceRepo *repo_model.Repository, -) (sourceCommitSHA string, detected, filtered []*actions_module.DetectedWorkflow, err error) { +) (detected, filtered []*actions_module.DetectedWorkflow, err error) { // scoped workflow content is always taken from the source repo's default branch; the parse is cached per (source, default-branch SHA) and reused across consuming repos/events sourceCommitSHA, parsed, err := LoadParsedScopedWorkflows(ctx, sourceRepo) if err != nil { - return "", nil, nil, err + return nil, nil, err } - detected, filtered = actions_module.MatchScopedWorkflows(parsed, consumerGitRepo, consumerCommit, input.Event, input.Payload) - return sourceCommitSHA, detected, filtered, nil + detected, filtered = actions_module.MatchScopedWorkflows(parsed, sourceCommitSHA, consumerGitRepo, consumerCommit, input.Event, input.Payload) + return detected, filtered, nil } diff --git a/services/actions/reusable_workflow.go b/services/actions/reusable_workflow.go index ce179245082..4b61b854d4e 100644 --- a/services/actions/reusable_workflow.go +++ b/services/actions/reusable_workflow.go @@ -20,6 +20,7 @@ import ( "gitea.dev/modules/gitrepo" "gitea.dev/modules/httplib" "gitea.dev/modules/json" + "gitea.dev/modules/log" "gitea.dev/modules/setting" api "gitea.dev/modules/structs" "gitea.dev/modules/util" @@ -60,7 +61,11 @@ func loadReusableWorkflowSource(ctx context.Context, run *actions_model.ActionRu 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) + sourceCommitSHA := resolveSameRepoWorkflowSourceCommit(run, caller) + if sourceCommitSHA != caller.WorkflowSourceCommitSHA { + log.Warn("run %d (pull_request_target) records workflow source commit %s, resolving %q at base commit %s instead", run.ID, caller.WorkflowSourceCommitSHA, ref.Path, sourceCommitSHA) + } + bytes, resolvedSHA, err := readWorkflowFromRepo(ctx, callerRepo, sourceCommitSHA, ref.Path) if err != nil { return nil, 0, "", err } @@ -92,6 +97,19 @@ func loadReusableWorkflowSource(ctx context.Context, run *actions_model.ActionRu return nil, 0, "", fmt.Errorf("unsupported uses kind %d", ref.Kind) } +// resolveSameRepoWorkflowSourceCommit returns the commit to read a same-repo reusable workflow from. +// pull_request_target runs must resolve local `uses:` at the PR base commit, not a stored head SHA. +func resolveSameRepoWorkflowSourceCommit(run *actions_model.ActionRun, caller *actions_model.ActionRunJob) string { + // only a SHA copied from the run row can be the polluted head one; a SHA resolved from a `uses:` ref is right by construction + if run.IsScopedRun || caller.WorkflowSourceRepoID != run.RepoID || caller.WorkflowSourceCommitSHA != run.WorkflowCommitSHA { + return caller.WorkflowSourceCommitSHA + } + if baseSHA, ok := pullRequestTargetBaseSHA(run); ok && baseSHA != caller.WorkflowSourceCommitSHA { + return baseSHA + } + return caller.WorkflowSourceCommitSHA +} + // readWorkflowFromRepo loads a workflow file from `repo` at `refOrSHA` and returns its content plus the resolved commit SHA. func readWorkflowFromRepo(ctx context.Context, repo *repo_model.Repository, refOrSHA, path string) ([]byte, string, error) { gitRepo, err := gitrepo.OpenRepository(ctx, repo) diff --git a/services/actions/reusable_workflow_test.go b/services/actions/reusable_workflow_test.go index 49ee7016530..987061da15e 100644 --- a/services/actions/reusable_workflow_test.go +++ b/services/actions/reusable_workflow_test.go @@ -10,9 +10,13 @@ import ( actions_model "gitea.dev/models/actions" "gitea.dev/models/db" "gitea.dev/models/unittest" + actions_module "gitea.dev/modules/actions" "gitea.dev/modules/actions/jobparser" + "gitea.dev/modules/json" "gitea.dev/modules/setting" + api "gitea.dev/modules/structs" "gitea.dev/modules/test" + webhook_module "gitea.dev/modules/webhook" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -278,3 +282,74 @@ func TestUndoExpansion(t *testing.T) { assert.False(t, refreshed.IsExpanded) unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: sibling.ID}) } + +func TestResolveSameRepoWorkflowSourceCommit(t *testing.T) { + prtRun := func(baseSHA string) *actions_model.ActionRun { + payload, err := json.Marshal(api.PullRequestPayload{ + PullRequest: &api.PullRequest{ + Base: &api.PRBranchInfo{Sha: baseSHA}, + }, + }) + require.NoError(t, err) + // a run recorded before the fix points at the PR head commit + return &actions_model.ActionRun{ + ID: 42, + RepoID: 1, + Event: webhook_module.HookEventPullRequest, + TriggerEvent: actions_module.GithubEventPullRequestTarget, + EventPayload: string(payload), + WorkflowCommitSHA: "head-sha", + } + } + pushRun := &actions_model.ActionRun{ + RepoID: 1, + TriggerEvent: "push", + WorkflowCommitSHA: "head-sha", + } + caller := func(sourceRepoID int64, sourceCommitSHA string) *actions_model.ActionRunJob { + return &actions_model.ActionRunJob{WorkflowSourceRepoID: sourceRepoID, WorkflowSourceCommitSHA: sourceCommitSHA} + } + + t.Run("pull_request_target pins to base commit", func(t *testing.T) { + got := resolveSameRepoWorkflowSourceCommit(prtRun("base-sha"), caller(1, "head-sha")) + assert.Equal(t, "base-sha", got) + }) + + t.Run("legacy nested caller (with head-sha) pins to base commit", func(t *testing.T) { + nested := caller(1, "head-sha") + nested.ParentJobID = 99 + got := resolveSameRepoWorkflowSourceCommit(prtRun("base-sha"), nested) + assert.Equal(t, "base-sha", got) + }) + + t.Run("pull_request_target keeps stored SHA when already base", func(t *testing.T) { + run := prtRun("base-sha") + run.WorkflowCommitSHA = "base-sha" + got := resolveSameRepoWorkflowSourceCommit(run, caller(1, "base-sha")) + assert.Equal(t, "base-sha", got) + }) + + t.Run("non pull_request_target keeps stored SHA", func(t *testing.T) { + got := resolveSameRepoWorkflowSourceCommit(pushRun, caller(1, "head-sha")) + assert.Equal(t, "head-sha", got) + }) + + t.Run("scoped run keeps stored SHA", func(t *testing.T) { + run := prtRun("base-sha") + run.IsScopedRun = true + got := resolveSameRepoWorkflowSourceCommit(run, caller(1, "head-sha")) + assert.Equal(t, "head-sha", got) + }) + + t.Run("cross-repo caller keeps stored SHA", func(t *testing.T) { + got := resolveSameRepoWorkflowSourceCommit(prtRun("base-sha"), caller(2, "head-sha")) + assert.Equal(t, "head-sha", got) + }) + + t.Run("caller resolved from a uses: ref keeps its own SHA", func(t *testing.T) { + nested := caller(1, "tag-v1-sha") + nested.ParentJobID = 99 + got := resolveSameRepoWorkflowSourceCommit(prtRun("base-sha"), nested) + assert.Equal(t, "tag-v1-sha", got) + }) +} diff --git a/tests/integration/actions_reusable_workflow_test.go b/tests/integration/actions_reusable_workflow_test.go index 241e9bf0eb2..32eb5a93895 100644 --- a/tests/integration/actions_reusable_workflow_test.go +++ b/tests/integration/actions_reusable_workflow_test.go @@ -17,6 +17,7 @@ import ( repo_model "gitea.dev/models/repo" "gitea.dev/models/unittest" user_model "gitea.dev/models/user" + actions_module "gitea.dev/modules/actions" "gitea.dev/modules/gitrepo" "gitea.dev/modules/json" "gitea.dev/modules/queue" @@ -623,8 +624,6 @@ jobs: 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", @@ -665,7 +664,6 @@ jobs: 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{ @@ -702,6 +700,100 @@ jobs: runner.execTask(t, task, &mockTaskOutcome{result: runnerv1.Result_RESULT_SUCCESS}) }) + t.Run("pull_request_target resolves a local reusable workflow at the base commit", func(t *testing.T) { + apiBaseRepo := createActionsTestRepo(t, user2Token, "prt-reusable-test", false) + baseRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: apiBaseRepo.ID}) + + runner := newMockRunner() + runner.registerAsRepoRunner(t, baseRepo.OwnerName, baseRepo.Name, "mock-prt-runner", []string{"ubuntu-latest"}, false) + + reusablePath := ".gitea/workflows/reusable.yaml" + // A pull_request_target run's workflow should always come from the base branch. + createRepoWorkflowFile(t, user2, user2Token, baseRepo, reusablePath, `name: Reusable +on: + workflow_call: +jobs: + trusted: + runs-on: ubuntu-latest + steps: + - run: echo trusted +`) + baseFile := createWorkflowFile(t, user2Token, baseRepo.OwnerName, baseRepo.Name, ".gitea/workflows/prt.yaml", + getWorkflowCreateFileOptions(user2, baseRepo.DefaultBranch, "create prt.yaml", `name: PRT +on: pull_request_target +jobs: + call_reusable: + uses: ./.gitea/workflows/reusable.yaml + secrets: inherit +`)) + baseSHA := baseFile.Commit.SHA + + // user4 forks + req := NewRequestWithJSON(t, "POST", + fmt.Sprintf("/api/v1/repos/%s/%s/forks", baseRepo.OwnerName, baseRepo.Name), + &api.CreateForkOption{Name: new("prt-reusable-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) + + // user4 rewrites the reusable workflow the base branch calls into, and opens a PR + req = NewRequest(t, "GET", + fmt.Sprintf("/api/v1/repos/%s/%s/contents/%s", forkRepo.OwnerName, forkRepo.Name, reusablePath)).AddTokenAuth(user4Token) + resp = MakeRequest(t, req, http.StatusOK) + forkReusable := DecodeJSON(t, resp, &api.ContentsResponse{}) + + req = NewRequestWithJSON(t, "PUT", + fmt.Sprintf("/api/v1/repos/%s/%s/contents/%s", forkRepo.OwnerName, forkRepo.Name, reusablePath), &api.UpdateFileOptions{ + FileOptions: api.FileOptions{ + NewBranchName: "fork-branch", + Message: "rewrite the reusable workflow", + 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()}, + }, + SHA: forkReusable.SHA, + ContentBase64: base64.StdEncoding.EncodeToString([]byte(`name: Reusable +on: + workflow_call: +jobs: + from-fork: + runs-on: ubuntu-latest + steps: + - run: echo from-fork +`)), + }).AddTokenAuth(user4Token) + resp = MakeRequest(t, req, http.StatusOK) + forkHeadSHA := DecodeJSON(t, resp, &api.FileResponse{}).Commit.SHA + require.NotEqual(t, baseSHA, forkHeadSHA) + + doAPICreatePullRequest(user4APICtx, baseRepo.OwnerName, baseRepo.Name, baseRepo.DefaultBranch, user4.Name+":fork-branch")(t) + + assert.Equal(t, 1, unittest.GetCount(t, &actions_model.ActionRun{RepoID: baseRepo.ID})) + prtRun := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{RepoID: baseRepo.ID}) + assert.Equal(t, actions_module.GithubEventPullRequestTarget, prtRun.TriggerEvent) + assert.True(t, prtRun.IsForkPullRequest) + assert.False(t, prtRun.NeedApproval) + // The run still points at the PR head, but its workflow source must be the base commit. + assert.Equal(t, forkHeadSHA, prtRun.CommitSHA) + assert.Equal(t, baseSHA, prtRun.WorkflowCommitSHA) + + caller := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: prtRun.ID, JobID: "call_reusable"}) + assert.Equal(t, baseSHA, caller.WorkflowSourceCommitSHA) + assert.NotContains(t, string(caller.ReusableWorkflowContent), "from-fork") + + // The caller has no needs, so it is expanded inline at insert time: the child comes from the base branch. + unittest.AssertNotExistsBean(t, &actions_model.ActionRunJob{RunID: prtRun.ID, JobID: "from-fork"}) + child := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: prtRun.ID, JobID: "trusted"}) + assert.Equal(t, caller.ID, child.ParentJobID) + assert.Equal(t, baseSHA, child.WorkflowSourceCommitSHA) + + task := runner.fetchTask(t) + _, taskJob, _ := getTaskAndJobAndRunByTaskID(t, task.Id) + require.Equal(t, "trusted", taskJob.JobID) + 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})