From 8161479fdefe847b10afa8d6d08a7145325d795c Mon Sep 17 00:00:00 2001 From: bircni Date: Wed, 12 Aug 2026 19:03:44 +0200 Subject: [PATCH] fix(actions): resolve pull_request_target reusable workflows at the base commit (#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. ## Fix **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: Zettat --- modules/actions/scoped_workflows.go | 8 +- modules/actions/workflows.go | 23 +++-- services/actions/helper.go | 18 ++++ services/actions/helper_test.go | 60 ++++++++++++ services/actions/notifier_helper.go | 25 ++--- services/actions/reusable_workflow.go | 20 +++- services/actions/reusable_workflow_test.go | 75 ++++++++++++++ .../actions_reusable_workflow_test.go | 98 ++++++++++++++++++- 8 files changed, 300 insertions(+), 27 deletions(-) diff --git a/modules/actions/scoped_workflows.go b/modules/actions/scoped_workflows.go index 376faecacee..31e59ef3bf0 100644 --- a/modules/actions/scoped_workflows.go +++ b/modules/actions/scoped_workflows.go @@ -62,6 +62,7 @@ func ParseScopedWorkflows(ctx context.Context, gitRepo *git.Repository, sourceCo func MatchScopedWorkflows( ctx context.Context, parsed []*ParsedScopedWorkflow, + sourceCommitSHA string, consumerGitRepo *git.Repository, consumerCommit *git.Commit, inputEvent webhook_module.HookEventType, @@ -74,9 +75,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(ctx, consumerGitRepo, consumerCommit, inputEvent, payload, evt) { case detectMatched: diff --git a/modules/actions/workflows.go b/modules/actions/workflows.go index 6b268400436..97af9e38bd3 100644 --- a/modules/actions/workflows.go +++ b/modules/actions/workflows.go @@ -29,6 +29,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 @@ -205,17 +207,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(ctx, gitRepo, commit, triggedEvent, payload, evt) { case detectMatched: @@ -254,9 +258,10 @@ func DetectScheduledWorkflows(ctx context.Context, gitRepo *git.Repository, comm 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 3c55169579e..01ec304d327 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" "gitea.dev/modules/util" ) @@ -51,6 +53,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 index b6172e44a2b..0635a73cd85 100644 --- a/services/actions/helper_test.go +++ b/services/actions/helper_test.go @@ -8,6 +8,10 @@ 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" @@ -58,3 +62,59 @@ jobs: }) } } + +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 429afdac21c..527e9da5caf 100644 --- a/services/actions/notifier_helper.go +++ b/services/actions/notifier_helper.go @@ -336,8 +336,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.FullName(), err) continue } @@ -348,7 +348,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, @@ -359,9 +359,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, @@ -378,7 +381,7 @@ func buildApproveAndInsertRun( TriggerEvent: dwf.TriggerEvent.Name, Status: actions_model.StatusWaiting, WorkflowRepoID: workflowRepoID, - WorkflowCommitSHA: workflowCommitSHA, + WorkflowCommitSHA: dwf.SourceCommitSHA, IsScopedRun: isScopedRun, } @@ -693,7 +696,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.FullName(), err) continue @@ -706,7 +709,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.FullName(), dwf.EntryName, err) continue } @@ -739,13 +742,13 @@ 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(ctx, parsed, consumerGitRepo, consumerCommit, input.Event, input.Payload) - return sourceCommitSHA, detected, filtered, nil + detected, filtered = actions_module.MatchScopedWorkflows(ctx, 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 2a1b50f290d..173a28d8fd7 100644 --- a/services/actions/reusable_workflow.go +++ b/services/actions/reusable_workflow.go @@ -20,6 +20,7 @@ import ( "gitea.dev/modules/git" "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 := git.OpenRepository(ctx, repo) diff --git a/services/actions/reusable_workflow_test.go b/services/actions/reusable_workflow_test.go index 4875b5777f0..1528e991220 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" @@ -289,3 +293,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 f53f9828997..77b858ec342 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/git" "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})