From 7ad12038d19310721985c5cbc51ab221808c343e Mon Sep 17 00:00:00 2001 From: bircni Date: Thu, 24 Sep 2026 15:47:58 +0000 Subject: [PATCH] feat(runner): add built-in actions framework with `checkout` (#871) Adds built-in actions, native Go actions shipped with the runner and selected with `uses: builtin:`. Jobs need no action download and no Node in their image. The first one is `builtin:checkout`, a subset of `actions/checkout` with `repository`, `ref`, `token`, `path` and `fetch-depth`. Other inputs fail the step. The runner clones shallowly on its host and copies the tree into the job, while `gitea-runner exec` copies the local working directory for the workflow's own ref. `actions/checkout` and other remote actions are unchanged. A clone ref of `HEAD` now shallow-clones the remote's default branch, which checkout uses for other repositories. --------- Co-authored-by: silverwind Co-authored-by: silverwind <2021+silverwind@noreply.gitea.com> Reviewed-on: https://gitea.com/gitea/runner/pulls/871 Reviewed-by: silverwind <2021+silverwind@noreply.gitea.com> Co-authored-by: bircni --- DEVELOPMENT.md | 5 + README.md | 4 + act/common/git/git.go | 9 +- act/runner/runner_test.go | 1 + act/runner/step_action_builtin.go | 69 +++++++++++ act/runner/step_factory.go | 15 +++ act/runner/step_factory_test.go | 25 ++-- act/runner/testdata/builtin-checkout/push.yml | 10 ++ docs/builtin-actions.md | 21 ++++ internal/pkg/action/action.go | 23 ++++ internal/pkg/action/checkout/checkout.go | 97 +++++++++++++++ internal/pkg/action/checkout/checkout_test.go | 115 ++++++++++++++++++ 12 files changed, 381 insertions(+), 13 deletions(-) create mode 100644 act/runner/step_action_builtin.go create mode 100644 act/runner/testdata/builtin-checkout/push.yml create mode 100644 docs/builtin-actions.md create mode 100644 internal/pkg/action/action.go create mode 100644 internal/pkg/action/checkout/checkout.go create mode 100644 internal/pkg/action/checkout/checkout_test.go diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 615adb22..accae3a7 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -31,6 +31,11 @@ depending on the prefix: - Command *properties* also escape `%3A` and `%2C`, which the UI never decodes, so the reporter decodes exactly those two when folding a location into an annotation. +## Built-in actions + +Each lives in `internal/pkg/action/` and is registered in `builtinActions` in +`act/runner/step_action_builtin.go`. + ## End-to-end compatibility tests `make test-e2e` runs the runner against `E2E_GITEA_IMAGE`. It defaults to the nightly image. diff --git a/README.md b/README.md index 5c9c4524..42a23845 100644 --- a/README.md +++ b/README.md @@ -306,6 +306,10 @@ Dockerfile actions are built with these variables as build arguments, so their ` A password in a proxy URL is hidden in job logs. Any step can still read it, because the step is given the proxy URL in its environment. +#### Built-in actions (`uses: builtin:`) + +Use `uses: builtin:checkout` to check out a repository without downloading `actions/checkout` or needing Node in the job image, see **[docs/builtin-actions.md](docs/builtin-actions.md)**. + #### Caching (`actions/cache`) Each runner starts its own cache server, so runners do not share cached entries. When the runner itself runs in Docker, set `cache.host` to an address job containers can reach and `cache.port` to a fixed published port, or put jobs on a shared `container.network`. diff --git a/act/common/git/git.go b/act/common/git/git.go index 1f1885d9..69d1438b 100644 --- a/act/common/git/git.go +++ b/act/common/git/git.go @@ -438,10 +438,11 @@ func cloneAtDepth(ctx context.Context, input NewGitCloneExecutorInput, opts git. return nil, err } } else { - for _, refName := range []plumbing.ReferenceName{ - plumbing.NewBranchReferenceName(input.Ref), - plumbing.NewTagReferenceName(input.Ref), - } { + refNames := []plumbing.ReferenceName{plumbing.NewBranchReferenceName(input.Ref), plumbing.NewTagReferenceName(input.Ref)} + if input.Ref == plumbing.HEAD.String() || strings.HasPrefix(input.Ref, "refs/") { + refNames = []plumbing.ReferenceName{plumbing.ReferenceName(input.Ref)} // HEAD is the remote's default branch + } + for _, refName := range refNames { shallowOpts := opts shallowOpts.Depth = input.Depth shallowOpts.SingleBranch = true diff --git a/act/runner/runner_test.go b/act/runner/runner_test.go index 4db76adb..e1790ced 100644 --- a/act/runner/runner_test.go +++ b/act/runner/runner_test.go @@ -434,6 +434,7 @@ func TestRunEventHostEnvironment(t *testing.T) { tables = append(tables, []TestJobFileInfo{ {workdir, "nix-prepend-path", "push", "", platforms, secrets}, + {workdir, "builtin-checkout", "push", "", platforms, secrets}, {workdir, "inputs-via-env-context", "push", "", platforms, secrets}, {workdir, "do-not-leak-step-env-in-composite", "push", "", platforms, secrets}, {workdir, "set-env-step-env-override", "push", "", platforms, secrets}, diff --git a/act/runner/step_action_builtin.go b/act/runner/step_action_builtin.go new file mode 100644 index 00000000..233a133e --- /dev/null +++ b/act/runner/step_action_builtin.go @@ -0,0 +1,69 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package runner + +import ( + "context" + + "gitea.com/gitea/runner/act/common" + "gitea.com/gitea/runner/internal/pkg/action" + "gitea.com/gitea/runner/internal/pkg/action/checkout" + + "gitea.dev/actionslib/pkg/model" +) + +var builtinActions = map[string]func(context.Context, *action.Context) error{ + "checkout": checkout.Main, +} + +type stepActionBuiltin struct { + Step *model.Step + RunContext *RunContext + run func(context.Context, *action.Context) error + env map[string]string +} + +func (sab *stepActionBuiltin) pre() common.Executor { return common.NewPipelineExecutor() } + +func (sab *stepActionBuiltin) main() common.Executor { + sab.env = map[string]string{} + return runStepExecutor(sab, stepStageMain, func(ctx context.Context) error { + if common.Dryrun(ctx) { + return nil + } + + printRunActionHeader(ctx, sab.Step, sab.env, sab.RunContext) + rawLogger := common.Logger(ctx).WithField(rawOutputField, true) + defer rawLogger.Infof("::endgroup::") + + rc := sab.RunContext + return sab.run(ctx, &action.Context{ + Container: rc.JobContainer, + Github: rc.getGithubContext(ctx), + Inputs: sab.Step.With, + HostWorkdir: rc.Config.Workdir, + Workspace: rc.JobContainer.ToContainerPath(rc.Config.Workdir), + BindWorkdir: rc.Config.BindWorkdir, + NoSkipCheckout: rc.Config.NoSkipCheckout, + UseGitIgnore: rc.Config.UseGitIgnore, + InsecureSkipTLS: rc.Config.InsecureSkipTLS, + }) + }) +} + +func (sab *stepActionBuiltin) post() common.Executor { return common.NewPipelineExecutor() } + +func (sab *stepActionBuiltin) getRunContext() *RunContext { return sab.RunContext } + +func (sab *stepActionBuiltin) getGithubContext(ctx context.Context) *model.GithubContext { + return sab.RunContext.getGithubContext(ctx) +} + +func (sab *stepActionBuiltin) getStepModel() *model.Step { return sab.Step } + +func (sab *stepActionBuiltin) getEnv() *map[string]string { return &sab.env } + +func (sab *stepActionBuiltin) getIfExpression(_ context.Context, _ stepStage) string { + return sab.Step.If.Value +} diff --git a/act/runner/step_factory.go b/act/runner/step_factory.go index 5aede212..5461ae0e 100644 --- a/act/runner/step_factory.go +++ b/act/runner/step_factory.go @@ -6,6 +6,9 @@ package runner import ( "fmt" + "maps" + "slices" + "strings" "gitea.dev/actionslib/pkg/model" ) @@ -33,6 +36,18 @@ func (sf *stepFactoryImpl) newStep(stepModel *model.Step, rc *RunContext) (step, runAction: runActionImpl, }, nil case model.StepTypeUsesActionRemote: + if name, ok := strings.CutPrefix(stepModel.Uses, "builtin:"); ok { + run, ok := builtinActions[name] + if !ok { + return nil, fmt.Errorf("unknown built-in action %q (known: %v) for job:%s step:%+v", + name, slices.Sorted(maps.Keys(builtinActions)), rc.Run, stepModel) + } + return &stepActionBuiltin{ + Step: stepModel, + RunContext: rc, + run: run, + }, nil + } return &stepActionRemote{ Step: stepModel, RunContext: rc, diff --git a/act/runner/step_factory_test.go b/act/runner/step_factory_test.go index 55cea0d8..12cfb8d7 100644 --- a/act/runner/step_factory_test.go +++ b/act/runner/step_factory_test.go @@ -57,6 +57,16 @@ func TestStepFactoryNewStep(t *testing.T) { return ok }, }, + { + name: "StepBuiltinAction", + model: &model.Step{ + Uses: "builtin:checkout", + }, + check: func(s step) bool { + _, ok := s.(*stepActionBuiltin) + return ok + }, + }, } for _, tt := range table { @@ -72,14 +82,11 @@ func TestStepFactoryNewStep(t *testing.T) { } func TestStepFactoryInvalidStep(t *testing.T) { - model := &model.Step{ - Uses: "remote/action@v1", - Run: "cmd", + for _, stepModel := range []*model.Step{ + {Uses: "remote/action@v1", Run: "cmd"}, + {Uses: "builtin:unknown"}, + } { + _, err := (&stepFactoryImpl{}).newStep(stepModel, &RunContext{}) + assert.Error(t, err, stepModel.Uses) } - - sf := &stepFactoryImpl{} - - _, err := sf.newStep(model, &RunContext{}) - - assert.Error(t, err) } diff --git a/act/runner/testdata/builtin-checkout/push.yml b/act/runner/testdata/builtin-checkout/push.yml new file mode 100644 index 00000000..5a556b61 --- /dev/null +++ b/act/runner/testdata/builtin-checkout/push.yml @@ -0,0 +1,10 @@ +name: builtin-checkout +on: push +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: builtin:checkout + with: + path: ${{ 'sub' }} + - run: test -f sub/builtin-checkout/push.yml diff --git a/docs/builtin-actions.md b/docs/builtin-actions.md new file mode 100644 index 00000000..6ebb3265 --- /dev/null +++ b/docs/builtin-actions.md @@ -0,0 +1,21 @@ +# Built-in actions + +Built-in actions ship with the runner, so a job needs no action download and no Node in its image. + +```yaml +- uses: builtin:checkout +``` + +## checkout + +Works like `actions/checkout` with these inputs. Any other input fails the step. + +| Input | Default | +| --- | --- | +| `repository` | `github.repository` | +| `ref` | the triggering commit, or the default branch of another repository | +| `token` | `github.token` | +| `path` | workspace root | +| `fetch-depth` | `1`, `0` for full history | + +With `gitea-runner exec`, checking out the workflow's own repository copies your local working directory, unless `--no-skip-checkout` is set. diff --git a/internal/pkg/action/action.go b/internal/pkg/action/action.go new file mode 100644 index 00000000..e9c4f3c3 --- /dev/null +++ b/internal/pkg/action/action.go @@ -0,0 +1,23 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +// Package action holds the native actions run by `uses: builtin:`. +package action + +import ( + "gitea.com/gitea/runner/act/container" + + "gitea.dev/actionslib/pkg/model" +) + +type Context struct { + Container container.ExecutionsEnvironment + Github *model.GithubContext + Inputs map[string]string + HostWorkdir string + Workspace string // HostWorkdir as seen inside Container + BindWorkdir bool + NoSkipCheckout bool + UseGitIgnore bool + InsecureSkipTLS bool +} diff --git a/internal/pkg/action/checkout/checkout.go b/internal/pkg/action/checkout/checkout.go new file mode 100644 index 00000000..4220374b --- /dev/null +++ b/internal/pkg/action/checkout/checkout.go @@ -0,0 +1,97 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package checkout + +import ( + "cmp" + "context" + "fmt" + "os" + "path" + "path/filepath" + "slices" + "strconv" + "strings" + + "gitea.com/gitea/runner/act/common/git" + "gitea.com/gitea/runner/internal/pkg/action" +) + +var inputNames = []string{"repository", "ref", "token", "path", "fetch-depth"} + +func Main(ctx context.Context, c *action.Context) error { + for name := range c.Inputs { + if !slices.Contains(inputNames, name) { + return fmt.Errorf("checkout: unsupported input %q", name) + } + } + dst, err := workspaceDestination(c.Workspace, c.Inputs["path"]) + if err != nil { + return err + } + depth, err := fetchDepth(c.Inputs["fetch-depth"]) + if err != nil { + return err + } + + ghc := c.Github + repository := cmp.Or(c.Inputs["repository"], ghc.Repository) + ref := shortBranch(c.Inputs["ref"]) + isWorkflowRepo := repository == ghc.Repository + local := isWorkflowRepo && slices.Contains([]string{"", shortBranch(ghc.Ref), ghc.Sha}, ref) && !c.NoSkipCheckout + if local && !c.BindWorkdir { + return c.Container.CopyDir(dst, c.HostWorkdir+string(filepath.Separator)+".", c.UseGitIgnore, false)(ctx) + } + if local && dst == c.Workspace { + return nil + } + + if ref == "" { + ref = "HEAD" + if isWorkflowRepo { + ref = ghc.Sha + } + } + dir, err := os.MkdirTemp("", "checkout-") + if err != nil { + return err + } + defer os.RemoveAll(dir) + if err := git.NewGitCloneExecutor(git.NewGitCloneExecutorInput{ + URL: strings.TrimSuffix(ghc.ServerURL, "/") + "/" + repository, + Ref: ref, + Dir: dir, + Token: cmp.Or(c.Inputs["token"], ghc.Token), + Depth: depth, + InsecureSkipTLS: c.InsecureSkipTLS, + })(ctx); err != nil { + return fmt.Errorf("checkout: clone %s@%s: %w", repository, ref, err) + } + return c.Container.CopyDir(dst, dir+string(filepath.Separator)+".", false, false)(ctx) +} + +func workspaceDestination(workspace, checkoutPath string) (string, error) { + cleanPath := path.Clean(strings.TrimSpace(checkoutPath)) + if path.IsAbs(cleanPath) || cleanPath == ".." || strings.HasPrefix(cleanPath, "../") { + return "", fmt.Errorf("checkout: path %q must be relative to the workspace", checkoutPath) + } + return path.Join(workspace, cleanPath), nil +} + +func fetchDepth(input string) (int, error) { + input = strings.TrimSpace(input) + if input == "" { + return 1, nil + } + depth, err := strconv.Atoi(input) + if err != nil || depth < 0 { + return 0, fmt.Errorf("checkout: fetch-depth %q must be a non-negative integer", input) + } + return depth, nil +} + +// NewGitCloneExecutor resolves branches by short name, a full tag ref stays exact. +func shortBranch(ref string) string { + return strings.TrimPrefix(ref, "refs/heads/") +} diff --git a/internal/pkg/action/checkout/checkout_test.go b/internal/pkg/action/checkout/checkout_test.go new file mode 100644 index 00000000..b11ef419 --- /dev/null +++ b/internal/pkg/action/checkout/checkout_test.go @@ -0,0 +1,115 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package checkout + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "gitea.com/gitea/runner/act/common" + "gitea.com/gitea/runner/act/container" + "gitea.com/gitea/runner/internal/pkg/action" + + "gitea.dev/actionslib/pkg/model" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type fakeContainer struct { + container.ExecutionsEnvironment + dst, src string + log []string +} + +func (f *fakeContainer) CopyDir(dst, src string, _, _ bool) common.Executor { + return func(context.Context) error { + f.dst, f.src = dst, src + if out, err := exec.Command("git", "--git-dir", filepath.Join(src, ".git"), "log", "--format=%s").Output(); err == nil { + f.log = strings.Fields(string(out)) + } + return nil + } +} + +func runGit(t *testing.T, args ...string) string { + t.Helper() + cmd := exec.Command("git", args...) + cmd.Env = append(os.Environ(), + "GIT_AUTHOR_NAME=test", "GIT_AUTHOR_EMAIL=test@test.com", + "GIT_COMMITTER_NAME=test", "GIT_COMMITTER_EMAIL=test@test.com", + "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null", + ) + out, err := cmd.CombinedOutput() + require.NoError(t, err, string(out)) + return strings.TrimSpace(string(out)) +} + +func TestCheckout(t *testing.T) { + work := t.TempDir() + runGit(t, "init", "--initial-branch=main", work) + runGit(t, "-C", work, "commit", "--allow-empty", "-m", "c1") + sha := runGit(t, "-C", work, "rev-parse", "HEAD") + runGit(t, "-C", work, "commit", "--allow-empty", "-m", "c2") + runGit(t, "-C", work, "checkout", "-b", "feature") + runGit(t, "-C", work, "commit", "--allow-empty", "-m", "f1") + runGit(t, "-C", work, "checkout", "--orphan", "trunk") + runGit(t, "-C", work, "commit", "--allow-empty", "-m", "t1") + runGit(t, "-C", work, "commit", "--allow-empty", "-m", "t2") + runGit(t, "-C", work, "tag", "trunk", "main") + server := t.TempDir() + runGit(t, "clone", "--bare", "--branch", "main", work, filepath.Join(server, "org", "repo")) + runGit(t, "clone", "--bare", "--branch", "trunk", work, filepath.Join(server, "other", "repo")) + host := t.TempDir() + + for _, tc := range []struct { + name string + bind, noSkip bool + inputs map[string]string + dst string + log []string + hostCopy, err bool + }{ + {name: "bound workspace is skipped", bind: true}, + {name: "workflow ref copies host workdir", inputs: map[string]string{"ref": "main", "path": "a/../src"}, dst: "/workspace/src", hostCopy: true}, + {name: "bound path clones event sha", bind: true, inputs: map[string]string{"path": "src"}, dst: "/workspace/src", log: []string{"c1"}}, + {name: "no-skip clones event sha", noSkip: true, dst: "/workspace", log: []string{"c1"}}, + {name: "ref clones shallow", inputs: map[string]string{"ref": "refs/heads/feature"}, dst: "/workspace", log: []string{"f1"}}, + {name: "tag ref wins over same-named branch", inputs: map[string]string{"ref": "refs/tags/trunk"}, dst: "/workspace", log: []string{"c2"}}, + {name: "fetch-depth 0 clones full history", inputs: map[string]string{"ref": "feature", "fetch-depth": "0"}, dst: "/workspace", log: []string{"f1", "c2", "c1"}}, + {name: "other repository defaults to its default branch", inputs: map[string]string{"repository": "other/repo"}, dst: "/workspace", log: []string{"t2"}}, + {name: "unsupported input", inputs: map[string]string{"submodules": "true"}, err: true}, + {name: "relative path escaping workspace", inputs: map[string]string{"path": "../x"}, err: true}, + {name: "absolute path", inputs: map[string]string{"path": "/x"}, err: true}, + {name: "negative fetch-depth", inputs: map[string]string{"fetch-depth": "-1"}, err: true}, + } { + t.Run(tc.name, func(t *testing.T) { + fc := &fakeContainer{} + err := Main(t.Context(), &action.Context{ + Container: fc, + Github: &model.GithubContext{Repository: "org/repo", Ref: "refs/heads/main", Sha: sha, ServerURL: server}, + Inputs: tc.inputs, + HostWorkdir: host, + Workspace: "/workspace", + BindWorkdir: tc.bind, + NoSkipCheckout: tc.noSkip, + }) + if tc.err { + require.Error(t, err) + assert.Empty(t, fc.dst) + return + } + require.NoError(t, err) + assert.Equal(t, tc.dst, fc.dst) + assert.Equal(t, tc.log, fc.log) + assert.Equal(t, tc.hostCopy, strings.HasPrefix(fc.src, host)) + if tc.log != nil { + assert.NoDirExists(t, fc.src) + } + }) + } +}