mirror of
https://github.com/go-gitea/gitea.git
synced 2026-06-10 05:54:25 +02:00
Follow up #37478 ## Changes 1. #37478 doesn't support absolute URL in `uses`. This PR provides partial support for URL-style reusable workflow references. A reusable workflow can now be referenced by an absolute URL, as long as it points to the local Gitea instance: ```yaml jobs: call: uses: https://your-gitea.example.com/OWNER/REPO/.gitea/workflows/ci.yaml@v1 ``` 2. Show an error message in the UI for invalid `uses`. <img width="1600" alt="image" src="https://github.com/user-attachments/assets/21b34e61-bf10-4af1-b9fd-4ee4e9fde049" /> 3. Fix reusable caller cancellation issue. A reusable caller's status is aggregated from its children, so cancellation should processes a caller's descendants deepest-first. --------- Signed-off-by: Zettat123 <zettat123@gmail.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: bircni <bircni@icloud.com> Co-authored-by: Giteabot <teabot@gitea.io>
179 lines
6.2 KiB
Go
179 lines
6.2 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"
|
|
"gitea.dev/modules/actions/jobparser"
|
|
"gitea.dev/modules/setting"
|
|
"gitea.dev/modules/test"
|
|
|
|
"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("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")()
|
|
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("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")
|
|
})
|
|
}
|