From 1c92062c6986ee587efdf8d50575315c48efde8b Mon Sep 17 00:00:00 2001 From: Zettat123 Date: Wed, 12 Aug 2026 13:12:39 -0600 Subject: [PATCH] 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 --- modules/actions/scoped_workflows.go | 8 +- modules/actions/workflows.go | 23 +++-- services/actions/helper.go | 18 ++++ services/actions/helper_test.go | 73 ++++++++++++++ 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, 313 insertions(+), 27 deletions(-) create mode 100644 services/actions/helper_test.go 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})