mirror of
https://github.com/go-gitea/gitea.git
synced 2026-08-14 02:40:57 +09:00
8161479fde
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 <zettat123@gmail.com>
367 lines
13 KiB
Go
367 lines
13 KiB
Go
// Copyright 2026 The Gitea Authors. All rights reserved.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package actions
|
|
|
|
import (
|
|
"fmt"
|
|
"testing"
|
|
|
|
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"
|
|
)
|
|
|
|
func TestCheckCallerChain_Cycle(t *testing.T) {
|
|
t.Run("DirectCycle", func(t *testing.T) {
|
|
require.NoError(t, unittest.PrepareTestDatabase())
|
|
// A -> A: leaf's CallUses matches its direct parent's.
|
|
chain := buildCallerChain(t,
|
|
"./.gitea/workflows/a.yml",
|
|
"./.gitea/workflows/a.yml",
|
|
)
|
|
err := checkCallerChain(t.Context(), chain[len(chain)-1])
|
|
assert.ErrorContains(t, err, "cycle detected")
|
|
})
|
|
|
|
t.Run("IndirectCycle", func(t *testing.T) {
|
|
require.NoError(t, unittest.PrepareTestDatabase())
|
|
// A -> B -> A: leaf's CallUses matches its grandparent's.
|
|
chain := buildCallerChain(t,
|
|
"./.gitea/workflows/a.yml",
|
|
"./.gitea/workflows/b.yml",
|
|
"./.gitea/workflows/a.yml",
|
|
)
|
|
err := checkCallerChain(t.Context(), chain[len(chain)-1])
|
|
assert.ErrorContains(t, err, "cycle detected")
|
|
})
|
|
|
|
t.Run("MixedPrefixCycle", func(t *testing.T) {
|
|
require.NoError(t, unittest.PrepareTestDatabase())
|
|
// A -> A written with both same-repo prefixes: they name the same file.
|
|
chain := buildCallerChain(t,
|
|
"./.gitea/workflows/a.yml",
|
|
"$/.gitea/workflows/a.yml",
|
|
)
|
|
err := checkCallerChain(t.Context(), chain[len(chain)-1])
|
|
assert.ErrorContains(t, err, "cycle detected")
|
|
})
|
|
|
|
t.Run("NoCycle", func(t *testing.T) {
|
|
require.NoError(t, unittest.PrepareTestDatabase())
|
|
// Sanity: linear chain with distinct CallUses must not trip cycle detection.
|
|
chain := buildCallerChain(t,
|
|
"./.gitea/workflows/a.yml",
|
|
"./.gitea/workflows/b.yml",
|
|
"./.gitea/workflows/c.yml",
|
|
)
|
|
require.NoError(t, checkCallerChain(t.Context(), chain[len(chain)-1]))
|
|
})
|
|
}
|
|
|
|
func TestCheckCallerChain_DepthLimit(t *testing.T) {
|
|
// top + MaxReusableCallLevels nested callers is the longest accepted; one more exceeds the limit.
|
|
makeDistinctUses := func(n int) []string {
|
|
out := make([]string, n)
|
|
for i := range out {
|
|
out[i] = fmt.Sprintf("./.gitea/workflows/level%d.yml", i)
|
|
}
|
|
return out
|
|
}
|
|
|
|
t.Run("ExactlyAtLimit", func(t *testing.T) {
|
|
require.NoError(t, unittest.PrepareTestDatabase())
|
|
chain := buildCallerChain(t, makeDistinctUses(MaxReusableCallLevels+1)...)
|
|
require.NoError(t, checkCallerChain(t.Context(), chain[len(chain)-1]))
|
|
})
|
|
|
|
t.Run("OneOverLimit", func(t *testing.T) {
|
|
require.NoError(t, unittest.PrepareTestDatabase())
|
|
chain := buildCallerChain(t, makeDistinctUses(MaxReusableCallLevels+2)...)
|
|
err := checkCallerChain(t.Context(), chain[len(chain)-1])
|
|
assert.ErrorContains(t, err, "exceeds the maximum nesting level")
|
|
})
|
|
}
|
|
|
|
// buildCallerChain inserts a linear chain of reusable caller jobs in a single run+attempt.
|
|
// callerUses[0] is the top-level caller (ParentJobID=0); each subsequent caller is inserted as a child of the previous one.
|
|
// Returns the inserted jobs in order (index 0 = top, last = leaf).
|
|
func buildCallerChain(t *testing.T, callerUses ...string) []*actions_model.ActionRunJob {
|
|
t.Helper()
|
|
require.NotEmpty(t, callerUses)
|
|
ctx := t.Context()
|
|
|
|
run := &actions_model.ActionRun{
|
|
Title: "caller-chain-test",
|
|
RepoID: 4,
|
|
OwnerID: 1,
|
|
Index: 9601,
|
|
WorkflowID: "test.yaml",
|
|
TriggerUserID: 1,
|
|
Ref: "refs/heads/master",
|
|
CommitSHA: "c2d72f548424103f01ee1dc02889c1e2bff816b0",
|
|
Event: "push",
|
|
TriggerEvent: "push",
|
|
EventPayload: "{}",
|
|
Status: actions_model.StatusRunning,
|
|
}
|
|
require.NoError(t, db.Insert(ctx, run))
|
|
|
|
attempt := &actions_model.ActionRunAttempt{
|
|
RepoID: run.RepoID,
|
|
RunID: run.ID,
|
|
Attempt: 1,
|
|
TriggerUserID: 1,
|
|
Status: actions_model.StatusRunning,
|
|
}
|
|
require.NoError(t, db.Insert(ctx, attempt))
|
|
|
|
jobs := make([]*actions_model.ActionRunJob, 0, len(callerUses))
|
|
parentID := int64(0)
|
|
for i, uses := range callerUses {
|
|
job := &actions_model.ActionRunJob{
|
|
RunID: run.ID,
|
|
RunAttemptID: attempt.ID,
|
|
RepoID: run.RepoID,
|
|
OwnerID: run.OwnerID,
|
|
CommitSHA: run.CommitSHA,
|
|
Name: fmt.Sprintf("caller-%d", i),
|
|
JobID: fmt.Sprintf("caller-%d", i),
|
|
Attempt: 1,
|
|
Status: actions_model.StatusBlocked,
|
|
AttemptJobID: int64(i + 1),
|
|
IsReusableCaller: true,
|
|
CallUses: uses,
|
|
ParentJobID: parentID,
|
|
}
|
|
require.NoError(t, db.Insert(ctx, job))
|
|
jobs = append(jobs, job)
|
|
parentID = job.ID
|
|
}
|
|
return jobs
|
|
}
|
|
|
|
func TestResolveUses(t *testing.T) {
|
|
defer test.MockVariableValue(&setting.AppURL, "https://gitea.example.com/sub/")()
|
|
defer test.MockVariableValue(&setting.AppSubURL, "/sub")()
|
|
defer test.MockVariableValue(&setting.Actions.WorkflowDirs, []string{".gitea/workflows", ".github/workflows"})()
|
|
defer test.MockVariableValue(&setting.Actions.ScopedWorkflowDirs, []string{".gitea/scoped_workflows"})()
|
|
ctx := t.Context()
|
|
|
|
t.Run("LocalForms", func(t *testing.T) {
|
|
// Same-repo and cross-repo forms are not URLs and are parsed as-is.
|
|
ref, err := ResolveUses(ctx, "./.gitea/workflows/build.yml")
|
|
require.NoError(t, err)
|
|
assert.Equal(t, jobparser.UsesRef{Kind: jobparser.UsesKindLocalSameRepo, Path: ".gitea/workflows/build.yml"}, *ref)
|
|
|
|
ref, err = ResolveUses(ctx, "owner/repo/.gitea/workflows/build.yml@v1")
|
|
require.NoError(t, err)
|
|
assert.Equal(t, jobparser.UsesRef{Kind: jobparser.UsesKindLocalCrossRepo, Owner: "owner", Repo: "repo", Path: ".gitea/workflows/build.yml", Ref: "v1"}, *ref)
|
|
})
|
|
|
|
t.Run("DirectoryAllowlist", func(t *testing.T) {
|
|
// SCOPED_WORKFLOW_DIRS is allowed (local and cross-repo).
|
|
ref, err := ResolveUses(ctx, "./.gitea/scoped_workflows/lib.yml")
|
|
require.NoError(t, err)
|
|
assert.Equal(t, ".gitea/scoped_workflows/lib.yml", ref.Path)
|
|
|
|
ref, err = ResolveUses(ctx, "owner/repo/.gitea/scoped_workflows/lib.yml@v1")
|
|
require.NoError(t, err)
|
|
assert.Equal(t, ".gitea/scoped_workflows/lib.yml", ref.Path)
|
|
|
|
// A directory that is neither WORKFLOW_DIRS nor SCOPED_WORKFLOW_DIRS parses but is rejected by the allowlist.
|
|
_, err = ResolveUses(ctx, "./not-workflows/build.yml")
|
|
require.Error(t, err)
|
|
_, err = ResolveUses(ctx, "owner/repo/lib/build.yml@v1")
|
|
require.Error(t, err)
|
|
})
|
|
|
|
t.Run("ConfigurableWorkflowDirs", func(t *testing.T) {
|
|
// A non-default WORKFLOW_DIRS is honored (the hardcoded ".gitea/workflows" is no longer special).
|
|
defer test.MockVariableValue(&setting.Actions.WorkflowDirs, []string{".gitea/ci"})()
|
|
ref, err := ResolveUses(ctx, "./.gitea/ci/build.yml")
|
|
require.NoError(t, err)
|
|
assert.Equal(t, ".gitea/ci/build.yml", ref.Path)
|
|
|
|
_, err = ResolveUses(ctx, "./.gitea/workflows/build.yml") // no longer a configured dir
|
|
require.Error(t, err)
|
|
})
|
|
|
|
t.Run("LocalInstanceURL", func(t *testing.T) {
|
|
// An absolute URL on this instance (incl. AppSubURL) resolves to the equivalent cross-repo ref.
|
|
ref, err := ResolveUses(ctx, "https://gitea.example.com/sub/owner/repo/.gitea/workflows/ci.yml@refs/heads/main")
|
|
require.NoError(t, err)
|
|
assert.Equal(t, jobparser.UsesRef{Kind: jobparser.UsesKindLocalCrossRepo, Owner: "owner", Repo: "repo", Path: ".gitea/workflows/ci.yml", Ref: "refs/heads/main"}, *ref)
|
|
})
|
|
|
|
t.Run("InvalidSyntax", func(t *testing.T) {
|
|
for _, in := range []string{
|
|
"owner/.gitea/workflows/foo.yml", // missing repo segment
|
|
"owner/repo/.gitea/workflows/foo.yml", // missing @ref
|
|
"https://gitea.example.com/sub/repo/.gitea/workflows/ci.yml@refs/heads/main", // local absolute URL but missing owner
|
|
"not a valid uses at all",
|
|
} {
|
|
_, err := ResolveUses(ctx, in)
|
|
require.Error(t, err, "in = %s", in)
|
|
}
|
|
})
|
|
|
|
t.Run("ForeignURL", func(t *testing.T) {
|
|
_, err := ResolveUses(ctx, "https://other.gitea-example.com/owner/repo/.gitea/workflows/ci.yaml@v1")
|
|
assert.ErrorContains(t, err, "must point to this Gitea instance")
|
|
})
|
|
}
|
|
|
|
func TestCheckRunJobLimit(t *testing.T) {
|
|
require.NoError(t, unittest.PrepareTestDatabase())
|
|
|
|
const (
|
|
runID = 900100
|
|
attemptA = 910001
|
|
attemptB = 910002
|
|
)
|
|
|
|
seed := func(attemptID int64, n int) {
|
|
for i := range n {
|
|
name := fmt.Sprintf("job-%d-%d", attemptID, i)
|
|
require.NoError(t, db.Insert(t.Context(), &actions_model.ActionRunJob{
|
|
RunID: runID,
|
|
RunAttemptID: attemptID,
|
|
RepoID: 1,
|
|
OwnerID: 1,
|
|
CommitSHA: "abcdef",
|
|
Name: name,
|
|
JobID: name,
|
|
AttemptJobID: attemptID*1000 + int64(i),
|
|
Status: actions_model.StatusBlocked,
|
|
}))
|
|
}
|
|
}
|
|
|
|
seed(attemptA, 5)
|
|
seed(attemptB, 3) // a different attempt of the same run must not count toward attempt A
|
|
|
|
limit := actions_model.MaxJobNumPerRun
|
|
|
|
// attempt A already holds 5 jobs: filling up to the cap is allowed, one more is rejected.
|
|
require.NoError(t, checkRunJobLimit(t.Context(), runID, attemptA, limit-5))
|
|
require.ErrorContains(t, checkRunJobLimit(t.Context(), runID, attemptA, limit-4), "maximum")
|
|
require.ErrorContains(t, checkRunJobLimit(t.Context(), runID, attemptA, limit), "maximum")
|
|
|
|
// the count is scoped to the attempt: attempt B only holds 3 jobs, so attempt A's 5 must not leak in.
|
|
require.NoError(t, checkRunJobLimit(t.Context(), runID, attemptB, limit-3))
|
|
require.ErrorContains(t, checkRunJobLimit(t.Context(), runID, attemptB, limit-2), "maximum")
|
|
}
|
|
|
|
func TestUndoExpansion(t *testing.T) {
|
|
require.NoError(t, unittest.PrepareTestDatabase())
|
|
ctx := t.Context()
|
|
|
|
// A claimed caller with two children inserted by the aborted expansion, plus a sibling that must survive.
|
|
caller := &actions_model.ActionRunJob{
|
|
RunID: 991, RepoID: 4, OwnerID: 1, JobID: "caller", Name: "caller",
|
|
Status: actions_model.StatusBlocked, IsReusableCaller: true, IsExpanded: true,
|
|
}
|
|
require.NoError(t, db.Insert(ctx, caller))
|
|
for _, jobID := range []string{"child1", "child2"} {
|
|
require.NoError(t, db.Insert(ctx, &actions_model.ActionRunJob{
|
|
RunID: 991, RepoID: 4, OwnerID: 1, JobID: jobID, Name: jobID,
|
|
Status: actions_model.StatusBlocked, ParentJobID: caller.ID,
|
|
}))
|
|
}
|
|
sibling := &actions_model.ActionRunJob{
|
|
RunID: 991, RepoID: 4, OwnerID: 1, JobID: "sibling", Name: "sibling",
|
|
Status: actions_model.StatusBlocked,
|
|
}
|
|
require.NoError(t, db.Insert(ctx, sibling))
|
|
|
|
require.NoError(t, undoExpansion(ctx, caller))
|
|
|
|
assert.Equal(t, 0, unittest.GetCount(t, &actions_model.ActionRunJob{ParentJobID: caller.ID}))
|
|
assert.False(t, caller.IsExpanded)
|
|
refreshed := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: caller.ID})
|
|
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)
|
|
})
|
|
}
|