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 <[email protected]>
Co-authored-by: silverwind <[email protected]>
This commit is contained in:
silverwind
2026-08-07 17:36:58 +00:00
committed by silverwind
parent 24c13a1fd0
commit 9dd9204937
4 changed files with 111 additions and 7 deletions
+8 -2
View File
@@ -829,13 +829,19 @@ func (s *Step) Type() StepType {
} else if strings.HasPrefix(s.Uses, "./") { } else if strings.HasPrefix(s.Uses, "./") {
return StepTypeUsesActionLocal return StepTypeUsesActionLocal
} }
return StepTypeUsesActionRemote return StepTypeUsesActionRemote // `$/` self-repository refs land here and resolve in prepareActionExecutor
} }
// UsesHash returns a hash of the uses string. // UsesHash returns a hash of the uses string.
// For Gitea. // For Gitea.
func (s *Step) UsesHash() string { 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 // ReadWorkflow returns a list of jobs for a given workflow file reader
+1 -1
View File
@@ -597,7 +597,7 @@ func actionStagePaths(step actionStep) (actionDir, actionPath, actionName, conta
if sar, ok := step.(*stepActionRemote); ok { if sar, ok := step.(*stepActionRemote); ok {
actionDir = sar.actionDir() actionDir = sar.actionDir()
actionPath = newRemoteAction(stepModel.Uses).Path actionPath = sar.remoteAction.Path
} else { } else {
actionDir = filepath.Join(rc.Config.Workdir, stepModel.Uses) actionDir = filepath.Join(rc.Config.Workdir, stepModel.Uses)
} }
+33 -4
View File
@@ -39,6 +39,9 @@ type stepActionRemote struct {
var stepActionRemoteNewCloneExecutor = git.NewGitCloneExecutor 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 { func (sar *stepActionRemote) prepareActionExecutor() common.Executor {
return func(ctx context.Context) error { return func(ctx context.Context) error {
if sar.remoteAction != nil && sar.action != nil { 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. // so we need to interpolate the expression value for uses first.
sar.Step.Uses = sar.RunContext.NewExpressionEvaluator(ctx).Interpolate(ctx, sar.Step.Uses) 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 { 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 { 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") common.Logger(ctx).Debugf("Skipping local actions/checkout because workdir was already copied")
return nil return nil
@@ -260,7 +267,12 @@ func (sar *stepActionRemote) revertToolkitOnFailure(exec common.Executor) common
} }
func (sar *stepActionRemote) actionDir() string { 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 { func (sar *stepActionRemote) getRunContext() *RunContext {
@@ -390,6 +402,23 @@ func (ra *remoteAction) IsCheckout() bool {
return false 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 { func newRemoteAction(action string) *remoteAction {
// support http(s)://host/owner/repo@v3 // support http(s)://host/owner/repo@v3
for _, schema := range []string{"https://", "http://", "ssh://"} { for _, schema := range []string{"https://", "http://", "ssh://"} {
+69
View File
@@ -653,6 +653,8 @@ func TestStepActionRemotePost(t *testing.T) {
}, },
Step: tt.stepModel, Step: tt.stepModel,
action: tt.actionModel, action: tt.actionModel,
// post only ever runs after prepareActionExecutor resolved the action
remoteAction: newRemoteAction(tt.stepModel.Uses),
} }
sar.RunContext.ExprEval = sar.RunContext.NewExpressionEvaluator(ctx) 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) { func Test_remoteActionReference(t *testing.T) {
tests := []struct { tests := []struct {
uses string uses string