Files
act_runner/e2e/gitea_fixture.go
bircni 27e9b4de87 fix!: use the git CLI for action clones and make builtin:checkout match actions/checkout (#1277)
The runner now uses the `git` command line instead of go-git to download actions and reusable workflows. go-git had recurring bugs and does not support SHA-256 repositories, and full clones of actions are several times faster.

`builtin:checkout` now runs `git` where the job runs and behaves like `actions/checkout`:

1. A branch is checked out as a local branch tracking `origin`, tag and pull request runs keep their ref, and a triggering tag that moved fails the step.
1. Later steps can fetch and push with the persisted token or SSH key.
1. It supports the `ssh-*`, `lfs`, `submodules`, `sparse-checkout`, `filter`, `fetch-tags`, `clean`, `persist-credentials`, `show-progress` and `set-safe-directory` inputs and the `ref` and `commit` outputs.
1. SHA-256 repositories work for actions, reusable workflows and `builtin:checkout`.

Git keeps the token off its command line, ignores `GIT_DIR` and similar variables inherited by the runner, and keeps action files LF regardless of `core.autocrlf`. A cancelled job lets git remove its lock files.

This is a breaking change: the runner needs Git 2.34.1 or newer on its `PATH`, and so do job images that use `builtin:checkout`. The official runner images include it.

Fixes: https://gitea.com/gitea/runner/issues/1077
Fixes: https://gitea.com/gitea/runner/issues/1267
Fixes: https://gitea.com/gitea/runner/issues/1279
Fixes: https://gitea.com/gitea/runner/issues/1284
Fixes: https://gitea.com/gitea/runner/issues/1285
Co-authored-by: silverwind <me@silverwind.io>
Co-authored-by: silverwind <2021+silverwind@noreply.gitea.com>
Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1277
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
Reviewed-by: silverwind <2021+silverwind@noreply.gitea.com>
Co-authored-by: bircni <bircni@icloud.com>
2026-10-08 17:08:29 +00:00

379 lines
9.7 KiB
Go

// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
//go:build e2e
package e2e
import (
"bytes"
"context"
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/netip"
"os"
"strconv"
"strings"
"time"
"gitea.com/gitea/runner/act/container"
"github.com/moby/moby/api/pkg/stdcopy"
apicontainer "github.com/moby/moby/api/types/container"
"github.com/moby/moby/api/types/network"
mobyclient "github.com/moby/moby/client"
)
const (
giteaAdminUser = "e2e-admin"
giteaAdminMail = "e2e-admin@example.com"
)
type GiteaFixture struct {
cli mobyclient.APIClient
id string
image string
version string
baseURL string
network string // set in container mode; jobs must join it
adminToken string
}
func dockerClient(ctx context.Context) (mobyclient.APIClient, error) {
cli, err := container.GetDockerClient(ctx)
if err != nil {
return nil, err
}
if _, err := cli.Ping(ctx, mobyclient.PingOptions{}); err != nil {
return nil, fmt.Errorf("docker daemon unreachable: %w", err)
}
return cli, nil
}
// Prefer docker bridge gateway, then LAN IP; never 127.0.0.1 (ACTIONS_RUNTIME_URL must reach the host from job containers).
func hostAddress(ctx context.Context, cli mobyclient.APIClient) netip.Addr {
if gateway, ok := bridgeGateway(ctx, cli); ok && bindable(gateway) {
return gateway
}
loopback := netip.AddrFrom4([4]byte{127, 0, 0, 1})
conn, err := net.Dial("udp", "192.0.2.1:80") // TEST-NET-1: routing table only
if err != nil {
return loopback
}
defer conn.Close()
addr, ok := conn.LocalAddr().(*net.UDPAddr)
if !ok {
return loopback
}
return addr.AddrPort().Addr().Unmap()
}
func bridgeGateway(ctx context.Context, cli mobyclient.APIClient) (netip.Addr, bool) {
inspected, err := cli.NetworkInspect(ctx, "bridge", mobyclient.NetworkInspectOptions{})
if err != nil {
return netip.Addr{}, false
}
for _, cfg := range inspected.Network.IPAM.Config {
if cfg.Gateway.Is4() {
return cfg.Gateway, true
}
}
return netip.Addr{}, false
}
// When tests run inside a container (sibling docker daemon), join that network and address by name.
func selfNetwork(ctx context.Context, cli mobyclient.APIClient) (string, bool) {
if _, err := os.Stat("/.dockerenv"); err != nil {
return "", false
}
id, err := os.ReadFile("/proc/sys/kernel/hostname")
if err != nil {
id, err = os.ReadFile("/etc/hostname")
}
if err != nil {
return "", false
}
inspected, err := cli.ContainerInspect(ctx, strings.TrimSpace(string(id)), mobyclient.ContainerInspectOptions{})
if err != nil || inspected.Container.NetworkSettings == nil {
return "", false
}
for name := range inspected.Container.NetworkSettings.Networks {
if name != "host" && name != "none" && name != "bridge" { // only user-defined nets have DNS
return name, true
}
}
return "", false
}
func bindable(addr netip.Addr) bool {
l, err := net.Listen("tcp", net.JoinHostPort(addr.String(), "0"))
if err != nil {
return false
}
_ = l.Close()
return true
}
func freeHostPort(host string) (int, error) {
l, err := net.Listen("tcp", net.JoinHostPort(host, "0"))
if err != nil {
return 0, err
}
defer l.Close()
addr, ok := l.Addr().(*net.TCPAddr)
if !ok {
return 0, fmt.Errorf("unexpected listener address type %T", l.Addr())
}
return addr.Port, nil
}
func StartGitea(ctx context.Context, cli mobyclient.APIClient) (*GiteaFixture, error) {
closeClient := true
defer func() {
if closeClient {
_ = cli.Close()
}
}()
image := os.Getenv("E2E_GITEA_IMAGE")
if image == "" {
return nil, errors.New("E2E_GITEA_IMAGE is not set")
}
name := fmt.Sprintf("gitea-runner-e2e-%d", time.Now().UnixNano())
containerPort := network.MustParsePort("3000/tcp")
var (
baseURL string
hostConfig = &apicontainer.HostConfig{}
netConfig *network.NetworkingConfig
sharedNet, _ = selfNetwork(ctx, cli)
)
if sharedNet != "" {
baseURL = "http://" + net.JoinHostPort(name, "3000")
netConfig = &network.NetworkingConfig{
EndpointsConfig: map[string]*network.EndpointSettings{sharedNet: {}},
}
} else {
host := hostAddress(ctx, cli)
port, err := freeHostPort(host.String())
if err != nil {
return nil, fmt.Errorf("find a free host port: %w", err)
}
baseURL = "http://" + net.JoinHostPort(host.String(), strconv.Itoa(port))
hostConfig.PortBindings = network.PortMap{
containerPort: []network.PortBinding{{HostIP: host, HostPort: strconv.Itoa(port)}},
}
}
if _, err := cli.ImageInspect(ctx, image); err != nil {
return nil, fmt.Errorf("inspect gitea image %s: %w", image, err)
}
resp, err := cli.ContainerCreate(ctx, mobyclient.ContainerCreateOptions{
Config: &apicontainer.Config{
Image: image,
Env: []string{
"GITEA__security__INSTALL_LOCK=true",
"GITEA__database__DB_TYPE=sqlite3",
"GITEA__actions__ENABLED=true",
"GITEA__server__LFS_START_SERVER=true",
"GITEA__server__ROOT_URL=" + baseURL + "/",
},
ExposedPorts: network.PortSet{containerPort: struct{}{}},
},
HostConfig: hostConfig,
NetworkingConfig: netConfig,
Name: name,
})
if err != nil {
return nil, fmt.Errorf("create gitea container: %w", err)
}
f := &GiteaFixture{cli: cli, id: resp.ID, image: image, baseURL: baseURL, network: sharedNet}
closeClient = false
if _, err := cli.ContainerStart(ctx, f.id, mobyclient.ContainerStartOptions{}); err != nil {
_ = f.Close(ctx)
return nil, fmt.Errorf("start gitea container: %w", err)
}
if err := f.waitHealthy(ctx); err != nil {
_ = f.Close(ctx)
return nil, fmt.Errorf("gitea did not become healthy: %w", err)
}
if err := f.bootstrapAdmin(ctx); err != nil {
_ = f.Close(ctx)
return nil, fmt.Errorf("bootstrap gitea admin: %w", err)
}
if err := f.readVersion(ctx); err != nil {
_ = f.Close(ctx)
return nil, fmt.Errorf("read gitea version: %w", err)
}
return f, nil
}
func (f *GiteaFixture) readVersion(ctx context.Context) error {
var body struct {
Version string `json:"version"`
}
if err := f.doJSON(ctx, http.MethodGet, "/api/v1/version", nil, &body); err != nil {
return err
}
f.version = body.Version
return nil
}
func (f *GiteaFixture) waitHealthy(ctx context.Context) error {
ctx, cancel := context.WithTimeout(ctx, 90*time.Second)
defer cancel()
url := f.baseURL + "/api/healthz"
for {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err == nil {
resp, err := http.DefaultClient.Do(req)
if err == nil {
_ = resp.Body.Close()
if resp.StatusCode == http.StatusOK {
return nil
}
}
}
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(pollInterval):
}
}
}
func (f *GiteaFixture) bootstrapAdmin(ctx context.Context) error {
password := randomToken(16)
if _, err := f.exec(ctx, []string{
"gitea", "admin", "user", "create",
"--username", giteaAdminUser,
"--password", password,
"--email", giteaAdminMail,
"--admin",
"--must-change-password=false",
}); err != nil {
return fmt.Errorf("create admin user: %w", err)
}
out, err := f.exec(ctx, []string{
"gitea", "admin", "user", "generate-access-token",
"--username", giteaAdminUser,
"--scopes", "all",
"-t", "e2e-admin-token",
})
if err != nil {
return fmt.Errorf("generate admin token: %w", err)
}
token := extractToken(out)
if token == "" {
return fmt.Errorf("could not parse access token from CLI output: %q", out)
}
f.adminToken = token
return nil
}
// Last field of `gitea admin user generate-access-token` output (format varies by release).
func extractToken(cliOutput string) string {
fields := strings.Fields(cliOutput)
if len(fields) == 0 {
return ""
}
return fields[len(fields)-1]
}
func randomToken(n int) string {
b := make([]byte, n)
_, _ = rand.Read(b)
return hex.EncodeToString(b)
}
func (f *GiteaFixture) exec(ctx context.Context, cmd []string) (string, error) {
return containerExec(ctx, f.cli, f.id, "git", nil, cmd...) // gitea refuses root
}
func containerExec(ctx context.Context, cli mobyclient.APIClient, id, user string, stdin io.Reader, cmd ...string) (string, error) {
created, err := cli.ExecCreate(ctx, id, mobyclient.ExecCreateOptions{
Cmd: cmd,
User: user,
AttachStdin: stdin != nil,
AttachStdout: true,
AttachStderr: true,
})
if err != nil {
return "", err
}
attached, err := cli.ExecAttach(ctx, created.ID, mobyclient.ExecAttachOptions{})
if err != nil {
return "", err
}
defer attached.Close()
if stdin != nil {
go func() {
_, _ = io.Copy(attached.Conn, stdin)
_ = attached.CloseWrite()
}()
}
var out bytes.Buffer
if _, err := stdcopy.StdCopy(&out, &out, attached.Reader); err != nil {
return "", err
}
inspected, err := cli.ExecInspect(ctx, created.ID, mobyclient.ExecInspectOptions{})
if err != nil {
return "", err
}
if inspected.ExitCode != 0 {
return "", fmt.Errorf("exec %v exited %d: %s", cmd, inspected.ExitCode, out.String())
}
return out.String(), nil
}
func (f *GiteaFixture) RegistrationToken(ctx context.Context, repo string) (string, error) {
var body struct {
Token string `json:"token"`
}
path := "/api/v1/admin/actions/runners/registration-token"
if repo != "" {
path = fmt.Sprintf("/api/v1/repos/%s/%s/actions/runners/registration-token", giteaAdminUser, repo)
}
if err := f.doJSON(ctx, http.MethodPost, path, nil, &body); err != nil {
return "", err
}
return body.Token, nil
}
func (f *GiteaFixture) doJSON(ctx context.Context, method, path string, reqBody, respBody any) error {
api := &GiteaAPI{baseURL: f.baseURL, token: f.adminToken}
return api.doJSON(ctx, method, path, reqBody, respBody)
}
func (f *GiteaFixture) Close(ctx context.Context) error {
if f.id == "" {
return f.cli.Close()
}
_, removeErr := f.cli.ContainerRemove(ctx, f.id, mobyclient.ContainerRemoveOptions{Force: true, RemoveVolumes: true})
return errors.Join(removeErr, f.cli.Close())
}