mirror of
https://github.com/go-gitea/gitea.git
synced 2026-09-25 22:23:42 +09:00
Updates actionslib to https://gitea.com/gitea/actionslib/releases/tag/v1.2.1, moves workflow parsing into it and aligns behaviour with GitHub. 1. `uses:` supports `self:` (Gitea-only feature) and `$/` paths. 1. `strategy`, `matrix`, `max-parallel` and `fail-fast` accept expressions, including over `needs`. A job whose `name`, `runs-on` or `continue-on-error` reads `needs` is resolved once they finish. 1. A job `if:` may only read `github`, `needs`, `vars` and `inputs` and is decided before the matrix, as on github.com. 1. Matrix `fail-fast` cancels the other combinations, and `always()` jobs keep running when a run is cancelled. 1. Invalid workflow files, including a malformed `on:` and unknown or cyclic `needs`, show up on push as failed runs with the error. 1. A job whose `if:` or `concurrency:` fails to evaluate is skipped or failed with the error, instead of staying blocked. 1. Reusable workflows: a missing and an unreadable repository fail alike, public callers cannot use private workflows, nested jobs cannot exceed the caller's token permissions. 1. Runner labels match case-insensitively, and `runs-on` accepts an array from an expression. Runner PR: https://gitea.com/gitea/runner/pulls/1247 Docs PR: https://gitea.com/gitea/docs/pulls/553 Fixes: https://github.com/go-gitea/gitea/issues/38990 Fixes: https://github.com/go-gitea/gitea/issues/39382 Fixes: https://github.com/go-gitea/gitea/issues/32364 Fixes: https://github.com/go-gitea/gitea/issues/36077 Fixes: https://github.com/go-gitea/gitea/issues/23277 Fixes: https://github.com/go-gitea/gitea/issues/29020 Co-authored-by: Claude (Opus 5) <noreply@anthropic.com> Co-authored-by: Zettat123 <zettat123@gmail.com>
415 lines
16 KiB
Go
415 lines
16 KiB
Go
// Copyright 2026 The Gitea Authors. All rights reserved.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package actions
|
|
|
|
import (
|
|
"fmt"
|
|
"testing"
|
|
|
|
"gitea.dev/actionslib/pkg/model"
|
|
actions_model "gitea.dev/models/actions"
|
|
"gitea.dev/models/db"
|
|
"gitea.dev/models/unittest"
|
|
actions_module "gitea.dev/modules/actions"
|
|
"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")
|
|
assert.Equal(t, canonicalCallUses(&actions_model.ActionRunJob{CallUses: "owner/repo/.gitea/workflows/a.yml@v1"}), canonicalCallUses(&actions_model.ActionRunJob{CallUses: "self:owner/repo/.gitea/workflows/a.yml@v1"}))
|
|
})
|
|
|
|
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]))
|
|
})
|
|
|
|
t.Run("SameLocalPathInOtherRepo", func(t *testing.T) {
|
|
require.NoError(t, unittest.PrepareTestDatabase())
|
|
chain := buildCallerChain(t,
|
|
"./.gitea/workflows/a.yml",
|
|
"owner/lib/.gitea/workflows/lib.yml@v1",
|
|
"./.gitea/workflows/a.yml",
|
|
)
|
|
leaf := chain[len(chain)-1]
|
|
leaf.WorkflowSourceRepoID = 2
|
|
require.NoError(t, checkCallerChain(t.Context(), leaf))
|
|
})
|
|
|
|
t.Run("ResolvedIdentityCycle", func(t *testing.T) {
|
|
require.NoError(t, unittest.PrepareTestDatabase())
|
|
chain := buildCallerChain(t,
|
|
"./.gitea/workflows/a.yml",
|
|
"owner/repo/.gitea/workflows/b.yml@v1",
|
|
"owner/repo/.gitea/workflows/a.yml@v1",
|
|
)
|
|
chain[1].WorkflowSourceRepoID = 4
|
|
chain[1].WorkflowSourceCommitSHA = "first-commit"
|
|
_, err := actions_model.UpdateRunJob(t.Context(), chain[1], nil, "workflow_source_repo_id", "workflow_source_commit_sha")
|
|
require.NoError(t, err)
|
|
chain[2].WorkflowSourceRepoID = 5
|
|
chain[2].WorkflowSourceCommitSHA = "second-commit"
|
|
|
|
require.NoError(t, checkCallerChain(t.Context(), chain[2]))
|
|
require.ErrorContains(t, checkResolvedCallerCycle(t.Context(), chain[2], 4, "first-commit", ".gitea/workflows/a.yml"), "cycle detected")
|
|
require.NoError(t, checkResolvedCallerCycle(t.Context(), chain[2], 4, "other-commit", ".gitea/workflows/a.yml"))
|
|
})
|
|
}
|
|
|
|
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, model.ReusableWorkflowUses{Path: ".gitea/workflows/build.yml"}, *ref)
|
|
|
|
ref, err = ResolveUses(ctx, "owner/repo/.gitea/workflows/build.yml@v1")
|
|
require.NoError(t, err)
|
|
assert.Equal(t, model.ReusableWorkflowUses{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)
|
|
|
|
ref, err = ResolveUses(ctx, "self: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)
|
|
_, err = ResolveUses(ctx, "self: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, model.ReusableWorkflowUses{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 TestLoadReusableWorkflowSourceFailsAlikeForMissingAndPrivateRepo(t *testing.T) {
|
|
require.NoError(t, unittest.PrepareTestDatabase())
|
|
|
|
run := &actions_model.ActionRun{RepoID: 4, TriggerUserID: 1}
|
|
for _, repoName := range []string{"missing", "repo3"} {
|
|
_, _, _, err := loadReusableWorkflowSource(t.Context(), run, nil, &model.ReusableWorkflowUses{Owner: "org3", Repo: repoName, Path: ".gitea/workflows/build.yml", Ref: "main"})
|
|
assert.EqualError(t, err, "reusable workflow repository org3/"+repoName+" does not exist or is not readable")
|
|
}
|
|
}
|
|
|
|
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)
|
|
})
|
|
}
|