mirror of
https://gitea.com/gitea/act_runner.git
synced 2026-11-03 22:05:33 +08:00
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 <me@silverwind.io> 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 <bircni@icloud.com>
This commit is contained in:
@@ -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/<name>` 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.
|
||||
|
||||
@@ -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:<name>`)
|
||||
|
||||
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`.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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},
|
||||
|
||||
69
act/runner/step_action_builtin.go
Normal file
69
act/runner/step_action_builtin.go
Normal file
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
10
act/runner/testdata/builtin-checkout/push.yml
vendored
Normal file
10
act/runner/testdata/builtin-checkout/push.yml
vendored
Normal file
@@ -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
|
||||
21
docs/builtin-actions.md
Normal file
21
docs/builtin-actions.md
Normal file
@@ -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.
|
||||
23
internal/pkg/action/action.go
Normal file
23
internal/pkg/action/action.go
Normal 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
internal/pkg/action/checkout/checkout.go
Normal file
97
internal/pkg/action/checkout/checkout.go
Normal 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/")
|
||||
}
|
||||
115
internal/pkg/action/checkout/checkout_test.go
Normal file
115
internal/pkg/action/checkout/checkout_test.go
Normal file
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user