fix(actions): resolve pull_request_target reusable workflows at the base commit (#38886) (#38897)

Backport #38886

For a `pull_request_target` (PRT) run, Gitea loads the top-level
workflow from the trusted base branch, but any local reusable workflow
it calls (`uses: ./...`) was read from the PR **head** commit, which the
fork author controls.

**Record the source commit where the content is read.**
`DetectedWorkflow` now carries a `SourceCommitSHA` filled in next to
`Content`, so the PRT detection pass at the base commit records the base
SHA automatically.

**Defense in depth.** `loadReusableWorkflowSource` pins the PR base
commit for a PRT run's local `uses: ./...` rather than trusting the
stored SHA. This also covers runs recorded before this change, whose
rows still hold the head SHA and would otherwise resolve from the fork
on rerun.

Existing run rows are not migrated.

Co-authored-by: bircni <bircni@icloud.com>
This commit is contained in:
Zettat123
2026-08-12 13:12:39 -06:00
committed by GitHub
parent 51938de973
commit 1c92062c69
8 changed files with 313 additions and 27 deletions
+5 -3
View File
@@ -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:
+14 -9
View File
@@ -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)
}
+18
View File
@@ -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()
+73
View File
@@ -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)
})
}
+14 -11
View File
@@ -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
}
+19 -1
View File
@@ -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)
@@ -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)
})
}
@@ -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})