From 9dd92049376248652a99b214e7456c398395256b Mon Sep 17 00:00:00 2001 From: silverwind Date: Fri, 7 Aug 2026 17:36:58 +0000 Subject: [PATCH] feat: support `$/` prefix in action `uses:` (#1150) Accepts GitHub's `$/` self-repository prefix in a step `uses:`. It resolves to the repository holding the file that wrote the `uses:`, at the ref being run, with no checkout. Inside a composite action that is the enclosing action, otherwise the workflow's own repo and commit. The action cache is keyed on the resolved reference for these, because the same `$/x` names a different action per enclosing repository. Related PR for job-level support: https://github.com/go-gitea/gitea/pull/38822 Reviewed-on: https://gitea.com/gitea/runner/pulls/1150 Reviewed-by: Zettat123 <39446+zettat123@noreply.gitea.com> Co-authored-by: silverwind --- act/model/workflow.go | 10 +++- act/runner/action.go | 2 +- act/runner/step_action_remote.go | 37 ++++++++++++-- act/runner/step_action_remote_test.go | 69 +++++++++++++++++++++++++++ 4 files changed, 111 insertions(+), 7 deletions(-) diff --git a/act/model/workflow.go b/act/model/workflow.go index 0b4370b4..5bf39a1e 100644 --- a/act/model/workflow.go +++ b/act/model/workflow.go @@ -829,13 +829,19 @@ func (s *Step) Type() StepType { } else if strings.HasPrefix(s.Uses, "./") { return StepTypeUsesActionLocal } - return StepTypeUsesActionRemote + return StepTypeUsesActionRemote // `$/` self-repository refs land here and resolve in prepareActionExecutor } // UsesHash returns a hash of the uses string. // For Gitea. func (s *Step) UsesHash() string { - return fmt.Sprintf("%x", sha256.Sum256([]byte(s.Uses))) + return UsesHash(s.Uses) +} + +// UsesHash returns a hash of a `uses:` value. +// For Gitea. +func UsesHash(uses string) string { + return fmt.Sprintf("%x", sha256.Sum256([]byte(uses))) } // ReadWorkflow returns a list of jobs for a given workflow file reader diff --git a/act/runner/action.go b/act/runner/action.go index da2e811a..a3655d51 100644 --- a/act/runner/action.go +++ b/act/runner/action.go @@ -597,7 +597,7 @@ func actionStagePaths(step actionStep) (actionDir, actionPath, actionName, conta if sar, ok := step.(*stepActionRemote); ok { actionDir = sar.actionDir() - actionPath = newRemoteAction(stepModel.Uses).Path + actionPath = sar.remoteAction.Path } else { actionDir = filepath.Join(rc.Config.Workdir, stepModel.Uses) } diff --git a/act/runner/step_action_remote.go b/act/runner/step_action_remote.go index f063244c..ccfb2eb2 100644 --- a/act/runner/step_action_remote.go +++ b/act/runner/step_action_remote.go @@ -39,6 +39,9 @@ type stepActionRemote struct { var stepActionRemoteNewCloneExecutor = git.NewGitCloneExecutor +// selfRepoPrefix introduces a self-repository reference: the action lives in the repo holding the file that wrote the `uses:`. +const selfRepoPrefix = "$/" + func (sar *stepActionRemote) prepareActionExecutor() common.Executor { return func(ctx context.Context) error { if sar.remoteAction != nil && sar.action != nil { @@ -52,12 +55,16 @@ func (sar *stepActionRemote) prepareActionExecutor() common.Executor { // so we need to interpolate the expression value for uses first. sar.Step.Uses = sar.RunContext.NewExpressionEvaluator(ctx).Interpolate(ctx, sar.Step.Uses) - sar.remoteAction = newRemoteAction(sar.Step.Uses) + github := sar.getGithubContext(ctx) // read before remoteAction is set, so `$/` resolves against the enclosing action + if strings.HasPrefix(sar.Step.Uses, selfRepoPrefix) { + sar.remoteAction = newSelfRepoAction(sar.Step.Uses, github) + } else { + sar.remoteAction = newRemoteAction(sar.Step.Uses) + } if sar.remoteAction == nil { - return fmt.Errorf("Expected format {org}/{repo}[/path]@ref. Actual '%s' Input string was not in a correct format", sar.Step.Uses) + return fmt.Errorf("Expected format {org}/{repo}[/path]@ref or %s{path}. Actual '%s' Input string was not in a correct format", selfRepoPrefix, sar.Step.Uses) } - github := sar.getGithubContext(ctx) if sar.remoteAction.IsCheckout() && isLocalCheckout(github, sar.Step) && !sar.RunContext.Config.NoSkipCheckout { common.Logger(ctx).Debugf("Skipping local actions/checkout because workdir was already copied") return nil @@ -260,7 +267,12 @@ func (sar *stepActionRemote) revertToolkitOnFailure(exec common.Executor) common } func (sar *stepActionRemote) actionDir() string { - return fmt.Sprintf("%s/%s", sar.RunContext.ActionCacheDir(), sar.Step.UsesHash()) + uses := sar.Step.Uses + if strings.HasPrefix(uses, selfRepoPrefix) { + // The same `$/x` names a different action per enclosing repo, so key the cache on what it resolved to. + uses = sar.remoteAction.URL + "/" + sar.remoteAction.Reference() + } + return fmt.Sprintf("%s/%s", sar.RunContext.ActionCacheDir(), model.UsesHash(uses)) } func (sar *stepActionRemote) getRunContext() *RunContext { @@ -390,6 +402,23 @@ func (ra *remoteAction) IsCheckout() bool { return false } +// newSelfRepoAction resolves `$/{path}` against the enclosing composite action, falling back to the workflow's own repo and commit. +func newSelfRepoAction(action string, github *model.GithubContext) *remoteAction { + subPath := strings.TrimLeft(strings.TrimPrefix(action, selfRepoPrefix), "/") + if subPath == "" || strings.Contains(subPath, "@") || path.Clean("/"+subPath) != "/"+subPath { // rooted, so a leading ".." is rejected too + return nil + } + repo, ref := github.ActionRepository, github.ActionRef + if repo == "" || ref == "" { + repo, ref = github.Repository, github.Sha + } + org, name, _ := strings.Cut(repo, "/") + if org == "" || name == "" || ref == "" { + return nil + } + return &remoteAction{URL: github.ServerURL, Org: org, Repo: name, Path: subPath, Ref: ref} +} + func newRemoteAction(action string) *remoteAction { // support http(s)://host/owner/repo@v3 for _, schema := range []string{"https://", "http://", "ssh://"} { diff --git a/act/runner/step_action_remote_test.go b/act/runner/step_action_remote_test.go index 473cbb62..9b641257 100644 --- a/act/runner/step_action_remote_test.go +++ b/act/runner/step_action_remote_test.go @@ -653,6 +653,8 @@ func TestStepActionRemotePost(t *testing.T) { }, Step: tt.stepModel, action: tt.actionModel, + // post only ever runs after prepareActionExecutor resolved the action + remoteAction: newRemoteAction(tt.stepModel.Uses), } sar.RunContext.ExprEval = sar.RunContext.NewExpressionEvaluator(ctx) @@ -821,6 +823,73 @@ func Test_newRemoteAction(t *testing.T) { } } +func Test_newSelfRepoAction(t *testing.T) { + workflow := &model.GithubContext{ + ServerURL: "https://gitea.example.com", + Repository: "owner/workflow-repo", + Sha: "abc123", + } + composite := &model.GithubContext{ + ServerURL: "https://gitea.example.com", + Repository: "owner/workflow-repo", + Sha: "abc123", + ActionRepository: "other/action-repo", + ActionRef: "v1", + } + tests := []struct { + name string + action string + github *model.GithubContext + want *remoteAction + }{ + { + name: "top level resolves to the workflow repo at its commit", + action: "$/.gitea/actions/build", + github: workflow, + want: &remoteAction{ + URL: "https://gitea.example.com", + Org: "owner", + Repo: "workflow-repo", + Path: ".gitea/actions/build", + Ref: "abc123", + }, + }, + { + name: "inside a composite resolves to the enclosing action", + action: "$/.gitea/actions/build", + github: composite, + want: &remoteAction{ + URL: "https://gitea.example.com", + Org: "other", + Repo: "action-repo", + Path: ".gitea/actions/build", + Ref: "v1", + }, + }, + {name: "empty path", action: "$/", github: workflow}, + {name: "ref suffix is not allowed", action: "$/.gitea/actions/build@v1", github: workflow}, + {name: "path traversal", action: "$/../escape", github: workflow}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, newSelfRepoAction(tt.action, tt.github)) + }) + } +} + +func Test_stepActionRemoteSelfRepoActionDir(t *testing.T) { + dirFor := func(repo string) string { + sar := &stepActionRemote{ + Step: &model.Step{Uses: "$/.gitea/actions/build"}, + RunContext: &RunContext{Config: &Config{ActionCacheDir: "/cache"}}, + remoteAction: &remoteAction{Org: "owner", Repo: repo, Ref: "v1"}, + } + return sar.actionDir() + } + // The same `$/x` in two repos must not share a cache directory. + assert.NotEqual(t, dirFor("one"), dirFor("two")) +} + func Test_remoteActionReference(t *testing.T) { tests := []struct { uses string