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:<name>`. 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 <[email protected]>
Co-authored-by: silverwind <[email protected]>
Reviewed-on: https://gitea.com/gitea/runner/pulls/871
Reviewed-by: silverwind <[email protected]>
Co-authored-by: bircni <[email protected]>
This commit is contained in:
authored and silverwind committed 2026-09-24 15:47:58 +00:00
1 parent 98816a605c
commit 7ad12038d1
12 files changed
+381 -13

No files matched your search

+23
View File
@@ -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:<name>`.
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
}
+97
View File
@@ -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/")
}
@@ -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", "[email protected]",
"GIT_COMMITTER_NAME=test", "[email protected]",
"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)
}
})
}
}