diff --git a/modules/actions/jobparser/uses.go b/modules/actions/jobparser/uses.go index 3d0e3d44f94..1519dc4f865 100644 --- a/modules/actions/jobparser/uses.go +++ b/modules/actions/jobparser/uses.go @@ -15,7 +15,7 @@ import ( type UsesKind int const ( - // UsesKindLocalSameRepo is ".//foo.yml" - a path inside the calling repository. + // UsesKindLocalSameRepo is ".//foo.yml" or "$//foo.yml" - a path inside the calling repository. // For example: "./.gitea/workflows/foo.yml" UsesKindLocalSameRepo UsesKind = iota + 1 // UsesKindLocalCrossRepo is "owner/repo//foo.yml@ref" - a workflow in another repo on the same instance. @@ -33,13 +33,13 @@ type UsesRef struct { } var ( - reLocalSameRepo = regexp.MustCompile(`^\./([^@]+\.ya?ml)$`) + reLocalSameRepo = regexp.MustCompile(`^[.$]/([^@]+\.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: -// - ".//foo.yml" (UsesKindLocalSameRepo, no @ref) -// - "OWNER/REPO//foo.yml@REF" (UsesKindLocalCrossRepo) +// - ".//foo.yml" or "$//foo.yml" (UsesKindLocalSameRepo, no @ref) +// - "OWNER/REPO//foo.yml@REF" (UsesKindLocalCrossRepo) // // It deliberately does NOT validate that 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. @@ -49,10 +49,10 @@ func ParseUses(s string) (*UsesRef, error) { return nil, errors.New("empty uses value") } - if strings.HasPrefix(s, "./") { + if strings.HasPrefix(s, "./") || strings.HasPrefix(s, "$/") { m := reLocalSameRepo.FindStringSubmatch(s) if m == nil { - return nil, fmt.Errorf(`invalid local "uses:" %q (expect .//.yml)`, s) + return nil, fmt.Errorf(`invalid local "uses:" %q (expect .//.yml or $//.yml)`, s) } p := m[1] if path.Clean(p) != p { diff --git a/modules/actions/jobparser/uses_test.go b/modules/actions/jobparser/uses_test.go index 01f76d67de3..c7da1edc939 100644 --- a/modules/actions/jobparser/uses_test.go +++ b/modules/actions/jobparser/uses_test.go @@ -53,6 +53,11 @@ func TestParseUses(t *testing.T) { in: "./.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", 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) {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 missing extension", in: "./.gitea/workflows/build"}, {name: "same-repo absolute path", in: "/.gitea/workflows/build.yml"}, diff --git a/services/actions/reusable_workflow.go b/services/actions/reusable_workflow.go index 0a6b2c7d77b..2a1b50f290d 100644 --- a/services/actions/reusable_workflow.go +++ b/services/actions/reusable_workflow.go @@ -55,7 +55,7 @@ func loadReusableWorkflowSource(ctx context.Context, run *actions_model.ActionRu switch ref.Kind { 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) if err != nil { 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) // - 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. // Going semantic (Owner, Repo, Path, ResolvedSHA tuples) would require extra git reads. 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 } - visited := make(container.Set[string]) - visited.Add(caller.CallUses) + visited := container.SetOf(canonicalCallUses(caller.CallUses)) depth := 0 current := caller @@ -138,16 +137,21 @@ func checkCallerChain(ctx context.Context, caller *actions_model.ActionRunJob) e if depth > MaxReusableCallLevels { return fmt.Errorf("reusable workflow call exceeds the maximum nesting level of %d at %q", MaxReusableCallLevels, caller.CallUses) } - if current.IsReusableCaller && current.CallUses != "" { - if visited.Contains(current.CallUses) { - return fmt.Errorf("reusable workflow call cycle detected: %q", current.CallUses) - } - visited.Add(current.CallUses) + if current.IsReusableCaller && current.CallUses != "" && !visited.Add(canonicalCallUses(current.CallUses)) { + return fmt.Errorf("reusable workflow call cycle detected: %q", current.CallUses) } } 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. // 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. diff --git a/services/actions/reusable_workflow_test.go b/services/actions/reusable_workflow_test.go index 49ee7016530..4875b5777f0 100644 --- a/services/actions/reusable_workflow_test.go +++ b/services/actions/reusable_workflow_test.go @@ -42,6 +42,17 @@ func TestCheckCallerChain_Cycle(t *testing.T) { 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.