mirror of
https://github.com/go-gitea/gitea.git
synced 2026-08-10 05:24:18 +09:00
feat(actions): support $/ prefix in reusable workflow uses: (#38822)
Fixes: https://github.com/go-gitea/gitea/issues/38818 Accepts GitHub's `$/` self-repository prefix in a reusable workflow `uses:`, alongside `./`. Gitea's `./` already resolves against the caller's own source repo and commit, which is what `$/` means, so the two are aliases here. Cycle detection folds both prefixes onto one key. Related PR for step-level support: https://gitea.com/gitea/runner/pulls/1150
This commit is contained in:
@@ -15,7 +15,7 @@ import (
|
|||||||
type UsesKind int
|
type UsesKind int
|
||||||
|
|
||||||
const (
|
const (
|
||||||
// UsesKindLocalSameRepo is "./<dir>/foo.yml" - a path inside the calling repository.
|
// UsesKindLocalSameRepo is "./<dir>/foo.yml" or "$/<dir>/foo.yml" - a path inside the calling repository.
|
||||||
// For example: "./.gitea/workflows/foo.yml"
|
// For example: "./.gitea/workflows/foo.yml"
|
||||||
UsesKindLocalSameRepo UsesKind = iota + 1
|
UsesKindLocalSameRepo UsesKind = iota + 1
|
||||||
// UsesKindLocalCrossRepo is "owner/repo/<dir>/foo.yml@ref" - a workflow in another repo on the same instance.
|
// UsesKindLocalCrossRepo is "owner/repo/<dir>/foo.yml@ref" - a workflow in another repo on the same instance.
|
||||||
@@ -33,13 +33,13 @@ type UsesRef struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
reLocalSameRepo = regexp.MustCompile(`^\./([^@]+\.ya?ml)$`)
|
reLocalSameRepo = regexp.MustCompile(`^[.$]/([^@]+\.ya?ml)$`)
|
||||||
reLocalCrossRepo = regexp.MustCompile(`^([-.\w]+)/([-.\w]+)/([^@]+\.ya?ml)@(.+)$`)
|
reLocalCrossRepo = regexp.MustCompile(`^([-.\w]+)/([-.\w]+)/([^@]+\.ya?ml)@(.+)$`)
|
||||||
)
|
)
|
||||||
|
|
||||||
// ParseUses parses the SYNTAX of a reusable workflow "uses:" value into a UsesRef. Two forms are supported:
|
// ParseUses parses the SYNTAX of a reusable workflow "uses:" value into a UsesRef. Two forms are supported:
|
||||||
// - "./<dir>/foo.yml" (UsesKindLocalSameRepo, no @ref)
|
// - "./<dir>/foo.yml" or "$/<dir>/foo.yml" (UsesKindLocalSameRepo, no @ref)
|
||||||
// - "OWNER/REPO/<dir>/foo.yml@REF" (UsesKindLocalCrossRepo)
|
// - "OWNER/REPO/<dir>/foo.yml@REF" (UsesKindLocalCrossRepo)
|
||||||
//
|
//
|
||||||
// It deliberately does NOT validate that <dir> is an allowed workflow directory: the allowed directories are instance-configurable (WORKFLOW_DIRS / SCOPED_WORKFLOW_DIRS).
|
// It deliberately does NOT validate that <dir> is an allowed workflow directory: the allowed directories are instance-configurable (WORKFLOW_DIRS / SCOPED_WORKFLOW_DIRS).
|
||||||
// The caller (services/actions.ResolveUses) enforces the directory allowlist. The returned Path is the cleaned, repo-relative file path.
|
// The caller (services/actions.ResolveUses) enforces the directory allowlist. The returned Path is the cleaned, repo-relative file path.
|
||||||
@@ -49,10 +49,10 @@ func ParseUses(s string) (*UsesRef, error) {
|
|||||||
return nil, errors.New("empty uses value")
|
return nil, errors.New("empty uses value")
|
||||||
}
|
}
|
||||||
|
|
||||||
if strings.HasPrefix(s, "./") {
|
if strings.HasPrefix(s, "./") || strings.HasPrefix(s, "$/") {
|
||||||
m := reLocalSameRepo.FindStringSubmatch(s)
|
m := reLocalSameRepo.FindStringSubmatch(s)
|
||||||
if m == nil {
|
if m == nil {
|
||||||
return nil, fmt.Errorf(`invalid local "uses:" %q (expect ./<dir>/<file>.yml)`, s)
|
return nil, fmt.Errorf(`invalid local "uses:" %q (expect ./<dir>/<file>.yml or $/<dir>/<file>.yml)`, s)
|
||||||
}
|
}
|
||||||
p := m[1]
|
p := m[1]
|
||||||
if path.Clean(p) != p {
|
if path.Clean(p) != p {
|
||||||
|
|||||||
@@ -53,6 +53,11 @@ func TestParseUses(t *testing.T) {
|
|||||||
in: "./.gitea/custom_workflows/x.yaml",
|
in: "./.gitea/custom_workflows/x.yaml",
|
||||||
want: UsesRef{Kind: UsesKindLocalSameRepo, Path: ".gitea/custom_workflows/x.yaml"},
|
want: UsesRef{Kind: UsesKindLocalSameRepo, Path: ".gitea/custom_workflows/x.yaml"},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "self-repo prefix",
|
||||||
|
in: "$/.gitea/workflows/build.yml",
|
||||||
|
want: UsesRef{Kind: UsesKindLocalSameRepo, Path: ".gitea/workflows/build.yml"},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "leading/trailing whitespace is trimmed",
|
name: "leading/trailing whitespace is trimmed",
|
||||||
in: " ./.gitea/workflows/build.yml ",
|
in: " ./.gitea/workflows/build.yml ",
|
||||||
@@ -160,6 +165,7 @@ func TestParseUses(t *testing.T) {
|
|||||||
|
|
||||||
// Same-repo malformed (note: a wrong *directory* parses and should be rejected by the caller)
|
// Same-repo malformed (note: a wrong *directory* parses and should be rejected by the caller)
|
||||||
{name: "same-repo with @ref", in: "./.gitea/workflows/build.yml@v1"},
|
{name: "same-repo with @ref", in: "./.gitea/workflows/build.yml@v1"},
|
||||||
|
{name: "self-repo with @ref", in: "$/.gitea/workflows/build.yml@v1"},
|
||||||
{name: "same-repo wrong extension", in: "./.gitea/workflows/build.txt"},
|
{name: "same-repo wrong extension", in: "./.gitea/workflows/build.txt"},
|
||||||
{name: "same-repo missing extension", in: "./.gitea/workflows/build"},
|
{name: "same-repo missing extension", in: "./.gitea/workflows/build"},
|
||||||
{name: "same-repo absolute path", in: "/.gitea/workflows/build.yml"},
|
{name: "same-repo absolute path", in: "/.gitea/workflows/build.yml"},
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ func loadReusableWorkflowSource(ctx context.Context, run *actions_model.ActionRu
|
|||||||
|
|
||||||
switch ref.Kind {
|
switch ref.Kind {
|
||||||
case jobparser.UsesKindLocalSameRepo:
|
case jobparser.UsesKindLocalSameRepo:
|
||||||
// `./` is resolved against the workflow file containing the `uses:` - i.e. the caller's own source repo + commit.
|
// `./` and `$/` are resolved against the workflow file containing the `uses:` - i.e. the caller's own source repo + commit.
|
||||||
callerRepo, err := repo_model.GetRepositoryByID(ctx, caller.WorkflowSourceRepoID)
|
callerRepo, err := repo_model.GetRepositoryByID(ctx, caller.WorkflowSourceRepoID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, 0, "", fmt.Errorf("look up caller source repo %d: %w", caller.WorkflowSourceRepoID, err)
|
return nil, 0, "", fmt.Errorf("look up caller source repo %d: %w", caller.WorkflowSourceRepoID, err)
|
||||||
@@ -115,7 +115,7 @@ func readWorkflowFromRepo(ctx context.Context, repo *repo_model.Repository, refO
|
|||||||
// - rejects cycles (caller.CallUses appearing in any ancestor's CallUses)
|
// - rejects cycles (caller.CallUses appearing in any ancestor's CallUses)
|
||||||
// - enforces MaxReusableCallLevels on the number of ancestors above `caller`
|
// - enforces MaxReusableCallLevels on the number of ancestors above `caller`
|
||||||
//
|
//
|
||||||
// Cycle detection is intentionally *syntactic* (string equality on CallUses), not semantic.
|
// Cycle detection is intentionally *syntactic* (string equality on canonicalCallUses), not semantic.
|
||||||
// So `owner/repo/lib.yml@v1` and `owner/repo/lib.yml@refs/heads/v1` resolving to the same commit are NOT treated as the same node.
|
// So `owner/repo/lib.yml@v1` and `owner/repo/lib.yml@refs/heads/v1` resolving to the same commit are NOT treated as the same node.
|
||||||
// Going semantic (Owner, Repo, Path, ResolvedSHA tuples) would require extra git reads.
|
// Going semantic (Owner, Repo, Path, ResolvedSHA tuples) would require extra git reads.
|
||||||
func checkCallerChain(ctx context.Context, caller *actions_model.ActionRunJob) error {
|
func checkCallerChain(ctx context.Context, caller *actions_model.ActionRunJob) error {
|
||||||
@@ -123,8 +123,7 @@ func checkCallerChain(ctx context.Context, caller *actions_model.ActionRunJob) e
|
|||||||
return nil // top-level caller: depth 0, no ancestors to walk
|
return nil // top-level caller: depth 0, no ancestors to walk
|
||||||
}
|
}
|
||||||
|
|
||||||
visited := make(container.Set[string])
|
visited := container.SetOf(canonicalCallUses(caller.CallUses))
|
||||||
visited.Add(caller.CallUses)
|
|
||||||
|
|
||||||
depth := 0
|
depth := 0
|
||||||
current := caller
|
current := caller
|
||||||
@@ -138,16 +137,21 @@ func checkCallerChain(ctx context.Context, caller *actions_model.ActionRunJob) e
|
|||||||
if depth > MaxReusableCallLevels {
|
if depth > MaxReusableCallLevels {
|
||||||
return fmt.Errorf("reusable workflow call exceeds the maximum nesting level of %d at %q", MaxReusableCallLevels, caller.CallUses)
|
return fmt.Errorf("reusable workflow call exceeds the maximum nesting level of %d at %q", MaxReusableCallLevels, caller.CallUses)
|
||||||
}
|
}
|
||||||
if current.IsReusableCaller && current.CallUses != "" {
|
if current.IsReusableCaller && current.CallUses != "" && !visited.Add(canonicalCallUses(current.CallUses)) {
|
||||||
if visited.Contains(current.CallUses) {
|
return fmt.Errorf("reusable workflow call cycle detected: %q", current.CallUses)
|
||||||
return fmt.Errorf("reusable workflow call cycle detected: %q", current.CallUses)
|
|
||||||
}
|
|
||||||
visited.Add(current.CallUses)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// canonicalCallUses folds the two same-repo prefixes into one key, because `$/x.yml` and `./x.yml` name the same file.
|
||||||
|
func canonicalCallUses(uses string) string {
|
||||||
|
if ref, err := jobparser.ParseUses(uses); err == nil && ref.Kind == jobparser.UsesKindLocalSameRepo {
|
||||||
|
return "./" + ref.Path
|
||||||
|
}
|
||||||
|
return uses
|
||||||
|
}
|
||||||
|
|
||||||
// expandReusableWorkflowCaller loads and parses the target reusable workflow and inserts the caller's direct child jobs.
|
// expandReusableWorkflowCaller loads and parses the target reusable workflow and inserts the caller's direct child jobs.
|
||||||
// It expands only ONE level: a child that is itself a reusable caller is inserted Blocked and expanded later by a subsequent resolver pass.
|
// It expands only ONE level: a child that is itself a reusable caller is inserted Blocked and expanded later by a subsequent resolver pass.
|
||||||
// It does NOT schedule a follow-up resolver pass; the caller of this function is responsible for emitting.
|
// It does NOT schedule a follow-up resolver pass; the caller of this function is responsible for emitting.
|
||||||
|
|||||||
@@ -42,6 +42,17 @@ func TestCheckCallerChain_Cycle(t *testing.T) {
|
|||||||
assert.ErrorContains(t, err, "cycle detected")
|
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) {
|
t.Run("NoCycle", func(t *testing.T) {
|
||||||
require.NoError(t, unittest.PrepareTestDatabase())
|
require.NoError(t, unittest.PrepareTestDatabase())
|
||||||
// Sanity: linear chain with distinct CallUses must not trip cycle detection.
|
// Sanity: linear chain with distinct CallUses must not trip cycle detection.
|
||||||
|
|||||||
Reference in New Issue
Block a user