mirror of
https://gitea.com/gitea/runner.git
synced 2026-11-13 03:13:42 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b5305c6785 | ||
|
|
a021d3139d | ||
|
|
84c0fcd1fa | ||
|
|
c59caa9689 | ||
|
|
85d007c47f | ||
|
|
d34e4cf11a | ||
|
|
8e999cd039 | ||
|
|
cd748053b0 | ||
|
|
9bb72bbe5c | ||
|
|
79faf5e55d | ||
|
|
993389d428 | ||
|
|
db76fd5159 | ||
|
|
c8b16f4792 | ||
|
|
02446620df | ||
|
|
8c8950d9ef | ||
|
|
b88d111cc5 |
No files matched your search
@@ -51,11 +51,6 @@ fields.
|
||||
- Tests read what was logged with `logtest.NewLogger()`, or `logtest.SetDefault(t)` for the
|
||||
package functions.
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -4,10 +4,10 @@ EXECUTABLE := gitea-runner$(shell $(GO) env GOEXE)
|
||||
HAS_GO = $(shell hash $(GO) > /dev/null 2>&1 && echo "GO" || echo "NOGO" )
|
||||
|
||||
PODMAN_TEST_IMAGE ?= quay.io/containers/podman:v5.8.9-immutable@sha256:87f42f37fa85b421664c30b8f5f9cfc23920057f5e5fcadf0b6f68d9edc6d415 # renovate: datasource=docker
|
||||
E2E_JOB_IMAGE ?= docker.gitea.com/runner-images:ubuntu-latest@sha256:657378e5cfd898e36f820459c60ef8e69be8fcda9fd354e4381a8b654d091bbc # renovate: datasource=docker
|
||||
E2E_JOB_IMAGE ?= docker.gitea.com/runner-images:ubuntu-latest@sha256:b96ba465021b1a4c6a1c9e79220c2b9bfad0f29b09dbb528bbfb35411443a3a4 # renovate: datasource=docker
|
||||
SERVICE_IMAGE ?= nginx:1.31.7-alpine@sha256:892bb2152464f0c5d8972593a5f5afaf8054552580b699d95f8ff4af08c2d33d # renovate: datasource=docker
|
||||
E2E_GITEA_IMAGE ?= gitea/gitea:main-nightly
|
||||
E2E_K3S_IMAGE ?= rancher/k3s:v1.37.1-k3s1@sha256:ca7f37d993d82ef0dcdcfecb2e0e2618ea541dbaffc620c8cedebe01a82acd0d # renovate: datasource=docker
|
||||
E2E_K3S_IMAGE ?= rancher/k3s:v1.37.2-k3s1@sha256:21ea6e0fac4e3930c2ce8aa40ddd41a8b18cb3e9a0222c030dc005e7e88c4332 # renovate: datasource=docker
|
||||
WINDOWS_TEST_IMAGE ?= dockurr/windows:6.06@sha256:16fd7c39a3e48bb946d29c59dc6015942e5b900a1fa5d7e5552053bfc48ee2ea # renovate: datasource=docker
|
||||
|
||||
GOFILES := $(shell find . -type f -name "*.go" -o -name "go.mod" ! -name "generated.*")
|
||||
|
||||
@@ -7,6 +7,7 @@ Gitea Runner runs the jobs of Gitea Actions workflows. Register it with a Gitea
|
||||
### Prerequisites
|
||||
|
||||
- Linux, macOS or Windows, or FreeBSD.
|
||||
- Gitea 1.26.2 or newer.
|
||||
- `git` 2.34.1 or newer.
|
||||
- `docker`, or `podman` with `podman.socket` enabled, for `docker` labels and Docker actions. See Docker's [install instructions](https://docs.docker.com/engine/install/).
|
||||
|
||||
@@ -14,9 +15,6 @@ In the job image, or on the host for host jobs:
|
||||
|
||||
- `bash` or `sh`, or `pwsh` or `powershell` on Windows.
|
||||
- `node` for JavaScript actions and `hashFiles()`.
|
||||
- `git` 2.34.1 or newer for [`builtin:checkout`](docs/builtin-actions.md).
|
||||
- `git-lfs` for `builtin:checkout` with `lfs: true`.
|
||||
- `ssh` for `builtin:checkout` with `ssh-key`.
|
||||
|
||||
Kubernetes mode needs [a few more tools](docs/kubernetes.md) in job images.
|
||||
|
||||
@@ -112,7 +110,7 @@ Versioned and `nightly` tags take the same suffixes, like `nightly-dind`. The im
|
||||
|
||||
### Example deployments
|
||||
|
||||
See the [examples](examples) directory for more deployments, among them systemd, Kubernetes and VMs.
|
||||
See the [examples](examples) directory for more deployments, among them systemd, launchd on macOS, Kubernetes and VMs.
|
||||
|
||||
## Configuration
|
||||
|
||||
@@ -183,7 +181,7 @@ Setup actions like `setup-go` install tools into `RUNNER_TOOL_CACHE`. `runner.to
|
||||
| Mode | Tool cache |
|
||||
| --- | --- |
|
||||
| `none` (default) | Provided by the job image. Versions it lacks are downloaded in every job. |
|
||||
| `shared` | One volume for all jobs. Only safe with `runner.capacity: 1`. |
|
||||
| `shared` | One volume per `runner.capacity` slot, reused by later jobs. It hides the tools later job images ship, so use images without them. |
|
||||
|
||||
To ship tools in a job image, install them to `/opt/hostedtoolcache/<tool>/<version>/<arch>` with an empty `<arch>.complete` file next to it, as the `-full` [runner images](https://gitea.com/gitea/runner-images) do:
|
||||
|
||||
@@ -245,10 +243,6 @@ volumes:
|
||||
|
||||
Set `container.sweep: true` to remove the containers, networks and volumes most Linux container jobs create when they end, `docker compose` services included. Leave it off if jobs deploy services to the runner's Docker daemon.
|
||||
|
||||
### Built-in actions (`uses: builtin:<name>`)
|
||||
|
||||
`uses: builtin:checkout` checks 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 runs its own cache server. As on GitHub, a job restores caches of its own ref, its pull request's base branch and the default branch.
|
||||
|
||||
@@ -104,7 +104,9 @@ type Handler struct {
|
||||
stopSync context.CancelFunc
|
||||
syncing sync.WaitGroup
|
||||
|
||||
outboundIPMu sync.RWMutex
|
||||
outboundIP string
|
||||
detectOutboundIP func() net.IP // nil when the address is configured
|
||||
instanceTransport http.RoundTripper
|
||||
|
||||
// internalSecret guards /_internal/{register,revoke}. When set, a remote
|
||||
@@ -158,6 +160,7 @@ func StartHandler(opts Options) (*Handler, error) {
|
||||
policy: opts.Policy.withDefaults(),
|
||||
freeDisk: disk.FreeBytes,
|
||||
dbLock: make(chan struct{}, 1),
|
||||
outboundIP: opts.OutboundIP,
|
||||
instanceTransport: opts.InstanceTransport,
|
||||
}
|
||||
|
||||
@@ -167,12 +170,11 @@ func StartHandler(opts Options) (*Handler, error) {
|
||||
logger = logger.With("module", "artifactcache")
|
||||
h.logger = logger
|
||||
|
||||
if opts.OutboundIP != "" {
|
||||
h.outboundIP = opts.OutboundIP
|
||||
} else if ip := common.GetOutboundIP(); ip == nil {
|
||||
return nil, errors.New("unable to determine outbound IP address")
|
||||
} else {
|
||||
h.outboundIP = ip.String()
|
||||
if opts.OutboundIP == "" {
|
||||
h.detectOutboundIP = common.GetOutboundIP
|
||||
if h.RefreshExternalURL() == "" {
|
||||
logger.Warn("unable to determine an outbound IP address for the cache server yet, set cache.host to configure one")
|
||||
}
|
||||
}
|
||||
|
||||
if opts.Upstream != "" {
|
||||
@@ -304,11 +306,36 @@ func (h *Handler) cacheForwarder(upstream string) (http.Handler, error) {
|
||||
}), nil
|
||||
}
|
||||
|
||||
// ExternalURL is "" while no address is known.
|
||||
func (h *Handler) ExternalURL() string {
|
||||
// TODO: make the external url configurable if necessary
|
||||
if h == nil {
|
||||
return ""
|
||||
}
|
||||
h.outboundIPMu.RLock()
|
||||
defer h.outboundIPMu.RUnlock()
|
||||
if h.outboundIP == "" {
|
||||
return ""
|
||||
}
|
||||
return "http://" + net.JoinHostPort(h.outboundIP, strconv.Itoa(h.port))
|
||||
}
|
||||
|
||||
// RefreshExternalURL re-detects an unconfigured address, which can change after startup.
|
||||
func (h *Handler) RefreshExternalURL() string {
|
||||
if h != nil && h.detectOutboundIP != nil {
|
||||
if ip := h.detectOutboundIP(); ip != nil {
|
||||
h.outboundIPMu.Lock()
|
||||
if address := ip.String(); address != h.outboundIP {
|
||||
h.outboundIP = address
|
||||
if ip.To4() == nil {
|
||||
h.logger.Warnf("detected IPv6 address %s for the cache server, jobs on IPv4-only networks cannot reach it, set cache.host if they fail to", address)
|
||||
}
|
||||
}
|
||||
h.outboundIPMu.Unlock()
|
||||
}
|
||||
}
|
||||
return h.ExternalURL()
|
||||
}
|
||||
|
||||
func (h *Handler) baseURL(cred JobCredential) string {
|
||||
if base := strings.TrimRight(cred.PublicURL, "/"); base != "" {
|
||||
return base
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
@@ -22,6 +23,8 @@ import (
|
||||
"testing/synctest"
|
||||
"time"
|
||||
|
||||
"gitea.com/gitea/runner/act/common/logging/logtest"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.etcd.io/bbolt"
|
||||
@@ -50,7 +53,23 @@ func signArtifactURL(h *Handler, id int64) string {
|
||||
|
||||
func TestHandler_ExternalURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
assert.Equal(t, "http://[2001:db8::1]:8080", (&Handler{outboundIP: "2001:db8::1", port: 8080}).ExternalURL())
|
||||
assert.Empty(t, (*Handler)(nil).RefreshExternalURL())
|
||||
assert.Equal(t, "http://cache.example:8080", (&Handler{outboundIP: "cache.example", port: 8080}).RefreshExternalURL())
|
||||
|
||||
logger, logs := logtest.NewLogger()
|
||||
var ip net.IP
|
||||
handler := &Handler{port: 8080, logger: logger, detectOutboundIP: func() net.IP { return ip }}
|
||||
assert.Empty(t, handler.RefreshExternalURL())
|
||||
ip = net.ParseIP("2001:db8::1")
|
||||
assert.Equal(t, "http://[2001:db8::1]:8080", handler.RefreshExternalURL())
|
||||
assert.Equal(t, "http://[2001:db8::1]:8080", handler.RefreshExternalURL())
|
||||
ip = net.ParseIP("192.0.2.1")
|
||||
assert.Equal(t, "http://[2001:db8::1]:8080", handler.ExternalURL())
|
||||
assert.Equal(t, "http://192.0.2.1:8080", handler.RefreshExternalURL())
|
||||
ip = nil
|
||||
assert.Equal(t, "http://192.0.2.1:8080", handler.RefreshExternalURL())
|
||||
require.Len(t, logs.AllRecords(), 1)
|
||||
assert.Contains(t, logs.LastRecord().Message, "IPv6")
|
||||
}
|
||||
|
||||
func TestHandler_Upstream(t *testing.T) {
|
||||
|
||||
@@ -6,6 +6,7 @@ package common
|
||||
|
||||
import (
|
||||
"net"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
@@ -55,19 +56,22 @@ func GetOutboundIP() net.IP {
|
||||
}
|
||||
|
||||
func preferredIP(ips []interfaceIP) net.IP {
|
||||
ips = slices.DeleteFunc(ips, func(ip interfaceIP) bool {
|
||||
return len(ip.Name) == 15 && strings.HasPrefix(ip.Name, "br-") && strings.Trim(ip.Name[3:], "0123456789abcdef") == "" // a Docker network's bridge, removed with the network
|
||||
})
|
||||
if len(ips) == 0 {
|
||||
return nil
|
||||
}
|
||||
sort.Slice(ips, func(i, j int) bool {
|
||||
first, second := ips[i], ips[j]
|
||||
|
||||
// ethernet is preferred
|
||||
if vi, vj := strings.HasPrefix(first.Name, "e"), strings.HasPrefix(second.Name, "e"); vi != vj {
|
||||
// IPv4 is preferred, as job networks are IPv4-only by default
|
||||
if vi, vj := first.To4() != nil, second.To4() != nil; vi != vj {
|
||||
return vi
|
||||
}
|
||||
|
||||
// IPv4 is preferred
|
||||
if vi, vj := first.To4() != nil, second.To4() != nil; vi != vj {
|
||||
// ethernet is preferred
|
||||
if vi, vj := strings.HasPrefix(first.Name, "e"), strings.HasPrefix(second.Name, "e"); vi != vj {
|
||||
return vi
|
||||
}
|
||||
|
||||
|
||||
@@ -13,8 +13,9 @@ import (
|
||||
func TestPreferredIP(t *testing.T) {
|
||||
assert.Nil(t, preferredIP(nil))
|
||||
assert.Equal(t, net.ParseIP("2001:db8::1"), preferredIP([]interfaceIP{{IP: net.ParseIP("2001:db8::1")}}))
|
||||
assert.Equal(t, net.ParseIP("2001:db8::1"), preferredIP([]interfaceIP{
|
||||
assert.Equal(t, net.ParseIP("192.0.2.1"), preferredIP([]interfaceIP{
|
||||
{IP: net.ParseIP("192.0.2.1"), Name: "wlan0"},
|
||||
{IP: net.ParseIP("2001:db8::1"), Name: "eth0"},
|
||||
{IP: net.ParseIP("172.18.0.1"), Name: "br-0123456789ab"},
|
||||
}))
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"gitea.com/gitea/runner/act/common"
|
||||
|
||||
@@ -23,6 +24,9 @@ import (
|
||||
|
||||
var hostOS = runtime.GOOS // replaced in tests
|
||||
|
||||
// DrainGracePeriod caps the wait for container output to drain so the log tail is not truncated
|
||||
const DrainGracePeriod = 2 * time.Second
|
||||
|
||||
// ExitCodeError reports a non-zero process exit code from a container command.
|
||||
type ExitCodeError int
|
||||
|
||||
|
||||
+11
-13
@@ -41,13 +41,6 @@ import (
|
||||
specs "github.com/opencontainers/image-spec/specs-go/v1"
|
||||
)
|
||||
|
||||
// drainGracePeriod bounds how long we wait for an output-copy goroutine to
|
||||
// finish draining a container's output before returning, so that neither a
|
||||
// cancellation (waitForCommand) nor a normal container exit (wait) truncates
|
||||
// the tail of the log. It is a safety bound: in the common case the stream
|
||||
// reaches EOF and the goroutine returns well before this elapses.
|
||||
const drainGracePeriod = 2 * time.Second
|
||||
|
||||
// NewContainer creates a reference to a container
|
||||
func NewContainer(input *NewContainerInput) ExecutionsEnvironment {
|
||||
// Resolved up front because the image pull runs before the container is created.
|
||||
@@ -861,6 +854,7 @@ func (cr *containerReference) tryReadID(opt string, cbk func(id int)) common.Exe
|
||||
return nil
|
||||
}
|
||||
defer resp.Close()
|
||||
defer context.AfterFunc(ctx, resp.Close)()
|
||||
|
||||
sid, err := resp.Reader.ReadString('\n')
|
||||
if err != nil {
|
||||
@@ -906,7 +900,7 @@ func (cr *containerReference) waitForCommand(ctx context.Context, resp client.Hi
|
||||
if err != nil {
|
||||
logger.Debugf("Failed to drain command output after cancellation: %v", err)
|
||||
}
|
||||
case <-time.After(drainGracePeriod):
|
||||
case <-time.After(DrainGracePeriod):
|
||||
logger.Warn("Timed out draining command output after cancellation")
|
||||
}
|
||||
|
||||
@@ -938,10 +932,7 @@ func (cr *containerReference) copyDir(dstPath, srcPath string, useGitIgnore, ski
|
||||
logger.Error(err)
|
||||
}
|
||||
}()
|
||||
extractionRoot := "/"
|
||||
if actPath := cr.GetActPath(); strings.HasPrefix(dstPath+"/", actPath+"/") {
|
||||
extractionRoot = actPath // keeps tar entries from crossing the image's /var/run symlink
|
||||
}
|
||||
extractionRoot := cr.extractionRoot(dstPath)
|
||||
if err := writeDirTar(ctx, tarFile, path.Join("/", strings.TrimPrefix(dstPath, extractionRoot)), srcPath, useGitIgnore, skipGitDir, cr.UID, cr.GID, cr.markExecutable(ctx)); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -954,6 +945,13 @@ func (cr *containerReference) copyDir(dstPath, srcPath string, useGitIgnore, ski
|
||||
}
|
||||
}
|
||||
|
||||
func (cr *containerReference) extractionRoot(dstPath string) string {
|
||||
if actPath := cr.GetActPath(); strings.HasPrefix(dstPath+"/", actPath+"/") {
|
||||
return actPath // keeps tar entries from crossing the image's /var/run symlink
|
||||
}
|
||||
return "/"
|
||||
}
|
||||
|
||||
func (cr *containerReference) copyContent(dstPath string, files ...*FileEntry) common.Executor {
|
||||
return func(ctx context.Context) error {
|
||||
if cr.id == "" {
|
||||
@@ -1046,7 +1044,7 @@ func (cr *containerReference) wait() common.Executor {
|
||||
if cr.attachDone != nil {
|
||||
select {
|
||||
case <-cr.attachDone:
|
||||
case <-time.After(drainGracePeriod):
|
||||
case <-time.After(DrainGracePeriod):
|
||||
logger.Warn("Timed out draining container output")
|
||||
}
|
||||
cr.attachDone = nil
|
||||
|
||||
@@ -122,6 +122,11 @@ func (m *mockDockerClient) CopyToContainer(ctx context.Context, id string, optio
|
||||
return args.Get(0).(mobyclient.CopyToContainerResult), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *mockDockerClient) ContainerStatPath(ctx context.Context, id string, options mobyclient.ContainerStatPathOptions) (mobyclient.ContainerStatPathResult, error) {
|
||||
args := m.Called(ctx, id, options)
|
||||
return args.Get(0).(mobyclient.ContainerStatPathResult), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *mockDockerClient) ContainerInspect(ctx context.Context, id string, opts mobyclient.ContainerInspectOptions) (mobyclient.ContainerInspectResult, error) {
|
||||
args := m.Called(ctx, id, opts)
|
||||
return args.Get(0).(mobyclient.ContainerInspectResult), args.Error(1)
|
||||
@@ -351,6 +356,21 @@ func TestDockerAttachStreamClosesWithItsContext(t *testing.T) {
|
||||
client.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestDockerReadIDStreamClosesWithItsContext(t *testing.T) {
|
||||
conn, peer := net.Pipe()
|
||||
t.Cleanup(func() { _ = peer.Close() })
|
||||
client := &mockDockerClient{}
|
||||
client.On("ExecCreate", mock.Anything, "123", mock.AnythingOfType("client.ExecCreateOptions")).
|
||||
Return(mobyclient.ExecCreateResult{ID: "id"}, nil)
|
||||
client.On("ExecAttach", mock.Anything, "id", mobyclient.ExecAttachOptions{}).
|
||||
Return(mobyclient.ExecAttachResult{HijackedResponse: mobyclient.NewHijackedResponse(conn, "")}, nil)
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
cancel()
|
||||
|
||||
require.NoError(t, (&containerReference{id: "123", cli: client}).tryReadID("-u", func(int) { t.Error("read an ID after cancellation") })(ctx))
|
||||
client.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestDockerWaitFailure(t *testing.T) {
|
||||
ctx := t.Context()
|
||||
statusCh := make(chan container.WaitResponse, 1)
|
||||
|
||||
@@ -6,7 +6,6 @@ package container
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bytes"
|
||||
"cmp"
|
||||
"context"
|
||||
"errors"
|
||||
@@ -117,17 +116,24 @@ func (e *HostEnvironment) Inspect(_ context.Context) (*Info, error) {
|
||||
}
|
||||
|
||||
func (e *HostEnvironment) GetContainerArchive(ctx context.Context, srcPath string) (io.ReadCloser, error) {
|
||||
buf := &bytes.Buffer{}
|
||||
tw := tar.NewWriter(buf)
|
||||
defer tw.Close()
|
||||
srcPath = filepath.Clean(srcPath)
|
||||
fi, err := os.Lstat(srcPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tc := &filecollector.TarCollector{
|
||||
TarWriter: tw,
|
||||
}
|
||||
reader, writer := io.Pipe()
|
||||
go func() {
|
||||
tw := tar.NewWriter(writer)
|
||||
err := writeHostArchive(ctx, &filecollector.TarCollector{TarWriter: tw}, srcPath, fi)
|
||||
if err == nil {
|
||||
err = tw.Close()
|
||||
}
|
||||
writer.CloseWithError(err)
|
||||
}()
|
||||
return startedArchive(reader)
|
||||
}
|
||||
|
||||
func writeHostArchive(ctx context.Context, tc *filecollector.TarCollector, srcPath string, fi fs.FileInfo) error {
|
||||
if fi.IsDir() {
|
||||
srcPrefix := srcPath
|
||||
if !strings.HasSuffix(srcPrefix, string(filepath.Separator)) {
|
||||
@@ -138,31 +144,22 @@ func (e *HostEnvironment) GetContainerArchive(ctx context.Context, srcPath strin
|
||||
SrcPrefix: srcPrefix,
|
||||
Handler: tc,
|
||||
}
|
||||
err = filepath.Walk(srcPath, fc.CollectFiles(ctx))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return filepath.Walk(srcPath, fc.CollectFiles(ctx))
|
||||
}
|
||||
var f io.ReadCloser
|
||||
var linkname string
|
||||
var err error
|
||||
if fi.Mode()&fs.ModeSymlink != 0 {
|
||||
if linkname, err = os.Readlink(srcPath); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
var f io.ReadCloser
|
||||
var linkname string
|
||||
if fi.Mode()&fs.ModeSymlink != 0 {
|
||||
linkname, err = os.Readlink(srcPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
f, err = os.Open(srcPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
}
|
||||
err := tc.WriteFile(fi.Name(), fi, linkname, f)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
if f, err = os.Open(srcPath); err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
}
|
||||
return io.NopCloser(buf), nil
|
||||
return tc.WriteFile(fi.Name(), fi, linkname, f)
|
||||
}
|
||||
|
||||
func (e *HostEnvironment) Pull(_ bool) common.Executor {
|
||||
|
||||
@@ -76,7 +76,11 @@ func TestGetContainerArchive(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, expectedContent, content)
|
||||
_, err = reader.Next()
|
||||
assert.ErrorIs(t, err, io.EOF)
|
||||
require.ErrorIs(t, err, io.EOF)
|
||||
require.NoError(t, archive.Close())
|
||||
|
||||
_, err = e.GetContainerArchive(ctx, filepath.Join(e.Path, "missing"))
|
||||
assert.ErrorIs(t, err, os.ErrNotExist)
|
||||
}
|
||||
|
||||
func TestHostEnvironmentExecExitCode(t *testing.T) {
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
package container
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"cmp"
|
||||
"compress/gzip"
|
||||
@@ -573,15 +572,7 @@ func (c *kubernetesContainer) GetContainerArchive(ctx context.Context, srcPath s
|
||||
}
|
||||
writer.CloseWithError(err)
|
||||
}()
|
||||
buffered := bufio.NewReader(reader)
|
||||
if _, err := buffered.Peek(1); err != nil {
|
||||
reader.Close()
|
||||
return nil, err
|
||||
}
|
||||
return struct {
|
||||
io.Reader
|
||||
io.Closer
|
||||
}{buffered, reader}, nil
|
||||
return startedArchive(reader)
|
||||
}
|
||||
|
||||
func (c *kubernetesContainer) UpdateFromEnv(srcPath string, env *map[string]string) common.Executor {
|
||||
|
||||
@@ -6,6 +6,7 @@ package container
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bufio"
|
||||
"context"
|
||||
"io"
|
||||
"path/filepath"
|
||||
@@ -15,6 +16,19 @@ import (
|
||||
"gitea.com/gitea/runner/act/filecollector"
|
||||
)
|
||||
|
||||
// startedArchive returns reader once its first byte arrives, so failing to start archiving errors here, not on read.
|
||||
func startedArchive(reader *io.PipeReader) (io.ReadCloser, error) {
|
||||
buffered := bufio.NewReader(reader)
|
||||
if _, err := buffered.Peek(1); err != nil {
|
||||
reader.Close()
|
||||
return nil, err
|
||||
}
|
||||
return struct {
|
||||
io.Reader
|
||||
io.Closer
|
||||
}{buffered, reader}, nil
|
||||
}
|
||||
|
||||
func writeFilesTar(ctx context.Context, w io.Writer, uid, gid int, files ...*FileEntry) error {
|
||||
tw := tar.NewWriter(w)
|
||||
for _, file := range files {
|
||||
|
||||
@@ -472,7 +472,7 @@ func newStepContainer(ctx context.Context, step step, image string, cmd, entrypo
|
||||
|
||||
envList = append(envList, rc.runnerEnv(ctx)...)
|
||||
|
||||
binds, mounts := rc.GetBindsAndMounts(ctx)
|
||||
binds, mounts, _ := rc.GetBindsAndMounts(ctx)
|
||||
networkMode := "container:" + rc.jobContainerName()
|
||||
if rc.IsHostEnv() {
|
||||
networkMode = "default"
|
||||
|
||||
@@ -118,7 +118,11 @@ func (rc *RunContext) commandHandler(ctx context.Context) common.LineHandler {
|
||||
rawLogger.Infof("%s", formatAnnotation(command, kvPairs, arg))
|
||||
return false
|
||||
case "group", "endgroup":
|
||||
return true
|
||||
if strings.HasPrefix(line, "::"+command+"::") || strings.HasPrefix(line, "##["+command+"]") {
|
||||
return true
|
||||
}
|
||||
rawLogger.Infof("::%s::%s", command, EscapeCommandData(arg))
|
||||
return false
|
||||
}
|
||||
return echoed
|
||||
}
|
||||
|
||||
@@ -240,10 +240,23 @@ func TestCommandLogWriter(t *testing.T) {
|
||||
"::notice::title: m", "::error::x: m", "::error::x:1: m", "::notice::std::vector", "::error::x;y: m]",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "groups normalize unsupported prefixes and preserve recognized prefixes",
|
||||
steps: [][]string{{
|
||||
"::group::a%3Bb%5Dc%250A", "::endgroup::", "##[group]a%3Bb%5Dc%250A", "##[endgroup]",
|
||||
"::GrOuP::a%25b%0Ac", "::EnDgRoUp::", " \t::group::indented", " \t::endgroup::",
|
||||
"prefix ##[GrOuP]a%3Bb%5Dc%253B%0A", "prefix ##[EnDgRoUp]",
|
||||
}},
|
||||
log: []string{
|
||||
"::group::a%3Bb%5Dc%250A", "::endgroup::", "##[group]a%3Bb%5Dc%250A", "##[endgroup]",
|
||||
"::group::a%25b%0Ac", "::endgroup::", "::group::indented", "::endgroup::",
|
||||
"::group::a;b]c%253B%0A", "::endgroup::",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "stop-commands keeps lines verbatim until resumed",
|
||||
steps: [][]string{{"::stop-commands::resume-token", "::add-mask::kept", "::set-output name=a::b", "::RESUME-TOKEN::", "::add-mask::masked"}},
|
||||
log: []string{"::stop-commands::resume-token", "::add-mask::kept", "::set-output name=a::b", "::RESUME-TOKEN::"},
|
||||
steps: [][]string{{"::stop-commands::resume-token", "::add-mask::kept", "::set-output name=a::b", " \t::GrOuP::kept%3B", "prefix ##[EnDgRoUp]", "::RESUME-TOKEN::", "::add-mask::masked"}},
|
||||
log: []string{"::stop-commands::resume-token", "::add-mask::kept", "::set-output name=a::b", " \t::GrOuP::kept%3B", "prefix ##[EnDgRoUp]", "::RESUME-TOKEN::"},
|
||||
masks: []string{"resume-token", "masked"},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -82,3 +82,8 @@ func (cm *containerMock) Inspect(ctx context.Context) (*container.Info, error) {
|
||||
err, _ := args.Get(1).(error)
|
||||
return info, err
|
||||
}
|
||||
|
||||
func (cm *containerMock) ReplaceLogWriter(stdout, stderr io.Writer) (io.Writer, io.Writer) {
|
||||
args := cm.Called(stdout, stderr)
|
||||
return args.Get(0).(io.Writer), args.Get(1).(io.Writer)
|
||||
}
|
||||
@@ -136,7 +136,7 @@ func getHashFilesFunction(ctx context.Context, rc *RunContext) func(v []reflect.
|
||||
followSymlink := false
|
||||
|
||||
for i, p := range v {
|
||||
s := p.String()
|
||||
s := exprparser.CoerceToString(p)
|
||||
if i == 0 && strings.HasPrefix(s, "--") {
|
||||
if strings.EqualFold(s, "--follow-symbolic-links") {
|
||||
followSymlink = true
|
||||
|
||||
@@ -178,7 +178,7 @@ func (c *hashFilesContainer) ReplaceLogWriter(stdout, stderr io.Writer) (io.Writ
|
||||
}
|
||||
|
||||
func TestGetHashFilesFunction(t *testing.T) {
|
||||
const failureMessage = "hashFiles('dir''file, *.sum') failed. Fail to hash files under directory '/workspace'"
|
||||
const failureMessage = "hashFiles('dir''file, *.sum, 123, true, ') failed. Fail to hash files under directory '/workspace'"
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
output string
|
||||
@@ -192,7 +192,7 @@ func TestGetHashFilesFunction(t *testing.T) {
|
||||
{name: "success", output: "patterns __OUTPUT__wrong__OUTPUT__ echoed\n__OUTPUT__digest__OUTPUT__", hash: "digest"},
|
||||
{name: "no matches", output: "__OUTPUT____OUTPUT__"},
|
||||
{name: "script failure", output: "Cannot find: node in PATH\n__OUTPUT__digest__OUTPUT__", execErr: container.ExitCodeError(1), err: failureMessage},
|
||||
{name: "timeout", execErr: context.DeadlineExceeded, err: "hashFiles('dir''file, *.sum') couldn't finish within 120 seconds"},
|
||||
{name: "timeout", execErr: context.DeadlineExceeded, err: "hashFiles('dir''file, *.sum, 123, true, ') couldn't finish within 120 seconds"},
|
||||
{name: "job deadline", execErr: context.DeadlineExceeded, expired: true, err: context.DeadlineExceeded.Error()},
|
||||
{name: "node missing", execErr: fmt.Errorf("wrapped: %w", container.ExitCodeError(127)), warning: true},
|
||||
{name: "node not executable", execErr: container.ExitCodeError(126), warning: true},
|
||||
@@ -205,7 +205,7 @@ func TestGetHashFilesFunction(t *testing.T) {
|
||||
jobContainer := &hashFilesContainer{stdout: stdout, stderr: stderr, copyErr: test.copyErr}
|
||||
jobContainer.execute = func(command []string, env map[string]string, user, workdir string) common.Executor {
|
||||
assert.Equal(t, []string{"node", "/var/run/act/workflow/hashfiles/index.js"}, command)
|
||||
assert.Equal(t, map[string]string{"patterns": "dir'file\n*.sum", "followSymbolicLinks": "true"}, env)
|
||||
assert.Equal(t, map[string]string{"patterns": "dir'file\n*.sum\n123\ntrue\n", "followSymbolicLinks": "true"}, env)
|
||||
assert.Empty(t, user)
|
||||
assert.Empty(t, workdir)
|
||||
return func(_ctx context.Context) error {
|
||||
@@ -223,7 +223,7 @@ func TestGetHashFilesFunction(t *testing.T) {
|
||||
result, err := getHashFilesFunction(ctx, &RunContext{
|
||||
Config: &Config{Workdir: "/workspace"},
|
||||
JobContainer: jobContainer,
|
||||
})([]reflect.Value{reflect.ValueOf("--follow-symbolic-links"), reflect.ValueOf("dir'file"), reflect.ValueOf("*.sum")})
|
||||
})([]reflect.Value{reflect.ValueOf("--follow-symbolic-links"), reflect.ValueOf("dir'file"), reflect.ValueOf("*.sum"), reflect.ValueOf(float64(123)), reflect.ValueOf(true), {}})
|
||||
if test.err == "" {
|
||||
require.NoError(t, err)
|
||||
} else {
|
||||
|
||||
@@ -30,6 +30,7 @@ import (
|
||||
"time"
|
||||
|
||||
"gitea.com/gitea/runner/act/common"
|
||||
"gitea.com/gitea/runner/act/common/logging"
|
||||
"gitea.com/gitea/runner/act/container"
|
||||
"gitea.com/gitea/runner/act/ghcontext"
|
||||
"gitea.com/gitea/runner/internal/pkg/lock"
|
||||
@@ -75,6 +76,7 @@ type RunContext struct {
|
||||
actionInputs map[string]any // inputs of the composite action this runs, nil for a job
|
||||
Masks []string
|
||||
cleanUpJobContainer common.Executor
|
||||
taskWorkspace string
|
||||
deferVolumeCleanup func(common.Executor)
|
||||
caller *caller // job calling this RunContext (reusable workflows)
|
||||
workflowCallInputs map[string]any // the caller's with:, resolved once by resolveWorkflowCall
|
||||
@@ -271,7 +273,7 @@ func (rc *RunContext) validVolumes() []string {
|
||||
volumes = append(volumes, hostEnv.Path, hostEnv.GetActPath())
|
||||
}
|
||||
if rc.Config.SharedToolCache {
|
||||
volumes = append(volumes, sharedToolCacheVolume)
|
||||
volumes = append(volumes, rc.toolCacheVolume())
|
||||
}
|
||||
if rc.Config.BindWorkdir {
|
||||
volumes = append(volumes, rc.Config.Workdir, rc.workdirMountRoot())
|
||||
@@ -331,6 +333,13 @@ func (rc *RunContext) startDockerProxy(ctx context.Context) {
|
||||
rc.dockerProxy = container.NewDockerProxy(ctx, rc.jobContainerName())
|
||||
}
|
||||
|
||||
func (rc *RunContext) toolCacheVolume() string {
|
||||
if rc.Config.ToolCacheSlot == "" {
|
||||
return sharedToolCacheVolume
|
||||
}
|
||||
return sharedToolCacheVolume + "-" + rc.Config.ToolCacheSlot
|
||||
}
|
||||
|
||||
// toolCache returns the tool cache path the job sees, relocatable through RUNNER_TOOL_CACHE.
|
||||
func (rc *RunContext) toolCache(fallback string) string {
|
||||
return cmp.Or(rc.GetEnv()["RUNNER_TOOL_CACHE"], fallback)
|
||||
@@ -378,8 +387,8 @@ func splitVolumes(specs []string) ([]string, map[string]string, map[string]bool)
|
||||
return binds, mounts, targets
|
||||
}
|
||||
|
||||
// Returns the binds and mounts for the container, resolving paths as appopriate
|
||||
func (rc *RunContext) GetBindsAndMounts(ctx context.Context) ([]string, map[string]string) {
|
||||
// Returns the container's binds, mounts and bound task workspace, resolving paths as appropriate
|
||||
func (rc *RunContext) GetBindsAndMounts(ctx context.Context) ([]string, map[string]string, string) {
|
||||
name := rc.jobContainerName()
|
||||
ext := container.LinuxContainerEnvironmentExtensions{}
|
||||
|
||||
@@ -391,7 +400,7 @@ func (rc *RunContext) GetBindsAndMounts(ctx context.Context) ([]string, map[stri
|
||||
}
|
||||
if rc.Config.SharedToolCache {
|
||||
if toolCache := rc.toolCache(container.DefaultToolCache); !claimed[toolCache] {
|
||||
mounts[sharedToolCacheVolume] = toolCache
|
||||
mounts[rc.toolCacheVolume()] = toolCache
|
||||
}
|
||||
}
|
||||
if hostEnv, ok := rc.JobContainer.(*container.HostEnvironment); ok {
|
||||
@@ -399,12 +408,16 @@ func (rc *RunContext) GetBindsAndMounts(ctx context.Context) ([]string, map[stri
|
||||
}
|
||||
mounts[name+"-env"] = ext.GetActPath() // runner-internal, never overridable
|
||||
|
||||
taskWorkspace := ""
|
||||
if workdir := ext.ToContainerPath(rc.Config.Workdir); !claimed[workdir] {
|
||||
source := rc.workdirMountRoot()
|
||||
target := ext.ToContainerPath(source)
|
||||
if claimed[target] {
|
||||
source, target = rc.Config.Workdir, workdir
|
||||
}
|
||||
if rc.Config.BindWorkdir && rc.Config.PresetGitHubContext != nil {
|
||||
taskWorkspace = target
|
||||
}
|
||||
if rc.Config.BindWorkdir {
|
||||
bindModifiers := ""
|
||||
if runtime.GOOS == "darwin" {
|
||||
@@ -419,7 +432,7 @@ func (rc *RunContext) GetBindsAndMounts(ctx context.Context) ([]string, map[stri
|
||||
}
|
||||
}
|
||||
|
||||
return binds, mounts
|
||||
return binds, mounts, taskWorkspace
|
||||
}
|
||||
|
||||
func (rc *RunContext) startHostEnvironment() common.Executor {
|
||||
@@ -447,6 +460,10 @@ func (rc *RunContext) startHostEnvironment() common.Executor {
|
||||
toolCacheParent = cacheDir
|
||||
}
|
||||
toolCache := rc.toolCache(filepath.Join(toolCacheParent, "tool_cache"))
|
||||
if rc.Config.ToolCacheSlot != "" {
|
||||
toolCache = filepath.Join(toolCache, rc.Config.ToolCacheSlot)
|
||||
rc.Env["RUNNER_TOOL_CACHE"] = toolCache // overrides a RUNNER_TOOL_CACHE the slots would otherwise share
|
||||
}
|
||||
if err := os.MkdirAll(toolCache, 0o777); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -672,7 +689,8 @@ func (rc *RunContext) startJobContainer() common.Executor {
|
||||
return err
|
||||
}
|
||||
rc.startDockerProxy(ctx)
|
||||
containerInput.Binds, containerInput.Mounts = rc.GetBindsAndMounts(ctx)
|
||||
var taskWorkspace string
|
||||
containerInput.Binds, containerInput.Mounts, taskWorkspace = rc.GetBindsAndMounts(ctx)
|
||||
containerInput.ValidVolumes = rc.validVolumes()
|
||||
|
||||
rc.jobNetworkName = networkName
|
||||
@@ -687,6 +705,7 @@ func (rc *RunContext) startJobContainer() common.Executor {
|
||||
func(ctx context.Context) error { return rc.createJobVolumes(ctx, containerInput.Mounts) },
|
||||
rc.JobContainer.Create(rc.Config.ContainerCapAdd, rc.Config.ContainerCapDrop),
|
||||
rc.JobContainer.Start(false),
|
||||
func(context.Context) error { rc.taskWorkspace = taskWorkspace; return nil },
|
||||
rc.captureJobContainerInfo(),
|
||||
rc.copyWorkflowFiles(),
|
||||
rc.waitForServiceContainers(),
|
||||
@@ -801,7 +820,10 @@ func (rc *RunContext) cleanupJobResources(networkName string, createAndDeleteNet
|
||||
errs := []error{rc.closeDockerProxy(ctx)}
|
||||
var containers []common.Executor
|
||||
if rc.JobContainer != nil {
|
||||
containers = append(containers, rc.JobContainer.Remove())
|
||||
containers = append(containers, func(ctx context.Context) error {
|
||||
rc.emptyTaskWorkspace(ctx)
|
||||
return rc.JobContainer.Remove()(ctx)
|
||||
})
|
||||
}
|
||||
if len(rc.serviceContainers) > 0 {
|
||||
logger.Infof("Cleaning up services for job %s", rc.JobName)
|
||||
@@ -831,6 +853,42 @@ func (rc *RunContext) cleanupJobResources(networkName string, createAndDeleteNet
|
||||
}
|
||||
}
|
||||
|
||||
// exits 3 unless workspace $1 is safe to empty per mountinfo $2
|
||||
const emptyTaskWorkspaceScript = `case "$1" in /?*/?*) ;; *) exit 3 ;; esac
|
||||
mounted=
|
||||
while read -r _ _ _ _ point _; do
|
||||
case "$point" in "$1") mounted=1 ;; "$1"/*) exit 3 ;; esac
|
||||
done <"$2"
|
||||
[ "$mounted" ] || exit 3
|
||||
rm -rf "$1"/* "$1"/.[!.]* "$1"/..?*`
|
||||
|
||||
var runnerCanRemoveJobFiles = runtime.GOOS != "linux" || os.Geteuid() == 0 // root, or a desktop VM mapping file ownership, replaced in tests
|
||||
|
||||
// runs as root with root-owned PATH dirs only, skipped for called workflows sharing the workspace
|
||||
func (rc *RunContext) emptyTaskWorkspace(ctx context.Context) {
|
||||
if rc.taskWorkspace == "" || rc.caller != nil || runnerCanRemoveJobFiles {
|
||||
return
|
||||
}
|
||||
budget := 30 * time.Second
|
||||
if deadline, ok := ctx.Deadline(); ok {
|
||||
budget = min(budget, time.Until(deadline)/2-container.DrainGracePeriod)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, budget)
|
||||
defer cancel()
|
||||
stdout, stderr := rc.JobContainer.ReplaceLogWriter(io.Discard, io.Discard)
|
||||
defer rc.JobContainer.ReplaceLogWriter(stdout, stderr)
|
||||
err := rc.JobContainer.Exec(
|
||||
[]string{"sh", "-c", emptyTaskWorkspaceScript, "sh", rc.taskWorkspace, "/proc/self/mountinfo"},
|
||||
map[string]string{"PATH": "/usr/sbin:/usr/bin:/sbin:/bin"}, "0", "/",
|
||||
)(common.WithLogger(ctx, logging.New(logging.DiscardHandler)))
|
||||
switch {
|
||||
case errors.Is(err, container.ExitCodeError(3)):
|
||||
logging.Infof("not emptying workspace %s of %s: under two levels deep, not a mountpoint as given, or a mount below it", rc.taskWorkspace, rc.jobContainerName())
|
||||
case err != nil:
|
||||
logging.Infof("could not empty workspace %s of %s: %v", rc.taskWorkspace, rc.jobContainerName(), err)
|
||||
}
|
||||
}
|
||||
|
||||
func runAll(ctx context.Context, executors []common.Executor) error {
|
||||
errs := make([]error, len(executors))
|
||||
var wg sync.WaitGroup
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"slices"
|
||||
@@ -455,7 +456,7 @@ func TestRunContext_GetBindsAndMounts(t *testing.T) {
|
||||
t.Run(testcase.name+testBindSuffix, func(t *testing.T) {
|
||||
rctemplate.Config.Workdir = testcase.name
|
||||
rctemplate.Config.BindWorkdir = bindWorkDir
|
||||
gotbind, gotmount := rctemplate.GetBindsAndMounts(t.Context())
|
||||
gotbind, gotmount, _ := rctemplate.GetBindsAndMounts(t.Context())
|
||||
|
||||
if bindWorkDir {
|
||||
fullBind := testcase.name + ":" + testcase.want
|
||||
@@ -507,7 +508,7 @@ func TestRunContext_GetBindsAndMounts(t *testing.T) {
|
||||
rc.ExprEval = rc.NewExpressionEvaluator(t.Context())
|
||||
require.NoError(t, rc.resolvePlatformImage(t.Context()))
|
||||
|
||||
gotbind, gotmount := rc.GetBindsAndMounts(t.Context())
|
||||
gotbind, gotmount, _ := rc.GetBindsAndMounts(t.Context())
|
||||
assert.Contains(t, gotbind, "/host/mame/roms:/root/.mame/roms:ro")
|
||||
assert.NotContains(t, gotbind, "${{ secrets.MAME }}")
|
||||
assert.NotContains(t, gotmount, "${{ secrets.MAME }}")
|
||||
@@ -522,7 +523,7 @@ func TestRunContext_GetBindsAndMounts(t *testing.T) {
|
||||
rc.containerSpec.Volumes = testcase.volumes
|
||||
rc.Run.JobID = "job1"
|
||||
|
||||
gotbind, gotmount := rc.GetBindsAndMounts(t.Context())
|
||||
gotbind, gotmount, _ := rc.GetBindsAndMounts(t.Context())
|
||||
|
||||
if len(testcase.wantbind) > 0 {
|
||||
assert.Contains(t, gotbind, testcase.wantbind)
|
||||
@@ -551,13 +552,19 @@ func TestRunContext_GetBindsAndMounts(t *testing.T) {
|
||||
t.Run("ToolCacheMount", func(t *testing.T) {
|
||||
rc := newRunContext(&Config{})
|
||||
|
||||
_, gotmount := rc.GetBindsAndMounts(t.Context())
|
||||
_, gotmount, _ := rc.GetBindsAndMounts(t.Context())
|
||||
assert.NotContains(t, gotmount, sharedToolCacheVolume)
|
||||
|
||||
rc.Config.SharedToolCache = true
|
||||
rc.containerSpec.Volumes = []string{"/unapproved:" + container.DefaultToolCache}
|
||||
_, gotmount = rc.GetBindsAndMounts(t.Context())
|
||||
_, gotmount, _ = rc.GetBindsAndMounts(t.Context())
|
||||
assert.Equal(t, container.DefaultToolCache, gotmount[sharedToolCacheVolume])
|
||||
|
||||
rc.Config.ToolCacheSlot = "1"
|
||||
_, gotmount, _ = rc.GetBindsAndMounts(t.Context())
|
||||
assert.Equal(t, container.DefaultToolCache, gotmount[sharedToolCacheVolume+"-1"])
|
||||
assert.NotContains(t, gotmount, sharedToolCacheVolume)
|
||||
assert.Contains(t, rc.validVolumes(), sharedToolCacheVolume+"-1")
|
||||
})
|
||||
|
||||
t.Run("DaemonMountsAboveOwnerRepo", func(t *testing.T) {
|
||||
@@ -569,16 +576,26 @@ func TestRunContext_GetBindsAndMounts(t *testing.T) {
|
||||
Config: &Config{BindWorkdir: true, Workdir: "/workspace/1/owner/repo", PresetGitHubContext: &model.GithubContext{}, ValidVolumes: []string{"claimed"}},
|
||||
}
|
||||
|
||||
gotbind, _ := rc.GetBindsAndMounts(t.Context())
|
||||
gotbind, _, workspace := rc.GetBindsAndMounts(t.Context())
|
||||
assert.True(t, slices.ContainsFunc(gotbind, func(bind string) bool { return strings.HasPrefix(bind, "/workspace/1:/workspace/1") }), gotbind)
|
||||
assert.Equal(t, "/workspace/1", workspace)
|
||||
|
||||
rc.Config.BindWorkdir = false
|
||||
_, gotmount := rc.GetBindsAndMounts(t.Context())
|
||||
_, gotmount, workspace := rc.GetBindsAndMounts(t.Context())
|
||||
assert.Equal(t, "/workspace/1", gotmount[rc.jobContainerName()])
|
||||
assert.Empty(t, workspace)
|
||||
|
||||
rc.containerSpec.Volumes = []string{"claimed:/workspace/1"}
|
||||
_, gotmount = rc.GetBindsAndMounts(t.Context())
|
||||
_, gotmount, _ = rc.GetBindsAndMounts(t.Context())
|
||||
assert.Equal(t, "/workspace/1/owner/repo", gotmount[rc.jobContainerName()])
|
||||
rc.Config.BindWorkdir = true
|
||||
_, _, workspace = rc.GetBindsAndMounts(t.Context())
|
||||
assert.Equal(t, "/workspace/1/owner/repo", workspace)
|
||||
|
||||
rc.containerSpec.Volumes = nil
|
||||
rc.Config.PresetGitHubContext = nil
|
||||
_, _, workspace = rc.GetBindsAndMounts(t.Context())
|
||||
assert.Empty(t, workspace)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -611,7 +628,7 @@ func TestRunContextValidVolumes(t *testing.T) {
|
||||
rc.JobContainer = hostEnv
|
||||
rc.Config.BindWorkdir = false
|
||||
rc.Config.SharedToolCache = true
|
||||
binds, mounts := rc.GetBindsAndMounts(t.Context())
|
||||
binds, mounts, _ := rc.GetBindsAndMounts(t.Context())
|
||||
assert.Subset(t, binds, []string{hostEnv.Path + ":" + hostEnv.Path, hostEnv.GetActPath() + ":" + hostEnv.GetActPath()})
|
||||
assert.Contains(t, mounts, sharedToolCacheVolume)
|
||||
assert.Equal(t, binds, container.SanitizeBinds(t.Context(), rc.validVolumes(), binds))
|
||||
@@ -683,6 +700,110 @@ func TestCleanupJobVolumesReapsAbandonedDeferredCleanup(t *testing.T) {
|
||||
assert.ElementsMatch(t, []string{"became-active", "remove-failed", "foreign", "fresh", "unknown-age"}, slices.Collect(maps.Keys(volumes)))
|
||||
}
|
||||
|
||||
func TestCleanupJobResourcesEmptiesTaskWorkspace(t *testing.T) {
|
||||
defer func(previous bool) { runnerCanRemoveJobFiles = previous }(runnerCanRemoveJobFiles)
|
||||
for _, tt := range []struct {
|
||||
name, workspace string
|
||||
caller *caller
|
||||
runnerCanRemove bool
|
||||
execErr error
|
||||
log string
|
||||
}{
|
||||
{name: "refusal is logged", workspace: "/workspace/1", execErr: container.ExitCodeError(3), log: "not emptying workspace /workspace/1"},
|
||||
{name: "failure does not fail the job", workspace: "/workspace/1", execErr: errors.New("no rm"), log: "could not empty workspace /workspace/1"},
|
||||
{name: "no runner bind"},
|
||||
{name: "called workflow shares it", workspace: "/workspace/1", caller: &caller{}},
|
||||
{name: "runner removes it itself", workspace: "/workspace/1", runnerCanRemove: true},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
runnerCanRemoveJobFiles = tt.runnerCanRemove
|
||||
processLog := logtest.SetDefault(t)
|
||||
var jobLog bytes.Buffer
|
||||
var calls []string
|
||||
job := &containerMock{}
|
||||
if tt.log != "" {
|
||||
job.On("ReplaceLogWriter", io.Discard, io.Discard).Return(&jobLog, &jobLog).Once()
|
||||
job.On("ReplaceLogWriter", &jobLog, &jobLog).Return(io.Discard, io.Discard).Once()
|
||||
job.On("Exec", mock.MatchedBy(func(command []string) bool { return command[4] == tt.workspace }), map[string]string{"PATH": "/usr/sbin:/usr/bin:/sbin:/bin"}, "0", "/").Return(func(ctx context.Context) error {
|
||||
calls = append(calls, "exec")
|
||||
deadline, ok := ctx.Deadline()
|
||||
assert.True(t, ok)
|
||||
assert.LessOrEqual(t, time.Until(deadline), 10*time.Second-container.DrainGracePeriod)
|
||||
common.Logger(ctx).Infof("workspace script output")
|
||||
return tt.execErr
|
||||
})
|
||||
}
|
||||
job.On("Remove").Return(func(context.Context) error {
|
||||
calls = append(calls, "remove")
|
||||
return nil
|
||||
})
|
||||
rc := &RunContext{
|
||||
Config: &Config{},
|
||||
Run: &model.Run{Workflow: &model.Workflow{Name: "workflow"}, JobID: "job"},
|
||||
JobContainer: job,
|
||||
taskWorkspace: tt.workspace,
|
||||
caller: tt.caller,
|
||||
deferVolumeCleanup: func(common.Executor) {},
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(t.Context(), 20*time.Second)
|
||||
defer cancel()
|
||||
ctx = common.WithLogger(ctx, logging.New(logging.NewLevelHandler(newJobLogHandler(&jobLog, cyan), logging.LevelInfo)))
|
||||
require.NoError(t, rc.cleanupJobResources("", false, false)(ctx))
|
||||
job.AssertExpectations(t)
|
||||
assert.NotContains(t, jobLog.String(), "workspace")
|
||||
if tt.log == "" {
|
||||
assert.Equal(t, []string{"remove"}, calls)
|
||||
return
|
||||
}
|
||||
assert.Equal(t, []string{"exec", "remove"}, calls)
|
||||
assert.True(t, slices.ContainsFunc(processLog.AllRecords(), func(record logging.Record) bool {
|
||||
return strings.HasPrefix(record.Message, tt.log)
|
||||
}), processLog.AllRecords())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmptyTaskWorkspaceScript(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("the script runs in the linux job container")
|
||||
}
|
||||
run := func(t *testing.T, workspace string, mountpoints ...string) error {
|
||||
t.Helper()
|
||||
var mountinfo strings.Builder
|
||||
for _, mountpoint := range mountpoints {
|
||||
fmt.Fprintf(&mountinfo, "36 35 98:0 /src %s rw - ext4 /dev/sda1 rw\n", mountpoint)
|
||||
}
|
||||
info := filepath.Join(t.TempDir(), "mountinfo")
|
||||
require.NoError(t, os.WriteFile(info, []byte(mountinfo.String()), 0o600))
|
||||
return exec.Command("sh", "-c", emptyTaskWorkspaceScript, "sh", workspace, info).Run()
|
||||
}
|
||||
|
||||
workspace := t.TempDir()
|
||||
for _, file := range []string{"owner/repo/file", ".hidden", "..odd"} {
|
||||
require.NoError(t, os.MkdirAll(filepath.Dir(filepath.Join(workspace, file)), 0o755))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(workspace, file), nil, 0o600))
|
||||
}
|
||||
require.NoError(t, run(t, workspace, workspace))
|
||||
entries, err := os.ReadDir(workspace)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, entries)
|
||||
|
||||
for _, tt := range []struct {
|
||||
name, workspace string
|
||||
mountpoints []string
|
||||
}{
|
||||
{"a mount below it", "/nonexistent/1", []string{"/nonexistent/1", "/nonexistent/1/owner/repo/cache"}},
|
||||
{"a workspace that is not a mountpoint", "/nonexistent/1", []string{"/nonexistent/2"}},
|
||||
{"a top-level directory", "/nonexistent", []string{"/nonexistent"}},
|
||||
} {
|
||||
t.Run("refuses "+tt.name, func(t *testing.T) {
|
||||
var exitErr *exec.ExitError
|
||||
require.ErrorAs(t, run(t, tt.workspace, tt.mountpoints...), &exitErr)
|
||||
assert.Equal(t, 3, exitErr.ExitCode())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoveDockerActionImagesByTag(t *testing.T) {
|
||||
var removed []string
|
||||
fakeDockerDaemon(t, func(writer http.ResponseWriter, request *http.Request) {
|
||||
@@ -1181,6 +1302,26 @@ func TestPrintStartJobContainerGroupGolden(t *testing.T) {
|
||||
assert.Equal(t, want, buf.String())
|
||||
}
|
||||
|
||||
func TestStartHostEnvironmentToolCacheSlotIsSubdirOfSharedCache(t *testing.T) {
|
||||
cacheDir := t.TempDir()
|
||||
override := filepath.Join(cacheDir, "override")
|
||||
for sharedCache, env := range map[string]map[string]string{
|
||||
filepath.Join(cacheDir, "tool_cache"): {},
|
||||
override: {"RUNNER_TOOL_CACHE": override},
|
||||
} {
|
||||
rc := &RunContext{
|
||||
Config: &Config{ActionCacheDir: cacheDir, SharedToolCache: true, ToolCacheSlot: "1"},
|
||||
Env: env,
|
||||
Run: &model.Run{Workflow: &model.Workflow{Name: "wf"}},
|
||||
}
|
||||
require.NoError(t, rc.startHostEnvironment()(t.Context()))
|
||||
t.Cleanup(func() { require.NoError(t, rc.cleanUpJobContainer(context.Background())) })
|
||||
want := filepath.Join(sharedCache, "1")
|
||||
assert.Equal(t, want, rc.JobContainer.(*container.HostEnvironment).ToolCache)
|
||||
assert.Equal(t, want, rc.Env["RUNNER_TOOL_CACHE"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunContext_cleanupFailedStart(t *testing.T) {
|
||||
type ctxKey string
|
||||
const sentinel = ctxKey("sentinel")
|
||||
|
||||
@@ -84,7 +84,8 @@ type Config struct {
|
||||
JobLoggerLevel *logging.Level // the level of job logger
|
||||
ValidVolumes []string // only volumes (and bind mounts) in this slice can be mounted on the job container or service containers
|
||||
ContainerVolumes []string // mounted into every job, service and docker action container
|
||||
SharedToolCache bool // one tool cache for all jobs instead of one per job
|
||||
SharedToolCache bool // keeps the tool cache across jobs instead of one per job
|
||||
ToolCacheSlot string // keeps concurrent tasks' tool caches apart, empty for slot 0 and in exec
|
||||
InsecureSkipTLS bool // whether to skip verifying TLS certificate of the Gitea instance
|
||||
ClientCertFile, ClientKeyFile string // PEM client certificate for the Gitea instance
|
||||
CAFile string // PEM CA certificates the Gitea instance's certificate chains to
|
||||
|
||||
@@ -455,7 +455,6 @@ func TestRunEventHostEnvironment(t *testing.T) {
|
||||
{workdir, "nix-prepend-path", "push", "", platforms, secrets},
|
||||
{workdir, "uses-docker-url", "push", "", platforms, secrets},
|
||||
{workdir, "local-action-dockerfile", "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},
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
// 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, nil, sab.env, sab.RunContext)
|
||||
|
||||
rc := sab.RunContext
|
||||
rc.ApplyExtraPath(ctx, &sab.env)
|
||||
return sab.run(ctx, &action.Context{
|
||||
Container: rc.JobContainer,
|
||||
Github: getStepGithubContext(ctx, sab),
|
||||
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,
|
||||
ClientCertFile: rc.Config.ClientCertFile,
|
||||
ClientKeyFile: rc.Config.ClientKeyFile,
|
||||
CAFile: rc.jobCAFile(),
|
||||
Env: sab.env,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func (sab *stepActionBuiltin) post() common.Executor { return common.NewPipelineExecutor() }
|
||||
|
||||
func (sab *stepActionBuiltin) getRunContext() *RunContext { return sab.RunContext }
|
||||
|
||||
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,9 +6,6 @@ package runner
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"maps"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"gitea.dev/actionslib/pkg/model"
|
||||
)
|
||||
@@ -36,18 +33,6 @@ 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.Name, stepModel)
|
||||
}
|
||||
return &stepActionBuiltin{
|
||||
Step: stepModel,
|
||||
RunContext: rc,
|
||||
run: run,
|
||||
}, nil
|
||||
}
|
||||
return &stepActionRemote{
|
||||
Step: stepModel,
|
||||
RunContext: rc,
|
||||
|
||||
@@ -22,7 +22,6 @@ func TestStepFactoryNewStep(t *testing.T) {
|
||||
{"StepLocalAction", &model.Step{Uses: "./action@v1"}, &stepActionLocal{}},
|
||||
{"StepDocker", &model.Step{Uses: "docker://image:tag"}, &stepDocker{}},
|
||||
{"StepRun", &model.Step{Run: "cmd"}, &stepRun{}},
|
||||
{"StepBuiltinAction", &model.Step{Uses: "builtin:checkout"}, &stepActionBuiltin{}},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
step, err := (&stepFactoryImpl{}).newStep(test.model, &RunContext{})
|
||||
@@ -35,7 +34,6 @@ func TestStepFactoryNewStep(t *testing.T) {
|
||||
func TestStepFactoryInvalidStep(t *testing.T) {
|
||||
for _, stepModel := range []*model.Step{
|
||||
{Uses: "remote/action@v1", Run: "cmd"},
|
||||
{Uses: "builtin:unknown"},
|
||||
} {
|
||||
_, err := (&stepFactoryImpl{}).newStep(stepModel, &RunContext{Name: "build"})
|
||||
assert.ErrorContains(t, err, "job:build ", stepModel.Uses)
|
||||
|
||||
-10
@@ -1,10 +0,0 @@
|
||||
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
|
||||
@@ -1,39 +0,0 @@
|
||||
# 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. It runs `git` where the job runs, see the [prerequisites](../README.md#prerequisites).
|
||||
|
||||
| Input | Default |
|
||||
| --- | --- |
|
||||
| `repository` | `github.repository` |
|
||||
| `ref` | the triggering ref and commit, or the default branch of another repository |
|
||||
| `token` | `github.token` |
|
||||
| `ssh-key` | empty, a private key fetches over SSH instead |
|
||||
| `ssh-known-hosts` | empty, host keys trusted besides `~/.ssh/known_hosts` |
|
||||
| `ssh-strict` | `true`, rejects unknown host keys |
|
||||
| `ssh-user` | the user of the repository's SSH URL, else `git` |
|
||||
| `persist-credentials` | `true`, later steps of the job can fetch and push with `token` or `ssh-key` |
|
||||
| `path` | workspace root |
|
||||
| `clean` | `true`, removes untracked files, also in submodules |
|
||||
| `filter` | empty, a partial clone filter such as `blob:none` |
|
||||
| `sparse-checkout` | empty, the paths to check out, one per line |
|
||||
| `sparse-checkout-cone-mode` | `true`, `false` reads `sparse-checkout` as patterns |
|
||||
| `fetch-depth` | `1`, `0` for the full history of every branch and tag |
|
||||
| `fetch-tags` | `false`, `true` fetches every tag |
|
||||
| `show-progress` | `true`, shows the fetch progress |
|
||||
| `lfs` | `false`, `true` downloads Git LFS files |
|
||||
| `submodules` | `false`, `true` checks out submodules, `recursive` also theirs |
|
||||
| `set-safe-directory` | `true`, lets checkout's `git` work in a directory another user owns |
|
||||
|
||||
A branch is checked out as a local branch tracking `origin`, a tag or commit as a detached `HEAD`. A triggering tag that moved since the event fails the step. The step outputs `ref` and `commit` like `actions/checkout`.
|
||||
|
||||
Persisted credentials end with the job. `token` is only sent to `github.server_url`, while `ssh-key` is offered to any SSH host, as with `actions/checkout`. The runner's `client_cert_file` is only offered to `github.server_url` and only during the step, so later steps do not get it. Its `ca_file` replaces the job image's CA bundle for `github.server_url`, in later steps too when credentials persist. Without `ssh-key`, submodule URLs of the form `git@<server host>:` are fetched from `github.server_url` with `token`. With `ssh-key`, the repository is fetched from the SSH URL in the event's `repository.ssh_url`, or from the server's host on port 22.
|
||||
|
||||
With `gitea-runner exec`, checking out the workflow's own repository copies your local working directory, unless `--no-skip-checkout` or `ssh-key` is set.
|
||||
@@ -1,22 +0,0 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build e2e
|
||||
|
||||
package e2e
|
||||
|
||||
import "testing"
|
||||
|
||||
func testBuiltinCheckout(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
api, repo := newScenario(t)
|
||||
pushWorkflow(t, api, repo, "checkout.yml")
|
||||
branchRun := waitForRun(t, api, repo)
|
||||
if err := api.CreateTag(t.Context(), repo, "v1"); err != nil {
|
||||
t.Fatalf("create tag: %v", err)
|
||||
}
|
||||
tagRun := waitForRunAfter(t, api, repo, branchRun.ID)
|
||||
requireSuccess(t, api, repo, branchRun.ID)
|
||||
requireSuccess(t, api, repo, tagRun.ID)
|
||||
}
|
||||
+36
-47
@@ -90,20 +90,29 @@ container:
|
||||
type cliDaemon struct {
|
||||
cmd *exec.Cmd
|
||||
log *syncBuffer
|
||||
exited chan error
|
||||
exited chan struct{}
|
||||
err error
|
||||
}
|
||||
|
||||
func (c *cliRunner) startDaemon(t *testing.T, args ...string) *cliDaemon {
|
||||
t.Helper()
|
||||
d := &cliDaemon{log: &syncBuffer{}, exited: make(chan error, 1)}
|
||||
d := &cliDaemon{log: &syncBuffer{}, exited: make(chan struct{})}
|
||||
d.cmd = exec.Command(c.bin, append([]string{"daemon", "-c", c.cfgFile}, args...)...)
|
||||
d.cmd.Stdout, d.cmd.Stderr = d.log, d.log
|
||||
if err := d.cmd.Start(); err != nil {
|
||||
t.Fatalf("start daemon: %v", err)
|
||||
}
|
||||
go func() { d.exited <- d.cmd.Wait() }()
|
||||
go func() {
|
||||
d.err = d.cmd.Wait()
|
||||
close(d.exited)
|
||||
}()
|
||||
t.Cleanup(func() {
|
||||
_ = d.cmd.Process.Kill() // no-op once the daemon has exited
|
||||
_ = d.cmd.Process.Signal(syscall.SIGTERM)
|
||||
select {
|
||||
case <-d.exited:
|
||||
case <-time.After(30 * time.Second):
|
||||
_ = d.cmd.Process.Kill()
|
||||
}
|
||||
})
|
||||
return d
|
||||
}
|
||||
@@ -112,8 +121,8 @@ func (c *cliRunner) startDaemon(t *testing.T, args ...string) *cliDaemon {
|
||||
func (d *cliDaemon) wait(t *testing.T, timeout time.Duration) error {
|
||||
t.Helper()
|
||||
select {
|
||||
case err := <-d.exited:
|
||||
return err
|
||||
case <-d.exited:
|
||||
return d.err
|
||||
case <-time.After(timeout):
|
||||
t.Fatalf("daemon still running after %s\n%s", timeout, d.log)
|
||||
return nil
|
||||
@@ -140,8 +149,8 @@ func (d *cliDaemon) requireSuccess(t *testing.T, c *cliRunner, runID int64) {
|
||||
// testCLIRegisterDaemon drives the built binary the way a user does: register, then daemon.
|
||||
func testCLIRegisterDaemon(t *testing.T) {
|
||||
t.Parallel()
|
||||
c := newCLIRunner(t, "e2e-cli", "")
|
||||
d := c.startDaemon(t)
|
||||
c := newCLIRunner(t, "e2e-registered", "")
|
||||
d := c.startDaemon(t, "--labels", "e2e-cli:docker://"+os.Getenv("E2E_JOB_IMAGE"))
|
||||
|
||||
pushWorkflow(t, c.api, c.repo, "cli.yml")
|
||||
wfRun := waitForRun(t, c.api, c.repo)
|
||||
@@ -150,6 +159,22 @@ func testCLIRegisterDaemon(t *testing.T) {
|
||||
t.Errorf("job logs are missing the marker:\n%s", logs)
|
||||
}
|
||||
|
||||
runners, err := c.api.Runners(t.Context(), c.repo)
|
||||
if err != nil {
|
||||
t.Fatalf("list runners: %v", err)
|
||||
}
|
||||
var labels []string
|
||||
for _, runner := range runners {
|
||||
if runner.Name == c.repo {
|
||||
for _, label := range runner.Labels {
|
||||
labels = append(labels, label.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !slices.Equal(labels, []string{"e2e-cli"}) {
|
||||
t.Errorf("runner %q has labels %v on the server, want only the --labels override", c.repo, labels)
|
||||
}
|
||||
|
||||
d.terminate(t)
|
||||
if err := d.wait(t, 30*time.Second); err != nil {
|
||||
t.Errorf("daemon did not exit cleanly on SIGTERM: %v\n%s", err, d.log)
|
||||
@@ -186,36 +211,6 @@ func testCLIOnce(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// testCLILabelsOverride checks that daemon --labels replaces the labels given at registration.
|
||||
func testCLILabelsOverride(t *testing.T) {
|
||||
t.Parallel()
|
||||
c := newCLIRunner(t, "e2e-registered", "")
|
||||
d := c.startDaemon(t, "--labels", "e2e-override:docker://"+os.Getenv("E2E_JOB_IMAGE"))
|
||||
|
||||
pushWorkflow(t, c.api, c.repo, "cli_labels.yml")
|
||||
wfRun := waitForRun(t, c.api, c.repo)
|
||||
d.requireSuccess(t, c, wfRun.ID)
|
||||
|
||||
runners, err := c.api.Runners(t.Context(), c.repo)
|
||||
if err != nil {
|
||||
t.Fatalf("list runners: %v", err)
|
||||
}
|
||||
for _, runner := range runners {
|
||||
if runner.Name != c.repo {
|
||||
continue
|
||||
}
|
||||
var labels []string
|
||||
for _, label := range runner.Labels {
|
||||
labels = append(labels, label.Name)
|
||||
}
|
||||
if !slices.Equal(labels, []string{"e2e-override"}) {
|
||||
t.Errorf("runner has labels %v on the server, want only the --labels override", labels)
|
||||
}
|
||||
return
|
||||
}
|
||||
t.Errorf("runner %q not found among %+v", c.repo, runners)
|
||||
}
|
||||
|
||||
// testCLIShutdownDrains checks that SIGTERM lets a running job finish within shutdown_timeout.
|
||||
func testCLIShutdownDrains(t *testing.T) {
|
||||
t.Parallel()
|
||||
@@ -282,10 +277,6 @@ func testExecArtifacts(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := t.Context()
|
||||
|
||||
bin, err := runnerBinary()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
workflow, err := os.ReadFile("testdata/workflows/exec_artifact.yml")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -309,11 +300,9 @@ func testExecArtifacts(t *testing.T) {
|
||||
}
|
||||
|
||||
artifacts := t.TempDir()
|
||||
args := []string{"exec", "-C", workdir, "-W", workflows, "-i", os.Getenv("E2E_JOB_IMAGE"), "--artifact-server-path", artifacts, "--network", fixture.network}
|
||||
cmd := exec.CommandContext(ctx, bin, args...)
|
||||
cmd.Env = slices.DeleteFunc(os.Environ(), func(kv string) bool { return strings.HasPrefix(kv, "ACTIONS_") })
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
t.Fatalf("exec: %v\n%s", err, out)
|
||||
stdout, stderr, err := runExec(t, nil, "-C", workdir, "-W", workflows, "-i", os.Getenv("E2E_JOB_IMAGE"), "--artifact-server-path", artifacts, "--network", fixture.network)
|
||||
if err != nil {
|
||||
t.Fatalf("exec: %v\n%s%s", err, stdout, stderr)
|
||||
}
|
||||
if blobs, _ := filepath.Glob(filepath.Join(artifacts, "v4", "*", "*", "blob")); len(blobs) != 2 {
|
||||
t.Fatalf("want one v4 artifact blob per workflow run, got %v", blobs)
|
||||
|
||||
@@ -29,7 +29,6 @@ func TestCompatibility(t *testing.T) {
|
||||
t.Run("cache_and_artifacts", testCacheAndArtifactRoundTrips)
|
||||
t.Run("cancellation_and_log_streaming", testRunCancellation)
|
||||
t.Run("dispatch", inAllModes(testWorkflowDispatch))
|
||||
t.Run("builtin_checkout", testBuiltinCheckout)
|
||||
t.Run("exec_artifacts", testExecArtifacts)
|
||||
t.Run("moving_action_ref", testMovingActionRef)
|
||||
t.Run("ephemeral", testEphemeralRunner)
|
||||
@@ -37,7 +36,6 @@ func TestCompatibility(t *testing.T) {
|
||||
t.Run("job_timeout", testJobTimeout)
|
||||
t.Run("cli_register_daemon", testCLIRegisterDaemon)
|
||||
t.Run("cli_once", testCLIOnce)
|
||||
t.Run("cli_labels_override", testCLILabelsOverride)
|
||||
t.Run("cli_shutdown_drains", testCLIShutdownDrains)
|
||||
t.Run("cli_shutdown_cancels", testCLIShutdownCancels)
|
||||
t.Run("host_mode", testHostMode)
|
||||
|
||||
@@ -65,12 +65,6 @@ func (a *GiteaAPI) CreateFiles(ctx context.Context, repo string, files map[strin
|
||||
return a.doJSON(ctx, http.MethodPost, fmt.Sprintf("/api/v1/repos/%s/%s/contents", giteaAdminUser, repo), body, nil)
|
||||
}
|
||||
|
||||
func (a *GiteaAPI) CreateTag(ctx context.Context, repo, name string) error {
|
||||
body := map[string]any{"tag_name": name}
|
||||
url := fmt.Sprintf("/api/v1/repos/%s/%s/tags", giteaAdminUser, repo)
|
||||
return a.doJSON(ctx, http.MethodPost, url, body, nil)
|
||||
}
|
||||
|
||||
func (a *GiteaAPI) CreateSecret(ctx context.Context, repo, name, value string) error {
|
||||
body := map[string]any{"data": value}
|
||||
url := fmt.Sprintf("/api/v1/repos/%s/%s/actions/secrets/%s", giteaAdminUser, repo, name)
|
||||
|
||||
+14
-13
@@ -80,11 +80,11 @@ func selfNetwork(ctx context.Context, cli mobyclient.APIClient) (string, string,
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
func StartGitea(ctx context.Context, cli mobyclient.APIClient) (*GiteaFixture, error) {
|
||||
closeClient := true
|
||||
func StartGitea(ctx context.Context, cli mobyclient.APIClient) (_ *GiteaFixture, err error) {
|
||||
f := &GiteaFixture{cli: cli}
|
||||
defer func() {
|
||||
if closeClient {
|
||||
_ = cli.Close()
|
||||
if err != nil {
|
||||
_ = f.Close()
|
||||
}
|
||||
}()
|
||||
|
||||
@@ -111,7 +111,6 @@ func StartGitea(ctx context.Context, cli mobyclient.APIClient) (*GiteaFixture, e
|
||||
"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 + "/",
|
||||
},
|
||||
},
|
||||
@@ -123,26 +122,21 @@ func StartGitea(ctx context.Context, cli mobyclient.APIClient) (*GiteaFixture, e
|
||||
return nil, fmt.Errorf("create gitea container: %w", err)
|
||||
}
|
||||
|
||||
f := &GiteaFixture{cli: cli, id: resp.ID, image: image, baseURL: baseURL, network: sharedNet, selfIP: selfIP}
|
||||
closeClient = false
|
||||
f.id, f.image, f.baseURL, f.network, f.selfIP = resp.ID, image, baseURL, sharedNet, selfIP
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -249,6 +243,7 @@ func containerExec(ctx context.Context, cli mobyclient.APIClient, id, user strin
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer context.AfterFunc(ctx, attached.Close)()
|
||||
defer attached.Close()
|
||||
if stdin != nil {
|
||||
go func() {
|
||||
@@ -258,7 +253,11 @@ func containerExec(ctx context.Context, cli mobyclient.APIClient, id, user strin
|
||||
}
|
||||
|
||||
var out bytes.Buffer
|
||||
if _, err := stdcopy.StdCopy(&out, &out, attached.Reader); err != nil {
|
||||
_, err = stdcopy.StdCopy(&out, &out, attached.Reader)
|
||||
if ctx.Err() != nil {
|
||||
return "", ctx.Err()
|
||||
}
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
@@ -291,10 +290,12 @@ func (f *GiteaFixture) doJSON(ctx context.Context, method, path string, reqBody,
|
||||
return api.doJSON(ctx, method, path, reqBody, respBody)
|
||||
}
|
||||
|
||||
func (f *GiteaFixture) Close(ctx context.Context) error {
|
||||
func (f *GiteaFixture) Close() error {
|
||||
if f.id == "" {
|
||||
return f.cli.Close()
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), cleanupTimeout)
|
||||
defer cancel()
|
||||
_, removeErr := f.cli.ContainerRemove(ctx, f.id, mobyclient.ContainerRemoveOptions{Force: true, RemoveVolumes: true})
|
||||
return errors.Join(removeErr, f.cli.Close())
|
||||
}
|
||||
+13
-7
@@ -55,7 +55,9 @@ func checkClusterLeaks() error {
|
||||
if err != nil {
|
||||
return nil // the tests that needed the cluster already failed
|
||||
}
|
||||
if out, err := containerExec(context.Background(), dockerCLI, cluster.id, "", nil,
|
||||
ctx, cancel := context.WithTimeout(context.Background(), cleanupTimeout)
|
||||
defer cancel()
|
||||
if out, err := containerExec(ctx, dockerCLI, cluster.id, "", nil,
|
||||
"kubectl", "wait", "--for=delete", "pods,secrets", "--selector=app.kubernetes.io/managed-by=gitea-runner", "--timeout=1m",
|
||||
); err != nil {
|
||||
return fmt.Errorf("job pods or secrets left behind: %w: %s", err, out)
|
||||
@@ -72,7 +74,7 @@ type kubeCluster struct {
|
||||
var (
|
||||
k3s = sync.OnceValues(startK3s) // independent of Gitea, so it can boot alongside it
|
||||
clusterStarted atomic.Bool
|
||||
clusterCleanup []func()
|
||||
clusterCleanup []func(context.Context)
|
||||
sharedCluster = sync.OnceValues(func() (*kubeCluster, error) {
|
||||
cluster, err := k3s()
|
||||
if err != nil {
|
||||
@@ -109,7 +111,9 @@ func stopCluster() {
|
||||
}
|
||||
_, _ = k3s()
|
||||
for _, cleanup := range slices.Backward(clusterCleanup) {
|
||||
cleanup()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), cleanupTimeout)
|
||||
cleanup(ctx)
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,8 +142,8 @@ func startK3s() (*kubeCluster, error) {
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create k3s container: %w", err)
|
||||
}
|
||||
clusterCleanup = append(clusterCleanup, func() {
|
||||
_, _ = cli.ContainerRemove(context.Background(), created.ID, mobyclient.ContainerRemoveOptions{Force: true, RemoveVolumes: true})
|
||||
clusterCleanup = append(clusterCleanup, func(ctx context.Context) {
|
||||
_, _ = cli.ContainerRemove(ctx, created.ID, mobyclient.ContainerRemoveOptions{Force: true, RemoveVolumes: true})
|
||||
})
|
||||
if _, err := cli.ContainerStart(ctx, created.ID, mobyclient.ContainerStartOptions{}); err != nil {
|
||||
return nil, fmt.Errorf("start k3s container: %w", err)
|
||||
@@ -170,7 +174,9 @@ func startK3s() (*kubeCluster, error) {
|
||||
inspected, inspectErr := cli.ContainerInspect(ctx, created.ID, mobyclient.ContainerInspectOptions{})
|
||||
if inspectErr != nil || !inspected.Container.State.Running || ctx.Err() != nil {
|
||||
var logs bytes.Buffer
|
||||
if reader, logsErr := cli.ContainerLogs(context.Background(), created.ID, mobyclient.ContainerLogsOptions{ShowStdout: true, ShowStderr: true, Tail: "30"}); logsErr == nil {
|
||||
logsCtx, logsCancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer logsCancel()
|
||||
if reader, logsErr := cli.ContainerLogs(logsCtx, created.ID, mobyclient.ContainerLogsOptions{ShowStdout: true, ShowStderr: true, Tail: "30"}); logsErr == nil {
|
||||
_, _ = stdcopy.StdCopy(&logs, &logs, reader)
|
||||
_ = reader.Close()
|
||||
}
|
||||
@@ -226,7 +232,7 @@ func preloadImages(ctx context.Context, k3sID string, images ...string) ([]strin
|
||||
if _, err := dockerCLI.ImageTag(ctx, mobyclient.ImageTagOptions{Source: image, Target: alias}); err != nil {
|
||||
return nil, fmt.Errorf("tag %s: %w", image, err)
|
||||
}
|
||||
clusterCleanup = append(clusterCleanup, func() { _, _ = dockerCLI.ImageRemove(context.Background(), alias, mobyclient.ImageRemoveOptions{}) })
|
||||
clusterCleanup = append(clusterCleanup, func(ctx context.Context) { _, _ = dockerCLI.ImageRemove(ctx, alias, mobyclient.ImageRemoveOptions{}) })
|
||||
aliases = append(aliases, alias)
|
||||
}
|
||||
archive, err := dockerCLI.ImageSave(ctx, aliases)
|
||||
|
||||
+6
-2
@@ -31,6 +31,8 @@ import (
|
||||
|
||||
const runTimeout = 3 * time.Minute
|
||||
|
||||
const cleanupTimeout = 2 * time.Minute
|
||||
|
||||
var nonRepoChars = regexp.MustCompile(`[^a-zA-Z0-9._-]+`)
|
||||
|
||||
var fixture *GiteaFixture
|
||||
@@ -42,8 +44,10 @@ var binDir string
|
||||
var dockerCLI mobyclient.APIClient
|
||||
|
||||
var runnerBinary = sync.OnceValues(func() (string, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), runTimeout)
|
||||
defer cancel()
|
||||
bin := filepath.Join(binDir, "gitea-runner")
|
||||
if out, err := exec.Command(cmp.Or(os.Getenv("GO"), "go"), "build", "-o", bin, "..").CombinedOutput(); err != nil {
|
||||
if out, err := exec.CommandContext(ctx, cmp.Or(os.Getenv("GO"), "go"), "build", "-o", bin, "..").CombinedOutput(); err != nil {
|
||||
return "", fmt.Errorf("build runner: %w\n%s", err, out)
|
||||
}
|
||||
return bin, nil
|
||||
@@ -80,7 +84,7 @@ func runSuite(m *testing.M) int {
|
||||
return 1
|
||||
}
|
||||
fixture = f
|
||||
defer func() { _ = fixture.Close(context.Background()) }()
|
||||
defer func() { _ = fixture.Close() }()
|
||||
|
||||
fmt.Fprintf(os.Stderr, "gitea fixture: image=%s version=%s\n", fixture.image, fixture.version)
|
||||
|
||||
|
||||
+29
-15
@@ -9,6 +9,7 @@ import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"cmp"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
@@ -138,14 +139,31 @@ func daemonWorkflow(t *testing.T, m mode, spec workflowSpec) *workflowResult {
|
||||
return result
|
||||
}
|
||||
|
||||
func execWorkflow(t *testing.T, m mode, spec workflowSpec) *workflowResult {
|
||||
func runExec(t *testing.T, env []string, args ...string) (stdout, stderr []byte, err error) {
|
||||
t.Helper()
|
||||
ctx := t.Context()
|
||||
|
||||
bin, err := runnerBinary()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(t.Context(), runTimeout)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, bin, append([]string{"exec"}, args...)...)
|
||||
cmd.Env = append(slices.DeleteFunc(os.Environ(), func(kv string) bool { return strings.HasPrefix(kv, "ACTIONS_") }), env...)
|
||||
cmd.Cancel = func() error { return cmd.Process.Signal(os.Interrupt) }
|
||||
cmd.WaitDelay = cleanupTimeout
|
||||
var stdoutBuf, stderrBuf bytes.Buffer
|
||||
cmd.Stdout, cmd.Stderr = &stdoutBuf, &stderrBuf
|
||||
err = cmd.Run()
|
||||
if ctx.Err() != nil {
|
||||
t.Fatalf("exec did not finish within %s\n%s%s", runTimeout, stdoutBuf.Bytes(), stderrBuf.Bytes())
|
||||
}
|
||||
return stdoutBuf.Bytes(), stderrBuf.Bytes(), err
|
||||
}
|
||||
|
||||
func execWorkflow(t *testing.T, m mode, spec workflowSpec) *workflowResult {
|
||||
t.Helper()
|
||||
ctx := t.Context()
|
||||
|
||||
workdir := t.TempDir()
|
||||
workflows := filepath.Join(workdir, ".gitea", "workflows")
|
||||
if err := os.MkdirAll(workflows, 0o755); err != nil {
|
||||
@@ -170,12 +188,12 @@ func execWorkflow(t *testing.T, m mode, spec workflowSpec) *workflowResult {
|
||||
}
|
||||
}
|
||||
|
||||
args := []string{"exec", "--json", "-C", workdir, "-W", workflows, "--artifact-server-path", t.TempDir(), "--artifact-server-port", "0"}
|
||||
env := slices.DeleteFunc(os.Environ(), func(kv string) bool { return strings.HasPrefix(kv, "ACTIONS_") })
|
||||
args := []string{"--json", "-C", workdir, "-W", workflows, "--artifact-server-path", t.TempDir(), "--artifact-server-port", "0"}
|
||||
var env []string
|
||||
if m.kubernetes {
|
||||
cluster := requireCluster(t)
|
||||
args = append(args, "-i", "kubernetes://"+cluster.jobImage)
|
||||
env = append(env, "KUBECONFIG="+cluster.kubernetes.Kubeconfig)
|
||||
env = []string{"KUBECONFIG=" + cluster.kubernetes.Kubeconfig}
|
||||
} else {
|
||||
args = append(args, "-i", os.Getenv("E2E_JOB_IMAGE"), "--network", fixture.network)
|
||||
}
|
||||
@@ -185,21 +203,17 @@ func execWorkflow(t *testing.T, m mode, spec workflowSpec) *workflowResult {
|
||||
args = append(args, "--input", name+"="+value)
|
||||
}
|
||||
}
|
||||
cmd := exec.CommandContext(ctx, bin, append(args, spec.execArgs...)...)
|
||||
cmd.Env = env
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout, cmd.Stderr = &stdout, &stderr
|
||||
runErr := cmd.Run()
|
||||
stdout, stderr, runErr := runExec(t, env, append(args, spec.execArgs...)...)
|
||||
var exitErr *exec.ExitError
|
||||
if runErr != nil && !errors.As(runErr, &exitErr) {
|
||||
t.Fatalf("exec: %v\n%s", runErr, stderr.String())
|
||||
t.Fatalf("exec: %v\n%s", runErr, stderr)
|
||||
}
|
||||
|
||||
result := &workflowResult{conclusion: "success", jobs: map[string]string{}}
|
||||
if runErr != nil {
|
||||
result.conclusion = "failure"
|
||||
}
|
||||
scanner := bufio.NewScanner(&stdout)
|
||||
scanner := bufio.NewScanner(bytes.NewReader(stdout))
|
||||
scanner.Buffer(nil, 1<<20)
|
||||
for scanner.Scan() {
|
||||
var entry struct {
|
||||
@@ -220,8 +234,8 @@ func execWorkflow(t *testing.T, m mode, spec workflowSpec) *workflowResult {
|
||||
}
|
||||
}
|
||||
}
|
||||
if result.conclusion != "success" && len(result.jobs) == 0 {
|
||||
t.Fatalf("exec ran no job: %v\n%s", runErr, stderr.String())
|
||||
if len(result.jobs) == 0 {
|
||||
t.Fatalf("exec ran no job: %v\n%s", runErr, stderr)
|
||||
}
|
||||
return result
|
||||
}
|
||||
Vendored
-61
@@ -1,61 +0,0 @@
|
||||
name: checkout
|
||||
on: push
|
||||
jobs:
|
||||
checkout:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- id: checkout
|
||||
uses: builtin:checkout
|
||||
- uses: builtin:checkout
|
||||
with:
|
||||
ref: ${{ github.ref_name }}
|
||||
path: short
|
||||
- run: test "$(git -C short rev-parse HEAD)" = "$GITHUB_SHA"
|
||||
- if: github.ref_type == 'branch'
|
||||
run: |
|
||||
test "$(git symbolic-ref HEAD)" = "$GITHUB_REF"
|
||||
test "${{ steps.checkout.outputs.ref }}" = "$GITHUB_REF"
|
||||
test "${{ steps.checkout.outputs.commit }}" = "$GITHUB_SHA"
|
||||
git -c user.name=e2e -c [email protected] commit --allow-empty -m pushed
|
||||
git push
|
||||
git tag moved
|
||||
git push --force origin moved
|
||||
git switch --orphan child
|
||||
echo child > child.txt
|
||||
git add child.txt
|
||||
git -c user.name=e2e -c [email protected] commit -m child
|
||||
git push origin child
|
||||
git switch -c content main
|
||||
git submodule add -b child "../${{ github.event.repository.name }}.git" sub
|
||||
git lfs install --local
|
||||
git lfs track '*.bin'
|
||||
mkdir keep drop
|
||||
echo big > keep/big.bin
|
||||
echo big > drop/big.bin
|
||||
git add .gitattributes keep drop
|
||||
git -c user.name=e2e -c [email protected] commit -m content
|
||||
git push origin content
|
||||
- if: github.ref_type == 'branch'
|
||||
uses: builtin:checkout
|
||||
with:
|
||||
ref: content
|
||||
path: full
|
||||
submodules: true
|
||||
lfs: true
|
||||
- if: github.ref_type == 'branch'
|
||||
uses: builtin:checkout
|
||||
with:
|
||||
ref: content
|
||||
path: sparse
|
||||
sparse-checkout: keep
|
||||
lfs: true
|
||||
- if: github.ref_type == 'branch'
|
||||
run: |
|
||||
test "$(cat full/sub/child.txt)" = child
|
||||
test "$(cat full/keep/big.bin)" = big
|
||||
test "$(cat sparse/keep/big.bin)" = big
|
||||
test ! -e sparse/drop
|
||||
- if: github.ref_type == 'tag'
|
||||
run: test "$(git describe --tags)" = "$GITHUB_REF_NAME"
|
||||
-7
@@ -1,7 +0,0 @@
|
||||
name: cli-labels
|
||||
on: push
|
||||
jobs:
|
||||
hello:
|
||||
runs-on: e2e-override
|
||||
steps:
|
||||
- run: echo e2e-override-ran
|
||||
@@ -16,7 +16,6 @@ This uses the recommended Docker-in-Docker image, see [image flavours](../../REA
|
||||
timeout: 10s
|
||||
environment:
|
||||
# GITEA_RUNNER_REGISTRATION_TOKEN can be used to set a global runner registration token.
|
||||
# The Gitea version must be v1.23 or higher.
|
||||
# It's also possible to use GITEA_RUNNER_REGISTRATION_TOKEN_FILE to pass the location.
|
||||
# - GITEA_RUNNER_REGISTRATION_TOKEN=<user-defined registration token>
|
||||
|
||||
|
||||
@@ -12,3 +12,15 @@ docker run --privileged \
|
||||
```
|
||||
|
||||
The volumes keep the runner's registration and the job image cache. See [image flavours](../../README.md#image-flavours) for alternatives.
|
||||
|
||||
#### Pull job images from a registry with a private CA
|
||||
|
||||
The bundled Docker daemon pulls job images, so it needs to trust the registry's CA itself. `runner.ca_file` doesn't cover this. Mount the CA where the daemon looks for it, naming the directory after the registry's host and port:
|
||||
|
||||
```sh
|
||||
--mount "type=bind,src=$PWD/ca.crt,dst=/etc/docker/certs.d/registry.example.com:5000/ca.crt,readonly"
|
||||
```
|
||||
|
||||
Use `--mount`, since `-v` rejects a container path with a port in it.
|
||||
|
||||
On `latest-dind-rootless`, use `/home/rootless/.config/docker/certs.d/registry.example.com:5000/ca.crt` as the target.
|
||||
@@ -0,0 +1,54 @@
|
||||
# Running the runner with launchd on macOS
|
||||
|
||||
[`com.gitea.runner.plist`](./com.gitea.runner.plist) runs the runner in the
|
||||
login session of the user that jobs should run as. Jobs then have that user's
|
||||
login keychain and the GUI.
|
||||
|
||||
## Setup
|
||||
|
||||
1. Install the `gitea-runner` binary (e.g. to `/usr/local/bin/gitea-runner`).
|
||||
2. Register as that user, with a host label so that jobs run on the Mac itself:
|
||||
|
||||
```bash
|
||||
mkdir ~/gitea-runner && cd ~/gitea-runner
|
||||
gitea-runner register --instance https://gitea.example.com --token <token> --labels macos-latest:host
|
||||
```
|
||||
|
||||
3. Replace `/Users/gitea-runner` in the file with the user's home directory,
|
||||
then install it and start the runner. From now on, it also starts whenever
|
||||
the user logs in:
|
||||
|
||||
```bash
|
||||
mkdir -p ~/Library/LaunchAgents
|
||||
cp com.gitea.runner.plist ~/Library/LaunchAgents/
|
||||
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.gitea.runner.plist
|
||||
```
|
||||
|
||||
4. Enable automatic login for the user in System Settings, so that the runner
|
||||
starts after a restart too. macOS does not offer automatic login while
|
||||
FileVault is on.
|
||||
|
||||
The runner does not get the environment of your shell. Set what jobs need, such
|
||||
as proxy variables, in `EnvironmentVariables`.
|
||||
|
||||
## Local network access
|
||||
|
||||
macOS blocks a runner that was started this way from other hosts on the Mac's
|
||||
own subnet, such as the Gitea instance or a shared cache server. The runner
|
||||
then logs `no route to host`. Commands run in Terminal or over SSH are not
|
||||
blocked, so registering works even when the running runner cannot connect. See
|
||||
[local network privacy](https://developer.apple.com/documentation/technotes/tn3179-understanding-local-network-privacy).
|
||||
|
||||
macOS asks on the Mac's screen whether to allow the access. To allow it without
|
||||
that prompt, exempt the subnet on macOS 15.5 or later and restart the Mac. On
|
||||
Wi-Fi, the key is `AllowedWiFiLocalNetworkAddresses`:
|
||||
|
||||
```bash
|
||||
sudo defaults write com.apple.network.local-network AllowedEthernetLocalNetworkAddresses -array "192.168.1.0/24"
|
||||
```
|
||||
|
||||
## Git credentials
|
||||
|
||||
Git on macOS saves credentials from remote URLs to the keychain, which can stop
|
||||
a job at a dialog that nobody answers. To turn that off, set
|
||||
`GIT_CONFIG_NOSYSTEM` to `1` in `EnvironmentVariables`.
|
||||
@@ -0,0 +1,26 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key>
|
||||
<string>com.gitea.runner</string>
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>/usr/local/bin/gitea-runner</string>
|
||||
<string>daemon</string>
|
||||
</array>
|
||||
<key>WorkingDirectory</key>
|
||||
<string>/Users/gitea-runner/gitea-runner</string>
|
||||
<key>EnvironmentVariables</key>
|
||||
<dict>
|
||||
<key>PATH</key>
|
||||
<string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
|
||||
</dict>
|
||||
<key>StandardErrorPath</key>
|
||||
<string>/Users/gitea-runner/gitea-runner/runner.log</string>
|
||||
<key>RunAtLoad</key>
|
||||
<true/>
|
||||
<key>KeepAlive</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -12,7 +12,7 @@ require (
|
||||
github.com/containerd/errdefs v1.0.0
|
||||
github.com/creack/pty v1.1.24
|
||||
github.com/distribution/reference v0.6.0
|
||||
github.com/docker/cli v29.9.2+incompatible
|
||||
github.com/docker/cli v29.9.3+incompatible
|
||||
github.com/docker/go-connections v0.8.2
|
||||
github.com/docker/go-units v0.5.0
|
||||
github.com/joho/godotenv v1.5.1
|
||||
|
||||
@@ -21,8 +21,8 @@ github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
|
||||
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
|
||||
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
|
||||
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
|
||||
github.com/docker/cli v29.9.2+incompatible h1:trup/vUxx5uUo2t+87Cd9AmITR+DvKUe4H2tTtZN8FY=
|
||||
github.com/docker/cli v29.9.2+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
|
||||
github.com/docker/cli v29.9.3+incompatible h1:SBaMp7ecohadEHMDrUJg22t9I3awqsAVhnJ7GOcWpfE=
|
||||
github.com/docker/cli v29.9.3+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
|
||||
github.com/docker/go-connections v0.8.2 h1:9d9DEokx5W7qlr0shkFXIxEA7+TTD+llwzcykSlxMNc=
|
||||
github.com/docker/go-connections v0.8.2/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q=
|
||||
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
|
||||
|
||||
@@ -208,7 +208,7 @@ func runDaemon(ctx context.Context, daemArgs *daemonArgs, configFile *string) fu
|
||||
case err != nil && ctx.Err() != nil && !client.IsRegistrationRejected(err):
|
||||
return nil
|
||||
case err != nil && connect.CodeOf(err) == connect.CodeUnimplemented:
|
||||
logging.Errorf("Your Gitea version is too old to support runner declare, please upgrade to v1.21 or later")
|
||||
logging.Errorf("Your Gitea version is too old to support runner declare, please upgrade to v%s or later", ver.MinGiteaVersion)
|
||||
return err
|
||||
case err != nil:
|
||||
logging.WithError(err).Error("fail to invoke Declare")
|
||||
|
||||
@@ -188,10 +188,14 @@ func (i *executeArgs) eventJSON() (string, error) {
|
||||
func (i *executeArgs) LoadEnvs() map[string]string {
|
||||
envs := parseKVAndFile(i.envs, i.Envfile())
|
||||
|
||||
envs["ACTIONS_CACHE_URL"] = i.cacheHandler.ExternalURL() + "/"
|
||||
cacheURL := i.cacheHandler.ExternalURL()
|
||||
if cacheURL == "" {
|
||||
return envs
|
||||
}
|
||||
envs["ACTIONS_CACHE_URL"] = cacheURL + "/"
|
||||
// The same server answers cache v2, which docker buildx reads from the results origin alone.
|
||||
if envs["ACTIONS_RESULTS_URL"] == "" {
|
||||
envs["ACTIONS_RESULTS_URL"] = cmp.Or(os.Getenv("ACTIONS_RESULTS_URL"), i.cacheHandler.ExternalURL())
|
||||
envs["ACTIONS_RESULTS_URL"] = cmp.Or(os.Getenv("ACTIONS_RESULTS_URL"), cacheURL)
|
||||
}
|
||||
envs[runner.CacheServiceV2Env] = "true"
|
||||
|
||||
|
||||
@@ -89,6 +89,7 @@ const registerTokenEnvVar = "GITEA_RUNNER_REGISTRATION_TOKEN"
|
||||
|
||||
var defaultLabels = []string{
|
||||
"ubuntu-latest:docker://docker.gitea.com/runner-images:ubuntu-latest",
|
||||
"ubuntu-26.04:docker://docker.gitea.com/runner-images:ubuntu-26.04",
|
||||
"ubuntu-24.04:docker://docker.gitea.com/runner-images:ubuntu-24.04",
|
||||
"ubuntu-22.04:docker://docker.gitea.com/runner-images:ubuntu-22.04",
|
||||
}
|
||||
|
||||
@@ -55,7 +55,7 @@ func JobProxyEnv(jobEnvs map[string]string, cacheURL string, serviceNames []stri
|
||||
setPair("https_proxy", "HTTPS_PROXY", cfg.HTTPSProxy)
|
||||
|
||||
// no_proxy is merged rather than replaced: the hosts above are structural, and an
|
||||
// operator cannot list the cache server's startup-assigned address in advance.
|
||||
// operator cannot list the cache server's detected address in advance.
|
||||
noProxy := appendNoProxy(cfg.NoProxy, direct...)
|
||||
for _, name := range []string{"no_proxy", "NO_PROXY"} {
|
||||
if existing, ok := jobEnvs[name]; ok {
|
||||
|
||||
+47
-26
@@ -73,12 +73,13 @@ type Runner struct {
|
||||
cacheHandler *artifactcache.Handler
|
||||
capabilities string
|
||||
|
||||
isolatedCacheContainer func() string
|
||||
isolatedCacheContainer func(cacheURL string) string
|
||||
instanceTransport http.RoundTripper
|
||||
|
||||
runningTasks sync.Map
|
||||
runningCount atomic.Int64
|
||||
lastIdleCleanupUnixNano atomic.Int64
|
||||
slots slotAllocator
|
||||
now func() time.Time
|
||||
healthCheckLast time.Time
|
||||
healthCheckReady bool
|
||||
@@ -118,7 +119,6 @@ func NewRunner(cfg *config.Config, reg *config.Registration, cli client.Client)
|
||||
// go on
|
||||
} else {
|
||||
cacheHandler = handler
|
||||
envs["ACTIONS_CACHE_URL"] = handler.ExternalURL() + "/"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -141,7 +141,7 @@ func NewRunner(cfg *config.Config, reg *config.Registration, cli client.Client)
|
||||
now: time.Now,
|
||||
runHealthCheck: executeHealthCheck,
|
||||
}
|
||||
runner.isolatedCacheContainer = sync.OnceValue(runner.detectIsolatedCacheContainer)
|
||||
runner.isolatedCacheContainer = lastResult(runner.detectIsolatedCacheContainer)
|
||||
runner.instanceTransport = instanceTransport
|
||||
return runner
|
||||
}
|
||||
@@ -196,7 +196,7 @@ func (r *Runner) cleanupOrphanDockerResources(ctx context.Context) {
|
||||
return
|
||||
}
|
||||
cutoff := r.now().Add(-orphanDockerResourceAge)
|
||||
if err := removeOrphanNetworks(ctx, r.uuid, r.isolatedCacheContainer(), cutoff); err != nil {
|
||||
if err := removeOrphanNetworks(ctx, r.uuid, r.isolatedCacheContainer(r.cacheHandler.ExternalURL()), cutoff); err != nil {
|
||||
logging.Warnf("failed to clean up networks left behind by earlier jobs: %v", err)
|
||||
}
|
||||
if err := removeOrphanJobVolumes(ctx, r.uuid, cutoff); err != nil {
|
||||
@@ -452,6 +452,9 @@ func (r *Runner) run(ctx context.Context, task *runnerv1.Task, reporter *report.
|
||||
}
|
||||
}()
|
||||
|
||||
slot := r.slots.acquire()
|
||||
defer r.slots.release(slot)
|
||||
|
||||
workflowCallInputs := restoreCalledWorkflowContext(task)
|
||||
for _, line := range r.setupLines(task) {
|
||||
reporter.Logf("%s", line)
|
||||
@@ -485,7 +488,7 @@ func (r *Runner) run(ctx context.Context, task *runnerv1.Task, reporter *report.
|
||||
return fallbackPlatform()
|
||||
}
|
||||
platform := platformPicker(job.RunsOn())
|
||||
cacheURL, cacheContainer := r.cacheForJob(job, platform)
|
||||
cacheURL, cacheContainer := r.cacheForJob(job, platform, reporter.Logf)
|
||||
if cacheURL != "" {
|
||||
envs["ACTIONS_CACHE_URL"] = cacheURL + "/"
|
||||
}
|
||||
@@ -624,6 +627,7 @@ func (r *Runner) run(ctx context.Context, task *runnerv1.Task, reporter *report.
|
||||
ValidVolumes: r.cfg.Container.ValidVolumes,
|
||||
ContainerVolumes: r.cfg.Container.Volumes,
|
||||
SharedToolCache: r.cfg.Runner.ToolCacheMode == config.ToolCacheModeShared,
|
||||
ToolCacheSlot: toolCacheSlot(r.cfg.Runner.ToolCacheMode, slot),
|
||||
InsecureSkipTLS: r.cfg.Runner.Insecure,
|
||||
ClientCertFile: r.cfg.Runner.ClientCertFile,
|
||||
ClientKeyFile: r.cfg.Runner.ClientKeyFile,
|
||||
@@ -663,11 +667,24 @@ func (r *Runner) run(ctx context.Context, task *runnerv1.Task, reporter *report.
|
||||
return execErr
|
||||
}
|
||||
|
||||
func (r *Runner) builtInCacheURL() string {
|
||||
if r.cacheHandler == nil {
|
||||
return ""
|
||||
func lastResult(compute func(key string) (string, bool)) func(key string) string {
|
||||
var (
|
||||
mu sync.Mutex
|
||||
lastKey string
|
||||
result string
|
||||
)
|
||||
return func(key string) string {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if key == lastKey {
|
||||
return result
|
||||
}
|
||||
value, ok := compute(key)
|
||||
if ok {
|
||||
lastKey, result = key, value
|
||||
}
|
||||
return value
|
||||
}
|
||||
return r.envs["ACTIONS_CACHE_URL"]
|
||||
}
|
||||
|
||||
func (r *Runner) setResultsService(envs map[string]string, resultsURL string) {
|
||||
@@ -698,7 +715,7 @@ func (r *Runner) instanceOutOfReach(instance string) bool {
|
||||
// Safe with an empty token (older Gitea did not issue one).
|
||||
// It also returns the URL to advertise as ACTIONS_RESULTS_URL.
|
||||
func (r *Runner) registerCacheForTask(token string, cred artifactcache.JobCredential, reporter *report.Reporter) (func(), string) {
|
||||
if token == "" || r.cacheHandler == nil {
|
||||
if token == "" || cred.PublicURL == "" {
|
||||
return func() {}, ""
|
||||
}
|
||||
cred.Results = r.envs["ACTIONS_RESULTS_URL"] // the instance as the job would reach it
|
||||
@@ -929,35 +946,39 @@ func warnIgnoredCacheSecret(cfg *config.Config) {
|
||||
}
|
||||
|
||||
// cacheForJob returns the cache address for the job, and the container to attach to its network to reach it there.
|
||||
func (r *Runner) cacheForJob(job *model.Job, platform string) (string, string) {
|
||||
func (r *Runner) cacheForJob(job *model.Job, platform string, logf func(string, ...any)) (string, string) {
|
||||
cacheURL := r.cacheHandler.RefreshExternalURL()
|
||||
jobContainer := job.Container()
|
||||
cacheContainer := ""
|
||||
if !strings.HasPrefix(platform, labels.SchemeKubernetes+"://") && (jobContainer != nil && jobContainer.Image != "" || platform != labels.SelfHostedPlatform) {
|
||||
cacheContainer = r.isolatedCacheContainer()
|
||||
if cacheURL == "" || strings.HasPrefix(platform, labels.SchemeKubernetes+"://") || (jobContainer == nil || jobContainer.Image == "") && platform == labels.SelfHostedPlatform {
|
||||
return cacheURL, ""
|
||||
}
|
||||
if cacheContainer == "" {
|
||||
return strings.TrimSuffix(r.builtInCacheURL(), "/"), ""
|
||||
if cacheContainer := r.isolatedCacheContainer(cacheURL); cacheContainer != "" {
|
||||
isolatedURL, _ := url.Parse(cacheURL)
|
||||
isolatedURL.Host = net.JoinHostPort(cacheContainer, isolatedURL.Port())
|
||||
return isolatedURL.String(), cacheContainer
|
||||
}
|
||||
cacheURL, _ := url.Parse(r.cacheHandler.ExternalURL())
|
||||
cacheURL.Host = net.JoinHostPort(cacheContainer, cacheURL.Port())
|
||||
return cacheURL.String(), cacheContainer
|
||||
network, enableIPv6 := r.cfg.Container.Network, r.cfg.Container.NetworkCreateOptions.EnableIPv6
|
||||
if addr, err := netip.ParseAddr(hostOf(cacheURL)); err == nil && addr.Is6() && r.cfg.Cache.Host == "" &&
|
||||
network != "host" && (network != "" || enableIPv6 == nil || !*enableIPv6) {
|
||||
logf("::warning::%s", runner.EscapeCommandData("The runner's cache server is at the IPv6 address "+addr.String()+
|
||||
", so cache and artifact steps fail if this job's network has no IPv6. Set cache.host to an address the job reaches, or enable IPv6 on the job network."))
|
||||
}
|
||||
return cacheURL, ""
|
||||
}
|
||||
|
||||
func (r *Runner) detectIsolatedCacheContainer() string {
|
||||
if r.cacheHandler == nil {
|
||||
return ""
|
||||
}
|
||||
addr, err := netip.ParseAddr(hostOf(r.cacheHandler.ExternalURL()))
|
||||
func (r *Runner) detectIsolatedCacheContainer(cacheURL string) (string, bool) {
|
||||
addr, err := netip.ParseAddr(hostOf(cacheURL))
|
||||
if err != nil {
|
||||
return ""
|
||||
return "", true
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
cacheContainer, err := container.IsolatedCacheContainer(ctx, addr, r.cfg.Container.Network)
|
||||
if err != nil {
|
||||
logging.Warnf("cannot check whether jobs reach the cache server: %v", err)
|
||||
return "", false
|
||||
}
|
||||
return cacheContainer
|
||||
return cacheContainer, true
|
||||
}
|
||||
|
||||
// taskLogFields returns key/value pairs for logging.With.
|
||||
|
||||
Loaded 50 of 63 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user