mirror of
https://gitea.com/gitea/runner.git
synced 2026-11-11 18:34:09 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b5305c6785 | ||
|
|
a021d3139d | ||
|
|
84c0fcd1fa | ||
|
|
c59caa9689 | ||
|
|
85d007c47f | ||
|
|
d34e4cf11a | ||
|
|
8e999cd039 | ||
|
|
cd748053b0 | ||
|
|
9bb72bbe5c | ||
|
|
79faf5e55d | ||
|
|
993389d428 | ||
|
|
db76fd5159 | ||
|
|
c8b16f4792 | ||
|
|
02446620df | ||
|
|
8c8950d9ef | ||
|
|
b88d111cc5 | ||
|
|
bb6d793cb9 | ||
|
|
95ef17d022 | ||
|
|
ee03687688 | ||
|
|
30e600d8f6 | ||
|
|
664978c819 |
No files matched your search
+7
-9
@@ -23,11 +23,14 @@ depending on the prefix:
|
|||||||
covers both forms: it escapes `%` first, so a literal `%3B` becomes `%253B` that the extra
|
covers both forms: it escapes `%` first, so a literal `%3B` becomes `%253B` that the extra
|
||||||
`##[…]` rules cannot match, and a raw `;` or `]` is never decoded. It is also what makes
|
`##[…]` rules cannot match, and a raw `;` or `]` is never decoded. It is also what makes
|
||||||
multi-line work, `\n` becomes `%0A` and the UI turns it back into a line break.
|
multi-line work, `\n` becomes `%0A` and the UI turns it back into a line break.
|
||||||
- **Forwarding a command from step output?** Leave the payload alone, it arrived escaped and is
|
- **Forwarding a command from step output?** Preserve its escaping unless masking changes it.
|
||||||
decoded once. Decoding here double-decodes and destroys multi-line.
|
Decoding for display happens in the UI.
|
||||||
- **No prefix?** Do not escape, and split multi-line values into one row each.
|
- **No prefix?** Do not escape, and split multi-line values into one row each.
|
||||||
- **Interpolating a secret?** Masking runs after escaping, so `AppendSecretMasker` registers the
|
- **Interpolating a secret?** Masking runs after escaping. Recognised command payloads are
|
||||||
encoded forms too.
|
normalised before masking to cover partially escaped values. Their original escaping is
|
||||||
|
preserved when no replacement occurs. `AppendSecretMasker` also registers encoded forms for
|
||||||
|
other output. Complete nonblank one-character values are masked, while one-byte fragments
|
||||||
|
of multiline values are skipped.
|
||||||
- Command *properties* also escape `%3A` and `%2C`, which the UI never decodes, so the command
|
- Command *properties* also escape `%3A` and `%2C`, which the UI never decodes, so the command
|
||||||
handler folds an annotation's location into its decoded message and escapes the result again.
|
handler folds an annotation's location into its decoded message and escapes the result again.
|
||||||
|
|
||||||
@@ -48,11 +51,6 @@ fields.
|
|||||||
- Tests read what was logged with `logtest.NewLogger()`, or `logtest.SetDefault(t)` for the
|
- Tests read what was logged with `logtest.NewLogger()`, or `logtest.SetDefault(t)` for the
|
||||||
package functions.
|
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
|
## End-to-end compatibility tests
|
||||||
|
|
||||||
`make test-e2e` runs the runner against `E2E_GITEA_IMAGE`. It defaults to the nightly image.
|
`make test-e2e` runs the runner against `E2E_GITEA_IMAGE`. It defaults to the nightly image.
|
||||||
|
|||||||
+7
-5
@@ -19,14 +19,15 @@ RUN --mount=type=cache,target=/go/pkg/mod \
|
|||||||
### DIND VARIANT
|
### DIND VARIANT
|
||||||
#
|
#
|
||||||
#
|
#
|
||||||
FROM docker:29.9.2-dind AS dind
|
FROM docker:29.9.3-dind AS dind
|
||||||
|
|
||||||
ARG VERSION=dev
|
ARG VERSION=dev
|
||||||
|
|
||||||
LABEL org.opencontainers.image.source="https://gitea.com/gitea/runner"
|
LABEL org.opencontainers.image.source="https://gitea.com/gitea/runner"
|
||||||
LABEL org.opencontainers.image.version="${VERSION}"
|
LABEL org.opencontainers.image.version="${VERSION}"
|
||||||
|
|
||||||
RUN apk add --no-cache s6 bash git tzdata nftables
|
# upgrade first: the docker base image can lag behind Alpine security fixes
|
||||||
|
RUN apk upgrade --no-cache && apk add --no-cache s6 bash git tzdata nftables
|
||||||
|
|
||||||
COPY --from=builder /opt/src/runner/gitea-runner /usr/local/bin/gitea-runner
|
COPY --from=builder /opt/src/runner/gitea-runner /usr/local/bin/gitea-runner
|
||||||
COPY scripts/run.sh /usr/local/bin/run.sh
|
COPY scripts/run.sh /usr/local/bin/run.sh
|
||||||
@@ -41,7 +42,7 @@ ENTRYPOINT ["s6-svscan","/etc/s6"]
|
|||||||
### DIND-ROOTLESS VARIANT
|
### DIND-ROOTLESS VARIANT
|
||||||
#
|
#
|
||||||
#
|
#
|
||||||
FROM docker:29.9.2-dind-rootless AS dind-rootless
|
FROM docker:29.9.3-dind-rootless AS dind-rootless
|
||||||
|
|
||||||
ARG VERSION=dev
|
ARG VERSION=dev
|
||||||
|
|
||||||
@@ -49,7 +50,8 @@ LABEL org.opencontainers.image.source="https://gitea.com/gitea/runner"
|
|||||||
LABEL org.opencontainers.image.version="${VERSION}"
|
LABEL org.opencontainers.image.version="${VERSION}"
|
||||||
|
|
||||||
USER root
|
USER root
|
||||||
RUN apk add --no-cache s6 bash git tzdata nftables
|
# upgrade first: the docker base image can lag behind Alpine security fixes
|
||||||
|
RUN apk upgrade --no-cache && apk add --no-cache s6 bash git tzdata nftables
|
||||||
|
|
||||||
COPY --from=builder /opt/src/runner/gitea-runner /usr/local/bin/gitea-runner
|
COPY --from=builder /opt/src/runner/gitea-runner /usr/local/bin/gitea-runner
|
||||||
COPY scripts/run.sh /usr/local/bin/run.sh
|
COPY scripts/run.sh /usr/local/bin/run.sh
|
||||||
@@ -75,7 +77,7 @@ ARG VERSION=dev
|
|||||||
LABEL org.opencontainers.image.source="https://gitea.com/gitea/runner"
|
LABEL org.opencontainers.image.source="https://gitea.com/gitea/runner"
|
||||||
LABEL org.opencontainers.image.version="${VERSION}"
|
LABEL org.opencontainers.image.version="${VERSION}"
|
||||||
|
|
||||||
RUN apk add --no-cache tini bash git tzdata
|
RUN apk upgrade --no-cache && apk add --no-cache tini bash git tzdata
|
||||||
|
|
||||||
COPY --from=builder /opt/src/runner/gitea-runner /usr/local/bin/gitea-runner
|
COPY --from=builder /opt/src/runner/gitea-runner /usr/local/bin/gitea-runner
|
||||||
COPY scripts/run.sh /usr/local/bin/run.sh
|
COPY scripts/run.sh /usr/local/bin/run.sh
|
||||||
|
|||||||
@@ -4,10 +4,10 @@ EXECUTABLE := gitea-runner$(shell $(GO) env GOEXE)
|
|||||||
HAS_GO = $(shell hash $(GO) > /dev/null 2>&1 && echo "GO" || echo "NOGO" )
|
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
|
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
|
SERVICE_IMAGE ?= nginx:1.31.7-alpine@sha256:892bb2152464f0c5d8972593a5f5afaf8054552580b699d95f8ff4af08c2d33d # renovate: datasource=docker
|
||||||
E2E_GITEA_IMAGE ?= gitea/gitea:main-nightly
|
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
|
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.*")
|
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
|
### Prerequisites
|
||||||
|
|
||||||
- Linux, macOS or Windows, or FreeBSD.
|
- Linux, macOS or Windows, or FreeBSD.
|
||||||
|
- Gitea 1.26.2 or newer.
|
||||||
- `git` 2.34.1 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/).
|
- `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.
|
- `bash` or `sh`, or `pwsh` or `powershell` on Windows.
|
||||||
- `node` for JavaScript actions and `hashFiles()`.
|
- `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.
|
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
|
### 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
|
## Configuration
|
||||||
|
|
||||||
@@ -183,7 +181,7 @@ Setup actions like `setup-go` install tools into `RUNNER_TOOL_CACHE`. `runner.to
|
|||||||
| Mode | Tool cache |
|
| Mode | Tool cache |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `none` (default) | Provided by the job image. Versions it lacks are downloaded in every job. |
|
| `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:
|
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:
|
||||||
|
|
||||||
@@ -243,11 +241,7 @@ volumes:
|
|||||||
- ${GITEA_DOCKER_WORKSPACE:-.}/data:/app/data
|
- ${GITEA_DOCKER_WORKSPACE:-.}/data:/app/data
|
||||||
```
|
```
|
||||||
|
|
||||||
In most Linux container jobs, the containers, networks and volumes a job creates are removed when it ends, `docker compose` services included. Set `container.sweep: false` if jobs deploy services to the runner's Docker daemon.
|
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`)
|
### Caching (`actions/cache`)
|
||||||
|
|
||||||
|
|||||||
@@ -104,7 +104,9 @@ type Handler struct {
|
|||||||
stopSync context.CancelFunc
|
stopSync context.CancelFunc
|
||||||
syncing sync.WaitGroup
|
syncing sync.WaitGroup
|
||||||
|
|
||||||
|
outboundIPMu sync.RWMutex
|
||||||
outboundIP string
|
outboundIP string
|
||||||
|
detectOutboundIP func() net.IP // nil when the address is configured
|
||||||
instanceTransport http.RoundTripper
|
instanceTransport http.RoundTripper
|
||||||
|
|
||||||
// internalSecret guards /_internal/{register,revoke}. When set, a remote
|
// internalSecret guards /_internal/{register,revoke}. When set, a remote
|
||||||
@@ -158,6 +160,7 @@ func StartHandler(opts Options) (*Handler, error) {
|
|||||||
policy: opts.Policy.withDefaults(),
|
policy: opts.Policy.withDefaults(),
|
||||||
freeDisk: disk.FreeBytes,
|
freeDisk: disk.FreeBytes,
|
||||||
dbLock: make(chan struct{}, 1),
|
dbLock: make(chan struct{}, 1),
|
||||||
|
outboundIP: opts.OutboundIP,
|
||||||
instanceTransport: opts.InstanceTransport,
|
instanceTransport: opts.InstanceTransport,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -167,12 +170,11 @@ func StartHandler(opts Options) (*Handler, error) {
|
|||||||
logger = logger.With("module", "artifactcache")
|
logger = logger.With("module", "artifactcache")
|
||||||
h.logger = logger
|
h.logger = logger
|
||||||
|
|
||||||
if opts.OutboundIP != "" {
|
if opts.OutboundIP == "" {
|
||||||
h.outboundIP = opts.OutboundIP
|
h.detectOutboundIP = common.GetOutboundIP
|
||||||
} else if ip := common.GetOutboundIP(); ip == nil {
|
if h.RefreshExternalURL() == "" {
|
||||||
return nil, errors.New("unable to determine outbound IP address")
|
logger.Warn("unable to determine an outbound IP address for the cache server yet, set cache.host to configure one")
|
||||||
} else {
|
}
|
||||||
h.outboundIP = ip.String()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if opts.Upstream != "" {
|
if opts.Upstream != "" {
|
||||||
@@ -304,11 +306,36 @@ func (h *Handler) cacheForwarder(upstream string) (http.Handler, error) {
|
|||||||
}), nil
|
}), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ExternalURL is "" while no address is known.
|
||||||
func (h *Handler) ExternalURL() string {
|
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))
|
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 {
|
func (h *Handler) baseURL(cred JobCredential) string {
|
||||||
if base := strings.TrimRight(cred.PublicURL, "/"); base != "" {
|
if base := strings.TrimRight(cred.PublicURL, "/"); base != "" {
|
||||||
return base
|
return base
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
@@ -22,6 +23,8 @@ import (
|
|||||||
"testing/synctest"
|
"testing/synctest"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"gitea.com/gitea/runner/act/common/logging/logtest"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"go.etcd.io/bbolt"
|
"go.etcd.io/bbolt"
|
||||||
@@ -50,7 +53,23 @@ func signArtifactURL(h *Handler, id int64) string {
|
|||||||
|
|
||||||
func TestHandler_ExternalURL(t *testing.T) {
|
func TestHandler_ExternalURL(t *testing.T) {
|
||||||
t.Parallel()
|
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) {
|
func TestHandler_Upstream(t *testing.T) {
|
||||||
@@ -851,6 +870,9 @@ func TestHandler_touchCacheSkipsFreshCompletedEntries(t *testing.T) {
|
|||||||
func newTestHandler(t *testing.T, policy Policy, entries ...*Cache) *Handler {
|
func newTestHandler(t *testing.T, policy Policy, entries ...*Cache) *Handler {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
handler := startTestHandler(t, Options{Policy: policy})
|
handler := startTestHandler(t, Options{Policy: policy})
|
||||||
|
if len(entries) == 0 {
|
||||||
|
return handler
|
||||||
|
}
|
||||||
|
|
||||||
db, err := handler.openDB()
|
db, err := handler.openDB()
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@@ -940,6 +962,10 @@ func TestHandler_RejectsBearer(t *testing.T) {
|
|||||||
|
|
||||||
func TestHandler_UnregisterRevokes(t *testing.T) {
|
func TestHandler_UnregisterRevokes(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
handler, err := StartHandler(Options{Dir: filepath.Join(t.TempDir(), "artifactcache")})
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer handler.Close()
|
||||||
|
|
||||||
for _, tc := range []struct {
|
for _, tc := range []struct {
|
||||||
name string
|
name string
|
||||||
token string
|
token string
|
||||||
@@ -950,9 +976,6 @@ func TestHandler_UnregisterRevokes(t *testing.T) {
|
|||||||
{"reference counted", "shared", "v", 2},
|
{"reference counted", "shared", "v", 2},
|
||||||
} {
|
} {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
handler, err := StartHandler(Options{Dir: filepath.Join(t.TempDir(), "artifactcache")})
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer handler.Close()
|
|
||||||
revokers := make([]func(), tc.registrations)
|
revokers := make([]func(), tc.registrations)
|
||||||
for i := range revokers {
|
for i := range revokers {
|
||||||
revokers[i] = handler.RegisterJob(tc.token, JobCredential{Repo: testRepo})
|
revokers[i] = handler.RegisterJob(tc.token, JobCredential{Repo: testRepo})
|
||||||
@@ -1049,9 +1072,8 @@ func TestHandler_ArtifactSignature(t *testing.T) {
|
|||||||
|
|
||||||
base := handler.ExternalURL() + apiPath
|
base := handler.ExternalURL() + apiPath
|
||||||
good := signArtifactURL(handler, 1)
|
good := signArtifactURL(handler, 1)
|
||||||
other, err := StartHandler(Options{Dir: filepath.Join(t.TempDir(), "artifactcache2")})
|
secret := bytes.Clone(handler.secret)
|
||||||
require.NoError(t, err)
|
secret[0] ^= 1
|
||||||
defer other.Close()
|
|
||||||
|
|
||||||
for _, tc := range []struct {
|
for _, tc := range []struct {
|
||||||
name string
|
name string
|
||||||
@@ -1060,7 +1082,7 @@ func TestHandler_ArtifactSignature(t *testing.T) {
|
|||||||
{"missing signature", fmt.Sprintf("%s/artifacts/%d", base, 1)},
|
{"missing signature", fmt.Sprintf("%s/artifacts/%d", base, 1)},
|
||||||
{"tampered signature", good[:len(good)-4] + "dead"},
|
{"tampered signature", good[:len(good)-4] + "dead"},
|
||||||
{"expired signature", handler.signedArtifactURL(JobCredential{}, 1, time.Now().Add(-time.Second))},
|
{"expired signature", handler.signedArtifactURL(JobCredential{}, 1, time.Now().Add(-time.Second))},
|
||||||
{"signature from a different server", base + strings.SplitN(signArtifactURL(other, 1), apiPath, 2)[1]},
|
{"signature from a different server", base + strings.SplitN(signArtifactURL(&Handler{secret: secret}, 1), apiPath, 2)[1]},
|
||||||
} {
|
} {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
resp, err := testClient.Get(tc.url)
|
resp, err := testClient.Get(tc.url)
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ func TestFetchAction(t *testing.T) {
|
|||||||
|
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
remote := filepath.Join(root, "org", "action")
|
remote := filepath.Join(root, "org", "action")
|
||||||
require.NoError(t, gitCmd("init", "--initial-branch=main", remote))
|
require.NoError(t, os.CopyFS(remote, os.DirFS(filepath.Join(gitFixtureDir, "work"))))
|
||||||
require.NoError(t, os.WriteFile(filepath.Join(remote, "action.yml"), []byte("name: action\n"), 0o644))
|
require.NoError(t, os.WriteFile(filepath.Join(remote, "action.yml"), []byte("name: action\n"), 0o644))
|
||||||
require.NoError(t, os.WriteFile(filepath.Join(remote, "run.sh"), []byte("#!/bin/sh\n"), 0o755))
|
require.NoError(t, os.WriteFile(filepath.Join(remote, "run.sh"), []byte("#!/bin/sh\n"), 0o755))
|
||||||
require.NoError(t, os.Symlink("action.yml", filepath.Join(remote, "link")))
|
require.NoError(t, os.Symlink("action.yml", filepath.Join(remote, "link")))
|
||||||
@@ -53,6 +53,7 @@ func TestFetchAction(t *testing.T) {
|
|||||||
var archiveRequests []http.Header
|
var archiveRequests []http.Header
|
||||||
var mirror *httptest.Server
|
var mirror *httptest.Server
|
||||||
archiveCache := map[string][]byte{}
|
archiveCache := map[string][]byte{}
|
||||||
|
revisions := map[string]string{}
|
||||||
requests, archives, failures := 0, "", 0
|
requests, archives, failures := 0, "", 0
|
||||||
handler := http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
handler := http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||||
mu.Lock()
|
mu.Lock()
|
||||||
@@ -79,7 +80,14 @@ func TestFetchAction(t *testing.T) {
|
|||||||
http.Redirect(writer, request, mirror.URL+request.URL.Path, http.StatusFound)
|
http.Redirect(writer, request, mirror.URL+request.URL.Path, http.StatusFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
sha, _ := gitOutput(request.Context(), remote, "rev-parse", strings.TrimSuffix(ref, ".tar.gz"))
|
sha, ok := revisions[ref]
|
||||||
|
if !ok {
|
||||||
|
var err error
|
||||||
|
sha, err = gitOutput(request.Context(), remote, "rev-parse", strings.TrimSuffix(ref, ".tar.gz"))
|
||||||
|
if err == nil {
|
||||||
|
revisions[ref] = sha
|
||||||
|
}
|
||||||
|
}
|
||||||
etag := fmt.Sprintf(`W/"%s-tar.gz-%t"`, sha, archives != "unprefixed")
|
etag := fmt.Sprintf(`W/"%s-tar.gz-%t"`, sha, archives != "unprefixed")
|
||||||
if request.Header.Get("If-None-Match") == etag {
|
if request.Header.Get("If-None-Match") == etag {
|
||||||
writer.WriteHeader(http.StatusNotModified)
|
writer.WriteHeader(http.StatusNotModified)
|
||||||
@@ -208,6 +216,9 @@ func TestFetchAction(t *testing.T) {
|
|||||||
require.NoError(t, gitCmd("-C", remote, "commit", "--allow-empty", "-m", "moved"))
|
require.NoError(t, gitCmd("-C", remote, "commit", "--allow-empty", "-m", "moved"))
|
||||||
require.NoError(t, gitCmd("-C", remote, "tag", "--force", "v1"))
|
require.NoError(t, gitCmd("-C", remote, "tag", "--force", "v1"))
|
||||||
expectedSha = gitRevParse(t, remote, "v1")
|
expectedSha = gitRevParse(t, remote, "v1")
|
||||||
|
mu.Lock()
|
||||||
|
clear(revisions)
|
||||||
|
mu.Unlock()
|
||||||
moved, movedSha, err := FetchAction(t.Context(), input)
|
moved, movedSha, err := FetchAction(t.Context(), input)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, expectedSha, movedSha)
|
assert.Equal(t, expectedSha, movedSha)
|
||||||
@@ -253,7 +264,7 @@ func TestFetchAction(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
t.Run("downloads again over a tree whose files changed size or that has no record, not under a job copying it", func(t *testing.T) {
|
t.Run("downloads again over a tree whose files changed size or that has no record, not under a job copying it", func(t *testing.T) {
|
||||||
sha := gitRevParse(t, remote, "main")
|
sha := expectedSha
|
||||||
for _, mode := range []string{"prefixed", "none"} {
|
for _, mode := range []string{"prefixed", "none"} {
|
||||||
serve(mode, 0)
|
serve(mode, 0)
|
||||||
input := newInput(sha)
|
input := newInput(sha)
|
||||||
|
|||||||
+25
-31
@@ -112,8 +112,7 @@ func TestFindGitMetadataOfSHA256RepositoryUsesOrigin(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
basedir := t.TempDir()
|
basedir := t.TempDir()
|
||||||
const remoteURL = "https://github.com/owner/repo.git"
|
const remoteURL = "https://github.com/owner/repo.git"
|
||||||
require.NoError(t, gitCmd("init", "--object-format=sha256", "--initial-branch=main", basedir))
|
require.NoError(t, gitCmd("init", "--template=", "--object-format=sha256", "--initial-branch=main", basedir))
|
||||||
require.NoError(t, os.RemoveAll(filepath.Join(basedir, ".git", "hooks")))
|
|
||||||
require.NoError(t, gitCmd("-C", basedir, "commit", "--allow-empty", "-m", "init"))
|
require.NoError(t, gitCmd("-C", basedir, "commit", "--allow-empty", "-m", "init"))
|
||||||
require.NoError(t, gitCmd("-C", basedir, "remote", "add", "origin", remoteURL))
|
require.NoError(t, gitCmd("-C", basedir, "remote", "add", "origin", remoteURL))
|
||||||
require.NoError(t, gitCmd("-C", basedir, "config", "--add", "remote.origin.url", "https://github.com/other/repo.git"))
|
require.NoError(t, gitCmd("-C", basedir, "config", "--add", "remote.origin.url", "https://github.com/other/repo.git"))
|
||||||
@@ -205,16 +204,13 @@ func TestGitCloneExecutor(t *testing.T) {
|
|||||||
require.NoError(t, gitCmd("-C", workDir, "add", "action.sh", ".gitattributes"))
|
require.NoError(t, gitCmd("-C", workDir, "add", "action.sh", ".gitattributes"))
|
||||||
require.NoError(t, gitCmd("-C", workDir, "commit", "-m", "initial"))
|
require.NoError(t, gitCmd("-C", workDir, "commit", "-m", "initial"))
|
||||||
require.NoError(t, gitCmd("-C", workDir, "tag", "v2"))
|
require.NoError(t, gitCmd("-C", workDir, "tag", "v2"))
|
||||||
require.NoError(t, gitCmd("-C", workDir, "push", "-u", "origin", "main"))
|
|
||||||
require.NoError(t, gitCmd("-C", workDir, "push", "origin", "v2"))
|
|
||||||
|
|
||||||
require.NoError(t, gitCmd("-C", workDir, "checkout", "-b", "act-fails"))
|
require.NoError(t, gitCmd("-C", workDir, "checkout", "-b", "act-fails"))
|
||||||
require.NoError(t, gitCmd("-C", workDir, "commit", "--allow-empty", "-m", "branch-commit"))
|
require.NoError(t, gitCmd("-C", workDir, "commit", "--allow-empty", "-m", "branch-commit"))
|
||||||
require.NoError(t, gitCmd("-C", workDir, "push", "origin", "act-fails"))
|
|
||||||
|
|
||||||
fullSha := gitRevParse(t, workDir, "main")
|
fullSha := gitRevParse(t, workDir, "main")
|
||||||
|
|
||||||
require.NoError(t, gitCmd("-C", workDir, "push", "origin", "main:refs/heads/"+fullSha[:4]))
|
require.NoError(t, gitCmd("-C", workDir, "push", "-u", "origin", "main", "v2", "act-fails", "main:refs/heads/"+fullSha[:4]))
|
||||||
|
|
||||||
hostConfig := filepath.Join(t.TempDir(), "gitconfig")
|
hostConfig := filepath.Join(t.TempDir(), "gitconfig")
|
||||||
require.NoError(t, os.WriteFile(hostConfig, []byte("[core]\n\tautocrlf = true\n\teol = crlf\n[url "+strconv.Quote(remoteDir)+"]\n\tinsteadOf = https://example.invalid/action\n"), 0o644))
|
require.NoError(t, os.WriteFile(hostConfig, []byte("[core]\n\tautocrlf = true\n\teol = crlf\n[url "+strconv.Quote(remoteDir)+"]\n\tinsteadOf = https://example.invalid/action\n"), 0o644))
|
||||||
@@ -282,10 +278,10 @@ func TestGitCloneExecutor(t *testing.T) {
|
|||||||
func TestGitCloneExecutorReclonesWhenOriginURLChanges(t *testing.T) {
|
func TestGitCloneExecutorReclonesWhenOriginURLChanges(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
createRemote := func(message string) string {
|
createRemote := func(message string) string {
|
||||||
remoteDir, workDir := newGitRemote(t, message)
|
dir := t.TempDir()
|
||||||
require.NoError(t, gitCmd("-C", workDir, "push", "-u", "origin", "main"))
|
require.NoError(t, os.CopyFS(dir, os.DirFS(filepath.Join(gitFixtureDir, "work"))))
|
||||||
|
require.NoError(t, gitCmd("-C", dir, "commit", "--allow-empty", "-m", message))
|
||||||
return remoteDir
|
return dir
|
||||||
}
|
}
|
||||||
|
|
||||||
oldRemoteDir := createRemote("old-action")
|
oldRemoteDir := createRemote("old-action")
|
||||||
@@ -405,31 +401,30 @@ func TestGitCloneExecutorShallow(t *testing.T) {
|
|||||||
remoteDir, workDir := newGitRemote(t, "c1", "c2", "c3")
|
remoteDir, workDir := newGitRemote(t, "c1", "c2", "c3")
|
||||||
require.NoError(t, gitCmd("-C", workDir, "tag", "v1"))
|
require.NoError(t, gitCmd("-C", workDir, "tag", "v1"))
|
||||||
unadvertisedSHA := gitRevParse(t, workDir, "HEAD~1")
|
unadvertisedSHA := gitRevParse(t, workDir, "HEAD~1")
|
||||||
require.NoError(t, gitCmd("-C", workDir, "push", "-u", "origin", "main"))
|
require.NoError(t, gitCmd("-C", workDir, "push", "-u", "origin", "main", "v1"))
|
||||||
require.NoError(t, gitCmd("-C", workDir, "push", "origin", "v1"))
|
|
||||||
|
|
||||||
shallowMarker := func(dir string) string { return filepath.Join(dir, ".git", "shallow") }
|
shallowMarker := func(dir string) string { return filepath.Join(dir, ".git", "shallow") }
|
||||||
|
|
||||||
for name, ref := range map[string]string{"branch is cloned shallowly": "main", "tag is cloned shallowly": "v1"} {
|
for name, ref := range map[string]string{"branch is cloned shallowly": "main", "tag is cloned shallowly": "v1"} {
|
||||||
t.Run(name, func(t *testing.T) {
|
t.Run(name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
require.NoError(t, NewGitCloneExecutor(NewGitCloneExecutorInput{
|
require.NoError(t, NewGitCloneExecutor(NewGitCloneExecutorInput{
|
||||||
URL: remoteDir, Ref: ref, Dir: dir, Depth: 1,
|
URL: remoteDir, Ref: ref, Dir: dir, Depth: 1,
|
||||||
})(t.Context()))
|
})(t.Context()))
|
||||||
assert.FileExists(t, shallowMarker(dir), "clone should be shallow")
|
assert.FileExists(t, shallowMarker(dir), "clone should be shallow")
|
||||||
assert.Equal(t, 1, gitRevCount(t, dir), "only the tip commit should be present")
|
assert.Equal(t, []string{"c3"}, gitSubjects(t, dir), "only the tip commit should be present")
|
||||||
assert.Equal(t, "c3", gitHeadSubject(t, dir))
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
unadvertisedRemote := func(t *testing.T, allowUnadvertised bool) string {
|
unadvertisedRemote := func(t *testing.T, allowUnadvertised bool) string {
|
||||||
remote := filepath.Join(t.TempDir(), "remote.git")
|
remote := filepath.Join(t.TempDir(), "remote.git")
|
||||||
require.NoError(t, gitCmd("clone", "--bare", remoteDir, remote))
|
require.NoError(t, gitCmd("clone", "--bare", "--template=", "--config", "uploadpack.allowAnySHA1InWant="+strconv.FormatBool(allowUnadvertised), remoteDir, remote))
|
||||||
require.NoError(t, gitCmd("-C", remote, "config", "uploadpack.allowAnySHA1InWant", strconv.FormatBool(allowUnadvertised)))
|
|
||||||
return remote
|
return remote
|
||||||
}
|
}
|
||||||
|
|
||||||
t.Run("commit hash resolves when the remote accepts a direct shallow fetch", func(t *testing.T) {
|
t.Run("commit hash resolves when the remote accepts a direct shallow fetch", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
require.NoError(t, NewGitCloneExecutor(NewGitCloneExecutorInput{
|
require.NoError(t, NewGitCloneExecutor(NewGitCloneExecutorInput{
|
||||||
URL: unadvertisedRemote(t, false), Ref: unadvertisedSHA, Dir: dir, Depth: 1,
|
URL: unadvertisedRemote(t, false), Ref: unadvertisedSHA, Dir: dir, Depth: 1,
|
||||||
@@ -438,6 +433,7 @@ func TestGitCloneExecutorShallow(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
t.Run("commit hash is fetched shallowly, moved to another hash, then reused without reaching the remote", func(t *testing.T) {
|
t.Run("commit hash is fetched shallowly, moved to another hash, then reused without reaching the remote", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
remote := unadvertisedRemote(t, true)
|
remote := unadvertisedRemote(t, true)
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
cloneAt := func(ref string) error {
|
cloneAt := func(ref string) error {
|
||||||
@@ -448,7 +444,7 @@ func TestGitCloneExecutorShallow(t *testing.T) {
|
|||||||
|
|
||||||
require.NoError(t, cloneAt(unadvertisedSHA))
|
require.NoError(t, cloneAt(unadvertisedSHA))
|
||||||
assert.FileExists(t, shallowMarker(dir))
|
assert.FileExists(t, shallowMarker(dir))
|
||||||
assert.Equal(t, 1, gitRevCount(t, dir))
|
assert.Len(t, gitSubjects(t, dir), 1)
|
||||||
assert.Equal(t, unadvertisedSHA, gitRevParse(t, dir, "HEAD"))
|
assert.Equal(t, unadvertisedSHA, gitRevParse(t, dir, "HEAD"))
|
||||||
|
|
||||||
olderSHA := gitRevParse(t, workDir, "HEAD~2")
|
olderSHA := gitRevParse(t, workDir, "HEAD~2")
|
||||||
@@ -461,6 +457,8 @@ func TestGitCloneExecutorShallow(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
t.Run("moving hexadecimal-named branch updates while staying shallow", func(t *testing.T) {
|
t.Run("moving hexadecimal-named branch updates while staying shallow", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
remoteDir, workDir := newGitRemote(t, "c2", "c3")
|
||||||
require.NoError(t, gitCmd("-C", workDir, "push", "origin", "main:deadbeef"))
|
require.NoError(t, gitCmd("-C", workDir, "push", "origin", "main:deadbeef"))
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
clone := NewGitCloneExecutor(NewGitCloneExecutorInput{
|
clone := NewGitCloneExecutor(NewGitCloneExecutorInput{
|
||||||
@@ -473,19 +471,17 @@ func TestGitCloneExecutorShallow(t *testing.T) {
|
|||||||
require.NoError(t, gitCmd("-C", workDir, "push", "origin", "main:deadbeef"))
|
require.NoError(t, gitCmd("-C", workDir, "push", "origin", "main:deadbeef"))
|
||||||
|
|
||||||
require.NoError(t, clone(t.Context()))
|
require.NoError(t, clone(t.Context()))
|
||||||
assert.Equal(t, "c4", gitHeadSubject(t, dir), "reused shallow clone should update to the new tip")
|
assert.Equal(t, []string{"c4"}, gitSubjects(t, dir), "reused shallow clone should update to the new tip")
|
||||||
assert.FileExists(t, shallowMarker(dir), "repo should remain shallow after update")
|
assert.FileExists(t, shallowMarker(dir), "repo should remain shallow after update")
|
||||||
assert.Equal(t, 1, gitRevCount(t, dir))
|
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("sha256 full hash is cloned shallowly, reused without the remote, and its 40-character prefix is short", func(t *testing.T) {
|
t.Run("sha256 full hash is cloned shallowly, reused without the remote, and its 40-character prefix is short", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
remote := filepath.Join(t.TempDir(), "remote.git")
|
remote := filepath.Join(t.TempDir(), "remote.git")
|
||||||
require.NoError(t, gitCmd("init", "--bare", "--object-format=sha256", "--initial-branch=main", remote))
|
|
||||||
work := t.TempDir()
|
work := t.TempDir()
|
||||||
require.NoError(t, gitCmd("init", "--object-format=sha256", "--initial-branch=main", work))
|
require.NoError(t, gitCmd("init", "--template=", "--object-format=sha256", "--initial-branch=main", work))
|
||||||
require.NoError(t, gitCmd("-C", work, "remote", "add", "origin", remote))
|
|
||||||
require.NoError(t, gitCmd("-C", work, "commit", "--allow-empty", "-m", "pinned"))
|
require.NoError(t, gitCmd("-C", work, "commit", "--allow-empty", "-m", "pinned"))
|
||||||
require.NoError(t, gitCmd("-C", work, "push", "origin", "main"))
|
require.NoError(t, gitCmd("clone", "--bare", "--template=", work, remote))
|
||||||
sha := gitRevParse(t, work, "HEAD")
|
sha := gitRevParse(t, work, "HEAD")
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
clone := NewGitCloneExecutor(NewGitCloneExecutorInput{URL: remote, Ref: sha, Dir: dir, Depth: 1})
|
clone := NewGitCloneExecutor(NewGitCloneExecutorInput{URL: remote, Ref: sha, Dir: dir, Depth: 1})
|
||||||
@@ -504,11 +500,11 @@ func TestGitCloneExecutorPinnedHashIgnoresShadowingRef(t *testing.T) {
|
|||||||
pinned := gitRevParse(t, workDir, "HEAD")
|
pinned := gitRevParse(t, workDir, "HEAD")
|
||||||
require.NoError(t, gitCmd("-C", workDir, "commit", "--allow-empty", "-m", "decoy"))
|
require.NoError(t, gitCmd("-C", workDir, "commit", "--allow-empty", "-m", "decoy"))
|
||||||
require.NoError(t, gitCmd("-C", workDir, "tag", pinned))
|
require.NoError(t, gitCmd("-C", workDir, "tag", pinned))
|
||||||
require.NoError(t, gitCmd("-C", workDir, "push", "-u", "origin", "main"))
|
require.NoError(t, gitCmd("-C", workDir, "push", "-u", "origin", "main", pinned))
|
||||||
require.NoError(t, gitCmd("-C", workDir, "push", "origin", pinned))
|
|
||||||
|
|
||||||
for name, depth := range map[string]int{"shallow": 1, "full clone": 0} {
|
for name, depth := range map[string]int{"shallow": 1, "full clone": 0} {
|
||||||
t.Run(name, func(t *testing.T) {
|
t.Run(name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
require.NoError(t, NewGitCloneExecutor(NewGitCloneExecutorInput{
|
require.NoError(t, NewGitCloneExecutor(NewGitCloneExecutorInput{
|
||||||
URL: remoteDir, Ref: pinned, Dir: dir, Depth: depth,
|
URL: remoteDir, Ref: pinned, Dir: dir, Depth: depth,
|
||||||
@@ -540,13 +536,11 @@ func gitRevParse(t *testing.T, dir, rev string) string {
|
|||||||
return strings.TrimSpace(string(out))
|
return strings.TrimSpace(string(out))
|
||||||
}
|
}
|
||||||
|
|
||||||
func gitRevCount(t *testing.T, dir string) int {
|
func gitSubjects(t *testing.T, dir string) []string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
out, err := exec.Command("git", "-C", dir, "rev-list", "--count", "HEAD").Output()
|
out, err := exec.Command("git", "-C", dir, "log", "--format=%s").Output()
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
n, err := strconv.Atoi(strings.TrimSpace(string(out)))
|
return strings.Split(strings.TrimSpace(string(out)), "\n")
|
||||||
require.NoError(t, err)
|
|
||||||
return n
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func gitHeadSubject(t *testing.T, dir string) string {
|
func gitHeadSubject(t *testing.T, dir string) string {
|
||||||
@@ -680,7 +674,7 @@ func TestNewGitCloneExecutorFetchSendsTokenAndHonoursContext(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
dir := filepath.Join(t.TempDir(), "cached-action")
|
dir := filepath.Join(t.TempDir(), "cached-action")
|
||||||
require.NoError(t, gitCmd("init", dir))
|
require.NoError(t, os.CopyFS(dir, os.DirFS(filepath.Join(gitFixtureDir, "work"))))
|
||||||
require.NoError(t, gitCmd("-C", dir, "remote", "add", "origin", server.URL))
|
require.NoError(t, gitCmd("-C", dir, "remote", "add", "origin", server.URL))
|
||||||
|
|
||||||
ctx, cancel := context.WithCancel(t.Context())
|
ctx, cancel := context.WithCancel(t.Context())
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ package common
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"net"
|
"net"
|
||||||
|
"slices"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
@@ -55,19 +56,22 @@ func GetOutboundIP() net.IP {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func preferredIP(ips []interfaceIP) 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 {
|
if len(ips) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
sort.Slice(ips, func(i, j int) bool {
|
sort.Slice(ips, func(i, j int) bool {
|
||||||
first, second := ips[i], ips[j]
|
first, second := ips[i], ips[j]
|
||||||
|
|
||||||
// ethernet is preferred
|
// IPv4 is preferred, as job networks are IPv4-only by default
|
||||||
if vi, vj := strings.HasPrefix(first.Name, "e"), strings.HasPrefix(second.Name, "e"); vi != vj {
|
if vi, vj := first.To4() != nil, second.To4() != nil; vi != vj {
|
||||||
return vi
|
return vi
|
||||||
}
|
}
|
||||||
|
|
||||||
// IPv4 is preferred
|
// ethernet is preferred
|
||||||
if vi, vj := first.To4() != nil, second.To4() != nil; vi != vj {
|
if vi, vj := strings.HasPrefix(first.Name, "e"), strings.HasPrefix(second.Name, "e"); vi != vj {
|
||||||
return vi
|
return vi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,8 +13,9 @@ import (
|
|||||||
func TestPreferredIP(t *testing.T) {
|
func TestPreferredIP(t *testing.T) {
|
||||||
assert.Nil(t, preferredIP(nil))
|
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{{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("192.0.2.1"), Name: "wlan0"},
|
||||||
{IP: net.ParseIP("2001:db8::1"), Name: "eth0"},
|
{IP: net.ParseIP("2001:db8::1"), Name: "eth0"},
|
||||||
|
{IP: net.ParseIP("172.18.0.1"), Name: "br-0123456789ab"},
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
@@ -14,6 +14,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
|
|
||||||
@@ -23,6 +24,9 @@ import (
|
|||||||
|
|
||||||
var hostOS = runtime.GOOS // replaced in tests
|
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.
|
// ExitCodeError reports a non-zero process exit code from a container command.
|
||||||
type ExitCodeError int
|
type ExitCodeError int
|
||||||
|
|
||||||
|
|||||||
@@ -42,11 +42,11 @@ func TestDockerBuildLabelsImageWithItsTagAndReportsDaemonError(t *testing.T) {
|
|||||||
|
|
||||||
func TestCreateBuildContextFollowsDockerignoreAndSymlinkedRootButKeepsDockerfile(t *testing.T) {
|
func TestCreateBuildContextFollowsDockerignoreAndSymlinkedRootButKeepsDockerfile(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
|
require.NoError(t, os.Mkdir(filepath.Join(dir, "build"), 0o755))
|
||||||
for name, content := range map[string]string{
|
for name, content := range map[string]string{
|
||||||
".dockerignore": "*.log\nbuild\n!build/keep.txt\nDockerfile", "Dockerfile": "FROM scratch", "a.txt": "a",
|
".dockerignore": "*.log\nbuild\n!build/keep.txt\nDockerfile", "Dockerfile": "FROM scratch", "a.txt": "a",
|
||||||
"a.log": "log", "build/drop.txt": "drop", "build/keep.txt": "keep",
|
"a.log": "log", "build/drop.txt": "drop", "build/keep.txt": "keep",
|
||||||
} {
|
} {
|
||||||
require.NoError(t, os.MkdirAll(filepath.Join(dir, filepath.Dir(name)), 0o755))
|
|
||||||
require.NoError(t, os.WriteFile(filepath.Join(dir, name), []byte(content), 0o644))
|
require.NoError(t, os.WriteFile(filepath.Join(dir, name), []byte(content), 0o644))
|
||||||
}
|
}
|
||||||
require.NoError(t, os.Mkdir(filepath.Join(dir, "empty"), 0o755))
|
require.NoError(t, os.Mkdir(filepath.Join(dir, "empty"), 0o755))
|
||||||
|
|||||||
+11
-13
@@ -41,13 +41,6 @@ import (
|
|||||||
specs "github.com/opencontainers/image-spec/specs-go/v1"
|
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
|
// NewContainer creates a reference to a container
|
||||||
func NewContainer(input *NewContainerInput) ExecutionsEnvironment {
|
func NewContainer(input *NewContainerInput) ExecutionsEnvironment {
|
||||||
// Resolved up front because the image pull runs before the container is created.
|
// 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
|
return nil
|
||||||
}
|
}
|
||||||
defer resp.Close()
|
defer resp.Close()
|
||||||
|
defer context.AfterFunc(ctx, resp.Close)()
|
||||||
|
|
||||||
sid, err := resp.Reader.ReadString('\n')
|
sid, err := resp.Reader.ReadString('\n')
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -906,7 +900,7 @@ func (cr *containerReference) waitForCommand(ctx context.Context, resp client.Hi
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Debugf("Failed to drain command output after cancellation: %v", err)
|
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")
|
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)
|
logger.Error(err)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
extractionRoot := "/"
|
extractionRoot := cr.extractionRoot(dstPath)
|
||||||
if actPath := cr.GetActPath(); strings.HasPrefix(dstPath+"/", actPath+"/") {
|
|
||||||
extractionRoot = actPath // keeps tar entries from crossing the image's /var/run symlink
|
|
||||||
}
|
|
||||||
if err := writeDirTar(ctx, tarFile, path.Join("/", strings.TrimPrefix(dstPath, extractionRoot)), srcPath, useGitIgnore, skipGitDir, cr.UID, cr.GID, cr.markExecutable(ctx)); err != nil {
|
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
|
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 {
|
func (cr *containerReference) copyContent(dstPath string, files ...*FileEntry) common.Executor {
|
||||||
return func(ctx context.Context) error {
|
return func(ctx context.Context) error {
|
||||||
if cr.id == "" {
|
if cr.id == "" {
|
||||||
@@ -1046,7 +1044,7 @@ func (cr *containerReference) wait() common.Executor {
|
|||||||
if cr.attachDone != nil {
|
if cr.attachDone != nil {
|
||||||
select {
|
select {
|
||||||
case <-cr.attachDone:
|
case <-cr.attachDone:
|
||||||
case <-time.After(drainGracePeriod):
|
case <-time.After(DrainGracePeriod):
|
||||||
logger.Warn("Timed out draining container output")
|
logger.Warn("Timed out draining container output")
|
||||||
}
|
}
|
||||||
cr.attachDone = nil
|
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)
|
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) {
|
func (m *mockDockerClient) ContainerInspect(ctx context.Context, id string, opts mobyclient.ContainerInspectOptions) (mobyclient.ContainerInspectResult, error) {
|
||||||
args := m.Called(ctx, id, opts)
|
args := m.Called(ctx, id, opts)
|
||||||
return args.Get(0).(mobyclient.ContainerInspectResult), args.Error(1)
|
return args.Get(0).(mobyclient.ContainerInspectResult), args.Error(1)
|
||||||
@@ -351,6 +356,21 @@ func TestDockerAttachStreamClosesWithItsContext(t *testing.T) {
|
|||||||
client.AssertExpectations(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) {
|
func TestDockerWaitFailure(t *testing.T) {
|
||||||
ctx := t.Context()
|
ctx := t.Context()
|
||||||
statusCh := make(chan container.WaitResponse, 1)
|
statusCh := make(chan container.WaitResponse, 1)
|
||||||
@@ -540,6 +560,9 @@ func TestRejectsMissingContainer(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestPublicCopyPipelineHandlesStaleID(t *testing.T) {
|
func TestPublicCopyPipelineHandlesStaleID(t *testing.T) {
|
||||||
|
source := t.TempDir()
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(source, "x"), nil, 0o644))
|
||||||
|
|
||||||
for _, testcase := range []struct {
|
for _, testcase := range []struct {
|
||||||
name, destination, extractionRoot, entry string
|
name, destination, extractionRoot, entry string
|
||||||
directory bool
|
directory bool
|
||||||
@@ -567,8 +590,6 @@ func TestPublicCopyPipelineHandlesStaleID(t *testing.T) {
|
|||||||
|
|
||||||
cr := &containerReference{id: "stale", cli: client, input: &NewContainerInput{Name: "job-1"}}
|
cr := &containerReference{id: "stale", cli: client, input: &NewContainerInput{Name: "job-1"}}
|
||||||
if testcase.directory {
|
if testcase.directory {
|
||||||
source := t.TempDir()
|
|
||||||
require.NoError(t, os.WriteFile(filepath.Join(source, "x"), nil, 0o644))
|
|
||||||
require.NoError(t, cr.CopyDir(testcase.destination, source+string(filepath.Separator), false, true)(ctx))
|
require.NoError(t, cr.CopyDir(testcase.destination, source+string(filepath.Separator), false, true)(ctx))
|
||||||
} else {
|
} else {
|
||||||
require.NoError(t, cr.Copy(testcase.destination, &FileEntry{Name: "x", Mode: 0o644})(ctx))
|
require.NoError(t, cr.Copy(testcase.destination, &FileEntry{Name: "x", Mode: 0o644})(ctx))
|
||||||
@@ -1108,6 +1129,10 @@ func TestConnectTakesDaemonOSFromPing(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestContainerCopyModes(t *testing.T) {
|
func TestContainerCopyModes(t *testing.T) {
|
||||||
|
source := t.TempDir()
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(source, "run.sh"), []byte("#!/bin/sh\n"), 0o666))
|
||||||
|
require.NoError(t, os.Chmod(filepath.Join(source, "run.sh"), 0o666))
|
||||||
|
|
||||||
for _, testcase := range []struct {
|
for _, testcase := range []struct {
|
||||||
hostOS, serverOS string
|
hostOS, serverOS string
|
||||||
expected int64
|
expected int64
|
||||||
@@ -1117,9 +1142,6 @@ func TestContainerCopyModes(t *testing.T) {
|
|||||||
{"linux", "linux", 0o666},
|
{"linux", "linux", 0o666},
|
||||||
} {
|
} {
|
||||||
t.Run(testcase.hostOS+"-"+testcase.serverOS, func(t *testing.T) {
|
t.Run(testcase.hostOS+"-"+testcase.serverOS, func(t *testing.T) {
|
||||||
source := t.TempDir()
|
|
||||||
require.NoError(t, os.WriteFile(filepath.Join(source, "run.sh"), []byte("#!/bin/sh\n"), 0o666))
|
|
||||||
require.NoError(t, os.Chmod(filepath.Join(source, "run.sh"), 0o666))
|
|
||||||
file := &FileEntry{Name: "run.sh", Mode: 0o600, Body: "#!/bin/sh\n"}
|
file := &FileEntry{Name: "run.sh", Mode: 0o600, Body: "#!/bin/sh\n"}
|
||||||
original := hostOS
|
original := hostOS
|
||||||
hostOS = testcase.hostOS
|
hostOS = testcase.hostOS
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ package container
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"archive/tar"
|
"archive/tar"
|
||||||
"bytes"
|
|
||||||
"cmp"
|
"cmp"
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"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) {
|
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)
|
srcPath = filepath.Clean(srcPath)
|
||||||
fi, err := os.Lstat(srcPath)
|
fi, err := os.Lstat(srcPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
tc := &filecollector.TarCollector{
|
reader, writer := io.Pipe()
|
||||||
TarWriter: tw,
|
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() {
|
if fi.IsDir() {
|
||||||
srcPrefix := srcPath
|
srcPrefix := srcPath
|
||||||
if !strings.HasSuffix(srcPrefix, string(filepath.Separator)) {
|
if !strings.HasSuffix(srcPrefix, string(filepath.Separator)) {
|
||||||
@@ -138,31 +144,22 @@ func (e *HostEnvironment) GetContainerArchive(ctx context.Context, srcPath strin
|
|||||||
SrcPrefix: srcPrefix,
|
SrcPrefix: srcPrefix,
|
||||||
Handler: tc,
|
Handler: tc,
|
||||||
}
|
}
|
||||||
err = filepath.Walk(srcPath, fc.CollectFiles(ctx))
|
return filepath.Walk(srcPath, fc.CollectFiles(ctx))
|
||||||
if err != nil {
|
}
|
||||||
return nil, err
|
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 {
|
} else {
|
||||||
var f io.ReadCloser
|
if f, err = os.Open(srcPath); err != nil {
|
||||||
var linkname string
|
return err
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
defer f.Close()
|
||||||
}
|
}
|
||||||
return io.NopCloser(buf), nil
|
return tc.WriteFile(fi.Name(), fi, linkname, f)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *HostEnvironment) Pull(_ bool) common.Executor {
|
func (e *HostEnvironment) Pull(_ bool) common.Executor {
|
||||||
|
|||||||
@@ -32,6 +32,11 @@ func TestActionPlatformNames(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestCopyDir(t *testing.T) {
|
func TestCopyDir(t *testing.T) {
|
||||||
|
src := filepath.Join(t.TempDir(), "action")
|
||||||
|
require.NoError(t, os.MkdirAll(filepath.Join(src, ".git", "objects"), 0o700))
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(src, ".git", "objects", "pack"), []byte("x"), 0o600))
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(src, "action.yml"), []byte("name: x\n"), 0o600))
|
||||||
|
|
||||||
for _, test := range []struct {
|
for _, test := range []struct {
|
||||||
name string
|
name string
|
||||||
useGitIgnore, skipGitDir bool
|
useGitIgnore, skipGitDir bool
|
||||||
@@ -41,11 +46,6 @@ func TestCopyDir(t *testing.T) {
|
|||||||
{name: "unfiltered outside a git repository", useGitIgnore: true},
|
{name: "unfiltered outside a git repository", useGitIgnore: true},
|
||||||
} {
|
} {
|
||||||
t.Run(test.name, func(t *testing.T) {
|
t.Run(test.name, func(t *testing.T) {
|
||||||
src := filepath.Join(t.TempDir(), "action")
|
|
||||||
require.NoError(t, os.MkdirAll(filepath.Join(src, ".git", "objects"), 0o700))
|
|
||||||
require.NoError(t, os.WriteFile(filepath.Join(src, ".git", "objects", "pack"), []byte("x"), 0o600))
|
|
||||||
require.NoError(t, os.WriteFile(filepath.Join(src, "action.yml"), []byte("name: x\n"), 0o600))
|
|
||||||
|
|
||||||
dest := filepath.Join(t.TempDir(), "dest")
|
dest := filepath.Join(t.TempDir(), "dest")
|
||||||
require.NoError(t, (&HostEnvironment{}).CopyDir(dest, src+string(filepath.Separator), test.useGitIgnore, test.skipGitDir)(t.Context()))
|
require.NoError(t, (&HostEnvironment{}).CopyDir(dest, src+string(filepath.Separator), test.useGitIgnore, test.skipGitDir)(t.Context()))
|
||||||
|
|
||||||
@@ -76,7 +76,11 @@ func TestGetContainerArchive(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, expectedContent, content)
|
assert.Equal(t, expectedContent, content)
|
||||||
_, err = reader.Next()
|
_, 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) {
|
func TestHostEnvironmentExecExitCode(t *testing.T) {
|
||||||
|
|||||||
@@ -4,7 +4,6 @@
|
|||||||
package container
|
package container
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
|
||||||
"bytes"
|
"bytes"
|
||||||
"cmp"
|
"cmp"
|
||||||
"compress/gzip"
|
"compress/gzip"
|
||||||
@@ -573,15 +572,7 @@ func (c *kubernetesContainer) GetContainerArchive(ctx context.Context, srcPath s
|
|||||||
}
|
}
|
||||||
writer.CloseWithError(err)
|
writer.CloseWithError(err)
|
||||||
}()
|
}()
|
||||||
buffered := bufio.NewReader(reader)
|
return startedArchive(reader)
|
||||||
if _, err := buffered.Peek(1); err != nil {
|
|
||||||
reader.Close()
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return struct {
|
|
||||||
io.Reader
|
|
||||||
io.Closer
|
|
||||||
}{buffered, reader}, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *kubernetesContainer) UpdateFromEnv(srcPath string, env *map[string]string) common.Executor {
|
func (c *kubernetesContainer) UpdateFromEnv(srcPath string, env *map[string]string) common.Executor {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ package container
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
|
"bytes"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -15,14 +16,9 @@ import (
|
|||||||
"golang.org/x/text/encoding/unicode"
|
"golang.org/x/text/encoding/unicode"
|
||||||
)
|
)
|
||||||
|
|
||||||
func newTestHostEnv(t *testing.T) (*HostEnvironment, string) {
|
|
||||||
t.Helper()
|
|
||||||
e := &HostEnvironment{Path: t.TempDir()}
|
|
||||||
return e, filepath.Join(e.Path, "envfile")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseEnvFile(t *testing.T) {
|
func TestParseEnvFile(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
hostEnv := &HostEnvironment{Path: t.TempDir()}
|
||||||
largeValue := strings.Repeat("x", 2*1024*1024)
|
largeValue := strings.Repeat("x", 2*1024*1024)
|
||||||
encodeUTF16 := func(endianness unicode.Endianness) string {
|
encodeUTF16 := func(endianness unicode.Endianness) string {
|
||||||
content, err := unicode.UTF16(endianness, unicode.UseBOM).NewEncoder().Bytes([]byte("FOO=bar\r\nMULTI<<EOF\r\nline1\r\nEOF\r\n"))
|
content, err := unicode.UTF16(endianness, unicode.UseBOM).NewEncoder().Bytes([]byte("FOO=bar\r\nMULTI<<EOF\r\nline1\r\nEOF\r\n"))
|
||||||
@@ -51,15 +47,15 @@ func TestParseEnvFile(t *testing.T) {
|
|||||||
{"UTF16 big endian", encodeUTF16(unicode.BigEndian), map[string]string{"FOO": "bar\r", "MULTI": "line1\r"}},
|
{"UTF16 big endian", encodeUTF16(unicode.BigEndian), map[string]string{"FOO": "bar\r", "MULTI": "line1\r"}},
|
||||||
} {
|
} {
|
||||||
t.Run(test.name, func(t *testing.T) {
|
t.Run(test.name, func(t *testing.T) {
|
||||||
e, envPath := newTestHostEnv(t)
|
envPath := filepath.Join(hostEnv.Path, test.name)
|
||||||
require.NoError(t, os.WriteFile(envPath, []byte(test.content), 0o600))
|
require.NoError(t, os.WriteFile(envPath, []byte(test.content), 0o600))
|
||||||
env := map[string]string{}
|
env := map[string]string{}
|
||||||
require.NoError(t, parseEnvFile(e, envPath, &env, false)(t.Context()))
|
require.NoError(t, parseEnvFile(hostEnv, envPath, &env, false)(t.Context()))
|
||||||
assert.Equal(t, test.want, env)
|
assert.Equal(t, test.want, env)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
t.Run("ordered entries preserve repeated names", func(t *testing.T) {
|
t.Run("ordered entries preserve repeated names", func(t *testing.T) {
|
||||||
hostEnv, envPath := newTestHostEnv(t)
|
envPath := filepath.Join(hostEnv.Path, "ordered")
|
||||||
require.NoError(t, os.WriteFile(envPath, []byte("FOO=first\r\nfoo<<EOF\r\nmiddle\r\nEOF\r\nFOO=last\r\n"), 0o600))
|
require.NoError(t, os.WriteFile(envPath, []byte("FOO=first\r\nfoo<<EOF\r\nmiddle\r\nEOF\r\nFOO=last\r\n"), 0o600))
|
||||||
var entries [][2]string
|
var entries [][2]string
|
||||||
require.NoError(t, ParseEnvFile(hostEnv, envPath, func(name, value string) {
|
require.NoError(t, ParseEnvFile(hostEnv, envPath, func(name, value string) {
|
||||||
@@ -70,7 +66,7 @@ func TestParseEnvFile(t *testing.T) {
|
|||||||
}, entries)
|
}, entries)
|
||||||
})
|
})
|
||||||
t.Run("Windows line endings", func(t *testing.T) {
|
t.Run("Windows line endings", func(t *testing.T) {
|
||||||
hostEnv, envPath := newTestHostEnv(t)
|
envPath := filepath.Join(hostEnv.Path, "windows")
|
||||||
require.NoError(t, os.WriteFile(envPath, []byte("FOO=bar\r\nMULTI<<EOF\r\nline1\r\nline2\nEOF\r\n"), 0o600))
|
require.NoError(t, os.WriteFile(envPath, []byte("FOO=bar\r\nMULTI<<EOF\r\nline1\r\nline2\nEOF\r\n"), 0o600))
|
||||||
env := map[string]string{}
|
env := map[string]string{}
|
||||||
require.NoError(t, parseEnvFile(hostEnv, envPath, &env, true)(t.Context()))
|
require.NoError(t, parseEnvFile(hostEnv, envPath, &env, true)(t.Context()))
|
||||||
@@ -80,16 +76,22 @@ func TestParseEnvFile(t *testing.T) {
|
|||||||
|
|
||||||
func TestParseEnvFileLineExceedsBufferReportsScannerError(t *testing.T) {
|
func TestParseEnvFileLineExceedsBufferReportsScannerError(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
e, envPath := newTestHostEnv(t)
|
hostEnv := &HostEnvironment{Path: t.TempDir()}
|
||||||
require.NoError(t, os.WriteFile(envPath, []byte("FOO<<EOF\n"+strings.Repeat("x", 17*1024*1024)+"\nEOF\n"), 0o600))
|
envPath := filepath.Join(hostEnv.Path, "envfile")
|
||||||
|
const header, footer = "FOO<<EOF\n", "\nEOF\n"
|
||||||
|
content := bytes.Repeat([]byte("x"), len(header)+17*1024*1024+len(footer))
|
||||||
|
copy(content, header)
|
||||||
|
copy(content[len(content)-len(footer):], footer)
|
||||||
|
require.NoError(t, os.WriteFile(envPath, content, 0o600))
|
||||||
|
|
||||||
env := map[string]string{}
|
env := map[string]string{}
|
||||||
err := parseEnvFile(e, envPath, &env, false)(t.Context())
|
err := parseEnvFile(hostEnv, envPath, &env, false)(t.Context())
|
||||||
require.ErrorIs(t, err, bufio.ErrTooLong)
|
require.ErrorIs(t, err, bufio.ErrTooLong)
|
||||||
assert.Contains(t, err.Error(), "reading env file")
|
assert.Contains(t, err.Error(), "reading env file")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestParseEnvFileInvalidHeredoc(t *testing.T) {
|
func TestParseEnvFileInvalidHeredoc(t *testing.T) {
|
||||||
|
hostEnv := &HostEnvironment{Path: t.TempDir()}
|
||||||
for name, content := range map[string]string{
|
for name, content := range map[string]string{
|
||||||
"missing delimiter": "FOO<<EOF\nline1\nline2\n",
|
"missing delimiter": "FOO<<EOF\nline1\nline2\n",
|
||||||
"empty name": "<<EOF\nline1\nEOF\n",
|
"empty name": "<<EOF\nline1\nEOF\n",
|
||||||
@@ -97,11 +99,11 @@ func TestParseEnvFileInvalidHeredoc(t *testing.T) {
|
|||||||
"delimiter with carriage return": "FOO<<EOF\nline1\nEOF\r\n",
|
"delimiter with carriage return": "FOO<<EOF\nline1\nEOF\r\n",
|
||||||
} {
|
} {
|
||||||
t.Run(name, func(t *testing.T) {
|
t.Run(name, func(t *testing.T) {
|
||||||
e, envPath := newTestHostEnv(t)
|
envPath := filepath.Join(hostEnv.Path, name)
|
||||||
require.NoError(t, os.WriteFile(envPath, []byte("APPLIED=before-error\n"+content), 0o600))
|
require.NoError(t, os.WriteFile(envPath, []byte("APPLIED=before-error\n"+content), 0o600))
|
||||||
|
|
||||||
env := map[string]string{}
|
env := map[string]string{}
|
||||||
err := parseEnvFile(e, envPath, &env, false)(t.Context())
|
err := parseEnvFile(hostEnv, envPath, &env, false)(t.Context())
|
||||||
require.Error(t, err)
|
require.Error(t, err)
|
||||||
assert.Contains(t, err.Error(), "delimiter")
|
assert.Contains(t, err.Error(), "delimiter")
|
||||||
assert.Equal(t, map[string]string{"APPLIED": "before-error"}, env)
|
assert.Equal(t, map[string]string{"APPLIED": "before-error"}, env)
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ package container
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"archive/tar"
|
"archive/tar"
|
||||||
|
"bufio"
|
||||||
"context"
|
"context"
|
||||||
"io"
|
"io"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -15,6 +16,19 @@ import (
|
|||||||
"gitea.com/gitea/runner/act/filecollector"
|
"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 {
|
func writeFilesTar(ctx context.Context, w io.Writer, uid, gid int, files ...*FileEntry) error {
|
||||||
tw := tar.NewWriter(w)
|
tw := tar.NewWriter(w)
|
||||||
for _, file := range files {
|
for _, file := range files {
|
||||||
|
|||||||
@@ -28,10 +28,12 @@ func gitCmd(t *testing.T, args ...string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestCollectFilesKeepsWhatGitTracksOrDoesNotIgnore(t *testing.T) {
|
func TestCollectFilesKeepsWhatGitTracksOrDoesNotIgnore(t *testing.T) {
|
||||||
repoDir := filepath.Join(t.TempDir(), "mygitrepo")
|
repoDir := t.TempDir()
|
||||||
for _, dir := range []string{repoDir, filepath.Join(repoDir, "sub"), filepath.Join(repoDir, "nested")} {
|
gitCmd(t, "init", "-q", "--template=", repoDir)
|
||||||
gitCmd(t, "init", "-q", dir)
|
for _, dir := range []string{"sub", "nested"} {
|
||||||
|
require.NoError(t, os.CopyFS(filepath.Join(repoDir, dir, ".git"), os.DirFS(filepath.Join(repoDir, ".git"))))
|
||||||
}
|
}
|
||||||
|
require.NoError(t, os.Mkdir(filepath.Join(repoDir, "ign"), 0o755))
|
||||||
for name, body := range map[string]string{
|
for name, body := range map[string]string{
|
||||||
".gitignore": ".*\nign/\n",
|
".gitignore": ".*\nign/\n",
|
||||||
".env": "",
|
".env": "",
|
||||||
@@ -43,15 +45,15 @@ func TestCollectFilesKeepsWhatGitTracksOrDoesNotIgnore(t *testing.T) {
|
|||||||
"sub/kept": "",
|
"sub/kept": "",
|
||||||
"nested/kept": "",
|
"nested/kept": "",
|
||||||
} {
|
} {
|
||||||
require.NoError(t, os.MkdirAll(filepath.Dir(filepath.Join(repoDir, name)), 0o755))
|
|
||||||
require.NoError(t, os.WriteFile(filepath.Join(repoDir, name), []byte(body), 0o644))
|
require.NoError(t, os.WriteFile(filepath.Join(repoDir, name), []byte(body), 0o644))
|
||||||
}
|
}
|
||||||
require.NoError(t, os.Mkdir(filepath.Join(repoDir, "uninitialized"), 0o755))
|
require.NoError(t, os.Mkdir(filepath.Join(repoDir, "uninitialized"), 0o755))
|
||||||
gitCmd(t, "-C", repoDir, "add", "-f", ".gitignore", "Renamed", "ign/tracked")
|
gitCmd(t, "-C", repoDir, "add", "-f", ".gitignore", "Renamed", "ign/tracked")
|
||||||
require.NoError(t, os.Rename(filepath.Join(repoDir, "Renamed"), filepath.Join(repoDir, "renamed")))
|
require.NoError(t, os.Rename(filepath.Join(repoDir, "Renamed"), filepath.Join(repoDir, "renamed")))
|
||||||
for _, gitlink := range []string{"sub", "uninitialized"} {
|
gitCmd(t, "-C", repoDir, "update-index", "--add",
|
||||||
gitCmd(t, "-C", repoDir, "update-index", "--add", "--cacheinfo", "160000,4b825dc642cb6eb9a060e54bf8d69288fbee4904,"+gitlink)
|
"--cacheinfo", "160000,4b825dc642cb6eb9a060e54bf8d69288fbee4904,sub",
|
||||||
}
|
"--cacheinfo", "160000,4b825dc642cb6eb9a060e54bf8d69288fbee4904,uninitialized",
|
||||||
|
)
|
||||||
expected := map[string]string{".gitignore": "", "ign/tracked": "", "nested/kept": "", "renamed": "", "sub/.gitignore": "", "sub/kept": ""}
|
expected := map[string]string{".gitignore": "", "ign/tracked": "", "nested/kept": "", "renamed": "", "sub/.gitignore": "", "sub/kept": ""}
|
||||||
if runtime.GOOS != "windows" {
|
if runtime.GOOS != "windows" {
|
||||||
require.NoError(t, os.Chmod(filepath.Join(repoDir, "renamed"), 0o777|os.ModeSticky))
|
require.NoError(t, os.Chmod(filepath.Join(repoDir, "renamed"), 0o777|os.ModeSticky))
|
||||||
|
|||||||
@@ -472,7 +472,7 @@ func newStepContainer(ctx context.Context, step step, image string, cmd, entrypo
|
|||||||
|
|
||||||
envList = append(envList, rc.runnerEnv(ctx)...)
|
envList = append(envList, rc.runnerEnv(ctx)...)
|
||||||
|
|
||||||
binds, mounts := rc.GetBindsAndMounts(ctx)
|
binds, mounts, _ := rc.GetBindsAndMounts(ctx)
|
||||||
networkMode := "container:" + rc.jobContainerName()
|
networkMode := "container:" + rc.jobContainerName()
|
||||||
if rc.IsHostEnv() {
|
if rc.IsHostEnv() {
|
||||||
networkMode = "default"
|
networkMode = "default"
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ func TestCompositeActionParity(t *testing.T) {
|
|||||||
return "", "", assert.AnError
|
return "", "", assert.AnError
|
||||||
})
|
})
|
||||||
rc := &RunContext{
|
rc := &RunContext{
|
||||||
Config: &Config{GitHubInstance: "github.com", ActionCacheDir: t.TempDir(), PresetGitHubContext: &model.GithubContext{}},
|
Config: &Config{GitHubInstance: "github.com", PresetGitHubContext: &model.GithubContext{}},
|
||||||
Run: &model.Run{JobID: "job", Workflow: &model.Workflow{Jobs: map[string]*model.Job{"job": {}}}},
|
Run: &model.Run{JobID: "job", Workflow: &model.Workflow{Jobs: map[string]*model.Job{"job": {}}}},
|
||||||
JobContainer: &jobContainerMock{},
|
JobContainer: &jobContainerMock{},
|
||||||
}
|
}
|
||||||
|
|||||||
+17
-13
@@ -249,6 +249,7 @@ func TestActionRunner(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestGoActionUsesLinuxExecutablePaths(t *testing.T) {
|
func TestGoActionUsesLinuxExecutablePaths(t *testing.T) {
|
||||||
|
actionRoot := t.TempDir()
|
||||||
for _, stage := range []string{"main", "pre", "post"} {
|
for _, stage := range []string{"main", "pre", "post"} {
|
||||||
t.Run(stage, func(t *testing.T) {
|
t.Run(stage, func(t *testing.T) {
|
||||||
cm := &containerMock{}
|
cm := &containerMock{}
|
||||||
@@ -264,7 +265,7 @@ func TestGoActionUsesLinuxExecutablePaths(t *testing.T) {
|
|||||||
JobContainer: cm,
|
JobContainer: cm,
|
||||||
},
|
},
|
||||||
action: &model.Action{Runs: model.ActionRuns{Using: model.ActionRunsUsingGo, Main: "main.go", Pre: "pre.go", Post: "post.go"}},
|
action: &model.Action{Runs: model.ActionRuns{Using: model.ActionRunsUsingGo, Main: "main.go", Pre: "pre.go", Post: "post.go"}},
|
||||||
actionDir: t.TempDir(),
|
actionDir: filepath.Join(actionRoot, stage),
|
||||||
env: map[string]string{},
|
env: map[string]string{},
|
||||||
remoteAction: mustNewRemoteAction(t, "org/repo@ref"),
|
remoteAction: mustNewRemoteAction(t, "org/repo@ref"),
|
||||||
}
|
}
|
||||||
@@ -504,11 +505,21 @@ func TestExecAsDockerHoldsCloneLockForRemoteUncached(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestExecAsDockerImageChecks(t *testing.T) {
|
func TestExecAsDockerImageChecks(t *testing.T) {
|
||||||
|
actionRoot := t.TempDir()
|
||||||
originalExists, originalBuild, originalContainer := ContainerImageExistsLocally, ContainerNewDockerBuildExecutor, ContainerNewContainer
|
originalExists, originalBuild, originalContainer := ContainerImageExistsLocally, ContainerNewDockerBuildExecutor, ContainerNewContainer
|
||||||
t.Cleanup(func() {
|
t.Cleanup(func() {
|
||||||
ContainerImageExistsLocally, ContainerNewDockerBuildExecutor, ContainerNewContainer = originalExists, originalBuild, originalContainer
|
ContainerImageExistsLocally, ContainerNewDockerBuildExecutor, ContainerNewContainer = originalExists, originalBuild, originalContainer
|
||||||
})
|
})
|
||||||
ContainerNewContainer = func(*container.NewContainerInput) container.ExecutionsEnvironment { return fakeContainer{} }
|
ContainerNewContainer = func(*container.NewContainerInput) container.ExecutionsEnvironment { return fakeContainer{} }
|
||||||
|
var removed bool
|
||||||
|
fakeDockerDaemon(t, func(writer http.ResponseWriter, request *http.Request) {
|
||||||
|
if request.Method == http.MethodDelete {
|
||||||
|
removed = true
|
||||||
|
_, _ = io.WriteString(writer, `[]`)
|
||||||
|
} else {
|
||||||
|
_, _ = io.WriteString(writer, `{"Architecture":"aarch64"}`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
for _, testcase := range []struct {
|
for _, testcase := range []struct {
|
||||||
name, platform string
|
name, platform string
|
||||||
@@ -529,18 +540,11 @@ func TestExecAsDockerImageChecks(t *testing.T) {
|
|||||||
checks = append(checks, platform)
|
checks = append(checks, platform)
|
||||||
return testcase.exists && (platform == "any" || testcase.matches), nil
|
return testcase.exists && (platform == "any" || testcase.matches), nil
|
||||||
}
|
}
|
||||||
var built, removed bool
|
var built bool
|
||||||
|
removed = false
|
||||||
ContainerNewDockerBuildExecutor = func(container.NewDockerBuildExecutorInput) common.Executor {
|
ContainerNewDockerBuildExecutor = func(container.NewDockerBuildExecutorInput) common.Executor {
|
||||||
return func(context.Context) error { built = true; return nil }
|
return func(context.Context) error { built = true; return nil }
|
||||||
}
|
}
|
||||||
fakeDockerDaemon(t, func(writer http.ResponseWriter, request *http.Request) {
|
|
||||||
if request.Method == http.MethodDelete {
|
|
||||||
removed = true
|
|
||||||
_, _ = io.WriteString(writer, `[]`)
|
|
||||||
} else {
|
|
||||||
_, _ = io.WriteString(writer, `{"Architecture":"aarch64"}`)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
step := &stepActionRemote{
|
step := &stepActionRemote{
|
||||||
Step: &model.Step{ID: "action"},
|
Step: &model.Step{ID: "action"},
|
||||||
RunContext: &RunContext{
|
RunContext: &RunContext{
|
||||||
@@ -555,7 +559,7 @@ func TestExecAsDockerImageChecks(t *testing.T) {
|
|||||||
action: &model.Action{Runs: model.ActionRuns{Using: "docker", Image: "Dockerfile"}},
|
action: &model.Action{Runs: model.ActionRuns{Using: "docker", Image: "Dockerfile"}},
|
||||||
env: map[string]string{},
|
env: map[string]string{},
|
||||||
}
|
}
|
||||||
dir := t.TempDir()
|
dir := filepath.Join(actionRoot, testcase.name)
|
||||||
require.NoError(t, execAsDocker(t.Context(), step, "action", dir, dir, false, stepStageMain))
|
require.NoError(t, execAsDocker(t.Context(), step, "action", dir, dir, false, stepStageMain))
|
||||||
assert.Equal(t, testcase.wantChecks, checks)
|
assert.Equal(t, testcase.wantChecks, checks)
|
||||||
assert.Equal(t, testcase.wantBuild, built)
|
assert.Equal(t, testcase.wantBuild, built)
|
||||||
@@ -727,9 +731,9 @@ func TestExecAsDockerStageEntrypoint(t *testing.T) {
|
|||||||
cm.On("Close").Return(func(context.Context) error { return nil })
|
cm.On("Close").Return(func(context.Context) error { return nil })
|
||||||
|
|
||||||
if tc.stage == stepStageMain {
|
if tc.stage == stepStageMain {
|
||||||
require.NoError(t, runActionImpl(step, t.TempDir(), step.remoteAction)(ctx))
|
require.NoError(t, runActionImpl(step, "", step.remoteAction)(ctx))
|
||||||
} else {
|
} else {
|
||||||
require.NoError(t, execAsDocker(ctx, step, "action", t.TempDir(), t.TempDir(), false, tc.stage))
|
require.NoError(t, execAsDocker(ctx, step, "action", "", "", false, tc.stage))
|
||||||
}
|
}
|
||||||
if tc.warning == "" {
|
if tc.warning == "" {
|
||||||
assert.Empty(t, hook.AllRecords())
|
assert.Empty(t, hook.AllRecords())
|
||||||
|
|||||||
@@ -118,7 +118,11 @@ func (rc *RunContext) commandHandler(ctx context.Context) common.LineHandler {
|
|||||||
rawLogger.Infof("%s", formatAnnotation(command, kvPairs, arg))
|
rawLogger.Infof("%s", formatAnnotation(command, kvPairs, arg))
|
||||||
return false
|
return false
|
||||||
case "group", "endgroup":
|
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
|
return echoed
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -193,6 +193,11 @@ func TestCommandLogWriter(t *testing.T) {
|
|||||||
log []string
|
log []string
|
||||||
masks []string
|
masks []string
|
||||||
}{
|
}{
|
||||||
|
{
|
||||||
|
name: "mask commands decode modern and legacy escapes once",
|
||||||
|
steps: [][]string{{"::add-mask::%25", "##[add-mask]alpha beta%3Bgamma%5Ddelta%250A"}},
|
||||||
|
masks: []string{"%", "alpha beta;gamma]delta%0A"},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "applied commands are hidden unless echoed",
|
name: "applied commands are hidden unless echoed",
|
||||||
steps: [][]string{{
|
steps: [][]string{{
|
||||||
@@ -235,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]",
|
"::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",
|
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"}},
|
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", "::RESUME-TOKEN::"},
|
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"},
|
masks: []string{"resume-token", "masked"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -82,3 +82,8 @@ func (cm *containerMock) Inspect(ctx context.Context) (*container.Info, error) {
|
|||||||
err, _ := args.Get(1).(error)
|
err, _ := args.Get(1).(error)
|
||||||
return info, err
|
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)
|
||||||
|
}
|
||||||
@@ -49,6 +49,7 @@ func TestDockerProxyMountedJob(t *testing.T) {
|
|||||||
ContainerNamePrefix: resourceName,
|
ContainerNamePrefix: resourceName,
|
||||||
ContainerDaemonSocket: dockerClient.DaemonHost(),
|
ContainerDaemonSocket: dockerClient.DaemonHost(),
|
||||||
ContainerMaxLifetime: 2 * time.Minute,
|
ContainerMaxLifetime: 2 * time.Minute,
|
||||||
|
Sweep: true,
|
||||||
ContainerOptions: "-v /usr/local/bin/docker:/usr/local/bin/docker:ro -v /usr/local/libexec/docker/cli-plugins:/usr/local/libexec/docker/cli-plugins:ro",
|
ContainerOptions: "-v /usr/local/bin/docker:/usr/local/bin/docker:ro -v /usr/local/libexec/docker/cli-plugins:/usr/local/libexec/docker/cli-plugins:ro",
|
||||||
ValidVolumes: []string{"/usr/local/bin/docker", "/usr/local/libexec/docker/cli-plugins"},
|
ValidVolumes: []string{"/usr/local/bin/docker", "/usr/local/libexec/docker/cli-plugins"},
|
||||||
Env: map[string]string{"PROXY_TEST_RESOURCE": resourceName, "PROXY_TEST_MODE": mode, "PROXY_TEST_IMAGE": baseImage, "COMPOSE_PROJECT_NAME": resourceName},
|
Env: map[string]string{"PROXY_TEST_RESOURCE": resourceName, "PROXY_TEST_MODE": mode, "PROXY_TEST_IMAGE": baseImage, "COMPOSE_PROJECT_NAME": resourceName},
|
||||||
|
|||||||
@@ -136,7 +136,7 @@ func getHashFilesFunction(ctx context.Context, rc *RunContext) func(v []reflect.
|
|||||||
followSymlink := false
|
followSymlink := false
|
||||||
|
|
||||||
for i, p := range v {
|
for i, p := range v {
|
||||||
s := p.String()
|
s := exprparser.CoerceToString(p)
|
||||||
if i == 0 && strings.HasPrefix(s, "--") {
|
if i == 0 && strings.HasPrefix(s, "--") {
|
||||||
if strings.EqualFold(s, "--follow-symbolic-links") {
|
if strings.EqualFold(s, "--follow-symbolic-links") {
|
||||||
followSymlink = true
|
followSymlink = true
|
||||||
|
|||||||
@@ -178,7 +178,7 @@ func (c *hashFilesContainer) ReplaceLogWriter(stdout, stderr io.Writer) (io.Writ
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestGetHashFilesFunction(t *testing.T) {
|
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 {
|
for _, test := range []struct {
|
||||||
name string
|
name string
|
||||||
output 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: "success", output: "patterns __OUTPUT__wrong__OUTPUT__ echoed\n__OUTPUT__digest__OUTPUT__", hash: "digest"},
|
||||||
{name: "no matches", output: "__OUTPUT____OUTPUT__"},
|
{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: "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: "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 missing", execErr: fmt.Errorf("wrapped: %w", container.ExitCodeError(127)), warning: true},
|
||||||
{name: "node not executable", execErr: container.ExitCodeError(126), 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 := &hashFilesContainer{stdout: stdout, stderr: stderr, copyErr: test.copyErr}
|
||||||
jobContainer.execute = func(command []string, env map[string]string, user, workdir string) common.Executor {
|
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, []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, user)
|
||||||
assert.Empty(t, workdir)
|
assert.Empty(t, workdir)
|
||||||
return func(_ctx context.Context) error {
|
return func(_ctx context.Context) error {
|
||||||
@@ -223,7 +223,7 @@ func TestGetHashFilesFunction(t *testing.T) {
|
|||||||
result, err := getHashFilesFunction(ctx, &RunContext{
|
result, err := getHashFilesFunction(ctx, &RunContext{
|
||||||
Config: &Config{Workdir: "/workspace"},
|
Config: &Config{Workdir: "/workspace"},
|
||||||
JobContainer: jobContainer,
|
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 == "" {
|
if test.err == "" {
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
} else {
|
} else {
|
||||||
@@ -412,11 +412,10 @@ func TestGetEvaluatorInputsBoolean(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for _, eventName := range []string{"workflow_call", "workflow_dispatch"} {
|
for _, eventName := range []string{"workflow_call", "workflow_dispatch"} {
|
||||||
|
wf, err := model.ReadWorkflow(strings.NewReader(workflow(eventName)))
|
||||||
|
require.NoError(t, err)
|
||||||
for _, table := range tables {
|
for _, table := range tables {
|
||||||
t.Run(eventName+"/"+table.name, func(t *testing.T) {
|
t.Run(eventName+"/"+table.name, func(t *testing.T) {
|
||||||
wf, err := model.ReadWorkflow(strings.NewReader(workflow(eventName)))
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
rc := &RunContext{
|
rc := &RunContext{
|
||||||
Config: &Config{Workdir: "."},
|
Config: &Config{Workdir: "."},
|
||||||
Run: &model.Run{JobID: "job1", Workflow: wf},
|
Run: &model.Run{JobID: "job1", Workflow: wf},
|
||||||
|
|||||||
@@ -993,6 +993,7 @@ func tarArchive(t *testing.T, entries ...tarEntry) []byte {
|
|||||||
buf := &bytes.Buffer{}
|
buf := &bytes.Buffer{}
|
||||||
tw := tar.NewWriter(buf)
|
tw := tar.NewWriter(buf)
|
||||||
for _, entry := range entries {
|
for _, entry := range entries {
|
||||||
|
buf.Grow(len(entry.body) + 3*512)
|
||||||
typeflag := entry.typeflag
|
typeflag := entry.typeflag
|
||||||
if typeflag == 0 {
|
if typeflag == 0 {
|
||||||
typeflag = tar.TypeReg
|
typeflag = tar.TypeReg
|
||||||
@@ -1009,7 +1010,7 @@ func tarArchive(t *testing.T, entries ...tarEntry) []byte {
|
|||||||
}
|
}
|
||||||
require.NoError(t, tw.WriteHeader(header))
|
require.NoError(t, tw.WriteHeader(header))
|
||||||
if typeflag == tar.TypeReg {
|
if typeflag == tar.TypeReg {
|
||||||
_, err := tw.Write([]byte(entry.body))
|
_, err := io.CopyN(tw, strings.NewReader(entry.body), int64(len(entry.body)))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1149,12 +1150,12 @@ func TestTryUploadJobSummaryMasksSecrets(t *testing.T) {
|
|||||||
}))
|
}))
|
||||||
defer server.Close()
|
defer server.Close()
|
||||||
|
|
||||||
content, err := testcase.encoding.NewEncoder().Bytes([]byte(testcase.content))
|
content, err := testcase.encoding.NewEncoder().String(testcase.content)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
cm := &containerMock{}
|
cm := &containerMock{}
|
||||||
cm.On("GetContainerArchive", mock.Anything, "/var/run/act/workflow/step-summary-0.md").Return(
|
cm.On("GetContainerArchive", mock.Anything, "/var/run/act/workflow/step-summary-0.md").Return(
|
||||||
io.NopCloser(bytes.NewReader(tarArchive(t, tarEntry{
|
io.NopCloser(bytes.NewReader(tarArchive(t, tarEntry{
|
||||||
name: "step-summary-0.md", body: string(content),
|
name: "step-summary-0.md", body: content,
|
||||||
}))),
|
}))),
|
||||||
nil,
|
nil,
|
||||||
).Once()
|
).Once()
|
||||||
|
|||||||
+37
-9
@@ -263,29 +263,27 @@ func AppendSecretMaskers(oldnew []string, secrets map[string]string) []string {
|
|||||||
return oldnew
|
return oldnew
|
||||||
}
|
}
|
||||||
|
|
||||||
// AppendSecretMasker registers v and each of its lines, as GitHub does.
|
// AppendSecretMasker skips one-byte line fragments to avoid masking punctuation from structured secrets.
|
||||||
func AppendSecretMasker(oldnew []string, v string) []string {
|
func AppendSecretMasker(oldnew []string, v string) []string {
|
||||||
ret := appendMaskedValue(oldnew, v)
|
ret := appendMaskedValue(oldnew, v)
|
||||||
for l := range strings.FieldsFuncSeq(v, func(r rune) bool { return r == '\r' || r == '\n' }) {
|
for l := range strings.FieldsFuncSeq(v, func(r rune) bool { return r == '\r' || r == '\n' }) {
|
||||||
ret = appendMaskedValue(ret, strings.TrimSpace(l))
|
if line := strings.TrimSpace(l); len(line) > 1 {
|
||||||
|
ret = appendMaskedValue(ret, line)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return ret
|
return ret
|
||||||
}
|
}
|
||||||
|
|
||||||
// appendMaskedValue registers one value and every shape it takes on its way into a log.
|
|
||||||
func appendMaskedValue(ret []string, v string) []string {
|
func appendMaskedValue(ret []string, v string) []string {
|
||||||
// formatted JSON secrets could otherwise mask {,[,],} everywhere
|
if strings.TrimSpace(v) == "" || slices.Contains(ret, v) {
|
||||||
if len(strings.TrimSpace(v)) <= 1 || slices.Contains(ret, v) {
|
|
||||||
return ret
|
return ret
|
||||||
}
|
}
|
||||||
ret = append(ret, v, "***")
|
ret = append(ret, v, "***")
|
||||||
// command data reaches the log escaped, so "pass%word" also arrives as "pass%25word"
|
|
||||||
if strings.ContainsAny(v, "%\r\n") {
|
if strings.ContainsAny(v, "%\r\n") {
|
||||||
ret = append(ret, EscapeCommandData(v), "***")
|
ret = append(ret, EscapeCommandData(v), "***")
|
||||||
}
|
}
|
||||||
for _, encode := range secretValueEncoders {
|
for _, encode := range secretValueEncoders {
|
||||||
encoded := encode(v)
|
encoded := encode(v)
|
||||||
// An encoding that leaves the value unchanged is already masked above.
|
|
||||||
if encoded == v || len(encoded) <= 1 || slices.Contains(ret, encoded) {
|
if encoded == v || len(encoded) <= 1 || slices.Contains(ret, encoded) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -319,7 +317,7 @@ func valueMasker(insecureSecrets bool, oldnew []string) entryProcessor {
|
|||||||
masks := Masks(ctx)
|
masks := Masks(ctx)
|
||||||
|
|
||||||
if len(*masks) == 0 {
|
if len(*masks) == 0 {
|
||||||
r.Message = defReplacer.Replace(r.Message)
|
r.Message = MaskLog(r.Message, defReplacer)
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -339,12 +337,42 @@ func valueMasker(insecureSecrets bool, oldnew []string) entryProcessor {
|
|||||||
cmasker := replacer
|
cmasker := replacer
|
||||||
mu.Unlock()
|
mu.Unlock()
|
||||||
|
|
||||||
r.Message = cmasker.Replace(r.Message)
|
r.Message = MaskLog(r.Message, cmasker)
|
||||||
|
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MaskLog decodes command data before masking to cover partially escaped values.
|
||||||
|
func MaskLog(message string, replacer *strings.Replacer) string {
|
||||||
|
var command, data string
|
||||||
|
var found bool
|
||||||
|
unescape := UnescapeCommandData
|
||||||
|
if strings.HasPrefix(message, "##[") {
|
||||||
|
command, data, found = strings.Cut(message[3:], "]")
|
||||||
|
unescape = UnescapeLegacyCommand
|
||||||
|
} else if strings.HasPrefix(message, "::") {
|
||||||
|
command, data, found = strings.Cut(message[2:], "::")
|
||||||
|
if name, _, properties := strings.Cut(command, " "); properties {
|
||||||
|
switch name {
|
||||||
|
case "error", "warning", "notice", "debug":
|
||||||
|
command = name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
switch command {
|
||||||
|
case "error", "warning", "notice", "debug", "group", "endgroup", "add-matcher":
|
||||||
|
if found {
|
||||||
|
trimmedData := strings.TrimRight(data, "\r\n")
|
||||||
|
normalized := message[:len(message)-len(data)] + EscapeCommandData(unescape(trimmedData)) + data[len(trimmedData):]
|
||||||
|
if masked := replacer.Replace(normalized); masked != normalized {
|
||||||
|
return masked
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return replacer.Replace(message)
|
||||||
|
}
|
||||||
|
|
||||||
type maskedHandler struct {
|
type maskedHandler struct {
|
||||||
logging.Handler
|
logging.Handler
|
||||||
masker entryProcessor
|
masker entryProcessor
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ func TestValueMasker(t *testing.T) {
|
|||||||
secrets map[string]string
|
secrets map[string]string
|
||||||
masks []string
|
masks []string
|
||||||
disallowed []string
|
disallowed []string
|
||||||
|
want string
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "Multiline Private Key",
|
name: "Multiline Private Key",
|
||||||
@@ -49,6 +50,60 @@ func TestValueMasker(t *testing.T) {
|
|||||||
secrets: map[string]string{"TOKEN": "pass%word"},
|
secrets: map[string]string{"TOKEN": "pass%word"},
|
||||||
disallowed: []string{"pass%25word"},
|
disallowed: []string{"pass%25word"},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "one-character mask",
|
||||||
|
lines: "%",
|
||||||
|
masks: []string{"%"},
|
||||||
|
want: "***",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "fully escaped legacy secret",
|
||||||
|
lines: "##[group]alpha beta%3Bgamma%5Ddelta",
|
||||||
|
secrets: map[string]string{"TOKEN": "alpha beta;gamma]delta"},
|
||||||
|
want: "##[group]***",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "partially escaped legacy mask",
|
||||||
|
lines: "##[group]alpha beta%3Bgamma]delta",
|
||||||
|
masks: []string{"alpha beta;gamma]delta"},
|
||||||
|
want: "##[group]***",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "partially escaped modern mask preserves unrelated escapes",
|
||||||
|
lines: "::group::alpha%25beta%gamma tail%250A%0A",
|
||||||
|
masks: []string{"alpha%beta%gamma"},
|
||||||
|
want: "::group::*** tail%250A%0A",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "command escapes are decoded once",
|
||||||
|
lines: "##[group]alpha%253Bbeta%5Dgamma",
|
||||||
|
masks: []string{"alpha;beta]gamma"},
|
||||||
|
want: "##[group]alpha%253Bbeta%5Dgamma",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "plain output preserves escapes",
|
||||||
|
lines: "alpha beta%3Bgamma%5Ddelta",
|
||||||
|
masks: []string{"alpha beta;gamma]delta"},
|
||||||
|
want: "alpha beta%3Bgamma%5Ddelta",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "command display preserves literal escapes around a mask",
|
||||||
|
lines: "##[command]alpha tail%3Bvalue%5D",
|
||||||
|
masks: []string{"alpha"},
|
||||||
|
want: "##[command]*** tail%3Bvalue%5D",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unknown modern commands preserve literal escapes",
|
||||||
|
lines: "::unknown::alpha tail%25value",
|
||||||
|
masks: []string{"alpha"},
|
||||||
|
want: "::unknown::*** tail%25value",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "masks can span command headers and data",
|
||||||
|
lines: "::group::hidden other",
|
||||||
|
masks: []string{"group::hidden", "other"},
|
||||||
|
want: "::*** ***",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
for _, entry := range table {
|
for _, entry := range table {
|
||||||
t.Run(entry.name, func(t *testing.T) {
|
t.Run(entry.name, func(t *testing.T) {
|
||||||
@@ -56,6 +111,9 @@ func TestValueMasker(t *testing.T) {
|
|||||||
masker := valueMasker(false, AppendSecretMaskers(nil, entry.secrets))
|
masker := valueMasker(false, AppendSecretMaskers(nil, entry.secrets))
|
||||||
for line := range strings.SplitSeq(entry.lines, "\n") {
|
for line := range strings.SplitSeq(entry.lines, "\n") {
|
||||||
lentry := masker(ctx, logging.Record{Message: line})
|
lentry := masker(ctx, logging.Record{Message: line})
|
||||||
|
if entry.want != "" {
|
||||||
|
assert.Equal(t, entry.want, lentry.Message)
|
||||||
|
}
|
||||||
for _, line := range entry.disallowed {
|
for _, line := range entry.disallowed {
|
||||||
assert.NotContains(t, lentry.Message, line)
|
assert.NotContains(t, lentry.Message, line)
|
||||||
}
|
}
|
||||||
@@ -139,6 +197,7 @@ func TestValueMaskerEncodedMasks(t *testing.T) {
|
|||||||
{"encoded: " + base64.StdEncoding.EncodeToString([]byte(" s3cr3t ")), "encoded: ***"},
|
{"encoded: " + base64.StdEncoding.EncodeToString([]byte(" s3cr3t ")), "encoded: ***"},
|
||||||
{"encoded: " + base64.StdEncoding.EncodeToString([]byte("s3cr3t")), "encoded: ***"},
|
{"encoded: " + base64.StdEncoding.EncodeToString([]byte("s3cr3t")), "encoded: ***"},
|
||||||
{"encoded: " + base64.StdEncoding.EncodeToString([]byte("first")), "encoded: ***"},
|
{"encoded: " + base64.StdEncoding.EncodeToString([]byte("first")), "encoded: ***"},
|
||||||
|
{"##[group]s3cr3t value\r\n", "##[group]***\r\n"},
|
||||||
} {
|
} {
|
||||||
entry := masker(WithMasks(t.Context(), &masks), logging.Record{Message: tc.line})
|
entry := masker(WithMasks(t.Context(), &masks), logging.Record{Message: tc.line})
|
||||||
assert.Equal(t, tc.want, entry.Message, tc.line)
|
assert.Equal(t, tc.want, entry.Message, tc.line)
|
||||||
@@ -194,17 +253,15 @@ func TestValueMaskerCachedReplacerSeesNewMasks(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestAppendSecretMaskerSkipsUselessEncodings(t *testing.T) {
|
func TestAppendSecretMaskerSkipsUselessEncodings(t *testing.T) {
|
||||||
// A token with no character an escape would touch only gains its base64 forms:
|
|
||||||
// JSON, query and path escaping all leave it unchanged.
|
|
||||||
pairs := AppendSecretMasker(nil, "plaintoken")
|
pairs := AppendSecretMasker(nil, "plaintoken")
|
||||||
assert.Equal(t, []string{
|
assert.Equal(t, []string{
|
||||||
"plaintoken", "***", "cGxhaW50b2tlbg==", "***", "bGFpbnRva2Vu", "***", "YWludG9rZW4=", "***",
|
"plaintoken", "***", "cGxhaW50b2tlbg==", "***", "bGFpbnRva2Vu", "***", "YWludG9rZW4=", "***",
|
||||||
"aW50b2tl", "***", "YWludG9r", "***", "bGFpbnRv", "***",
|
"aW50b2tl", "***", "YWludG9r", "***", "bGFpbnRv", "***",
|
||||||
}, pairs)
|
}, pairs)
|
||||||
|
|
||||||
// Too short to mask.
|
assert.Contains(t, AppendSecretMasker(nil, "x"), "x")
|
||||||
assert.Empty(t, AppendSecretMasker(nil, "x"))
|
|
||||||
assert.Empty(t, AppendSecretMasker(nil, " \t"))
|
assert.Empty(t, AppendSecretMasker(nil, " \t"))
|
||||||
|
assert.NotContains(t, AppendSecretMasker(nil, "{\nvalue\n}"), "{")
|
||||||
assert.NotContains(t, AppendSecretMasker(nil, `"123456"`), "123456")
|
assert.NotContains(t, AppendSecretMasker(nil, `"123456"`), "123456")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
package runner
|
package runner
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"io"
|
"io"
|
||||||
@@ -209,14 +210,14 @@ func TestPatchedBundleAcrossActions(t *testing.T) {
|
|||||||
assert.Equal(t, data, out, "an untouched bundle must come back byte for byte")
|
assert.Equal(t, data, out, "an untouched bundle must come back byte for byte")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
assert.NotContains(t, string(out), ".LOCALHOST", "a copy of the gate was missed")
|
assert.False(t, bytes.Contains(out, []byte(".LOCALHOST")), "a copy of the gate was missed")
|
||||||
|
|
||||||
if !strings.Contains(string(data), CacheServiceV2Env) {
|
if !bytes.Contains(data, []byte(CacheServiceV2Env)) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
assert.NotZero(t, strings.Count(string(out), "(process.env."+cacheURLEnv+"||process.env"),
|
assert.NotZero(t, bytes.Count(out, []byte("(process.env."+cacheURLEnv+"||process.env")),
|
||||||
"the cache service URL was not redirected")
|
"the cache service URL was not redirected")
|
||||||
assert.Equal(t, strings.Count(string(data), resultsURLEnv), strings.Count(string(out), resultsURLEnv),
|
assert.Equal(t, bytes.Count(data, []byte(resultsURLEnv)), bytes.Count(out, []byte(resultsURLEnv)),
|
||||||
"a read of the results URL was lost, it must stay as the fallback")
|
"a read of the results URL was lost, it must stay as the fallback")
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -311,18 +312,18 @@ func TestCodeSplitActionFindsTheCacheService(t *testing.T) {
|
|||||||
requireHostTools(t, "node", "rustc")
|
requireHostTools(t, "node", "rustc")
|
||||||
|
|
||||||
dir := tempDirPath(t)
|
dir := tempDirPath(t)
|
||||||
|
require.NoError(t, os.Mkdir(filepath.Join(dir, "dist"), 0o755))
|
||||||
for _, name := range []string{
|
for _, name := range []string{
|
||||||
"action.yml", "dist/restore.js", "dist/save.js", "dist/cleanup-BWEbZ6YT.js",
|
"action.yml", "dist/restore.js", "dist/save.js", "dist/cleanup-BWEbZ6YT.js",
|
||||||
"dist/cache-CTfQoCTL.js", "dist/cache-DC26rUaF.js", "dist/state-cjs-CkvFUaNy.js",
|
"dist/cache-CTfQoCTL.js", "dist/cache-DC26rUaF.js", "dist/state-cjs-CkvFUaNy.js",
|
||||||
} {
|
} {
|
||||||
body, err := os.ReadFile(bundleFromGitHub(t, "Swatinem/rust-cache", "v2.9.2", name))
|
body, err := os.ReadFile(bundleFromGitHub(t, "Swatinem/rust-cache", "v2.9.2", name))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NoError(t, os.MkdirAll(filepath.Dir(filepath.Join(dir, name)), 0o755))
|
|
||||||
require.NoError(t, os.WriteFile(filepath.Join(dir, name), body, 0o600))
|
require.NoError(t, os.WriteFile(filepath.Join(dir, name), body, 0o600))
|
||||||
}
|
}
|
||||||
body, err := os.ReadFile(filepath.Join(dir, "action.yml"))
|
body, err := os.ReadFile(filepath.Join(dir, "action.yml"))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
action, err := model.ReadAction(strings.NewReader(string(body)))
|
action, err := model.ReadAction(bytes.NewReader(body))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
inputs := map[string]string{"lookup-only": "true", "add-rust-environment-hash-key": "false", "cache-bin": "false"}
|
inputs := map[string]string{"lookup-only": "true", "add-rust-environment-hash-key": "false", "cache-bin": "false"}
|
||||||
for name, input := range action.Inputs {
|
for name, input := range action.Inputs {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
package runner
|
package runner
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
@@ -182,9 +183,9 @@ func TestPatchedBundleSurvivesInsideAStringLiteral(t *testing.T) {
|
|||||||
out, patched := patchedBundle([]byte(embedded))
|
out, patched := patchedBundle([]byte(embedded))
|
||||||
require.True(t, patched)
|
require.True(t, patched)
|
||||||
|
|
||||||
file := filepath.Join(t.TempDir(), "bundle.js")
|
cmd := exec.CommandContext(t.Context(), "node", "--check", "-")
|
||||||
require.NoError(t, os.WriteFile(file, out, 0o600))
|
cmd.Stdin = bytes.NewReader(out)
|
||||||
checked, err := exec.CommandContext(t.Context(), "node", "--check", file).CombinedOutput()
|
checked, err := cmd.CombinedOutput()
|
||||||
require.NoError(t, err, "%s", checked)
|
require.NoError(t, err, "%s", checked)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -256,6 +257,14 @@ func TestActionScriptPaths(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestPatchActionsAtTheContainerCopy(t *testing.T) {
|
func TestPatchActionsAtTheContainerCopy(t *testing.T) {
|
||||||
|
actionDir := t.TempDir()
|
||||||
|
script := filepath.Join(actionDir, "dist", "cache.js")
|
||||||
|
require.NoError(t, os.MkdirAll(filepath.Dir(script), 0o755))
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(filepath.Dir(script), "index.js"), []byte("require('./cache.js')"), 0o600))
|
||||||
|
require.NoError(t, os.WriteFile(script, []byte(gateTSC), 0o600))
|
||||||
|
info, err := os.Stat(script)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
copiedBundles := func(t *testing.T, noPatch bool) ([]string, []*container.FileEntry, int64) {
|
copiedBundles := func(t *testing.T, noPatch bool) ([]string, []*container.FileEntry, int64) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
@@ -264,19 +273,12 @@ func TestPatchActionsAtTheContainerCopy(t *testing.T) {
|
|||||||
Step: &model.Step{Uses: "owner/repo/sub@v1"},
|
Step: &model.Step{Uses: "owner/repo/sub@v1"},
|
||||||
remoteAction: &remoteAction{Org: "owner", Repo: "repo", Path: "sub", Ref: "v1"},
|
remoteAction: &remoteAction{Org: "owner", Repo: "repo", Path: "sub", Ref: "v1"},
|
||||||
action: &model.Action{Runs: model.ActionRuns{Using: "node20", Main: "../dist/index.js"}},
|
action: &model.Action{Runs: model.ActionRuns{Using: "node20", Main: "../dist/index.js"}},
|
||||||
actionDir: t.TempDir(),
|
actionDir: actionDir,
|
||||||
RunContext: &RunContext{
|
RunContext: &RunContext{
|
||||||
Config: &Config{ActionCacheDir: t.TempDir(), NoActionPatch: noPatch},
|
Config: &Config{NoActionPatch: noPatch},
|
||||||
JobContainer: cm,
|
JobContainer: cm,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
script := filepath.Join(sar.actionDir, "dist", "cache.js")
|
|
||||||
require.NoError(t, os.MkdirAll(filepath.Dir(script), 0o755))
|
|
||||||
require.NoError(t, os.WriteFile(filepath.Join(filepath.Dir(script), "index.js"), []byte("require('./cache.js')"), 0o600))
|
|
||||||
require.NoError(t, os.WriteFile(script, []byte(gateTSC), 0o600))
|
|
||||||
info, err := os.Stat(script)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
var calls []string
|
var calls []string
|
||||||
var copied []*container.FileEntry
|
var copied []*container.FileEntry
|
||||||
cm.On("CopyDir", "/var/run/act/actions/repo/", sar.actionDir+"/", false, true).Return(func(context.Context) error {
|
cm.On("CopyDir", "/var/run/act/actions/repo/", sar.actionDir+"/", false, true).Return(func(context.Context) error {
|
||||||
|
|||||||
@@ -33,24 +33,17 @@ func TestReusableWorkflowCachedBranchRefRefreshes(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
remoteDir := t.TempDir()
|
remoteDir := t.TempDir()
|
||||||
gitMust(t, "", "init", "--bare", "--initial-branch=master", remoteDir)
|
gitMust(t, "", "init", "--template=", "--initial-branch=master", remoteDir)
|
||||||
|
|
||||||
workDir := t.TempDir()
|
|
||||||
gitMust(t, "", "clone", remoteDir, workDir)
|
|
||||||
gitMust(t, workDir, "config", "user.email", "test@test")
|
|
||||||
gitMust(t, workDir, "config", "user.name", "test")
|
|
||||||
gitMust(t, workDir, "checkout", "-b", "master")
|
|
||||||
|
|
||||||
const workflowPath = ".gitea/workflows/reusable.yml"
|
const workflowPath = ".gitea/workflows/reusable.yml"
|
||||||
tmpl := func(tag string) string {
|
tmpl := func(tag string) string {
|
||||||
return "name: reusable\non:\n workflow_call:\njobs:\n build:\n runs-on: ubuntu-latest\n steps:\n - run: echo " + tag + "\n"
|
return "name: reusable\non:\n workflow_call:\njobs:\n build:\n runs-on: ubuntu-latest\n steps:\n - run: echo " + tag + "\n"
|
||||||
}
|
}
|
||||||
|
|
||||||
require.NoError(t, os.MkdirAll(filepath.Join(workDir, ".gitea/workflows"), 0o755))
|
require.NoError(t, os.MkdirAll(filepath.Join(remoteDir, ".gitea/workflows"), 0o755))
|
||||||
require.NoError(t, os.WriteFile(filepath.Join(workDir, workflowPath), []byte(tmpl("v1")), 0o644))
|
require.NoError(t, os.WriteFile(filepath.Join(remoteDir, workflowPath), []byte(tmpl("v1")), 0o644))
|
||||||
gitMust(t, workDir, "add", workflowPath)
|
gitMust(t, remoteDir, "add", workflowPath)
|
||||||
gitMust(t, workDir, "commit", "-m", "v1")
|
gitMust(t, remoteDir, "commit", "-m", "v1")
|
||||||
gitMust(t, workDir, "push", "-u", "origin", "master")
|
|
||||||
|
|
||||||
rc := &RunContext{
|
rc := &RunContext{
|
||||||
Config: &Config{Env: testCheckoutEnv()},
|
Config: &Config{Env: testCheckoutEnv()},
|
||||||
@@ -70,9 +63,8 @@ func TestReusableWorkflowCachedBranchRefRefreshes(t *testing.T) {
|
|||||||
require.Equal(t, tmpl("v1"), string(got))
|
require.Equal(t, tmpl("v1"), string(got))
|
||||||
|
|
||||||
// Branch tip moves; cache key (cacheDir) does not.
|
// Branch tip moves; cache key (cacheDir) does not.
|
||||||
require.NoError(t, os.WriteFile(filepath.Join(workDir, workflowPath), []byte(tmpl("v2")), 0o644))
|
require.NoError(t, os.WriteFile(filepath.Join(remoteDir, workflowPath), []byte(tmpl("v2")), 0o644))
|
||||||
gitMust(t, workDir, "commit", "-am", "v2")
|
gitMust(t, remoteDir, "commit", "-am", "v2")
|
||||||
gitMust(t, workDir, "push", "origin", "master")
|
|
||||||
|
|
||||||
require.NoError(t, cloneRemoteReusableWorkflow(rc, remoteDir, "master", cacheDir)(t.Context()))
|
require.NoError(t, cloneRemoteReusableWorkflow(rc, remoteDir, "master", cacheDir)(t.Context()))
|
||||||
got, err = os.ReadFile(filepath.Join(cacheDir, workflowPath))
|
got, err = os.ReadFile(filepath.Join(cacheDir, workflowPath))
|
||||||
@@ -214,7 +206,7 @@ func TestInstanceAuthAndTransportOnlyApplyToTheInstance(t *testing.T) {
|
|||||||
|
|
||||||
func gitMust(t *testing.T, dir string, args ...string) {
|
func gitMust(t *testing.T, dir string, args ...string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
cmd := exec.Command("git", args...)
|
cmd := exec.Command("git", append([]string{"-c", "user.name=test", "-c", "user.email=test@test"}, args...)...)
|
||||||
if dir != "" {
|
if dir != "" {
|
||||||
cmd.Dir = dir
|
cmd.Dir = dir
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
|
"gitea.com/gitea/runner/act/common/logging"
|
||||||
"gitea.com/gitea/runner/act/container"
|
"gitea.com/gitea/runner/act/container"
|
||||||
"gitea.com/gitea/runner/act/ghcontext"
|
"gitea.com/gitea/runner/act/ghcontext"
|
||||||
"gitea.com/gitea/runner/internal/pkg/lock"
|
"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
|
actionInputs map[string]any // inputs of the composite action this runs, nil for a job
|
||||||
Masks []string
|
Masks []string
|
||||||
cleanUpJobContainer common.Executor
|
cleanUpJobContainer common.Executor
|
||||||
|
taskWorkspace string
|
||||||
deferVolumeCleanup func(common.Executor)
|
deferVolumeCleanup func(common.Executor)
|
||||||
caller *caller // job calling this RunContext (reusable workflows)
|
caller *caller // job calling this RunContext (reusable workflows)
|
||||||
workflowCallInputs map[string]any // the caller's with:, resolved once by resolveWorkflowCall
|
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())
|
volumes = append(volumes, hostEnv.Path, hostEnv.GetActPath())
|
||||||
}
|
}
|
||||||
if rc.Config.SharedToolCache {
|
if rc.Config.SharedToolCache {
|
||||||
volumes = append(volumes, sharedToolCacheVolume)
|
volumes = append(volumes, rc.toolCacheVolume())
|
||||||
}
|
}
|
||||||
if rc.Config.BindWorkdir {
|
if rc.Config.BindWorkdir {
|
||||||
volumes = append(volumes, rc.Config.Workdir, rc.workdirMountRoot())
|
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())
|
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.
|
// toolCache returns the tool cache path the job sees, relocatable through RUNNER_TOOL_CACHE.
|
||||||
func (rc *RunContext) toolCache(fallback string) string {
|
func (rc *RunContext) toolCache(fallback string) string {
|
||||||
return cmp.Or(rc.GetEnv()["RUNNER_TOOL_CACHE"], fallback)
|
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
|
return binds, mounts, targets
|
||||||
}
|
}
|
||||||
|
|
||||||
// Returns the binds and mounts for the container, resolving paths as appopriate
|
// 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) {
|
func (rc *RunContext) GetBindsAndMounts(ctx context.Context) ([]string, map[string]string, string) {
|
||||||
name := rc.jobContainerName()
|
name := rc.jobContainerName()
|
||||||
ext := container.LinuxContainerEnvironmentExtensions{}
|
ext := container.LinuxContainerEnvironmentExtensions{}
|
||||||
|
|
||||||
@@ -391,7 +400,7 @@ func (rc *RunContext) GetBindsAndMounts(ctx context.Context) ([]string, map[stri
|
|||||||
}
|
}
|
||||||
if rc.Config.SharedToolCache {
|
if rc.Config.SharedToolCache {
|
||||||
if toolCache := rc.toolCache(container.DefaultToolCache); !claimed[toolCache] {
|
if toolCache := rc.toolCache(container.DefaultToolCache); !claimed[toolCache] {
|
||||||
mounts[sharedToolCacheVolume] = toolCache
|
mounts[rc.toolCacheVolume()] = toolCache
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if hostEnv, ok := rc.JobContainer.(*container.HostEnvironment); ok {
|
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
|
mounts[name+"-env"] = ext.GetActPath() // runner-internal, never overridable
|
||||||
|
|
||||||
|
taskWorkspace := ""
|
||||||
if workdir := ext.ToContainerPath(rc.Config.Workdir); !claimed[workdir] {
|
if workdir := ext.ToContainerPath(rc.Config.Workdir); !claimed[workdir] {
|
||||||
source := rc.workdirMountRoot()
|
source := rc.workdirMountRoot()
|
||||||
target := ext.ToContainerPath(source)
|
target := ext.ToContainerPath(source)
|
||||||
if claimed[target] {
|
if claimed[target] {
|
||||||
source, target = rc.Config.Workdir, workdir
|
source, target = rc.Config.Workdir, workdir
|
||||||
}
|
}
|
||||||
|
if rc.Config.BindWorkdir && rc.Config.PresetGitHubContext != nil {
|
||||||
|
taskWorkspace = target
|
||||||
|
}
|
||||||
if rc.Config.BindWorkdir {
|
if rc.Config.BindWorkdir {
|
||||||
bindModifiers := ""
|
bindModifiers := ""
|
||||||
if runtime.GOOS == "darwin" {
|
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 {
|
func (rc *RunContext) startHostEnvironment() common.Executor {
|
||||||
@@ -447,6 +460,10 @@ func (rc *RunContext) startHostEnvironment() common.Executor {
|
|||||||
toolCacheParent = cacheDir
|
toolCacheParent = cacheDir
|
||||||
}
|
}
|
||||||
toolCache := rc.toolCache(filepath.Join(toolCacheParent, "tool_cache"))
|
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 {
|
if err := os.MkdirAll(toolCache, 0o777); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -672,7 +689,8 @@ func (rc *RunContext) startJobContainer() common.Executor {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
rc.startDockerProxy(ctx)
|
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()
|
containerInput.ValidVolumes = rc.validVolumes()
|
||||||
|
|
||||||
rc.jobNetworkName = networkName
|
rc.jobNetworkName = networkName
|
||||||
@@ -687,6 +705,7 @@ func (rc *RunContext) startJobContainer() common.Executor {
|
|||||||
func(ctx context.Context) error { return rc.createJobVolumes(ctx, containerInput.Mounts) },
|
func(ctx context.Context) error { return rc.createJobVolumes(ctx, containerInput.Mounts) },
|
||||||
rc.JobContainer.Create(rc.Config.ContainerCapAdd, rc.Config.ContainerCapDrop),
|
rc.JobContainer.Create(rc.Config.ContainerCapAdd, rc.Config.ContainerCapDrop),
|
||||||
rc.JobContainer.Start(false),
|
rc.JobContainer.Start(false),
|
||||||
|
func(context.Context) error { rc.taskWorkspace = taskWorkspace; return nil },
|
||||||
rc.captureJobContainerInfo(),
|
rc.captureJobContainerInfo(),
|
||||||
rc.copyWorkflowFiles(),
|
rc.copyWorkflowFiles(),
|
||||||
rc.waitForServiceContainers(),
|
rc.waitForServiceContainers(),
|
||||||
@@ -801,7 +820,10 @@ func (rc *RunContext) cleanupJobResources(networkName string, createAndDeleteNet
|
|||||||
errs := []error{rc.closeDockerProxy(ctx)}
|
errs := []error{rc.closeDockerProxy(ctx)}
|
||||||
var containers []common.Executor
|
var containers []common.Executor
|
||||||
if rc.JobContainer != nil {
|
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 {
|
if len(rc.serviceContainers) > 0 {
|
||||||
logger.Infof("Cleaning up services for job %s", rc.JobName)
|
logger.Infof("Cleaning up services for job %s", rc.JobName)
|
||||||
@@ -810,7 +832,7 @@ func (rc *RunContext) cleanupJobResources(networkName string, createAndDeleteNet
|
|||||||
errs = append(errs, runAll(ctx, containers))
|
errs = append(errs, runAll(ctx, containers))
|
||||||
ctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), jobCleanupTimeout)
|
ctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), jobCleanupTimeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
if !common.Dryrun(ctx) && !rc.Config.NoSweep && (preclean || rc.hadDockerProxy) {
|
if !common.Dryrun(ctx) && rc.Config.Sweep && (preclean || rc.hadDockerProxy) {
|
||||||
errs = append(errs, container.RemoveDockerJobResources(ctx, rc.jobContainerName())) // after the containers, whose endpoints would block removing the job networks
|
errs = append(errs, container.RemoveDockerJobResources(ctx, rc.jobContainerName())) // after the containers, whose endpoints would block removing the job networks
|
||||||
}
|
}
|
||||||
var rest []common.Executor
|
var rest []common.Executor
|
||||||
@@ -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 {
|
func runAll(ctx context.Context, executors []common.Executor) error {
|
||||||
errs := make([]error, len(executors))
|
errs := make([]error, len(executors))
|
||||||
var wg sync.WaitGroup
|
var wg sync.WaitGroup
|
||||||
|
|||||||
+158
-17
@@ -16,6 +16,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
"slices"
|
"slices"
|
||||||
@@ -455,7 +456,7 @@ func TestRunContext_GetBindsAndMounts(t *testing.T) {
|
|||||||
t.Run(testcase.name+testBindSuffix, func(t *testing.T) {
|
t.Run(testcase.name+testBindSuffix, func(t *testing.T) {
|
||||||
rctemplate.Config.Workdir = testcase.name
|
rctemplate.Config.Workdir = testcase.name
|
||||||
rctemplate.Config.BindWorkdir = bindWorkDir
|
rctemplate.Config.BindWorkdir = bindWorkDir
|
||||||
gotbind, gotmount := rctemplate.GetBindsAndMounts(t.Context())
|
gotbind, gotmount, _ := rctemplate.GetBindsAndMounts(t.Context())
|
||||||
|
|
||||||
if bindWorkDir {
|
if bindWorkDir {
|
||||||
fullBind := testcase.name + ":" + testcase.want
|
fullBind := testcase.name + ":" + testcase.want
|
||||||
@@ -507,7 +508,7 @@ func TestRunContext_GetBindsAndMounts(t *testing.T) {
|
|||||||
rc.ExprEval = rc.NewExpressionEvaluator(t.Context())
|
rc.ExprEval = rc.NewExpressionEvaluator(t.Context())
|
||||||
require.NoError(t, rc.resolvePlatformImage(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.Contains(t, gotbind, "/host/mame/roms:/root/.mame/roms:ro")
|
||||||
assert.NotContains(t, gotbind, "${{ secrets.MAME }}")
|
assert.NotContains(t, gotbind, "${{ secrets.MAME }}")
|
||||||
assert.NotContains(t, gotmount, "${{ secrets.MAME }}")
|
assert.NotContains(t, gotmount, "${{ secrets.MAME }}")
|
||||||
@@ -522,7 +523,7 @@ func TestRunContext_GetBindsAndMounts(t *testing.T) {
|
|||||||
rc.containerSpec.Volumes = testcase.volumes
|
rc.containerSpec.Volumes = testcase.volumes
|
||||||
rc.Run.JobID = "job1"
|
rc.Run.JobID = "job1"
|
||||||
|
|
||||||
gotbind, gotmount := rc.GetBindsAndMounts(t.Context())
|
gotbind, gotmount, _ := rc.GetBindsAndMounts(t.Context())
|
||||||
|
|
||||||
if len(testcase.wantbind) > 0 {
|
if len(testcase.wantbind) > 0 {
|
||||||
assert.Contains(t, gotbind, testcase.wantbind)
|
assert.Contains(t, gotbind, testcase.wantbind)
|
||||||
@@ -551,13 +552,19 @@ func TestRunContext_GetBindsAndMounts(t *testing.T) {
|
|||||||
t.Run("ToolCacheMount", func(t *testing.T) {
|
t.Run("ToolCacheMount", func(t *testing.T) {
|
||||||
rc := newRunContext(&Config{})
|
rc := newRunContext(&Config{})
|
||||||
|
|
||||||
_, gotmount := rc.GetBindsAndMounts(t.Context())
|
_, gotmount, _ := rc.GetBindsAndMounts(t.Context())
|
||||||
assert.NotContains(t, gotmount, sharedToolCacheVolume)
|
assert.NotContains(t, gotmount, sharedToolCacheVolume)
|
||||||
|
|
||||||
rc.Config.SharedToolCache = true
|
rc.Config.SharedToolCache = true
|
||||||
rc.containerSpec.Volumes = []string{"/unapproved:" + container.DefaultToolCache}
|
rc.containerSpec.Volumes = []string{"/unapproved:" + container.DefaultToolCache}
|
||||||
_, gotmount = rc.GetBindsAndMounts(t.Context())
|
_, gotmount, _ = rc.GetBindsAndMounts(t.Context())
|
||||||
assert.Equal(t, container.DefaultToolCache, gotmount[sharedToolCacheVolume])
|
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) {
|
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"}},
|
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.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
|
rc.Config.BindWorkdir = false
|
||||||
_, gotmount := rc.GetBindsAndMounts(t.Context())
|
_, gotmount, workspace := rc.GetBindsAndMounts(t.Context())
|
||||||
assert.Equal(t, "/workspace/1", gotmount[rc.jobContainerName()])
|
assert.Equal(t, "/workspace/1", gotmount[rc.jobContainerName()])
|
||||||
|
assert.Empty(t, workspace)
|
||||||
|
|
||||||
rc.containerSpec.Volumes = []string{"claimed:/workspace/1"}
|
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()])
|
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.JobContainer = hostEnv
|
||||||
rc.Config.BindWorkdir = false
|
rc.Config.BindWorkdir = false
|
||||||
rc.Config.SharedToolCache = true
|
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.Subset(t, binds, []string{hostEnv.Path + ":" + hostEnv.Path, hostEnv.GetActPath() + ":" + hostEnv.GetActPath()})
|
||||||
assert.Contains(t, mounts, sharedToolCacheVolume)
|
assert.Contains(t, mounts, sharedToolCacheVolume)
|
||||||
assert.Equal(t, binds, container.SanitizeBinds(t.Context(), rc.validVolumes(), binds))
|
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)))
|
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) {
|
func TestRemoveDockerActionImagesByTag(t *testing.T) {
|
||||||
var removed []string
|
var removed []string
|
||||||
fakeDockerDaemon(t, func(writer http.ResponseWriter, request *http.Request) {
|
fakeDockerDaemon(t, func(writer http.ResponseWriter, request *http.Request) {
|
||||||
@@ -725,7 +846,7 @@ func TestCleanupJobResources(t *testing.T) {
|
|||||||
service.On("Remove").Return(removeAlongside(serviceRemoving, jobRemoving, removeError)).Once()
|
service.On("Remove").Return(removeAlongside(serviceRemoving, jobRemoving, removeError)).Once()
|
||||||
service.On("Close").Return(func(context.Context) error { return closeError }).Once()
|
service.On("Close").Return(func(context.Context) error { return closeError }).Once()
|
||||||
rc := &RunContext{
|
rc := &RunContext{
|
||||||
Config: &Config{CacheContainer: "cache-container", NoSweep: name == "kept proxy"},
|
Config: &Config{CacheContainer: "cache-container", Sweep: name != "kept proxy"},
|
||||||
Run: &model.Run{Workflow: &model.Workflow{Name: "wf"}, JobID: "job"},
|
Run: &model.Run{Workflow: &model.Workflow{Name: "wf"}, JobID: "job"},
|
||||||
JobContainer: job,
|
JobContainer: job,
|
||||||
serviceContainers: []*serviceContainer{{name: "svc", container: service}},
|
serviceContainers: []*serviceContainer{{name: "svc", container: service}},
|
||||||
@@ -1181,6 +1302,26 @@ func TestPrintStartJobContainerGroupGolden(t *testing.T) {
|
|||||||
assert.Equal(t, want, buf.String())
|
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) {
|
func TestRunContext_cleanupFailedStart(t *testing.T) {
|
||||||
type ctxKey string
|
type ctxKey string
|
||||||
const sentinel = ctxKey("sentinel")
|
const sentinel = ctxKey("sentinel")
|
||||||
@@ -1490,7 +1631,7 @@ func TestCopyWorkflowFilesHandsNodeTheRunnerCAUnlessTheJobOrImageSetsIt(t *testi
|
|||||||
return env
|
return env
|
||||||
}
|
}
|
||||||
|
|
||||||
assert.Equal(t, actPath+"/ca.crt", stepEnv(caFile, nil, nil, nil)["NODE_EXTRA_CA_CERTS"])
|
assert.Equal(t, filepath.ToSlash(actPath)+"/ca.crt", stepEnv(caFile, nil, nil, nil)["NODE_EXTRA_CA_CERTS"])
|
||||||
ca, err := os.ReadFile(filepath.Join(actPath, "ca.crt"))
|
ca, err := os.ReadFile(filepath.Join(actPath, "ca.crt"))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, "PEM\n", string(ca))
|
assert.Equal(t, "PEM\n", string(ca))
|
||||||
@@ -1666,21 +1807,21 @@ func TestRunContextUpdateExtraPath(t *testing.T) {
|
|||||||
longPath := strings.Repeat("x", 64*1024+1)
|
longPath := strings.Repeat("x", 64*1024+1)
|
||||||
for _, testcase := range []struct {
|
for _, testcase := range []struct {
|
||||||
name string
|
name string
|
||||||
content []byte
|
content string
|
||||||
want string
|
want string
|
||||||
wantErr bool
|
wantErr bool
|
||||||
}{
|
}{
|
||||||
{name: "UTF-8 BOM", content: []byte("\xef\xbb\xbf/utf8/tools\n"), want: "/utf8/tools"},
|
{name: "UTF-8 BOM", content: "\xef\xbb\xbf/utf8/tools\n", want: "/utf8/tools"},
|
||||||
{name: "UTF-16 BOM", content: []byte{0xff, 0xfe, 'C', 0, ':', 0, '\\', 0, 't', 0, 'o', 0, 'o', 0, 'l', 0, 's', 0, '\n', 0}, want: `C:\tools`},
|
{name: "UTF-16 BOM", content: "\xff\xfeC\x00:\x00\\\x00t\x00o\x00o\x00l\x00s\x00\n\x00", want: `C:\tools`},
|
||||||
{name: "over 64 KiB", content: []byte(longPath + "\n"), want: longPath},
|
{name: "over 64 KiB", content: longPath + "\n", want: longPath},
|
||||||
{name: "over 16 MiB", content: []byte(strings.Repeat("x", 16*1024*1024+1)), wantErr: true},
|
{name: "over 16 MiB", content: strings.Repeat("x", 16*1024*1024+1), wantErr: true},
|
||||||
} {
|
} {
|
||||||
t.Run(testcase.name, func(t *testing.T) {
|
t.Run(testcase.name, func(t *testing.T) {
|
||||||
logger := logging.New(logging.DiscardHandler)
|
logger := logging.New(logging.DiscardHandler)
|
||||||
ctx := common.WithLogger(t.Context(), logger)
|
ctx := common.WithLogger(t.Context(), logger)
|
||||||
jobContainer := &containerMock{}
|
jobContainer := &containerMock{}
|
||||||
jobContainer.On("GetContainerArchive", mock.Anything, "/github/path").
|
jobContainer.On("GetContainerArchive", mock.Anything, "/github/path").
|
||||||
Return(io.NopCloser(bytes.NewReader(tarArchive(t, tarEntry{name: "path", body: string(testcase.content)}))), nil).Once()
|
Return(io.NopCloser(bytes.NewReader(tarArchive(t, tarEntry{name: "path", body: testcase.content}))), nil).Once()
|
||||||
defer jobContainer.AssertExpectations(t)
|
defer jobContainer.AssertExpectations(t)
|
||||||
rc := &RunContext{JobContainer: jobContainer}
|
rc := &RunContext{JobContainer: jobContainer}
|
||||||
|
|
||||||
|
|||||||
@@ -84,14 +84,15 @@ type Config struct {
|
|||||||
JobLoggerLevel *logging.Level // the level of job logger
|
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
|
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
|
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
|
InsecureSkipTLS bool // whether to skip verifying TLS certificate of the Gitea instance
|
||||||
ClientCertFile, ClientKeyFile string // PEM client certificate for 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
|
CAFile string // PEM CA certificates the Gitea instance's certificate chains to
|
||||||
MaxParallel int // max parallel jobs to run across all workflows (0 = no limit, uses CPU count)
|
MaxParallel int // max parallel jobs to run across all workflows (0 = no limit, uses CPU count)
|
||||||
AllocatePTY bool // allocate a pseudo-TTY for each step's process
|
AllocatePTY bool // allocate a pseudo-TTY for each step's process
|
||||||
ServiceReadyTimeout time.Duration // how long a job waits for its service containers to report healthy (0 uses the default)
|
ServiceReadyTimeout time.Duration // how long a job waits for its service containers to report healthy (0 uses the default)
|
||||||
NoSweep bool // keep the containers, networks and volumes a job creates through its docker proxy
|
Sweep bool // remove the containers, networks and volumes a job creates through its docker proxy
|
||||||
RunnerName string // name this runner registered with, reported as `runner.name`, defaults to the hostname
|
RunnerName string // name this runner registered with, reported as `runner.name`, defaults to the hostname
|
||||||
JobStartedHook string // script run inside the job environment before the job's first step; ACTIONS_RUNNER_HOOK_JOB_STARTED is read from Env when empty
|
JobStartedHook string // script run inside the job environment before the job's first step; ACTIONS_RUNNER_HOOK_JOB_STARTED is read from Env when empty
|
||||||
JobCompletedHook string // script run inside the job environment after the job's last step; ACTIONS_RUNNER_HOOK_JOB_COMPLETED is read from Env when empty
|
JobCompletedHook string // script run inside the job environment after the job's last step; ACTIONS_RUNNER_HOOK_JOB_COMPLETED is read from Env when empty
|
||||||
|
|||||||
@@ -455,7 +455,6 @@ func TestRunEventHostEnvironment(t *testing.T) {
|
|||||||
{workdir, "nix-prepend-path", "push", "", platforms, secrets},
|
{workdir, "nix-prepend-path", "push", "", platforms, secrets},
|
||||||
{workdir, "uses-docker-url", "push", "", platforms, secrets},
|
{workdir, "uses-docker-url", "push", "", platforms, secrets},
|
||||||
{workdir, "local-action-dockerfile", "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, "inputs-via-env-context", "push", "", platforms, secrets},
|
||||||
{workdir, "do-not-leak-step-env-in-composite", "push", "", platforms, secrets},
|
{workdir, "do-not-leak-step-env-in-composite", "push", "", platforms, secrets},
|
||||||
{workdir, "set-env-step-env-override", "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
|
|
||||||
}
|
|
||||||
@@ -35,14 +35,8 @@ func setFetch(t *testing.T, fetch func(context.Context, git.NewGitCloneExecutorI
|
|||||||
func TestShortSHAActionRejected(t *testing.T) {
|
func TestShortSHAActionRejected(t *testing.T) {
|
||||||
actionRoot := t.TempDir()
|
actionRoot := t.TempDir()
|
||||||
repo := filepath.Join(actionRoot, "actions", "hello-world-docker-action")
|
repo := filepath.Join(actionRoot, "actions", "hello-world-docker-action")
|
||||||
require.NoError(t, os.MkdirAll(repo, 0o755))
|
gitMust(t, "", "init", "--template=", "--initial-branch=main", repo)
|
||||||
gitMust(t, "", "init", "--initial-branch=main", repo)
|
gitMust(t, repo, "commit", "--allow-empty", "-m", "initial")
|
||||||
gitMust(t, repo, "config", "user.email", "test@test")
|
|
||||||
gitMust(t, repo, "config", "user.name", "test")
|
|
||||||
require.NoError(t, os.WriteFile(filepath.Join(repo, "action.yml"),
|
|
||||||
[]byte("name: hello\nruns:\n using: node24\n main: index.js\n"), 0o644))
|
|
||||||
gitMust(t, repo, "add", ".")
|
|
||||||
gitMust(t, repo, "commit", "-m", "initial")
|
|
||||||
output, err := exec.Command("git", "-C", repo, "rev-parse", "--short=7", "HEAD").Output()
|
output, err := exec.Command("git", "-C", repo, "rev-parse", "--short=7", "HEAD").Output()
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
@@ -393,7 +387,7 @@ func TestStepActionRemotePreResolvesDownloadedCommit(t *testing.T) {
|
|||||||
[]byte("name: setup-go\nruns:\n using: node20\n main: index.js\n"), 0o600))
|
[]byte("name: setup-go\nruns:\n using: node20\n main: index.js\n"), 0o600))
|
||||||
|
|
||||||
for _, args := range [][]string{
|
for _, args := range [][]string{
|
||||||
{"init", "--initial-branch=main", actionDir},
|
{"init", "--template=", "--initial-branch=main", actionDir},
|
||||||
{"-C", actionDir, "add", "action.yml"},
|
{"-C", actionDir, "add", "action.yml"},
|
||||||
{"-C", actionDir, "-c", "user.name=runner", "-c", "[email protected]", "-c", "commit.gpgsign=false", "commit", "-m", "action"},
|
{"-C", actionDir, "-c", "user.name=runner", "-c", "[email protected]", "-c", "commit.gpgsign=false", "commit", "-m", "action"},
|
||||||
} {
|
} {
|
||||||
|
|||||||
@@ -6,9 +6,6 @@ package runner
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"maps"
|
|
||||||
"slices"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"gitea.dev/actionslib/pkg/model"
|
"gitea.dev/actionslib/pkg/model"
|
||||||
)
|
)
|
||||||
@@ -36,18 +33,6 @@ func (sf *stepFactoryImpl) newStep(stepModel *model.Step, rc *RunContext) (step,
|
|||||||
runAction: runActionImpl,
|
runAction: runActionImpl,
|
||||||
}, nil
|
}, nil
|
||||||
case model.StepTypeUsesActionRemote:
|
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{
|
return &stepActionRemote{
|
||||||
Step: stepModel,
|
Step: stepModel,
|
||||||
RunContext: rc,
|
RunContext: rc,
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ func TestStepFactoryNewStep(t *testing.T) {
|
|||||||
{"StepLocalAction", &model.Step{Uses: "./action@v1"}, &stepActionLocal{}},
|
{"StepLocalAction", &model.Step{Uses: "./action@v1"}, &stepActionLocal{}},
|
||||||
{"StepDocker", &model.Step{Uses: "docker://image:tag"}, &stepDocker{}},
|
{"StepDocker", &model.Step{Uses: "docker://image:tag"}, &stepDocker{}},
|
||||||
{"StepRun", &model.Step{Run: "cmd"}, &stepRun{}},
|
{"StepRun", &model.Step{Run: "cmd"}, &stepRun{}},
|
||||||
{"StepBuiltinAction", &model.Step{Uses: "builtin:checkout"}, &stepActionBuiltin{}},
|
|
||||||
} {
|
} {
|
||||||
t.Run(test.name, func(t *testing.T) {
|
t.Run(test.name, func(t *testing.T) {
|
||||||
step, err := (&stepFactoryImpl{}).newStep(test.model, &RunContext{})
|
step, err := (&stepFactoryImpl{}).newStep(test.model, &RunContext{})
|
||||||
@@ -35,7 +34,6 @@ func TestStepFactoryNewStep(t *testing.T) {
|
|||||||
func TestStepFactoryInvalidStep(t *testing.T) {
|
func TestStepFactoryInvalidStep(t *testing.T) {
|
||||||
for _, stepModel := range []*model.Step{
|
for _, stepModel := range []*model.Step{
|
||||||
{Uses: "remote/action@v1", Run: "cmd"},
|
{Uses: "remote/action@v1", Run: "cmd"},
|
||||||
{Uses: "builtin:unknown"},
|
|
||||||
} {
|
} {
|
||||||
_, err := (&stepFactoryImpl{}).newStep(stepModel, &RunContext{Name: "build"})
|
_, err := (&stepFactoryImpl{}).newStep(stepModel, &RunContext{Name: "build"})
|
||||||
assert.ErrorContains(t, err, "job:build ", stepModel.Uses)
|
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 {
|
type cliDaemon struct {
|
||||||
cmd *exec.Cmd
|
cmd *exec.Cmd
|
||||||
log *syncBuffer
|
log *syncBuffer
|
||||||
exited chan error
|
exited chan struct{}
|
||||||
|
err error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *cliRunner) startDaemon(t *testing.T, args ...string) *cliDaemon {
|
func (c *cliRunner) startDaemon(t *testing.T, args ...string) *cliDaemon {
|
||||||
t.Helper()
|
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 = exec.Command(c.bin, append([]string{"daemon", "-c", c.cfgFile}, args...)...)
|
||||||
d.cmd.Stdout, d.cmd.Stderr = d.log, d.log
|
d.cmd.Stdout, d.cmd.Stderr = d.log, d.log
|
||||||
if err := d.cmd.Start(); err != nil {
|
if err := d.cmd.Start(); err != nil {
|
||||||
t.Fatalf("start daemon: %v", err)
|
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() {
|
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
|
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 {
|
func (d *cliDaemon) wait(t *testing.T, timeout time.Duration) error {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
select {
|
select {
|
||||||
case err := <-d.exited:
|
case <-d.exited:
|
||||||
return err
|
return d.err
|
||||||
case <-time.After(timeout):
|
case <-time.After(timeout):
|
||||||
t.Fatalf("daemon still running after %s\n%s", timeout, d.log)
|
t.Fatalf("daemon still running after %s\n%s", timeout, d.log)
|
||||||
return nil
|
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.
|
// testCLIRegisterDaemon drives the built binary the way a user does: register, then daemon.
|
||||||
func testCLIRegisterDaemon(t *testing.T) {
|
func testCLIRegisterDaemon(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
c := newCLIRunner(t, "e2e-cli", "")
|
c := newCLIRunner(t, "e2e-registered", "")
|
||||||
d := c.startDaemon(t)
|
d := c.startDaemon(t, "--labels", "e2e-cli:docker://"+os.Getenv("E2E_JOB_IMAGE"))
|
||||||
|
|
||||||
pushWorkflow(t, c.api, c.repo, "cli.yml")
|
pushWorkflow(t, c.api, c.repo, "cli.yml")
|
||||||
wfRun := waitForRun(t, c.api, c.repo)
|
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)
|
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)
|
d.terminate(t)
|
||||||
if err := d.wait(t, 30*time.Second); err != nil {
|
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)
|
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.
|
// testCLIShutdownDrains checks that SIGTERM lets a running job finish within shutdown_timeout.
|
||||||
func testCLIShutdownDrains(t *testing.T) {
|
func testCLIShutdownDrains(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
@@ -282,10 +277,6 @@ func testExecArtifacts(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
ctx := t.Context()
|
ctx := t.Context()
|
||||||
|
|
||||||
bin, err := runnerBinary()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
workflow, err := os.ReadFile("testdata/workflows/exec_artifact.yml")
|
workflow, err := os.ReadFile("testdata/workflows/exec_artifact.yml")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -309,11 +300,9 @@ func testExecArtifacts(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
artifacts := t.TempDir()
|
artifacts := t.TempDir()
|
||||||
args := []string{"exec", "-C", workdir, "-W", workflows, "-i", os.Getenv("E2E_JOB_IMAGE"), "--artifact-server-path", artifacts, "--network", fixture.network}
|
stdout, stderr, err := runExec(t, nil, "-C", workdir, "-W", workflows, "-i", os.Getenv("E2E_JOB_IMAGE"), "--artifact-server-path", artifacts, "--network", fixture.network)
|
||||||
cmd := exec.CommandContext(ctx, bin, args...)
|
if err != nil {
|
||||||
cmd.Env = slices.DeleteFunc(os.Environ(), func(kv string) bool { return strings.HasPrefix(kv, "ACTIONS_") })
|
t.Fatalf("exec: %v\n%s%s", err, stdout, stderr)
|
||||||
if out, err := cmd.CombinedOutput(); err != nil {
|
|
||||||
t.Fatalf("exec: %v\n%s", err, out)
|
|
||||||
}
|
}
|
||||||
if blobs, _ := filepath.Glob(filepath.Join(artifacts, "v4", "*", "*", "blob")); len(blobs) != 2 {
|
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)
|
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("cache_and_artifacts", testCacheAndArtifactRoundTrips)
|
||||||
t.Run("cancellation_and_log_streaming", testRunCancellation)
|
t.Run("cancellation_and_log_streaming", testRunCancellation)
|
||||||
t.Run("dispatch", inAllModes(testWorkflowDispatch))
|
t.Run("dispatch", inAllModes(testWorkflowDispatch))
|
||||||
t.Run("builtin_checkout", testBuiltinCheckout)
|
|
||||||
t.Run("exec_artifacts", testExecArtifacts)
|
t.Run("exec_artifacts", testExecArtifacts)
|
||||||
t.Run("moving_action_ref", testMovingActionRef)
|
t.Run("moving_action_ref", testMovingActionRef)
|
||||||
t.Run("ephemeral", testEphemeralRunner)
|
t.Run("ephemeral", testEphemeralRunner)
|
||||||
@@ -37,7 +36,6 @@ func TestCompatibility(t *testing.T) {
|
|||||||
t.Run("job_timeout", testJobTimeout)
|
t.Run("job_timeout", testJobTimeout)
|
||||||
t.Run("cli_register_daemon", testCLIRegisterDaemon)
|
t.Run("cli_register_daemon", testCLIRegisterDaemon)
|
||||||
t.Run("cli_once", testCLIOnce)
|
t.Run("cli_once", testCLIOnce)
|
||||||
t.Run("cli_labels_override", testCLILabelsOverride)
|
|
||||||
t.Run("cli_shutdown_drains", testCLIShutdownDrains)
|
t.Run("cli_shutdown_drains", testCLIShutdownDrains)
|
||||||
t.Run("cli_shutdown_cancels", testCLIShutdownCancels)
|
t.Run("cli_shutdown_cancels", testCLIShutdownCancels)
|
||||||
t.Run("host_mode", testHostMode)
|
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)
|
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 {
|
func (a *GiteaAPI) CreateSecret(ctx context.Context, repo, name, value string) error {
|
||||||
body := map[string]any{"data": value}
|
body := map[string]any{"data": value}
|
||||||
url := fmt.Sprintf("/api/v1/repos/%s/%s/actions/secrets/%s", giteaAdminUser, repo, name)
|
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
|
return "", "", false
|
||||||
}
|
}
|
||||||
|
|
||||||
func StartGitea(ctx context.Context, cli mobyclient.APIClient) (*GiteaFixture, error) {
|
func StartGitea(ctx context.Context, cli mobyclient.APIClient) (_ *GiteaFixture, err error) {
|
||||||
closeClient := true
|
f := &GiteaFixture{cli: cli}
|
||||||
defer func() {
|
defer func() {
|
||||||
if closeClient {
|
if err != nil {
|
||||||
_ = cli.Close()
|
_ = f.Close()
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
@@ -111,7 +111,6 @@ func StartGitea(ctx context.Context, cli mobyclient.APIClient) (*GiteaFixture, e
|
|||||||
"GITEA__security__INSTALL_LOCK=true",
|
"GITEA__security__INSTALL_LOCK=true",
|
||||||
"GITEA__database__DB_TYPE=sqlite3",
|
"GITEA__database__DB_TYPE=sqlite3",
|
||||||
"GITEA__actions__ENABLED=true",
|
"GITEA__actions__ENABLED=true",
|
||||||
"GITEA__server__LFS_START_SERVER=true",
|
|
||||||
"GITEA__server__ROOT_URL=" + baseURL + "/",
|
"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)
|
return nil, fmt.Errorf("create gitea container: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
f := &GiteaFixture{cli: cli, id: resp.ID, image: image, baseURL: baseURL, network: sharedNet, selfIP: selfIP}
|
f.id, f.image, f.baseURL, f.network, f.selfIP = resp.ID, image, baseURL, sharedNet, selfIP
|
||||||
closeClient = false
|
|
||||||
|
|
||||||
if _, err := cli.ContainerStart(ctx, f.id, mobyclient.ContainerStartOptions{}); err != nil {
|
if _, err := cli.ContainerStart(ctx, f.id, mobyclient.ContainerStartOptions{}); err != nil {
|
||||||
_ = f.Close(ctx)
|
|
||||||
return nil, fmt.Errorf("start gitea container: %w", err)
|
return nil, fmt.Errorf("start gitea container: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := f.waitHealthy(ctx); err != nil {
|
if err := f.waitHealthy(ctx); err != nil {
|
||||||
_ = f.Close(ctx)
|
|
||||||
return nil, fmt.Errorf("gitea did not become healthy: %w", err)
|
return nil, fmt.Errorf("gitea did not become healthy: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := f.bootstrapAdmin(ctx); err != nil {
|
if err := f.bootstrapAdmin(ctx); err != nil {
|
||||||
_ = f.Close(ctx)
|
|
||||||
return nil, fmt.Errorf("bootstrap gitea admin: %w", err)
|
return nil, fmt.Errorf("bootstrap gitea admin: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := f.readVersion(ctx); err != nil {
|
if err := f.readVersion(ctx); err != nil {
|
||||||
_ = f.Close(ctx)
|
|
||||||
return nil, fmt.Errorf("read gitea version: %w", err)
|
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 {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
defer context.AfterFunc(ctx, attached.Close)()
|
||||||
defer attached.Close()
|
defer attached.Close()
|
||||||
if stdin != nil {
|
if stdin != nil {
|
||||||
go func() {
|
go func() {
|
||||||
@@ -258,7 +253,11 @@ func containerExec(ctx context.Context, cli mobyclient.APIClient, id, user strin
|
|||||||
}
|
}
|
||||||
|
|
||||||
var out bytes.Buffer
|
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
|
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)
|
return api.doJSON(ctx, method, path, reqBody, respBody)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *GiteaFixture) Close(ctx context.Context) error {
|
func (f *GiteaFixture) Close() error {
|
||||||
if f.id == "" {
|
if f.id == "" {
|
||||||
return f.cli.Close()
|
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})
|
_, removeErr := f.cli.ContainerRemove(ctx, f.id, mobyclient.ContainerRemoveOptions{Force: true, RemoveVolumes: true})
|
||||||
return errors.Join(removeErr, f.cli.Close())
|
return errors.Join(removeErr, f.cli.Close())
|
||||||
}
|
}
|
||||||
+13
-7
@@ -55,7 +55,9 @@ func checkClusterLeaks() error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil // the tests that needed the cluster already failed
|
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",
|
"kubectl", "wait", "--for=delete", "pods,secrets", "--selector=app.kubernetes.io/managed-by=gitea-runner", "--timeout=1m",
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return fmt.Errorf("job pods or secrets left behind: %w: %s", err, out)
|
return fmt.Errorf("job pods or secrets left behind: %w: %s", err, out)
|
||||||
@@ -72,7 +74,7 @@ type kubeCluster struct {
|
|||||||
var (
|
var (
|
||||||
k3s = sync.OnceValues(startK3s) // independent of Gitea, so it can boot alongside it
|
k3s = sync.OnceValues(startK3s) // independent of Gitea, so it can boot alongside it
|
||||||
clusterStarted atomic.Bool
|
clusterStarted atomic.Bool
|
||||||
clusterCleanup []func()
|
clusterCleanup []func(context.Context)
|
||||||
sharedCluster = sync.OnceValues(func() (*kubeCluster, error) {
|
sharedCluster = sync.OnceValues(func() (*kubeCluster, error) {
|
||||||
cluster, err := k3s()
|
cluster, err := k3s()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -109,7 +111,9 @@ func stopCluster() {
|
|||||||
}
|
}
|
||||||
_, _ = k3s()
|
_, _ = k3s()
|
||||||
for _, cleanup := range slices.Backward(clusterCleanup) {
|
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 {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("create k3s container: %w", err)
|
return nil, fmt.Errorf("create k3s container: %w", err)
|
||||||
}
|
}
|
||||||
clusterCleanup = append(clusterCleanup, func() {
|
clusterCleanup = append(clusterCleanup, func(ctx context.Context) {
|
||||||
_, _ = cli.ContainerRemove(context.Background(), created.ID, mobyclient.ContainerRemoveOptions{Force: true, RemoveVolumes: true})
|
_, _ = cli.ContainerRemove(ctx, created.ID, mobyclient.ContainerRemoveOptions{Force: true, RemoveVolumes: true})
|
||||||
})
|
})
|
||||||
if _, err := cli.ContainerStart(ctx, created.ID, mobyclient.ContainerStartOptions{}); err != nil {
|
if _, err := cli.ContainerStart(ctx, created.ID, mobyclient.ContainerStartOptions{}); err != nil {
|
||||||
return nil, fmt.Errorf("start k3s container: %w", err)
|
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{})
|
inspected, inspectErr := cli.ContainerInspect(ctx, created.ID, mobyclient.ContainerInspectOptions{})
|
||||||
if inspectErr != nil || !inspected.Container.State.Running || ctx.Err() != nil {
|
if inspectErr != nil || !inspected.Container.State.Running || ctx.Err() != nil {
|
||||||
var logs bytes.Buffer
|
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)
|
_, _ = stdcopy.StdCopy(&logs, &logs, reader)
|
||||||
_ = reader.Close()
|
_ = 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 {
|
if _, err := dockerCLI.ImageTag(ctx, mobyclient.ImageTagOptions{Source: image, Target: alias}); err != nil {
|
||||||
return nil, fmt.Errorf("tag %s: %w", image, err)
|
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)
|
aliases = append(aliases, alias)
|
||||||
}
|
}
|
||||||
archive, err := dockerCLI.ImageSave(ctx, aliases)
|
archive, err := dockerCLI.ImageSave(ctx, aliases)
|
||||||
|
|||||||
+6
-2
@@ -31,6 +31,8 @@ import (
|
|||||||
|
|
||||||
const runTimeout = 3 * time.Minute
|
const runTimeout = 3 * time.Minute
|
||||||
|
|
||||||
|
const cleanupTimeout = 2 * time.Minute
|
||||||
|
|
||||||
var nonRepoChars = regexp.MustCompile(`[^a-zA-Z0-9._-]+`)
|
var nonRepoChars = regexp.MustCompile(`[^a-zA-Z0-9._-]+`)
|
||||||
|
|
||||||
var fixture *GiteaFixture
|
var fixture *GiteaFixture
|
||||||
@@ -42,8 +44,10 @@ var binDir string
|
|||||||
var dockerCLI mobyclient.APIClient
|
var dockerCLI mobyclient.APIClient
|
||||||
|
|
||||||
var runnerBinary = sync.OnceValues(func() (string, error) {
|
var runnerBinary = sync.OnceValues(func() (string, error) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), runTimeout)
|
||||||
|
defer cancel()
|
||||||
bin := filepath.Join(binDir, "gitea-runner")
|
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 "", fmt.Errorf("build runner: %w\n%s", err, out)
|
||||||
}
|
}
|
||||||
return bin, nil
|
return bin, nil
|
||||||
@@ -80,7 +84,7 @@ func runSuite(m *testing.M) int {
|
|||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
fixture = f
|
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)
|
fmt.Fprintf(os.Stderr, "gitea fixture: image=%s version=%s\n", fixture.image, fixture.version)
|
||||||
|
|
||||||
|
|||||||
+29
-15
@@ -9,6 +9,7 @@ import (
|
|||||||
"bufio"
|
"bufio"
|
||||||
"bytes"
|
"bytes"
|
||||||
"cmp"
|
"cmp"
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"os"
|
"os"
|
||||||
@@ -138,14 +139,31 @@ func daemonWorkflow(t *testing.T, m mode, spec workflowSpec) *workflowResult {
|
|||||||
return result
|
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()
|
t.Helper()
|
||||||
ctx := t.Context()
|
|
||||||
|
|
||||||
bin, err := runnerBinary()
|
bin, err := runnerBinary()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
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()
|
workdir := t.TempDir()
|
||||||
workflows := filepath.Join(workdir, ".gitea", "workflows")
|
workflows := filepath.Join(workdir, ".gitea", "workflows")
|
||||||
if err := os.MkdirAll(workflows, 0o755); err != nil {
|
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"}
|
args := []string{"--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_") })
|
var env []string
|
||||||
if m.kubernetes {
|
if m.kubernetes {
|
||||||
cluster := requireCluster(t)
|
cluster := requireCluster(t)
|
||||||
args = append(args, "-i", "kubernetes://"+cluster.jobImage)
|
args = append(args, "-i", "kubernetes://"+cluster.jobImage)
|
||||||
env = append(env, "KUBECONFIG="+cluster.kubernetes.Kubeconfig)
|
env = []string{"KUBECONFIG=" + cluster.kubernetes.Kubeconfig}
|
||||||
} else {
|
} else {
|
||||||
args = append(args, "-i", os.Getenv("E2E_JOB_IMAGE"), "--network", fixture.network)
|
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)
|
args = append(args, "--input", name+"="+value)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
cmd := exec.CommandContext(ctx, bin, append(args, spec.execArgs...)...)
|
stdout, stderr, runErr := runExec(t, env, append(args, spec.execArgs...)...)
|
||||||
cmd.Env = env
|
|
||||||
var stdout, stderr bytes.Buffer
|
|
||||||
cmd.Stdout, cmd.Stderr = &stdout, &stderr
|
|
||||||
runErr := cmd.Run()
|
|
||||||
var exitErr *exec.ExitError
|
var exitErr *exec.ExitError
|
||||||
if runErr != nil && !errors.As(runErr, &exitErr) {
|
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{}}
|
result := &workflowResult{conclusion: "success", jobs: map[string]string{}}
|
||||||
if runErr != nil {
|
if runErr != nil {
|
||||||
result.conclusion = "failure"
|
result.conclusion = "failure"
|
||||||
}
|
}
|
||||||
scanner := bufio.NewScanner(&stdout)
|
scanner := bufio.NewScanner(bytes.NewReader(stdout))
|
||||||
scanner.Buffer(nil, 1<<20)
|
scanner.Buffer(nil, 1<<20)
|
||||||
for scanner.Scan() {
|
for scanner.Scan() {
|
||||||
var entry struct {
|
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 {
|
if len(result.jobs) == 0 {
|
||||||
t.Fatalf("exec ran no job: %v\n%s", runErr, stderr.String())
|
t.Fatalf("exec ran no job: %v\n%s", runErr, stderr)
|
||||||
}
|
}
|
||||||
return result
|
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
|
timeout: 10s
|
||||||
environment:
|
environment:
|
||||||
# GITEA_RUNNER_REGISTRATION_TOKEN can be used to set a global runner registration token.
|
# 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.
|
# It's also possible to use GITEA_RUNNER_REGISTRATION_TOKEN_FILE to pass the location.
|
||||||
# - GITEA_RUNNER_REGISTRATION_TOKEN=<user-defined registration token>
|
# - 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.
|
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/containerd/errdefs v1.0.0
|
||||||
github.com/creack/pty v1.1.24
|
github.com/creack/pty v1.1.24
|
||||||
github.com/distribution/reference v0.6.0
|
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-connections v0.8.2
|
||||||
github.com/docker/go-units v0.5.0
|
github.com/docker/go-units v0.5.0
|
||||||
github.com/joho/godotenv v1.5.1
|
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/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 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
|
||||||
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
|
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.3+incompatible h1:SBaMp7ecohadEHMDrUJg22t9I3awqsAVhnJ7GOcWpfE=
|
||||||
github.com/docker/cli v29.9.2+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
|
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 h1:9d9DEokx5W7qlr0shkFXIxEA7+TTD+llwzcykSlxMNc=
|
||||||
github.com/docker/go-connections v0.8.2/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q=
|
github.com/docker/go-connections v0.8.2/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q=
|
||||||
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
|
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):
|
case err != nil && ctx.Err() != nil && !client.IsRegistrationRejected(err):
|
||||||
return nil
|
return nil
|
||||||
case err != nil && connect.CodeOf(err) == connect.CodeUnimplemented:
|
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
|
return err
|
||||||
case err != nil:
|
case err != nil:
|
||||||
logging.WithError(err).Error("fail to invoke Declare")
|
logging.WithError(err).Error("fail to invoke Declare")
|
||||||
|
|||||||
@@ -128,6 +128,11 @@ func (*daemonTestService) UpdateLog(_ context.Context, req *runnerv1.UpdateLogRe
|
|||||||
func TestDaemonRetriesDeclareOutagesAndRemovesOnlyConsumedEphemeralRegistration(t *testing.T) {
|
func TestDaemonRetriesDeclareOutagesAndRemovesOnlyConsumedEphemeralRegistration(t *testing.T) {
|
||||||
defer func(delay time.Duration) { declareRetryDelay = delay }(declareRetryDelay)
|
defer func(delay time.Duration) { declareRetryDelay = delay }(declareRetryDelay)
|
||||||
declareRetryDelay = 0
|
declareRetryDelay = 0
|
||||||
|
dir := t.TempDir()
|
||||||
|
regFile := filepath.Join(dir, "registration.json")
|
||||||
|
configFile := filepath.Join(dir, "config.yaml")
|
||||||
|
require.NoError(t, os.WriteFile(configFile, []byte("runner:\n file: "+regFile+"\n idle_cleanup_interval: 0s\ncache:\n enabled: false\n"), 0o600))
|
||||||
|
|
||||||
for _, tc := range []struct {
|
for _, tc := range []struct {
|
||||||
name string
|
name string
|
||||||
ephemeral, beforeTask bool
|
ephemeral, beforeTask bool
|
||||||
@@ -154,10 +159,6 @@ func TestDaemonRetriesDeclareOutagesAndRemovesOnlyConsumedEphemeralRegistration(
|
|||||||
connecthttp.Mount(mux, rpcServer, connecthttp.WithReadMaxBytes(0))
|
connecthttp.Mount(mux, rpcServer, connecthttp.WithReadMaxBytes(0))
|
||||||
server := httptest.NewServer(http.StripPrefix("/api/actions", mux))
|
server := httptest.NewServer(http.StripPrefix("/api/actions", mux))
|
||||||
defer server.Close()
|
defer server.Close()
|
||||||
dir := t.TempDir()
|
|
||||||
regFile := filepath.Join(dir, "registration.json")
|
|
||||||
configFile := filepath.Join(dir, "config.yaml")
|
|
||||||
require.NoError(t, os.WriteFile(configFile, []byte("runner:\n file: "+regFile+"\n idle_cleanup_interval: 0s\ncache:\n enabled: false\n"), 0o600))
|
|
||||||
require.NoError(t, config.SaveRegistration(regFile, &config.Registration{Address: server.URL, UUID: "test", Name: "test", Token: "test", Labels: []string{"host:host"}, Ephemeral: tc.ephemeral}))
|
require.NoError(t, config.SaveRegistration(regFile, &config.Registration{Address: server.URL, UUID: "test", Name: "test", Token: "test", Labels: []string{"host:host"}, Ephemeral: tc.ephemeral}))
|
||||||
err := runDaemon(ctx, &daemonArgs{Once: true}, &configFile)(nil, nil)
|
err := runDaemon(ctx, &daemonArgs{Once: true}, &configFile)(nil, nil)
|
||||||
if tc.declareErr == nil && tc.fetchErr == nil || tc.beforeTask {
|
if tc.declareErr == nil && tc.fetchErr == nil || tc.beforeTask {
|
||||||
|
|||||||
@@ -188,10 +188,14 @@ func (i *executeArgs) eventJSON() (string, error) {
|
|||||||
func (i *executeArgs) LoadEnvs() map[string]string {
|
func (i *executeArgs) LoadEnvs() map[string]string {
|
||||||
envs := parseKVAndFile(i.envs, i.Envfile())
|
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.
|
// The same server answers cache v2, which docker buildx reads from the results origin alone.
|
||||||
if envs["ACTIONS_RESULTS_URL"] == "" {
|
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"
|
envs[runner.CacheServiceV2Env] = "true"
|
||||||
|
|
||||||
|
|||||||
@@ -75,40 +75,35 @@ func TestExecuteArgsLoadInputs(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestExecuteArgsEventJSON(t *testing.T) {
|
func TestExecuteArgsEventJSON(t *testing.T) {
|
||||||
payload := func(content string) string {
|
dir := t.TempDir()
|
||||||
path := filepath.Join(t.TempDir(), "event.json")
|
|
||||||
require.NoError(t, os.WriteFile(path, []byte(content), 0o600))
|
|
||||||
return path
|
|
||||||
}
|
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
eventpath string
|
payload string
|
||||||
inputs []string
|
inputs []string
|
||||||
expected string
|
expected string
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "keeps a payload without inputs to merge",
|
name: "keeps a payload without inputs to merge",
|
||||||
eventpath: payload(`{"inputs": {"kept": "from_event"}}`),
|
payload: `{"inputs": {"kept": "from_event"}}`,
|
||||||
expected: `{"inputs": {"kept": "from_event"}}`,
|
expected: `{"inputs": {"kept": "from_event"}}`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "overrides the event inputs it names, keeping the rest",
|
name: "overrides the event inputs it names, keeping the rest",
|
||||||
eventpath: payload(`{"inputs": {"kept": "from_event", "named": "from_event"}}`),
|
payload: `{"inputs": {"kept": "from_event", "named": "from_event"}}`,
|
||||||
inputs: []string{"named=from_cli"},
|
inputs: []string{"named=from_cli"},
|
||||||
expected: `{"inputs": {"kept": "from_event", "named": "from_cli"}}`,
|
expected: `{"inputs": {"kept": "from_event", "named": "from_cli"}}`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "adds inputs to a payload carrying none",
|
name: "adds inputs to a payload carrying none",
|
||||||
eventpath: payload(`{"repository": {"name": "test-repo"}}`),
|
payload: `{"repository": {"name": "test-repo"}}`,
|
||||||
inputs: []string{"flag=val"},
|
inputs: []string{"flag=val"},
|
||||||
expected: `{"repository": {"name": "test-repo"}, "inputs": {"flag": "val"}}`,
|
expected: `{"repository": {"name": "test-repo"}, "inputs": {"flag": "val"}}`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "handles a null payload",
|
name: "handles a null payload",
|
||||||
eventpath: payload("null"),
|
payload: "null",
|
||||||
inputs: []string{"flag=val"},
|
inputs: []string{"flag=val"},
|
||||||
expected: `{"inputs": {"flag": "val"}}`,
|
expected: `{"inputs": {"flag": "val"}}`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "builds a payload without an event file",
|
name: "builds a payload without an event file",
|
||||||
@@ -119,7 +114,12 @@ func TestExecuteArgsEventJSON(t *testing.T) {
|
|||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
eventJSON, err := (&executeArgs{eventpath: tt.eventpath, inputs: tt.inputs}).eventJSON()
|
var eventpath string
|
||||||
|
if tt.payload != "" {
|
||||||
|
eventpath = filepath.Join(dir, tt.name+".json")
|
||||||
|
require.NoError(t, os.WriteFile(eventpath, []byte(tt.payload), 0o600))
|
||||||
|
}
|
||||||
|
eventJSON, err := (&executeArgs{eventpath: eventpath, inputs: tt.inputs}).eventJSON()
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.JSONEq(t, tt.expected, eventJSON)
|
require.JSONEq(t, tt.expected, eventJSON)
|
||||||
})
|
})
|
||||||
@@ -275,21 +275,13 @@ func workflowForList(name, file, event, jobID, jobName string) *model.Workflow {
|
|||||||
return &model.Workflow{
|
return &model.Workflow{
|
||||||
Name: name,
|
Name: name,
|
||||||
File: file,
|
File: file,
|
||||||
RawOn: rawOnNode(event),
|
RawOn: yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: event},
|
||||||
Jobs: map[string]*model.Job{
|
Jobs: map[string]*model.Job{
|
||||||
jobID: {Name: jobName},
|
jobID: {Name: jobName},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func rawOnNode(event string) yaml.Node {
|
|
||||||
var node yaml.Node
|
|
||||||
if err := yaml.Unmarshal([]byte(event), &node); err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
return *node.Content[0]
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestExecuteArgsLoadEnvsResultsOrigin(t *testing.T) {
|
func TestExecuteArgsLoadEnvsResultsOrigin(t *testing.T) {
|
||||||
t.Setenv("ACTIONS_RESULTS_URL", "") // the default is only supplied when nothing else does
|
t.Setenv("ACTIONS_RESULTS_URL", "") // the default is only supplied when nothing else does
|
||||||
handler, err := artifactcache.StartHandler(artifactcache.Options{Dir: t.TempDir(), OutboundIP: "127.0.0.1"})
|
handler, err := artifactcache.StartHandler(artifactcache.Options{Dir: t.TempDir(), OutboundIP: "127.0.0.1"})
|
||||||
|
|||||||
@@ -89,6 +89,7 @@ const registerTokenEnvVar = "GITEA_RUNNER_REGISTRATION_TOKEN"
|
|||||||
|
|
||||||
var defaultLabels = []string{
|
var defaultLabels = []string{
|
||||||
"ubuntu-latest:docker://docker.gitea.com/runner-images:ubuntu-latest",
|
"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-24.04:docker://docker.gitea.com/runner-images:ubuntu-24.04",
|
||||||
"ubuntu-22.04:docker://docker.gitea.com/runner-images:ubuntu-22.04",
|
"ubuntu-22.04:docker://docker.gitea.com/runner-images:ubuntu-22.04",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,16 +27,9 @@ func TestRunPostTaskScriptSkippedWhenEmpty(t *testing.T) {
|
|||||||
r := &Runner{
|
r := &Runner{
|
||||||
cfg: &config.Config{},
|
cfg: &config.Config{},
|
||||||
}
|
}
|
||||||
ctx, cancel := context.WithCancel(t.Context())
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
taskCtx, err := structpb.NewStruct(map[string]any{})
|
|
||||||
require.NoError(t, err)
|
|
||||||
task := &runnerv1.Task{Id: 1, Context: taskCtx}
|
|
||||||
reporter := report.NewReporter(ctx, cancel, nil, task, r.cfg)
|
|
||||||
|
|
||||||
require.NotPanics(t, func() {
|
require.NotPanics(t, func() {
|
||||||
r.runPostTaskScript(ctx, reporter, task, "/workspace/owner/repo")
|
r.runPostTaskScript(t.Context(), nil, nil, "/workspace/owner/repo")
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,9 +52,7 @@ func TestRunPostTaskScriptNonZeroExitDoesNotPanic(t *testing.T) {
|
|||||||
ctx, cancel := context.WithCancel(t.Context())
|
ctx, cancel := context.WithCancel(t.Context())
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
taskCtx, err := structpb.NewStruct(map[string]any{})
|
task := &runnerv1.Task{Id: 1, Context: &structpb.Struct{}}
|
||||||
require.NoError(t, err)
|
|
||||||
task := &runnerv1.Task{Id: 1, Context: taskCtx}
|
|
||||||
reporter := report.NewReporter(ctx, cancel, nil, task, cfg)
|
reporter := report.NewReporter(ctx, cancel, nil, task, cfg)
|
||||||
|
|
||||||
require.NotPanics(t, func() {
|
require.NotPanics(t, func() {
|
||||||
@@ -146,9 +137,7 @@ func TestRunPostTaskScriptIntegration(t *testing.T) {
|
|||||||
ctx, cancel := context.WithCancel(t.Context())
|
ctx, cancel := context.WithCancel(t.Context())
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
taskCtx, err := structpb.NewStruct(map[string]any{})
|
task := &runnerv1.Task{Id: 11, Context: &structpb.Struct{}}
|
||||||
require.NoError(t, err)
|
|
||||||
task := &runnerv1.Task{Id: 11, Context: taskCtx}
|
|
||||||
reporter := report.NewReporter(ctx, cancel, nil, task, cfg)
|
reporter := report.NewReporter(ctx, cancel, nil, task, cfg)
|
||||||
setReporterJobResult(t, reporter, runnerv1.Result_RESULT_SUCCESS)
|
setReporterJobResult(t, reporter, runnerv1.Result_RESULT_SUCCESS)
|
||||||
|
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ func JobProxyEnv(jobEnvs map[string]string, cacheURL string, serviceNames []stri
|
|||||||
setPair("https_proxy", "HTTPS_PROXY", cfg.HTTPSProxy)
|
setPair("https_proxy", "HTTPS_PROXY", cfg.HTTPSProxy)
|
||||||
|
|
||||||
// no_proxy is merged rather than replaced: the hosts above are structural, and an
|
// 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...)
|
noProxy := appendNoProxy(cfg.NoProxy, direct...)
|
||||||
for _, name := range []string{"no_proxy", "NO_PROXY"} {
|
for _, name := range []string{"no_proxy", "NO_PROXY"} {
|
||||||
if existing, ok := jobEnvs[name]; ok {
|
if existing, ok := jobEnvs[name]; ok {
|
||||||
|
|||||||
+48
-27
@@ -73,12 +73,13 @@ type Runner struct {
|
|||||||
cacheHandler *artifactcache.Handler
|
cacheHandler *artifactcache.Handler
|
||||||
capabilities string
|
capabilities string
|
||||||
|
|
||||||
isolatedCacheContainer func() string
|
isolatedCacheContainer func(cacheURL string) string
|
||||||
instanceTransport http.RoundTripper
|
instanceTransport http.RoundTripper
|
||||||
|
|
||||||
runningTasks sync.Map
|
runningTasks sync.Map
|
||||||
runningCount atomic.Int64
|
runningCount atomic.Int64
|
||||||
lastIdleCleanupUnixNano atomic.Int64
|
lastIdleCleanupUnixNano atomic.Int64
|
||||||
|
slots slotAllocator
|
||||||
now func() time.Time
|
now func() time.Time
|
||||||
healthCheckLast time.Time
|
healthCheckLast time.Time
|
||||||
healthCheckReady bool
|
healthCheckReady bool
|
||||||
@@ -118,7 +119,6 @@ func NewRunner(cfg *config.Config, reg *config.Registration, cli client.Client)
|
|||||||
// go on
|
// go on
|
||||||
} else {
|
} else {
|
||||||
cacheHandler = handler
|
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,
|
now: time.Now,
|
||||||
runHealthCheck: executeHealthCheck,
|
runHealthCheck: executeHealthCheck,
|
||||||
}
|
}
|
||||||
runner.isolatedCacheContainer = sync.OnceValue(runner.detectIsolatedCacheContainer)
|
runner.isolatedCacheContainer = lastResult(runner.detectIsolatedCacheContainer)
|
||||||
runner.instanceTransport = instanceTransport
|
runner.instanceTransport = instanceTransport
|
||||||
return runner
|
return runner
|
||||||
}
|
}
|
||||||
@@ -196,7 +196,7 @@ func (r *Runner) cleanupOrphanDockerResources(ctx context.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
cutoff := r.now().Add(-orphanDockerResourceAge)
|
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)
|
logging.Warnf("failed to clean up networks left behind by earlier jobs: %v", err)
|
||||||
}
|
}
|
||||||
if err := removeOrphanJobVolumes(ctx, r.uuid, cutoff); err != nil {
|
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)
|
workflowCallInputs := restoreCalledWorkflowContext(task)
|
||||||
for _, line := range r.setupLines(task) {
|
for _, line := range r.setupLines(task) {
|
||||||
reporter.Logf("%s", line)
|
reporter.Logf("%s", line)
|
||||||
@@ -485,7 +488,7 @@ func (r *Runner) run(ctx context.Context, task *runnerv1.Task, reporter *report.
|
|||||||
return fallbackPlatform()
|
return fallbackPlatform()
|
||||||
}
|
}
|
||||||
platform := platformPicker(job.RunsOn())
|
platform := platformPicker(job.RunsOn())
|
||||||
cacheURL, cacheContainer := r.cacheForJob(job, platform)
|
cacheURL, cacheContainer := r.cacheForJob(job, platform, reporter.Logf)
|
||||||
if cacheURL != "" {
|
if cacheURL != "" {
|
||||||
envs["ACTIONS_CACHE_URL"] = cacheURL + "/"
|
envs["ACTIONS_CACHE_URL"] = cacheURL + "/"
|
||||||
}
|
}
|
||||||
@@ -612,7 +615,7 @@ func (r *Runner) run(ctx context.Context, task *runnerv1.Task, reporter *report.
|
|||||||
},
|
},
|
||||||
ContainerOptions: r.cfg.Container.Options,
|
ContainerOptions: r.cfg.Container.Options,
|
||||||
ServiceReadyTimeout: r.cfg.Container.ServiceReadyTimeout,
|
ServiceReadyTimeout: r.cfg.Container.ServiceReadyTimeout,
|
||||||
NoSweep: r.cfg.Container.Sweep != nil && !*r.cfg.Container.Sweep,
|
Sweep: r.cfg.Container.Sweep,
|
||||||
ContainerDaemonSocket: r.cfg.Container.DockerHost,
|
ContainerDaemonSocket: r.cfg.Container.DockerHost,
|
||||||
Privileged: r.cfg.Container.Privileged,
|
Privileged: r.cfg.Container.Privileged,
|
||||||
DefaultActionInstance: r.getDefaultActionsURL(task),
|
DefaultActionInstance: r.getDefaultActionsURL(task),
|
||||||
@@ -624,6 +627,7 @@ func (r *Runner) run(ctx context.Context, task *runnerv1.Task, reporter *report.
|
|||||||
ValidVolumes: r.cfg.Container.ValidVolumes,
|
ValidVolumes: r.cfg.Container.ValidVolumes,
|
||||||
ContainerVolumes: r.cfg.Container.Volumes,
|
ContainerVolumes: r.cfg.Container.Volumes,
|
||||||
SharedToolCache: r.cfg.Runner.ToolCacheMode == config.ToolCacheModeShared,
|
SharedToolCache: r.cfg.Runner.ToolCacheMode == config.ToolCacheModeShared,
|
||||||
|
ToolCacheSlot: toolCacheSlot(r.cfg.Runner.ToolCacheMode, slot),
|
||||||
InsecureSkipTLS: r.cfg.Runner.Insecure,
|
InsecureSkipTLS: r.cfg.Runner.Insecure,
|
||||||
ClientCertFile: r.cfg.Runner.ClientCertFile,
|
ClientCertFile: r.cfg.Runner.ClientCertFile,
|
||||||
ClientKeyFile: r.cfg.Runner.ClientKeyFile,
|
ClientKeyFile: r.cfg.Runner.ClientKeyFile,
|
||||||
@@ -663,11 +667,24 @@ func (r *Runner) run(ctx context.Context, task *runnerv1.Task, reporter *report.
|
|||||||
return execErr
|
return execErr
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Runner) builtInCacheURL() string {
|
func lastResult(compute func(key string) (string, bool)) func(key string) string {
|
||||||
if r.cacheHandler == nil {
|
var (
|
||||||
return ""
|
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) {
|
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).
|
// Safe with an empty token (older Gitea did not issue one).
|
||||||
// It also returns the URL to advertise as ACTIONS_RESULTS_URL.
|
// 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) {
|
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() {}, ""
|
return func() {}, ""
|
||||||
}
|
}
|
||||||
cred.Results = r.envs["ACTIONS_RESULTS_URL"] // the instance as the job would reach it
|
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.
|
// 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()
|
jobContainer := job.Container()
|
||||||
cacheContainer := ""
|
if cacheURL == "" || strings.HasPrefix(platform, labels.SchemeKubernetes+"://") || (jobContainer == nil || jobContainer.Image == "") && platform == labels.SelfHostedPlatform {
|
||||||
if !strings.HasPrefix(platform, labels.SchemeKubernetes+"://") && (jobContainer != nil && jobContainer.Image != "" || platform != labels.SelfHostedPlatform) {
|
return cacheURL, ""
|
||||||
cacheContainer = r.isolatedCacheContainer()
|
|
||||||
}
|
}
|
||||||
if cacheContainer == "" {
|
if cacheContainer := r.isolatedCacheContainer(cacheURL); cacheContainer != "" {
|
||||||
return strings.TrimSuffix(r.builtInCacheURL(), "/"), ""
|
isolatedURL, _ := url.Parse(cacheURL)
|
||||||
|
isolatedURL.Host = net.JoinHostPort(cacheContainer, isolatedURL.Port())
|
||||||
|
return isolatedURL.String(), cacheContainer
|
||||||
}
|
}
|
||||||
cacheURL, _ := url.Parse(r.cacheHandler.ExternalURL())
|
network, enableIPv6 := r.cfg.Container.Network, r.cfg.Container.NetworkCreateOptions.EnableIPv6
|
||||||
cacheURL.Host = net.JoinHostPort(cacheContainer, cacheURL.Port())
|
if addr, err := netip.ParseAddr(hostOf(cacheURL)); err == nil && addr.Is6() && r.cfg.Cache.Host == "" &&
|
||||||
return cacheURL.String(), cacheContainer
|
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 {
|
func (r *Runner) detectIsolatedCacheContainer(cacheURL string) (string, bool) {
|
||||||
if r.cacheHandler == nil {
|
addr, err := netip.ParseAddr(hostOf(cacheURL))
|
||||||
return ""
|
|
||||||
}
|
|
||||||
addr, err := netip.ParseAddr(hostOf(r.cacheHandler.ExternalURL()))
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ""
|
return "", true
|
||||||
}
|
}
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
cacheContainer, err := container.IsolatedCacheContainer(ctx, addr, r.cfg.Container.Network)
|
cacheContainer, err := container.IsolatedCacheContainer(ctx, addr, r.cfg.Container.Network)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logging.Warnf("cannot check whether jobs reach the cache server: %v", err)
|
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.
|
// taskLogFields returns key/value pairs for logging.With.
|
||||||
|
|||||||
@@ -155,12 +155,11 @@ func TestRunner_ExternalCacheServer_RegisterRevoke(t *testing.T) {
|
|||||||
}, &config.Registration{}, cli)
|
}, &config.Registration{}, cli)
|
||||||
t.Cleanup(func() { _ = r.Close() })
|
t.Cleanup(func() { _ = r.Close() })
|
||||||
require.NotNil(t, r.cacheHandler)
|
require.NotNil(t, r.cacheHandler)
|
||||||
require.Equal(t, r.cacheHandler.ExternalURL()+"/", r.envs["ACTIONS_CACHE_URL"])
|
|
||||||
|
|
||||||
const publicURL = "http://a1b2c3d4e5f6:8088"
|
const publicURL = "http://a1b2c3d4e5f6:8088"
|
||||||
token := "external-task-token"
|
token := "external-task-token"
|
||||||
repo := "owner/repoX"
|
repo := "owner/repoX"
|
||||||
base := r.envs["ACTIONS_CACHE_URL"] + "_apis/artifactcache"
|
base := r.cacheHandler.ExternalURL() + "/_apis/artifactcache"
|
||||||
probe := func() int {
|
probe := func() int {
|
||||||
resp := cacheRequest(t, token, http.MethodGet, base+"/cache?keys=k&version=v", "", "", "")
|
resp := cacheRequest(t, token, http.MethodGet, base+"/cache?keys=k&version=v", "", "", "")
|
||||||
resp.Body.Close()
|
resp.Body.Close()
|
||||||
@@ -213,7 +212,7 @@ func TestRunner_ExternalCacheServer_RegisterRevoke(t *testing.T) {
|
|||||||
require.NoError(t, decodeJSON(resp, &hit))
|
require.NoError(t, decodeJSON(resp, &hit))
|
||||||
require.True(t, strings.HasPrefix(hit.ArchiveLocation, publicURL+"/"), hit.ArchiveLocation)
|
require.True(t, strings.HasPrefix(hit.ArchiveLocation, publicURL+"/"), hit.ArchiveLocation)
|
||||||
|
|
||||||
unregisterOther, _ := r.registerCacheForTask("external-other-token", artifactcache.JobCredential{Repo: repo, Ref: "refs/heads/other"}, nil)
|
unregisterOther, _ := r.registerCacheForTask("external-other-token", artifactcache.JobCredential{Repo: repo, Ref: "refs/heads/other", PublicURL: publicURL}, nil)
|
||||||
defer unregisterOther()
|
defer unregisterOther()
|
||||||
resp = cacheRequest(t, "external-other-token", http.MethodGet, base+"/cache?keys=ext-key&version=v", "", "", "")
|
resp = cacheRequest(t, "external-other-token", http.MethodGet, base+"/cache?keys=ext-key&version=v", "", "", "")
|
||||||
resp.Body.Close()
|
resp.Body.Close()
|
||||||
|
|||||||
@@ -21,8 +21,7 @@ import (
|
|||||||
|
|
||||||
func TestRunnerCleanupStaleTaskDirs(t *testing.T) {
|
func TestRunnerCleanupStaleTaskDirs(t *testing.T) {
|
||||||
now := time.Date(2026, time.April, 29, 20, 0, 0, 0, time.UTC)
|
now := time.Date(2026, time.April, 29, 20, 0, 0, 0, time.UTC)
|
||||||
workdirRoot := filepath.Join(t.TempDir(), "workspace")
|
workdirRoot := t.TempDir()
|
||||||
require.NoError(t, os.MkdirAll(workdirRoot, 0o700))
|
|
||||||
|
|
||||||
oldTask := filepath.Join(workdirRoot, "1001")
|
oldTask := filepath.Join(workdirRoot, "1001")
|
||||||
freshTask := filepath.Join(workdirRoot, "1002")
|
freshTask := filepath.Join(workdirRoot, "1002")
|
||||||
@@ -59,8 +58,7 @@ func TestRunnerCleanupStaleTaskDirs(t *testing.T) {
|
|||||||
// be reclaimed, while the shared tool_cache and operator data are preserved.
|
// be reclaimed, while the shared tool_cache and operator data are preserved.
|
||||||
func TestRunnerOnIdleCleansStaleHostScratchDirs(t *testing.T) {
|
func TestRunnerOnIdleCleansStaleHostScratchDirs(t *testing.T) {
|
||||||
now := time.Date(2026, time.April, 29, 20, 0, 0, 0, time.UTC)
|
now := time.Date(2026, time.April, 29, 20, 0, 0, 0, time.UTC)
|
||||||
hostRoot := filepath.Join(t.TempDir(), "act")
|
hostRoot := t.TempDir()
|
||||||
require.NoError(t, os.MkdirAll(hostRoot, 0o700))
|
|
||||||
|
|
||||||
staleScratch := filepath.Join(hostRoot, "0123456789abcdef") // 16 hex
|
staleScratch := filepath.Join(hostRoot, "0123456789abcdef") // 16 hex
|
||||||
freshScratch := filepath.Join(hostRoot, "fedcba9876543210")
|
freshScratch := filepath.Join(hostRoot, "fedcba9876543210")
|
||||||
@@ -118,8 +116,7 @@ func TestRunnerCleanupStaleTaskDirsMissingRoot(t *testing.T) {
|
|||||||
|
|
||||||
func TestRunnerCleanupStaleTaskDirsHonorsContext(t *testing.T) {
|
func TestRunnerCleanupStaleTaskDirsHonorsContext(t *testing.T) {
|
||||||
now := time.Date(2026, time.April, 29, 20, 0, 0, 0, time.UTC)
|
now := time.Date(2026, time.April, 29, 20, 0, 0, 0, time.UTC)
|
||||||
workdirRoot := filepath.Join(t.TempDir(), "workspace")
|
workdirRoot := t.TempDir()
|
||||||
require.NoError(t, os.MkdirAll(workdirRoot, 0o700))
|
|
||||||
|
|
||||||
for i := 1001; i <= 1003; i++ {
|
for i := 1001; i <= 1003; i++ {
|
||||||
dir := filepath.Join(workdirRoot, strconv.Itoa(i))
|
dir := filepath.Join(workdirRoot, strconv.Itoa(i))
|
||||||
@@ -344,7 +341,7 @@ func TestRunnerOnIdleRemovesOrphanNetworks(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
r := &Runner{uuid: "runner-1", cfg: cfg, labels: labels.Labels{kubernetesLabel}, now: func() time.Time { return now }}
|
r := &Runner{uuid: "runner-1", cfg: cfg, labels: labels.Labels{kubernetesLabel}, now: func() time.Time { return now }}
|
||||||
r.isolatedCacheContainer = func() string { return "a1b2c3d4e5f6" }
|
r.isolatedCacheContainer = func(_cacheURL string) string { return "a1b2c3d4e5f6" }
|
||||||
r.OnIdle(t.Context())
|
r.OnIdle(t.Context())
|
||||||
assert.Equal(t, []string{"runner-1"}, swept)
|
assert.Equal(t, []string{"runner-1"}, swept)
|
||||||
assert.Equal(t, swept, sweptVolumes)
|
assert.Equal(t, swept, sweptVolumes)
|
||||||
@@ -356,7 +353,7 @@ func TestRunnerOnIdleRemovesOrphanNetworks(t *testing.T) {
|
|||||||
dockerReachable = func(context.Context) bool { return false }
|
dockerReachable = func(context.Context) bool { return false }
|
||||||
t.Cleanup(func() { dockerReachable = origDockerReachable })
|
t.Cleanup(func() { dockerReachable = origDockerReachable })
|
||||||
hostOnly := &Runner{uuid: "runner-2", cfg: &config.Config{Runner: cfg.Runner}, now: func() time.Time { return now }}
|
hostOnly := &Runner{uuid: "runner-2", cfg: &config.Config{Runner: cfg.Runner}, now: func() time.Time { return now }}
|
||||||
hostOnly.isolatedCacheContainer = func() string { return "" }
|
hostOnly.isolatedCacheContainer = func(_cacheURL string) string { return "" }
|
||||||
hostOnly.OnIdle(t.Context())
|
hostOnly.OnIdle(t.Context())
|
||||||
assert.Equal(t, []string{"runner-1"}, swept)
|
assert.Equal(t, []string{"runner-1"}, swept)
|
||||||
assert.Equal(t, swept, sweptVolumes)
|
assert.Equal(t, swept, sweptVolumes)
|
||||||
|
|||||||
@@ -306,7 +306,7 @@ func TestNewRunnerLeavesProxyToTheTask(t *testing.T) {
|
|||||||
|
|
||||||
require.NotContains(t, r.envs, "http_proxy")
|
require.NotContains(t, r.envs, "http_proxy")
|
||||||
require.NotContains(t, r.envs, "no_proxy")
|
require.NotContains(t, r.envs, "no_proxy")
|
||||||
proxyEnv := JobProxyEnv(r.envs, r.builtInCacheURL(), nil)
|
proxyEnv := JobProxyEnv(r.envs, r.cacheHandler.ExternalURL(), nil)
|
||||||
assert.Contains(t, proxyEnv["no_proxy"], "runner.local")
|
assert.Contains(t, proxyEnv["no_proxy"], "runner.local")
|
||||||
assert.NotContains(t, proxyEnv["no_proxy"], "cache.local")
|
assert.NotContains(t, proxyEnv["no_proxy"], "cache.local")
|
||||||
}
|
}
|
||||||
@@ -355,7 +355,7 @@ func TestNewRunnerCacheServiceV2(t *testing.T) {
|
|||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode, "the advertised results service serves no cache service")
|
assert.Equal(t, http.StatusOK, resp.StatusCode, "the advertised results service serves no cache service")
|
||||||
assert.Equal(t, r.envs["ACTIONS_CACHE_URL"], r.builtInCacheURL(), "the address only the runner knows is bypassed for the operator")
|
assert.NotContains(t, r.envs, "ACTIONS_CACHE_URL")
|
||||||
|
|
||||||
envs := r.cloneEnvs()
|
envs := r.cloneEnvs()
|
||||||
r.setResultsService(envs, resultsURL)
|
r.setResultsService(envs, resultsURL)
|
||||||
@@ -390,10 +390,10 @@ func TestNewRunnerCacheServiceV2(t *testing.T) {
|
|||||||
|
|
||||||
workflow, err := model.ReadWorkflow(strings.NewReader(`jobs: {native: {runs-on: native}, linux: {runs-on: linux}, containerized: {runs-on: native, container: alpine}, empty: {runs-on: native, container: ""}}`))
|
workflow, err := model.ReadWorkflow(strings.NewReader(`jobs: {native: {runs-on: native}, linux: {runs-on: linux}, containerized: {runs-on: native, container: alpine}, empty: {runs-on: native, container: ""}}`))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
r.isolatedCacheContainer = func() string { return "a1b2c3d4e5f6" }
|
r.isolatedCacheContainer = func(_cacheURL string) string { return "a1b2c3d4e5f6" }
|
||||||
pickPlatform := func(runsOn []string) string { return map[string]string{"native": labels.SelfHostedPlatform}[runsOn[0]] }
|
pickPlatform := func(runsOn []string) string { return map[string]string{"native": labels.SelfHostedPlatform}[runsOn[0]] }
|
||||||
for job, isolated := range map[string]bool{"native": false, "linux": true, "containerized": true, "empty": false} {
|
for job, isolated := range map[string]bool{"native": false, "linux": true, "containerized": true, "empty": false} {
|
||||||
cacheURL, cacheContainer := r.cacheForJob(workflow.GetJob(job), pickPlatform(workflow.GetJob(job).RunsOn()))
|
cacheURL, cacheContainer := r.cacheForJob(workflow.GetJob(job), pickPlatform(workflow.GetJob(job).RunsOn()), t.Logf)
|
||||||
if isolated {
|
if isolated {
|
||||||
assert.Equal(t, "a1b2c3d4e5f6", cacheContainer, job)
|
assert.Equal(t, "a1b2c3d4e5f6", cacheContainer, job)
|
||||||
assert.Equal(t, strings.Replace(r.cacheHandler.ExternalURL(), "127.0.0.1", cacheContainer, 1), cacheURL, job)
|
assert.Equal(t, strings.Replace(r.cacheHandler.ExternalURL(), "127.0.0.1", cacheContainer, 1), cacheURL, job)
|
||||||
@@ -404,6 +404,41 @@ func TestNewRunnerCacheServiceV2(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCacheForJobIPv6WarningAndIsolationRecheck(t *testing.T) {
|
||||||
|
handler, err := artifactcache.StartHandler(artifactcache.Options{OutboundIP: "2001:db8::1", Upstream: "http://cache.invalid"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer handler.Close()
|
||||||
|
var checked []string
|
||||||
|
r := &Runner{cfg: &config.Config{}, cacheHandler: handler, isolatedCacheContainer: lastResult(func(cacheURL string) (string, bool) {
|
||||||
|
checked = append(checked, cacheURL)
|
||||||
|
return "", cacheURL != "http://unreachable"
|
||||||
|
})}
|
||||||
|
warnings := 0
|
||||||
|
logf := func(_format string, _args ...any) { warnings++ }
|
||||||
|
workflow, err := model.ReadWorkflow(strings.NewReader(`jobs: {native: {runs-on: native}, containerized: {runs-on: native, container: alpine}}`))
|
||||||
|
require.NoError(t, err)
|
||||||
|
for _, job := range []string{"native", "containerized", "containerized"} {
|
||||||
|
cacheURL, _ := r.cacheForJob(workflow.GetJob(job), labels.SelfHostedPlatform, logf)
|
||||||
|
assert.Equal(t, handler.ExternalURL(), cacheURL)
|
||||||
|
}
|
||||||
|
assert.Equal(t, 2, warnings)
|
||||||
|
assert.Equal(t, []string{handler.ExternalURL()}, checked)
|
||||||
|
|
||||||
|
r.cfg.Container.NetworkCreateOptions.EnableIPv6 = new(true)
|
||||||
|
r.cacheForJob(workflow.GetJob("containerized"), labels.SelfHostedPlatform, logf)
|
||||||
|
assert.Equal(t, 2, warnings)
|
||||||
|
r.cfg.Container.Network = "bridge"
|
||||||
|
r.cacheForJob(workflow.GetJob("containerized"), labels.SelfHostedPlatform, logf)
|
||||||
|
assert.Equal(t, 3, warnings)
|
||||||
|
r.cfg.Cache.Host = "2001:db8::1"
|
||||||
|
r.cacheForJob(workflow.GetJob("containerized"), labels.SelfHostedPlatform, logf)
|
||||||
|
assert.Equal(t, 3, warnings)
|
||||||
|
|
||||||
|
r.isolatedCacheContainer("http://unreachable")
|
||||||
|
r.isolatedCacheContainer("http://unreachable")
|
||||||
|
assert.Len(t, checked, 3)
|
||||||
|
}
|
||||||
|
|
||||||
func TestRestoreCalledWorkflowContext(t *testing.T) {
|
func TestRestoreCalledWorkflowContext(t *testing.T) {
|
||||||
newTask := func(workflowCall map[string]any) *runnerv1.Task {
|
newTask := func(workflowCall map[string]any) *runnerv1.Task {
|
||||||
values := map[string]any{
|
values := map[string]any{
|
||||||
|
|||||||
@@ -96,6 +96,7 @@ func TestSetupLinesInputs(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestPrettyOSName(t *testing.T) {
|
func TestPrettyOSName(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
for _, tt := range []struct {
|
for _, tt := range []struct {
|
||||||
name string
|
name string
|
||||||
osRelease string
|
osRelease string
|
||||||
@@ -109,7 +110,7 @@ func TestPrettyOSName(t *testing.T) {
|
|||||||
{"missing file", "", "", true},
|
{"missing file", "", "", true},
|
||||||
} {
|
} {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
path := filepath.Join(t.TempDir(), "os-release")
|
path := filepath.Join(dir, tt.name)
|
||||||
if !tt.missing {
|
if !tt.missing {
|
||||||
require.NoError(t, os.WriteFile(path, []byte(tt.osRelease), 0o600))
|
require.NoError(t, os.WriteFile(path, []byte(tt.osRelease), 0o600))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package run
|
||||||
|
|
||||||
|
import (
|
||||||
|
"slices"
|
||||||
|
"strconv"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"gitea.com/gitea/runner/internal/pkg/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// slotAllocator hands out the lowest free slot, so slot numbers stay below capacity.
|
||||||
|
type slotAllocator struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
used []bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *slotAllocator) acquire() int {
|
||||||
|
a.mu.Lock()
|
||||||
|
defer a.mu.Unlock()
|
||||||
|
slot := slices.Index(a.used, false)
|
||||||
|
if slot < 0 {
|
||||||
|
slot = len(a.used)
|
||||||
|
a.used = append(a.used, false)
|
||||||
|
}
|
||||||
|
a.used[slot] = true
|
||||||
|
return slot
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *slotAllocator) release(slot int) {
|
||||||
|
a.mu.Lock()
|
||||||
|
defer a.mu.Unlock()
|
||||||
|
a.used[slot] = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// toolCacheSlot leaves slot 0 unnamed, so it keeps the cache a runner had before slots existed.
|
||||||
|
func toolCacheSlot(mode string, slot int) string {
|
||||||
|
if mode != config.ToolCacheModeShared || slot == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strconv.Itoa(slot)
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package run
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.com/gitea/runner/internal/pkg/config"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestToolCacheSlotReusesLowestFree(t *testing.T) {
|
||||||
|
var slots slotAllocator
|
||||||
|
require.Equal(t, 0, slots.acquire())
|
||||||
|
require.Equal(t, 1, slots.acquire())
|
||||||
|
require.Equal(t, 2, slots.acquire())
|
||||||
|
slots.release(1)
|
||||||
|
assert.Equal(t, 1, slots.acquire())
|
||||||
|
assert.Equal(t, 3, slots.acquire())
|
||||||
|
|
||||||
|
assert.Equal(t, "3", toolCacheSlot(config.ToolCacheModeShared, 3))
|
||||||
|
assert.Empty(t, toolCacheSlot(config.ToolCacheModeShared, 0))
|
||||||
|
assert.Empty(t, toolCacheSlot(config.ToolCacheModeNone, 3))
|
||||||
|
}
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
|
||||||
// SPDX-License-Identifier: MIT
|
|
||||||
|
|
||||||
// Package action holds the native actions run by `uses: builtin:<name>`.
|
|
||||||
package action
|
|
||||||
|
|
||||||
import (
|
|
||||||
"gitea.com/gitea/runner/act/container"
|
|
||||||
|
|
||||||
"gitea.dev/actionslib/pkg/model"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Context struct {
|
|
||||||
Container container.ExecutionsEnvironment
|
|
||||||
Github *model.GithubContext
|
|
||||||
Inputs map[string]string
|
|
||||||
HostWorkdir string
|
|
||||||
Workspace string // HostWorkdir as seen inside Container
|
|
||||||
BindWorkdir bool
|
|
||||||
NoSkipCheckout bool
|
|
||||||
UseGitIgnore bool
|
|
||||||
InsecureSkipTLS bool
|
|
||||||
ClientCertFile string
|
|
||||||
ClientKeyFile string
|
|
||||||
CAFile string // the runner CA as seen inside Container
|
|
||||||
Env map[string]string
|
|
||||||
}
|
|
||||||
@@ -1,483 +0,0 @@
|
|||||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
|
||||||
// SPDX-License-Identifier: MIT
|
|
||||||
|
|
||||||
package checkout
|
|
||||||
|
|
||||||
import (
|
|
||||||
"cmp"
|
|
||||||
"context"
|
|
||||||
"crypto/sha256"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"maps"
|
|
||||||
"net/url"
|
|
||||||
"os"
|
|
||||||
"path"
|
|
||||||
"path/filepath"
|
|
||||||
"runtime"
|
|
||||||
"slices"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/common/git"
|
|
||||||
"gitea.com/gitea/runner/act/container"
|
|
||||||
"gitea.com/gitea/runner/internal/pkg/action"
|
|
||||||
|
|
||||||
"gitea.dev/actionslib/pkg/model"
|
|
||||||
"github.com/kballard/go-shellquote"
|
|
||||||
)
|
|
||||||
|
|
||||||
var inputNames = []string{
|
|
||||||
"repository", "ref", "token", "ssh-key", "ssh-known-hosts", "ssh-strict", "ssh-user", "persist-credentials", "path", "clean",
|
|
||||||
"filter", "sparse-checkout", "sparse-checkout-cone-mode", "fetch-depth", "fetch-tags", "show-progress", "lfs", "submodules",
|
|
||||||
"set-safe-directory",
|
|
||||||
}
|
|
||||||
|
|
||||||
const credentialsInclude = "includeIf.gitdir:**.path" // the form actions/checkout removes from a repository it checks out again
|
|
||||||
|
|
||||||
func Main(ctx context.Context, c *action.Context) (err error) {
|
|
||||||
for name := range c.Inputs {
|
|
||||||
if !slices.Contains(inputNames, name) {
|
|
||||||
return fmt.Errorf("checkout: unsupported input %q", name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
runnerContext := c.Container.GetRunnerContext(ctx)
|
|
||||||
executionOS, _ := runnerContext["os"].(string)
|
|
||||||
windows := strings.EqualFold(executionOS, "windows")
|
|
||||||
dst, err := workspaceDestination(c.Workspace, c.Inputs["path"], windows)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
depth, err := fetchDepth(c.Inputs["fetch-depth"])
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
ghc := c.Github
|
|
||||||
repository := cmp.Or(c.Inputs["repository"], ghc.Repository)
|
|
||||||
ref := c.Inputs["ref"]
|
|
||||||
sshKey := strings.TrimSpace(c.Inputs["ssh-key"])
|
|
||||||
isWorkflowRepo := strings.EqualFold(repository, ghc.Repository)
|
|
||||||
local := isWorkflowRepo && slices.Contains([]string{"", strings.TrimPrefix(ghc.Ref, "refs/heads/"), ghc.Sha}, strings.TrimPrefix(ref, "refs/heads/")) && !c.NoSkipCheckout && sshKey == ""
|
|
||||||
if local && (!c.BindWorkdir || dst == c.Workspace) {
|
|
||||||
if !c.BindWorkdir {
|
|
||||||
if err := c.Container.CopyDir(dst, c.HostWorkdir+string(filepath.Separator)+".", c.UseGitIgnore, false)(ctx); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if output := filepath.ToSlash(c.Env["GITHUB_OUTPUT"]); output != "" {
|
|
||||||
return c.Container.Copy(container.JoinPath(output, ".."), &container.FileEntry{Name: path.Base(output), Mode: 0o666, Body: fmt.Sprintf("ref=%s\ncommit=%s\n", ghc.Ref, ghc.Sha)})(ctx)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
co := &checkout{
|
|
||||||
url: strings.TrimSuffix(ghc.ServerURL, "/") + "/" + repository,
|
|
||||||
dst: dst,
|
|
||||||
windows: windows,
|
|
||||||
ref: ref,
|
|
||||||
depth: depth,
|
|
||||||
fetchTags: boolInput(c.Inputs, "fetch-tags", false),
|
|
||||||
clean: boolInput(c.Inputs, "clean", true),
|
|
||||||
lfs: boolInput(c.Inputs, "lfs", false),
|
|
||||||
progress: boolInput(c.Inputs, "show-progress", true),
|
|
||||||
cone: boolInput(c.Inputs, "sparse-checkout-cone-mode", true),
|
|
||||||
filter: strings.TrimSpace(c.Inputs["filter"]),
|
|
||||||
output: c.Env["GITHUB_OUTPUT"],
|
|
||||||
}
|
|
||||||
for pattern := range strings.Lines(c.Inputs["sparse-checkout"]) {
|
|
||||||
if pattern = strings.TrimSpace(pattern); pattern != "" {
|
|
||||||
co.sparse = append(co.sparse, pattern)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(co.sparse) > 0 {
|
|
||||||
co.filter = cmp.Or(co.filter, "blob:none")
|
|
||||||
}
|
|
||||||
if submodules := strings.ToLower(strings.TrimSpace(c.Inputs["submodules"])); submodules == "true" || submodules == "recursive" {
|
|
||||||
co.submodules = submodules
|
|
||||||
}
|
|
||||||
if sshKey != "" {
|
|
||||||
if co.url, err = sshURL(ghc, repository, strings.TrimSpace(c.Inputs["ssh-user"])); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
co.objectFormat = git.ObjectFormat(ref)
|
|
||||||
switch {
|
|
||||||
case co.objectFormat != "":
|
|
||||||
co.sha, co.ref = ref, ""
|
|
||||||
case ref == "" && isWorkflowRepo && strings.HasPrefix(ghc.Ref, "refs/"):
|
|
||||||
co.sha, co.ref = ghc.Sha, ghc.Ref
|
|
||||||
case ref == "" && isWorkflowRepo: // a merged pull request's ref is its merge commit
|
|
||||||
co.sha = ghc.Sha
|
|
||||||
case ref == "HEAD":
|
|
||||||
co.ref = ""
|
|
||||||
}
|
|
||||||
if isWorkflowRepo {
|
|
||||||
co.objectFormat = cmp.Or(co.objectFormat, git.ObjectFormat(ghc.Sha))
|
|
||||||
}
|
|
||||||
|
|
||||||
// named like actions/checkout's, which create-pull-request knows to hide, and gone with the job
|
|
||||||
runnerTemp, _ := runnerContext["temp"].(string)
|
|
||||||
runnerTemp = filepath.ToSlash(runnerTemp)
|
|
||||||
digest := sha256.Sum256([]byte(dst))
|
|
||||||
credentials := fmt.Sprintf("git-credentials-%x.config", digest[:8])
|
|
||||||
co.credentials = container.JoinPath(runnerTemp, credentials)
|
|
||||||
persist := boolInput(c.Inputs, "persist-credentials", true)
|
|
||||||
config, remoteConfig := serverConfig(ghc.ServerURL, append(git.RemoteConfig(cmp.Or(c.Inputs["token"], ghc.Token), c.InsecureSkipTLS), git.SSLConfig("http.", "", "", c.CAFile, executionOS)...), co.submodules != "" && sshKey == "")
|
|
||||||
if boolInput(c.Inputs, "set-safe-directory", true) {
|
|
||||||
remoteConfig = append(remoteConfig, "safe.directory=*") // a bound workdir belongs to the host user
|
|
||||||
}
|
|
||||||
if c.ClientCertFile != "" { // only for this step, later steps never get the runner's key
|
|
||||||
var certPEM, keyPEM []byte
|
|
||||||
if certPEM, err = os.ReadFile(c.ClientCertFile); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if keyPEM, err = os.ReadFile(c.ClientKeyFile); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
cert, key := fmt.Sprintf("git-client-cert-%x.pem", digest[:8]), fmt.Sprintf("git-client-key-%x.pem", digest[:8])
|
|
||||||
defer func() {
|
|
||||||
cleanupCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), time.Minute)
|
|
||||||
defer cancel()
|
|
||||||
err = errors.Join(err, c.Container.Copy(runnerTemp, &container.FileEntry{Name: key, Mode: 0o600})(cleanupCtx))
|
|
||||||
}()
|
|
||||||
if err := c.Container.Copy(runnerTemp, &container.FileEntry{Name: cert, Mode: 0o600, Body: string(certPEM)}, &container.FileEntry{Name: key, Mode: 0o600, Body: string(keyPEM)})(ctx); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
remoteConfig = append(remoteConfig, git.SSLConfig("http."+strings.TrimSuffix(ghc.ServerURL, "/")+"/.", container.JoinPath(runnerTemp, cert), container.JoinPath(runnerTemp, key), "", executionOS)...)
|
|
||||||
}
|
|
||||||
env := gitEnv(c, remoteConfig)
|
|
||||||
env["GIT_LFS_SKIP_SMUDGE"] = strconv.FormatBool(!co.lfs || len(co.sparse) == 0) // lfs pull fetches the objects of a full checkout in one go
|
|
||||||
if sshKey != "" {
|
|
||||||
key := fmt.Sprintf("git-ssh-key-%x", digest[:8])
|
|
||||||
files := []*container.FileEntry{{Name: key, Mode: 0o600, Body: sshKey + "\n"}}
|
|
||||||
ssh := []string{"ssh", "-i", container.JoinPath(runnerTemp, key)}
|
|
||||||
if boolInput(c.Inputs, "ssh-strict", true) {
|
|
||||||
ssh = append(ssh, "-o", "StrictHostKeyChecking=yes", "-o", "CheckHostIP=no")
|
|
||||||
}
|
|
||||||
if knownHosts := strings.TrimSpace(c.Inputs["ssh-known-hosts"]); knownHosts != "" {
|
|
||||||
name := fmt.Sprintf("git-ssh-known-hosts-%x", digest[:8])
|
|
||||||
files = append(files, &container.FileEntry{Name: name, Mode: 0o600, Body: knownHosts + "\n"})
|
|
||||||
ssh = append(ssh, "-o", "UserKnownHostsFile=~/.ssh/known_hosts "+strconv.Quote(container.JoinPath(runnerTemp, name)))
|
|
||||||
}
|
|
||||||
if !persist {
|
|
||||||
defer func() {
|
|
||||||
cleanupCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), time.Minute)
|
|
||||||
defer cancel()
|
|
||||||
err = errors.Join(err, c.Container.Copy(runnerTemp, &container.FileEntry{Name: key, Mode: 0o600})(cleanupCtx))
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
if err := c.Container.Copy(runnerTemp, files...)(ctx); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
env["GIT_SSH_COMMAND"] = shellquote.Join(ssh...)
|
|
||||||
config += "[core]\n\tsshCommand = " + strconv.Quote(env["GIT_SSH_COMMAND"]) + "\n"
|
|
||||||
}
|
|
||||||
|
|
||||||
if co.objectFormat == "" || co.sha == "" && !strings.HasPrefix(co.ref, "refs/") {
|
|
||||||
advertised, err := lsRemote(ctx, c, env, co.url, co.ref)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("checkout: %s@%s: %w", repository, ref, err)
|
|
||||||
}
|
|
||||||
if co.resolve(advertised); co.ref == "" && co.sha == "" {
|
|
||||||
return fmt.Errorf("checkout: %s has no ref %q", repository, ref)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
co.outputRef = co.ref
|
|
||||||
if ref != co.sha {
|
|
||||||
co.outputRef = cmp.Or(ref, co.ref) // as given, like actions/checkout
|
|
||||||
}
|
|
||||||
if err := c.Container.Copy(runnerTemp, &container.FileEntry{Name: credentials, Mode: 0o600})(ctx); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
clone := func(commands [][]string) error {
|
|
||||||
if err := run(ctx, c, env, commands); err != nil {
|
|
||||||
return fmt.Errorf("checkout: clone %s@%s: %w", repository, cmp.Or(co.ref, co.sha), err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
commands, fetched := co.commands()
|
|
||||||
if co.sha != "" && strings.HasPrefix(co.ref, "refs/tags/") {
|
|
||||||
if err := clone(commands[:fetched]); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
tagged, err := output(ctx, c, env, "git", "-C", dst, "rev-parse", "--verify", "--quiet", co.ref+"^{commit}")
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("checkout: %s: %w", co.ref, err)
|
|
||||||
}
|
|
||||||
if !strings.EqualFold(strings.TrimSpace(tagged), co.sha) {
|
|
||||||
return fmt.Errorf("checkout: %s does not point to the expected commit %s, it may have been moved after the workflow was triggered", co.ref, co.sha)
|
|
||||||
}
|
|
||||||
commands = commands[fetched:]
|
|
||||||
}
|
|
||||||
if err := clone(commands); err != nil || !persist {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return c.Container.Copy(runnerTemp, &container.FileEntry{Name: credentials, Mode: 0o600, Body: config})(ctx) // for later steps, the step itself authenticates through env
|
|
||||||
}
|
|
||||||
|
|
||||||
func gitEnv(c *action.Context, config []string) map[string]string {
|
|
||||||
env := map[string]string{"GIT_TERMINAL_PROMPT": "0"}
|
|
||||||
maps.Copy(env, c.Env)
|
|
||||||
maps.DeleteFunc(env, func(name, _ string) bool { return slices.Contains(git.LocalEnvVars, name) })
|
|
||||||
maps.Copy(env, git.ConfigEnv(append([]string{"protocol.version=2", "init.defaultBranch=main", "advice.detachedHead=false", "maintenance.auto=false", "core.longpaths=true"}, config...)...))
|
|
||||||
return env
|
|
||||||
}
|
|
||||||
|
|
||||||
func run(ctx context.Context, c *action.Context, env map[string]string, commands [][]string) error {
|
|
||||||
if _, host := c.Container.(*container.HostEnvironment); !host || runtime.GOOS != "windows" {
|
|
||||||
script := []string{"set -e", `command -v git >/dev/null || { echo "builtin:checkout needs git on PATH" >&2; exit 127; }`}
|
|
||||||
for _, command := range commands {
|
|
||||||
script = append(script, shellquote.Join(command...))
|
|
||||||
}
|
|
||||||
return c.Container.Exec([]string{"sh", "-c", strings.Join(script, "\n")}, env, "", "")(ctx)
|
|
||||||
}
|
|
||||||
for _, command := range commands {
|
|
||||||
if err := c.Container.Exec(command, env, "", "")(ctx); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// output runs where the job runs, so the job's network and git config apply as for the fetch.
|
|
||||||
func output(ctx context.Context, c *action.Context, env map[string]string, command ...string) (string, error) {
|
|
||||||
var out strings.Builder
|
|
||||||
stdout, stderr := c.Container.ReplaceLogWriter(&out, &out)
|
|
||||||
defer c.Container.ReplaceLogWriter(stdout, stderr)
|
|
||||||
if err := c.Container.Exec(command, env, "", "")(ctx); err != nil {
|
|
||||||
return "", fmt.Errorf("%w: %s", err, strings.TrimSpace(out.String()))
|
|
||||||
}
|
|
||||||
return out.String(), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func lsRemote(ctx context.Context, c *action.Context, env map[string]string, url, ref string) (string, error) {
|
|
||||||
patterns := []string{ref}
|
|
||||||
switch {
|
|
||||||
case ref == "":
|
|
||||||
patterns = []string{"HEAD"}
|
|
||||||
case !strings.HasPrefix(ref, "refs/"):
|
|
||||||
patterns = []string{"refs/heads/" + ref, "refs/tags/" + ref}
|
|
||||||
}
|
|
||||||
return output(ctx, c, env, append([]string{"git", "ls-remote", "--symref", url}, patterns...)...)
|
|
||||||
}
|
|
||||||
|
|
||||||
// serverConfig scopes the token to the server, so no other host like a repository's LFS endpoint gets it.
|
|
||||||
func serverConfig(serverURL string, settings []string, rewriteSSH bool) (file string, env []string) {
|
|
||||||
scope := strings.TrimSuffix(serverURL, "/") + "/"
|
|
||||||
file = fmt.Sprintf("[http %q]\n", scope)
|
|
||||||
for _, entry := range settings {
|
|
||||||
setting := strings.TrimPrefix(entry, "http.")
|
|
||||||
key, value, _ := strings.Cut(setting, "=")
|
|
||||||
file += "\t" + key + " = " + strconv.Quote(value) + "\n"
|
|
||||||
env = append(env, "http."+scope+"."+setting)
|
|
||||||
}
|
|
||||||
if server, err := url.Parse(serverURL); rewriteSSH && err == nil && server.Hostname() != "" { // submodules on the server fetch over HTTP with the token
|
|
||||||
file += fmt.Sprintf("[url %q]\n\tinsteadOf = git@%s:\n", scope, server.Hostname())
|
|
||||||
env = append(env, "url."+scope+".insteadOf=git@"+server.Hostname()+":")
|
|
||||||
}
|
|
||||||
return file, env
|
|
||||||
}
|
|
||||||
|
|
||||||
func sshURL(ghc *model.GithubContext, repository, user string) (string, error) {
|
|
||||||
endpoint := &url.URL{Scheme: "ssh"}
|
|
||||||
if advertised, _ := model.NestedMapLookup(ghc.Event, "repository", "ssh_url").(string); advertised != "" {
|
|
||||||
if colon := strings.LastIndex(advertised, ":"); !strings.HasPrefix(advertised, "ssh://") && colon >= 0 {
|
|
||||||
advertised = "ssh://" + advertised[:colon] + "/" + advertised[colon+1:] // scp-like git@host:owner/repo.git
|
|
||||||
}
|
|
||||||
var err error
|
|
||||||
if endpoint, err = url.Parse(advertised); err != nil {
|
|
||||||
return "", fmt.Errorf("checkout: parse the event's ssh_url: %w", err)
|
|
||||||
}
|
|
||||||
} else if server, err := url.Parse(ghc.ServerURL); err == nil {
|
|
||||||
endpoint.Host = server.Hostname()
|
|
||||||
}
|
|
||||||
endpoint.User = url.User(cmp.Or(user, endpoint.User.Username(), "git"))
|
|
||||||
endpoint.Path = "/" + repository + ".git"
|
|
||||||
return endpoint.String(), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type checkout struct {
|
|
||||||
url, dst, objectFormat, credentials, output, outputRef string
|
|
||||||
ref, sha string // ref is fully qualified or empty, sha pins it when known
|
|
||||||
filter, submodules string
|
|
||||||
sparse []string
|
|
||||||
depth int
|
|
||||||
fetchTags, clean, lfs, progress, cone, windows bool
|
|
||||||
}
|
|
||||||
|
|
||||||
const fullDepth = "2147483647" // git's INFINITE_DEPTH, also deepens a shallow dst
|
|
||||||
|
|
||||||
// commands checks out co.dst, the first `fetched` of them fetch so a fetched tag can be verified before the rest.
|
|
||||||
func (co *checkout) commands() (commands [][]string, fetched int) {
|
|
||||||
repo := func(args ...string) []string { return append([]string{"git", "-C", co.dst}, args...) }
|
|
||||||
commands = [][]string{
|
|
||||||
{"git", "init", "--quiet", "--object-format=" + co.objectFormat, co.dst},
|
|
||||||
repo("config", "remote.origin.url", co.url), // `remote add` fails on a repeated checkout
|
|
||||||
repo("config", "remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*"),
|
|
||||||
repo("config", "--replace-all", "--fixed-value", credentialsInclude, co.credentials, co.credentials),
|
|
||||||
}
|
|
||||||
if co.windows {
|
|
||||||
commands = append(commands, repo("config", "core.longpaths", "true")) // later steps run git on the checkout too
|
|
||||||
}
|
|
||||||
if co.lfs {
|
|
||||||
commands = append(commands, repo("lfs", "install", "--local"))
|
|
||||||
}
|
|
||||||
|
|
||||||
depth := strconv.Itoa(co.depth)
|
|
||||||
var refSpecs []string
|
|
||||||
if co.depth == 0 {
|
|
||||||
depth = fullDepth
|
|
||||||
refSpecs = append(refSpecs, "+refs/heads/*:refs/remotes/origin/*")
|
|
||||||
}
|
|
||||||
fetch := []string{"fetch", "--force", "--no-tags", "--no-recurse-submodules", "--depth", depth}
|
|
||||||
if co.progress {
|
|
||||||
fetch = append(fetch, "--progress")
|
|
||||||
} else {
|
|
||||||
fetch = append(fetch, "--no-progress")
|
|
||||||
}
|
|
||||||
if co.filter != "" {
|
|
||||||
fetch = append(fetch, "--filter="+co.filter)
|
|
||||||
}
|
|
||||||
if co.fetchTags || co.depth == 0 {
|
|
||||||
refSpecs = append(refSpecs, "+refs/tags/*:refs/tags/*")
|
|
||||||
}
|
|
||||||
tag := strings.HasPrefix(co.ref, "refs/tags/") // fetched itself, which keeps an annotated tag's object
|
|
||||||
pinned := co.sha != "" && co.ref != "" && !tag
|
|
||||||
if co.ref != "" && !pinned {
|
|
||||||
refSpecs = append(refSpecs, "+"+co.ref+":"+trackingRef(co.ref))
|
|
||||||
}
|
|
||||||
if co.sha != "" && !tag {
|
|
||||||
refSpecs = append(refSpecs, co.sha)
|
|
||||||
}
|
|
||||||
commands = append(commands, repo(append(append(fetch, "origin"), refSpecs...)...))
|
|
||||||
if pinned {
|
|
||||||
commands = append(commands, repo("update-ref", trackingRef(co.ref), co.sha))
|
|
||||||
}
|
|
||||||
fetched = len(commands)
|
|
||||||
|
|
||||||
if len(co.sparse) > 0 {
|
|
||||||
mode := "--cone"
|
|
||||||
if !co.cone {
|
|
||||||
mode = "--no-cone"
|
|
||||||
}
|
|
||||||
commands = append(commands, repo("sparse-checkout", "init", mode), repo(append([]string{"sparse-checkout", "set", "--"}, co.sparse...)...))
|
|
||||||
}
|
|
||||||
if branch, ok := strings.CutPrefix(co.ref, "refs/heads/"); ok {
|
|
||||||
commands = append(commands, repo("checkout", "--force", "--ignore-other-worktrees", "-B", branch, "--track", trackingRef(co.ref)))
|
|
||||||
} else {
|
|
||||||
commands = append(commands, repo("checkout", "--force", cmp.Or(co.sha, trackingRef(co.ref))))
|
|
||||||
}
|
|
||||||
if len(co.sparse) == 0 { // after the checkout, so a sparse dst of another repository needs nothing of it
|
|
||||||
commands = append(commands, repo("sparse-checkout", "disable"), repo("config", "--local", "--unset-all", "extensions.worktreeConfig"))
|
|
||||||
}
|
|
||||||
if co.lfs && len(co.sparse) == 0 {
|
|
||||||
commands = append(commands, repo("lfs", "pull", "origin"))
|
|
||||||
}
|
|
||||||
if co.clean {
|
|
||||||
commands = append(commands, repo("clean", "-ffdx"))
|
|
||||||
}
|
|
||||||
if co.submodules != "" {
|
|
||||||
commands = append(commands, co.submoduleCommands(repo)...)
|
|
||||||
}
|
|
||||||
if co.output != "" {
|
|
||||||
commands = append(commands, repo("log", "-1", "--format=commit=%H%nref="+strings.ReplaceAll(co.outputRef, "%", "%%"), "--output="+co.output))
|
|
||||||
}
|
|
||||||
return commands, fetched
|
|
||||||
}
|
|
||||||
|
|
||||||
func (co *checkout) submoduleCommands(repo func(args ...string) []string) [][]string {
|
|
||||||
var recursive []string
|
|
||||||
if co.submodules == "recursive" {
|
|
||||||
recursive = []string{"--recursive"}
|
|
||||||
}
|
|
||||||
foreach := func(commands ...[]string) []string {
|
|
||||||
joined := make([]string, len(commands))
|
|
||||||
for i, command := range commands {
|
|
||||||
joined[i] = shellquote.Join(command...)
|
|
||||||
}
|
|
||||||
return repo(append(append([]string{"submodule", "foreach"}, recursive...), strings.Join(joined, " && "))...)
|
|
||||||
}
|
|
||||||
commands := [][]string{repo(append([]string{"submodule", "sync"}, recursive...)...)}
|
|
||||||
if co.clean {
|
|
||||||
commands = append(commands, foreach([]string{"git", "clean", "-ffdx"}))
|
|
||||||
}
|
|
||||||
update := append([]string{"submodule", "update", "--init", "--force"}, recursive...)
|
|
||||||
if co.depth > 0 {
|
|
||||||
update = append(update, "--depth="+strconv.Itoa(co.depth))
|
|
||||||
}
|
|
||||||
include := []string{"git", "config", "--replace-all", "--fixed-value", credentialsInclude, co.credentials, co.credentials}
|
|
||||||
if co.lfs {
|
|
||||||
return append(commands, repo(update...), foreach([]string{"git", "lfs", "install", "--local"}, []string{"git", "lfs", "pull", "origin"}, include))
|
|
||||||
}
|
|
||||||
return append(commands, repo(update...), foreach(include))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (co *checkout) resolve(advertised string) {
|
|
||||||
ref := co.ref
|
|
||||||
if !strings.HasPrefix(ref, "refs/") {
|
|
||||||
co.ref = ""
|
|
||||||
}
|
|
||||||
for line := range strings.Lines(advertised) {
|
|
||||||
value, name, _ := strings.Cut(strings.TrimSpace(line), "\t")
|
|
||||||
if target, ok := strings.CutPrefix(value, "ref: "); ok {
|
|
||||||
if name == "HEAD" {
|
|
||||||
co.ref = target
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
co.objectFormat = cmp.Or(co.objectFormat, git.ObjectFormat(value))
|
|
||||||
switch name {
|
|
||||||
case "HEAD":
|
|
||||||
if co.ref == "" {
|
|
||||||
co.sha = value
|
|
||||||
}
|
|
||||||
case "refs/heads/" + ref:
|
|
||||||
co.ref = name
|
|
||||||
case "refs/tags/" + ref:
|
|
||||||
co.ref = cmp.Or(co.ref, name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
co.objectFormat = cmp.Or(co.objectFormat, "sha1")
|
|
||||||
}
|
|
||||||
|
|
||||||
func trackingRef(ref string) string {
|
|
||||||
if branch, ok := strings.CutPrefix(ref, "refs/heads/"); ok {
|
|
||||||
return "refs/remotes/origin/" + branch
|
|
||||||
}
|
|
||||||
if pull, ok := strings.CutPrefix(ref, "refs/pull/"); ok {
|
|
||||||
return "refs/remotes/pull/" + pull
|
|
||||||
}
|
|
||||||
return ref
|
|
||||||
}
|
|
||||||
|
|
||||||
func boolInput(inputs map[string]string, name string, fallback bool) bool {
|
|
||||||
value := strings.TrimSpace(inputs[name])
|
|
||||||
return strings.EqualFold(value, "true") || value == "" && fallback
|
|
||||||
}
|
|
||||||
|
|
||||||
func workspaceDestination(workspace, checkoutPath string, windows bool) (string, error) {
|
|
||||||
cleanPath := strings.TrimSpace(checkoutPath)
|
|
||||||
if windows {
|
|
||||||
cleanPath = strings.ReplaceAll(cleanPath, `\`, "/")
|
|
||||||
}
|
|
||||||
cleanPath = path.Clean(cleanPath)
|
|
||||||
if path.IsAbs(cleanPath) || cleanPath == ".." || strings.HasPrefix(cleanPath, "../") || windows && len(cleanPath) > 1 && cleanPath[1] == ':' {
|
|
||||||
return "", fmt.Errorf("checkout: path %q must be relative to the workspace", checkoutPath)
|
|
||||||
}
|
|
||||||
return path.Join(workspace, cleanPath), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func fetchDepth(input string) (int, error) {
|
|
||||||
input = strings.TrimSpace(input)
|
|
||||||
if input == "" {
|
|
||||||
return 1, nil
|
|
||||||
}
|
|
||||||
depth, err := strconv.Atoi(input)
|
|
||||||
if err != nil || depth < 0 {
|
|
||||||
return 0, fmt.Errorf("checkout: fetch-depth %q must be a non-negative integer", input)
|
|
||||||
}
|
|
||||||
return depth, nil
|
|
||||||
}
|
|
||||||
@@ -1,395 +0,0 @@
|
|||||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
|
||||||
// SPDX-License-Identifier: MIT
|
|
||||||
|
|
||||||
package checkout
|
|
||||||
|
|
||||||
import (
|
|
||||||
"cmp"
|
|
||||||
"crypto/sha256"
|
|
||||||
"crypto/tls"
|
|
||||||
"crypto/x509"
|
|
||||||
"encoding/pem"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"net/url"
|
|
||||||
"os"
|
|
||||||
"os/exec"
|
|
||||||
"path/filepath"
|
|
||||||
"runtime"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/container"
|
|
||||||
"gitea.com/gitea/runner/internal/pkg/action"
|
|
||||||
|
|
||||||
"gitea.dev/actionslib/pkg/model"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
|
||||||
|
|
||||||
func runGit(t *testing.T, args ...string) string {
|
|
||||||
t.Helper()
|
|
||||||
cmd := exec.Command("git", args...)
|
|
||||||
cmd.Env = append(os.Environ(),
|
|
||||||
"GIT_AUTHOR_NAME=test", "[email protected]",
|
|
||||||
"GIT_COMMITTER_NAME=test", "[email protected]",
|
|
||||||
"GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null",
|
|
||||||
)
|
|
||||||
out, err := cmd.CombinedOutput()
|
|
||||||
require.NoError(t, err, string(out))
|
|
||||||
return strings.TrimSpace(string(out))
|
|
||||||
}
|
|
||||||
|
|
||||||
func gitLog(dir string) []string {
|
|
||||||
out, err := exec.Command("git", "--git-dir", filepath.Join(dir, ".git"), "log", "--format=%s").Output()
|
|
||||||
if err != nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return strings.Fields(string(out))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCheckout(t *testing.T) {
|
|
||||||
server := t.TempDir()
|
|
||||||
serverURL := (&url.URL{Scheme: "file", Path: "/" + strings.TrimPrefix(filepath.ToSlash(server), "/")}).String()
|
|
||||||
var sha string
|
|
||||||
lfsPath, lfsErr := exec.LookPath("git-lfs")
|
|
||||||
require.True(t, t.Run("fixtures", func(t *testing.T) {
|
|
||||||
t.Run("history", func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
work := t.TempDir()
|
|
||||||
runGit(t, "init", "--initial-branch=main", work)
|
|
||||||
runGit(t, "-C", work, "commit", "--allow-empty", "-m", "c1")
|
|
||||||
sha = runGit(t, "-C", work, "rev-parse", "HEAD")
|
|
||||||
runGit(t, "-C", work, "tag", "v1")
|
|
||||||
runGit(t, "-C", work, "commit", "--allow-empty", "-m", "c2")
|
|
||||||
runGit(t, "-C", work, "checkout", "-b", "feature")
|
|
||||||
runGit(t, "-C", work, "commit", "--allow-empty", "-m", "f1")
|
|
||||||
runGit(t, "-C", work, "checkout", "--orphan", "trunk")
|
|
||||||
runGit(t, "-C", work, "commit", "--allow-empty", "-m", "t1")
|
|
||||||
runGit(t, "-C", work, "commit", "--allow-empty", "-m", "t2")
|
|
||||||
runGit(t, "-C", work, "tag", "trunk", "main")
|
|
||||||
runGit(t, "clone", "--bare", "--branch", "main", work, filepath.Join(server, "org", "repo"))
|
|
||||||
runGit(t, "clone", "--bare", "--branch", "trunk", work, filepath.Join(server, "other", "repo"))
|
|
||||||
runGit(t, "-C", filepath.Join(server, "other", "repo"), "symbolic-ref", "refs/remotes/origin/HEAD", "refs/heads/main")
|
|
||||||
})
|
|
||||||
t.Run("sha256", func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
sha256Repo := filepath.Join(server, "org", "sha256")
|
|
||||||
runGit(t, "init", "--object-format=sha256", "--initial-branch=main", sha256Repo)
|
|
||||||
runGit(t, "-C", sha256Repo, "commit", "--allow-empty", "-m", "s1")
|
|
||||||
runGit(t, "-C", sha256Repo, "checkout", "--detach")
|
|
||||||
})
|
|
||||||
t.Run("submodules", func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
publish(t, server, "org/nested", map[string]string{"nested.txt": "nested"}, nil)
|
|
||||||
publish(t, server, "org/child", map[string]string{"child.txt": "child"}, map[string]string{"nested": "../nested"})
|
|
||||||
publish(t, server, "org/content", map[string]string{"root.txt": "root", "a/a.txt": "a", "b/b.txt": "b"}, map[string]string{"sub": "../child"})
|
|
||||||
runGit(t, "-C", filepath.Join(server, "org", "content"), "config", "uploadpack.allowFilter", "true")
|
|
||||||
})
|
|
||||||
t.Run("lfs", func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
oid := fmt.Sprintf("%x", sha256.Sum256([]byte("big")))
|
|
||||||
pointer := "version https://git-lfs.github.com/spec/v1\noid sha256:" + oid + "\nsize 3\n"
|
|
||||||
publish(t, server, "org/lfs", map[string]string{".gitattributes": "*.bin filter=lfs diff=lfs merge=lfs -text\n", "keep/big.bin": pointer, "drop/big.bin": pointer}, nil)
|
|
||||||
object := filepath.Join(server, "org", "lfs", "lfs", "objects", oid[:2], oid[2:4], oid)
|
|
||||||
require.NoError(t, os.MkdirAll(filepath.Dir(object), 0o755))
|
|
||||||
require.NoError(t, os.WriteFile(object, []byte("big"), 0o644))
|
|
||||||
})
|
|
||||||
}))
|
|
||||||
host := t.TempDir()
|
|
||||||
require.NoError(t, os.WriteFile(filepath.Join(host, "marker"), []byte("marker"), 0o644))
|
|
||||||
tools := t.TempDir()
|
|
||||||
require.NoError(t, os.WriteFile(filepath.Join(tools, "ssh"), []byte(`#!/bin/sh
|
|
||||||
test -s "$2" || exit 1
|
|
||||||
printf '%s\n' "$@" > "$SSH_ARGS"
|
|
||||||
for last; do :; done
|
|
||||||
exec sh -c "$(printf '%s' "$last" | sed "s#git-upload-pack '/\(.*\)\.git'#git upload-pack '$SSH_SERVER/\1'#")"
|
|
||||||
`), 0o755))
|
|
||||||
require.NoError(t, os.WriteFile(filepath.Join(tools, "git-lfs"), []byte(`#!/bin/sh
|
|
||||||
git config --get-regexp extraheader > "$LFS_HEADERS"
|
|
||||||
exec "$LFS" "$@"
|
|
||||||
`), 0o755))
|
|
||||||
globalConfig := filepath.Join(t.TempDir(), "gitconfig")
|
|
||||||
require.NoError(t, os.WriteFile(globalConfig, []byte("[protocol \"file\"]\n\tallow = always\n"), 0o644))
|
|
||||||
sshEvent := func(url string) map[string]any { return map[string]any{"repository": map[string]any{"ssh_url": url}} }
|
|
||||||
|
|
||||||
for _, tc := range []struct {
|
|
||||||
name string
|
|
||||||
bind, noSkip bool
|
|
||||||
eventRef string
|
|
||||||
event map[string]any
|
|
||||||
before, inputs map[string]string
|
|
||||||
dst, head, err string
|
|
||||||
log, refs []string
|
|
||||||
files, config map[string]string
|
|
||||||
}{
|
|
||||||
{name: "bound workspace is skipped", bind: true},
|
|
||||||
{name: "workflow ref copies host workdir", inputs: map[string]string{"ref": "main", "path": "a/../src"}, dst: "src", files: map[string]string{"marker": "marker"}},
|
|
||||||
{name: "bound path checks out the event branch", bind: true, inputs: map[string]string{"path": "src"}, dst: "src", head: "main", log: []string{"c1"}},
|
|
||||||
{name: "no-skip checks out the event branch", noSkip: true, head: "main", log: []string{"c1"}, refs: []string{"refs/remotes/origin/main"}},
|
|
||||||
{name: "event tag is kept", noSkip: true, eventRef: "refs/tags/v1", log: []string{"c1"}, refs: []string{"refs/tags/v1"}},
|
|
||||||
{name: "moved event tag fails", noSkip: true, eventRef: "refs/tags/trunk", err: "does not point to the expected commit " + sha},
|
|
||||||
{name: "deleted event tag fails", noSkip: true, eventRef: "refs/tags/deleted", err: "checkout: clone"},
|
|
||||||
{
|
|
||||||
name: "sha input removes previously persisted credentials without clean", noSkip: true, before: map[string]string{"ref": sha},
|
|
||||||
inputs: map[string]string{"ref": sha, "persist-credentials": "false", "clean": "false"},
|
|
||||||
log: []string{"c1"},
|
|
||||||
},
|
|
||||||
{name: "ref clones shallow", inputs: map[string]string{"ref": "refs/heads/feature"}, head: "feature", log: []string{"f1"}},
|
|
||||||
{name: "tag ref wins over same-named branch and is kept", inputs: map[string]string{"ref": "refs/tags/trunk"}, log: []string{"c2"}, refs: []string{"refs/tags/trunk"}},
|
|
||||||
{name: "branch ref wins over same-named tag", inputs: map[string]string{"ref": "refs/heads/trunk"}, head: "trunk", log: []string{"t2"}},
|
|
||||||
{
|
|
||||||
name: "short branch wins over same-named tag and fetch-tags fetches every tag", inputs: map[string]string{"ref": "trunk", "fetch-tags": "true"},
|
|
||||||
head: "trunk", log: []string{"t2"}, refs: []string{"refs/tags/trunk", "refs/tags/v1"},
|
|
||||||
},
|
|
||||||
{name: "short tag", inputs: map[string]string{"ref": "v1"}, log: []string{"c1"}, refs: []string{"refs/tags/v1"}},
|
|
||||||
{
|
|
||||||
name: "fetch-depth 0 deepens a shallow checkout to every branch and tag", before: map[string]string{"ref": "feature"},
|
|
||||||
inputs: map[string]string{"ref": "refs/heads/feature", "fetch-depth": "0"}, head: "feature", log: []string{"f1", "c2", "c1"},
|
|
||||||
refs: []string{"refs/remotes/origin/feature", "refs/remotes/origin/main", "refs/remotes/origin/trunk", "refs/tags/trunk", "refs/tags/v1"},
|
|
||||||
},
|
|
||||||
{name: "other repository defaults to its default branch", inputs: map[string]string{"repository": "other/repo"}, head: "trunk", log: []string{"t2"}},
|
|
||||||
{name: "explicit HEAD checks out the default branch", inputs: map[string]string{"repository": "other/repo", "ref": "HEAD"}, head: "trunk", log: []string{"t2"}},
|
|
||||||
{name: "detached sha256 HEAD is checked out", inputs: map[string]string{"repository": "org/sha256"}, log: []string{"s1"}},
|
|
||||||
{
|
|
||||||
name: "ssh key with the advertised endpoint persists", event: sshEvent("ssh://[email protected]:2222/org/repo.git"),
|
|
||||||
inputs: map[string]string{"ssh-key": "KEY", "ssh-user": "me", "ssh-known-hosts": "ssh.example ssh-ed25519 AAAA"},
|
|
||||||
head: "main", log: []string{"c1"}, config: map[string]string{"remote.origin.url": "ssh://[email protected]:2222/org/repo.git"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "ssh key without persisting is emptied", event: sshEvent("[email protected]:org/repo.git"),
|
|
||||||
inputs: map[string]string{"ssh-key": "KEY", "persist-credentials": "false"},
|
|
||||||
head: "main", log: []string{"c1"}, config: map[string]string{"remote.origin.url": "ssh://[email protected]/org/repo.git"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "sparse cone checkout filters blobs", inputs: map[string]string{"repository": "org/content", "sparse-checkout": "a\n", "show-progress": "false"},
|
|
||||||
head: "main", log: []string{"content"}, files: map[string]string{"root.txt": "root", "a/a.txt": "a", "b/b.txt": ""},
|
|
||||||
config: map[string]string{"remote.origin.partialclonefilter": "blob:none"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "non-cone sparse checkout with an explicit filter", head: "main", log: []string{"content"},
|
|
||||||
inputs: map[string]string{"repository": "org/content", "sparse-checkout": "b/b.txt", "sparse-checkout-cone-mode": "false", "filter": "blob:limit=1024"},
|
|
||||||
files: map[string]string{"root.txt": "", "a/a.txt": "", "b/b.txt": "b"}, config: map[string]string{"remote.origin.partialclonefilter": "blob:limit=1024"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "full checkout after a sparse one restores every file", before: map[string]string{"repository": "org/content", "sparse-checkout": "a"},
|
|
||||||
inputs: map[string]string{"repository": "org/content", "set-safe-directory": "false"}, head: "main", log: []string{"content"},
|
|
||||||
files: map[string]string{"root.txt": "root", "a/a.txt": "a", "b/b.txt": "b", "sub/child.txt": ""},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "recursive submodules after plain ones", before: map[string]string{"repository": "org/content", "submodules": "true"},
|
|
||||||
inputs: map[string]string{"repository": "org/content", "submodules": "Recursive"}, head: "main", log: []string{"content"},
|
|
||||||
files: map[string]string{"sub/child.txt": "child", "sub/nested/nested.txt": "nested"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "lfs content stays when narrowing a full checkout to a sparse one", before: map[string]string{"repository": "org/lfs", "lfs": "true"},
|
|
||||||
inputs: map[string]string{"repository": "org/lfs", "lfs": "true", "sparse-checkout": "keep"}, head: "main", log: []string{"content"},
|
|
||||||
files: map[string]string{"keep/big.bin": "big", "drop/big.bin": ""},
|
|
||||||
},
|
|
||||||
{name: "unknown ref", inputs: map[string]string{"ref": "missing"}, err: "has no ref"},
|
|
||||||
{name: "unsupported input", inputs: map[string]string{"github-server-url": "https://example.com"}, err: "unsupported input"},
|
|
||||||
{name: "relative path escaping workspace", inputs: map[string]string{"path": "../x"}, err: "must be relative"},
|
|
||||||
{name: "absolute path", inputs: map[string]string{"path": "/x"}, err: "must be relative"},
|
|
||||||
{name: "negative fetch-depth", inputs: map[string]string{"fetch-depth": "-1"}, err: "non-negative"},
|
|
||||||
} {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
if tc.inputs["lfs"] == "true" && lfsErr != nil {
|
|
||||||
t.Skip("needs git-lfs")
|
|
||||||
}
|
|
||||||
if tc.inputs["ssh-key"] != "" && runtime.GOOS == "windows" {
|
|
||||||
t.Skip("Git for Windows puts its own ssh ahead of the fake one on PATH")
|
|
||||||
}
|
|
||||||
workspace, runnerTemp := t.TempDir(), t.TempDir()
|
|
||||||
output := filepath.Join(runnerTemp, "output")
|
|
||||||
checkout := func(inputs map[string]string) error {
|
|
||||||
return Main(t.Context(), &action.Context{
|
|
||||||
Container: &container.HostEnvironment{Path: workspace, TmpDir: runnerTemp, StdOut: io.Discard},
|
|
||||||
Github: &model.GithubContext{
|
|
||||||
Repository: "org/repo", Ref: cmp.Or(tc.eventRef, "refs/heads/main"), Sha: sha, ServerURL: serverURL, Token: "tok", Event: tc.event,
|
|
||||||
},
|
|
||||||
Inputs: inputs,
|
|
||||||
HostWorkdir: host,
|
|
||||||
Workspace: workspace,
|
|
||||||
BindWorkdir: tc.bind,
|
|
||||||
NoSkipCheckout: tc.noSkip,
|
|
||||||
CAFile: "ca.crt",
|
|
||||||
Env: map[string]string{
|
|
||||||
"PATH": tools + string(os.PathListSeparator) + os.Getenv("PATH"), "GIT_CONFIG_GLOBAL": globalConfig, "GIT_CONFIG_NOSYSTEM": "1",
|
|
||||||
"GIT_DIR": host, "GITHUB_OUTPUT": output, "SSH_SERVER": filepath.ToSlash(server), "SSH_ARGS": filepath.Join(runnerTemp, "ssh-args"),
|
|
||||||
"LFS": lfsPath, "LFS_HEADERS": filepath.Join(runnerTemp, "lfs-headers"),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
dst := filepath.Join(workspace, tc.dst)
|
|
||||||
if tc.err != "" {
|
|
||||||
require.ErrorContains(t, checkout(tc.inputs), tc.err)
|
|
||||||
if tc.eventRef == "" {
|
|
||||||
entries, _ := os.ReadDir(workspace)
|
|
||||||
assert.Empty(t, entries)
|
|
||||||
} else {
|
|
||||||
assert.Nil(t, gitLog(dst))
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
first := tc.before
|
|
||||||
if first == nil {
|
|
||||||
first = tc.inputs
|
|
||||||
}
|
|
||||||
require.NoError(t, checkout(first))
|
|
||||||
repos := []string{dst}
|
|
||||||
if fileExists(filepath.Join(dst, "sub", ".git")) {
|
|
||||||
repos = append(repos, filepath.Join(dst, "sub"))
|
|
||||||
}
|
|
||||||
for _, repo := range repos {
|
|
||||||
require.NoError(t, os.WriteFile(filepath.Join(repo, "untracked"), nil, 0o644))
|
|
||||||
}
|
|
||||||
require.NoError(t, checkout(tc.inputs))
|
|
||||||
outputs, err := os.ReadFile(output)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, tc.log, gitLog(dst))
|
|
||||||
for name, content := range tc.files {
|
|
||||||
body, err := os.ReadFile(filepath.Join(dst, name))
|
|
||||||
assert.Equal(t, content, strings.TrimSpace(string(body)), name)
|
|
||||||
assert.Equal(t, content == "", os.IsNotExist(err), name)
|
|
||||||
}
|
|
||||||
assert.Equal(t, tc.files["marker"] != "", fileExists(filepath.Join(dst, "marker")))
|
|
||||||
for key, value := range tc.config {
|
|
||||||
assert.Equal(t, value, gitOutput(dst, "config", key), key)
|
|
||||||
}
|
|
||||||
if tc.refs != nil {
|
|
||||||
assert.Subset(t, strings.Fields(runGit(t, "-C", dst, "for-each-ref", "--format=%(refname)")), tc.refs)
|
|
||||||
}
|
|
||||||
if tc.log == nil {
|
|
||||||
assert.Contains(t, string(outputs), "commit="+sha+"\n")
|
|
||||||
assert.Contains(t, string(outputs), "ref="+cmp.Or(tc.eventRef, "refs/heads/main")+"\n")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if tc.head == "" {
|
|
||||||
assert.Empty(t, gitOutput(dst, "symbolic-ref", "-q", "HEAD"))
|
|
||||||
} else {
|
|
||||||
assert.Equal(t, "refs/heads/"+tc.head, gitOutput(dst, "symbolic-ref", "-q", "HEAD"))
|
|
||||||
assert.Equal(t, "origin/"+tc.head, gitOutput(dst, "rev-parse", "--abbrev-ref", "@{u}"))
|
|
||||||
}
|
|
||||||
assert.Contains(t, string(outputs), "commit="+gitOutput(dst, "rev-parse", "HEAD")+"\n")
|
|
||||||
if inputRef := tc.inputs["ref"]; inputRef != "" && inputRef != sha {
|
|
||||||
assert.Contains(t, string(outputs), "ref="+inputRef+"\n")
|
|
||||||
}
|
|
||||||
assert.Equal(t, tc.inputs["sparse-checkout"] != "", gitOutput(dst, "config", "--local", "extensions.worktreeConfig") == "true")
|
|
||||||
persisted := tc.inputs["persist-credentials"] != "false"
|
|
||||||
for _, repo := range repos {
|
|
||||||
assert.Equal(t, tc.inputs["clean"] == "false", fileExists(filepath.Join(repo, "untracked")), repo)
|
|
||||||
assert.Len(t, strings.Fields(gitOutput(repo, "config", "--local", "--get-all", credentialsInclude)), 1, repo)
|
|
||||||
headers, _ := exec.Command("git", "-C", repo, "config", "--get-all", "http."+serverURL+"/.extraheader").Output()
|
|
||||||
assert.Equal(t, persisted, string(headers) == "\nAuthorization: Basic dG9rZW46dG9r\n", repo)
|
|
||||||
assert.Equal(t, persisted, gitOutput(repo, "config", "http."+serverURL+"/.sslcainfo") != "", repo)
|
|
||||||
}
|
|
||||||
if tc.inputs["lfs"] == "true" && runtime.GOOS != "windows" {
|
|
||||||
headers, _ := os.ReadFile(filepath.Join(runnerTemp, "lfs-headers"))
|
|
||||||
assert.Equal(t, 1, strings.Count(string(headers), "Authorization"), string(headers))
|
|
||||||
}
|
|
||||||
if tc.inputs["ssh-key"] != "" {
|
|
||||||
keys, _ := filepath.Glob(filepath.Join(runnerTemp, "git-ssh-key-*"))
|
|
||||||
require.Len(t, keys, 1)
|
|
||||||
key, _ := os.ReadFile(keys[0])
|
|
||||||
if persisted {
|
|
||||||
assert.Equal(t, "KEY\n", string(key))
|
|
||||||
} else {
|
|
||||||
assert.Empty(t, key)
|
|
||||||
}
|
|
||||||
args, _ := os.ReadFile(filepath.Join(runnerTemp, "ssh-args"))
|
|
||||||
assert.Contains(t, string(args), "StrictHostKeyChecking=yes")
|
|
||||||
assert.Equal(t, tc.inputs["ssh-known-hosts"] != "", strings.Contains(string(args), "UserKnownHostsFile="))
|
|
||||||
assert.Equal(t, persisted, gitOutput(dst, "config", "core.sshCommand") != "")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCheckoutTrustsTheRunnerCAAndOffersClientCertOnlyDuringTheStep(t *testing.T) {
|
|
||||||
var gotCert bool
|
|
||||||
server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
gotCert = len(r.TLS.PeerCertificates) > 0
|
|
||||||
w.WriteHeader(http.StatusNotFound)
|
|
||||||
}))
|
|
||||||
server.TLS = &tls.Config{ClientAuth: tls.RequireAnyClientCert}
|
|
||||||
server.StartTLS()
|
|
||||||
defer server.Close()
|
|
||||||
pair := server.TLS.Certificates[0]
|
|
||||||
keyDER, err := x509.MarshalPKCS8PrivateKey(pair.PrivateKey)
|
|
||||||
require.NoError(t, err)
|
|
||||||
dir, workspace, runnerTemp := t.TempDir(), t.TempDir(), t.TempDir()
|
|
||||||
certFile, keyFile := filepath.Join(dir, "client.crt"), filepath.Join(dir, "client.key")
|
|
||||||
require.NoError(t, os.WriteFile(certFile, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: pair.Certificate[0]}), 0o600))
|
|
||||||
require.NoError(t, os.WriteFile(keyFile, pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyDER}), 0o600))
|
|
||||||
|
|
||||||
require.ErrorContains(t, Main(t.Context(), &action.Context{
|
|
||||||
Container: &container.HostEnvironment{Path: workspace, TmpDir: runnerTemp, StdOut: io.Discard},
|
|
||||||
Github: &model.GithubContext{Repository: "org/repo", Ref: "refs/heads/main", ServerURL: server.URL},
|
|
||||||
Inputs: map[string]string{"repository": "other/repo"},
|
|
||||||
Workspace: workspace,
|
|
||||||
ClientCertFile: certFile,
|
|
||||||
ClientKeyFile: keyFile,
|
|
||||||
CAFile: certFile,
|
|
||||||
Env: map[string]string{"PATH": os.Getenv("PATH"), "GIT_CONFIG_GLOBAL": os.DevNull, "GIT_CONFIG_NOSYSTEM": "1"},
|
|
||||||
}), "checkout: other/repo@")
|
|
||||||
require.True(t, gotCert)
|
|
||||||
keys, _ := filepath.Glob(filepath.Join(runnerTemp, "git-client-key-*"))
|
|
||||||
require.Len(t, keys, 1)
|
|
||||||
key, err := os.ReadFile(keys[0])
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.Empty(t, key)
|
|
||||||
}
|
|
||||||
|
|
||||||
func publish(t *testing.T, server, name string, files, submodules map[string]string) {
|
|
||||||
work := t.TempDir()
|
|
||||||
runGit(t, "init", "--initial-branch=main", work)
|
|
||||||
for file, content := range files {
|
|
||||||
require.NoError(t, os.MkdirAll(filepath.Dir(filepath.Join(work, file)), 0o755))
|
|
||||||
require.NoError(t, os.WriteFile(filepath.Join(work, file), []byte(content), 0o644))
|
|
||||||
}
|
|
||||||
runGit(t, "-C", work, "add", ".")
|
|
||||||
for path, url := range submodules {
|
|
||||||
runGit(t, "-C", work, "config", "-f", ".gitmodules", "submodule."+path+".path", path)
|
|
||||||
runGit(t, "-C", work, "config", "-f", ".gitmodules", "submodule."+path+".url", url)
|
|
||||||
commit := runGit(t, "--git-dir", filepath.Join(server, name, url), "rev-parse", "main")
|
|
||||||
runGit(t, "-C", work, "update-index", "--add", "--cacheinfo", "160000,"+commit+","+path)
|
|
||||||
runGit(t, "-C", work, "add", ".gitmodules")
|
|
||||||
}
|
|
||||||
runGit(t, "-C", work, "commit", "-m", "content")
|
|
||||||
runGit(t, "clone", "--bare", "--quiet", work, filepath.Join(server, name))
|
|
||||||
}
|
|
||||||
|
|
||||||
func gitOutput(dir string, args ...string) string {
|
|
||||||
out, _ := exec.Command("git", append([]string{"-C", dir}, args...)...).Output()
|
|
||||||
return strings.TrimSpace(string(out))
|
|
||||||
}
|
|
||||||
|
|
||||||
func fileExists(name string) bool {
|
|
||||||
_, err := os.Stat(name)
|
|
||||||
return err == nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWorkspaceDestination(t *testing.T) {
|
|
||||||
for _, tc := range []struct {
|
|
||||||
path string
|
|
||||||
windows bool
|
|
||||||
want string
|
|
||||||
}{
|
|
||||||
{`..\x`, true, ""},
|
|
||||||
{`C:x`, true, ""},
|
|
||||||
{`a\b`, true, "/w/a/b"},
|
|
||||||
{`..\x`, false, `/w/..\x`},
|
|
||||||
{`a:b`, false, "/w/a:b"},
|
|
||||||
} {
|
|
||||||
got, err := workspaceDestination("/w", tc.path, tc.windows)
|
|
||||||
if tc.want == "" {
|
|
||||||
assert.ErrorContains(t, err, "must be relative", tc.path)
|
|
||||||
} else {
|
|
||||||
require.NoError(t, err, tc.path)
|
|
||||||
assert.Equal(t, tc.want, got, tc.path)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -45,7 +45,7 @@ runner:
|
|||||||
#client_cert_file: /etc/gitea-runner/client.crt
|
#client_cert_file: /etc/gitea-runner/client.crt
|
||||||
#client_key_file: /etc/gitea-runner/client.key
|
#client_key_file: /etc/gitea-runner/client.key
|
||||||
# PEM CA certificates of a Gitea instance with a private CA, trusted besides the system's.
|
# PEM CA certificates of a Gitea instance with a private CA, trusted besides the system's.
|
||||||
# Runner-side git and builtin:checkout use it instead of the system's bundle for the instance.
|
# Runner-side git uses it instead of the system's bundle for the instance.
|
||||||
# Jobs get it at /var/run/act/ca.crt, or in their act directory on the host, with NODE_EXTRA_CA_CERTS
|
# Jobs get it at /var/run/act/ca.crt, or in their act directory on the host, with NODE_EXTRA_CA_CERTS
|
||||||
# pointing there unless the job, or on Docker its image, sets it. Other tools in jobs, such as the
|
# pointing there unless the job, or on Docker its image, sets it. Other tools in jobs, such as the
|
||||||
# git that actions/checkout runs, need the CA in the image's bundle, for example via container.volumes.
|
# git that actions/checkout runs, need the CA in the image's bundle, for example via container.volumes.
|
||||||
@@ -119,6 +119,7 @@ runner:
|
|||||||
# If it's empty when execute `daemon`, will use labels in `.runner` file.
|
# If it's empty when execute `daemon`, will use labels in `.runner` file.
|
||||||
#labels:
|
#labels:
|
||||||
# - "ubuntu-latest:docker://docker.gitea.com/runner-images:ubuntu-latest"
|
# - "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-24.04:docker://docker.gitea.com/runner-images:ubuntu-24.04"
|
||||||
# - "ubuntu-22.04:docker://docker.gitea.com/runner-images:ubuntu-22.04"
|
# - "ubuntu-22.04:docker://docker.gitea.com/runner-images:ubuntu-22.04"
|
||||||
# Allocate a pseudo-TTY for each step's process. Applies to both host and docker backends.
|
# Allocate a pseudo-TTY for each step's process. Applies to both host and docker backends.
|
||||||
@@ -131,8 +132,8 @@ runner:
|
|||||||
# What to mount at RUNNER_TOOL_CACHE (/opt/hostedtoolcache), where setup actions install tools:
|
# What to mount at RUNNER_TOOL_CACHE (/opt/hostedtoolcache), where setup actions install tools:
|
||||||
# none: nothing. A docker job sees what its image ships there, a host job an empty dir, and
|
# none: nothing. A docker job sees what its image ships there, a host job an empty dir, and
|
||||||
# either way what it installs is gone when the job ends.
|
# either way what it installs is gone when the job ends.
|
||||||
# shared: one tool cache every job reuses. Two jobs writing the same tool version at once
|
# shared: one tool cache per capacity slot, reused by later jobs. It hides the tools later job
|
||||||
# corrupt it, so use it only with capacity 1.
|
# images ship, so use images without them.
|
||||||
#tool_cache_mode: none
|
#tool_cache_mode: none
|
||||||
# Optional executable on the host, run once after each task's built-in cleanup
|
# Optional executable on the host, run once after each task's built-in cleanup
|
||||||
# (post-steps, container teardown, bind-workdir removal). Additive only.
|
# (post-steps, container teardown, bind-workdir removal). Additive only.
|
||||||
@@ -306,8 +307,8 @@ container:
|
|||||||
# healthy. A negative value (e.g. -1s) starts the steps without waiting.
|
# healthy. A negative value (e.g. -1s) starts the steps without waiting.
|
||||||
#service_ready_timeout: 5m
|
#service_ready_timeout: 5m
|
||||||
# Remove the containers, networks and volumes a job creates through its Docker proxy when
|
# Remove the containers, networks and volumes a job creates through its Docker proxy when
|
||||||
# the job ends. Set to false when jobs deploy services to the runner's Docker daemon.
|
# the job ends. Leave off when jobs deploy services to the runner's Docker daemon.
|
||||||
#sweep: true
|
#sweep: false
|
||||||
|
|
||||||
host:
|
host:
|
||||||
# The parent directory of a job's working directory.
|
# The parent directory of a job's working directory.
|
||||||
|
|||||||
@@ -190,10 +190,10 @@ type Container struct {
|
|||||||
DockerTimeout time.Duration `yaml:"docker_timeout"` // Timeout to wait for the docker daemon to be reachable, if docker is required by require_docker or runner
|
DockerTimeout time.Duration `yaml:"docker_timeout"` // Timeout to wait for the docker daemon to be reachable, if docker is required by require_docker or runner
|
||||||
BindWorkdir bool `yaml:"bind_workdir"` // BindWorkdir mounts the workspace from a host directory instead of a Docker volume.
|
BindWorkdir bool `yaml:"bind_workdir"` // BindWorkdir mounts the workspace from a host directory instead of a Docker volume.
|
||||||
ServiceReadyTimeout time.Duration `yaml:"service_ready_timeout"` // ServiceReadyTimeout bounds how long a job waits for a service container that declares a healthcheck to report healthy. Negative disables waiting.
|
ServiceReadyTimeout time.Duration `yaml:"service_ready_timeout"` // ServiceReadyTimeout bounds how long a job waits for a service container that declares a healthcheck to report healthy. Negative disables waiting.
|
||||||
Sweep *bool `yaml:"sweep"` // Sweep removes the containers, networks and volumes a job creates through its Docker proxy when the job ends. Unset means enabled.
|
Sweep bool `yaml:"sweep"` // Sweep removes the containers, networks and volumes a job creates through its Docker proxy when the job ends.
|
||||||
}
|
}
|
||||||
|
|
||||||
// Values of Runner.ToolCacheMode: the runner mounts no tool cache, or one that every job reuses.
|
// Values of Runner.ToolCacheMode: the runner mounts no tool cache, or one per capacity slot that its jobs reuse.
|
||||||
const (
|
const (
|
||||||
ToolCacheModeNone = "none"
|
ToolCacheModeNone = "none"
|
||||||
ToolCacheModeShared = "shared"
|
ToolCacheModeShared = "shared"
|
||||||
@@ -454,10 +454,6 @@ func LoadDefault(file string) (*Config, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Validate and fix invalid config combinations to prevent confusing behavior.
|
// Validate and fix invalid config combinations to prevent confusing behavior.
|
||||||
if cfg.Runner.ToolCacheMode == ToolCacheModeShared && cfg.Runner.Capacity > 1 {
|
|
||||||
logging.Warnf("runner.tool_cache_mode %q with capacity %d: two jobs writing the same tool version at once corrupt it",
|
|
||||||
ToolCacheModeShared, cfg.Runner.Capacity)
|
|
||||||
}
|
|
||||||
if cfg.Runner.FetchTimeout > RequestTimeout {
|
if cfg.Runner.FetchTimeout > RequestTimeout {
|
||||||
logging.Warnf("fetch_timeout (%v) exceeds the RPC timeout (%v), capping it", cfg.Runner.FetchTimeout, RequestTimeout)
|
logging.Warnf("fetch_timeout (%v) exceeds the RPC timeout (%v), capping it", cfg.Runner.FetchTimeout, RequestTimeout)
|
||||||
cfg.Runner.FetchTimeout = RequestTimeout
|
cfg.Runner.FetchTimeout = RequestTimeout
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ package process
|
|||||||
import (
|
import (
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
@@ -23,16 +22,13 @@ func TestGroupClosePropagatesToOrphan(t *testing.T) {
|
|||||||
childPID := ready()
|
childPID := ready()
|
||||||
|
|
||||||
require.NoError(t, cmd.Wait())
|
require.NoError(t, cmd.Wait())
|
||||||
require.Eventually(t, func() bool {
|
require.True(t, cmd.ProcessState.Exited(), "the step process should have exited on its own")
|
||||||
return !processAlive(cmd.Process.Pid)
|
|
||||||
}, 20*time.Second, 200*time.Millisecond, "the step process should have exited on its own")
|
|
||||||
|
|
||||||
require.True(t, processAlive(childPID), "orphan should outlive its parent")
|
require.True(t, processAlive(childPID), "orphan should outlive its parent")
|
||||||
|
|
||||||
|
waitForExit := newProcessExitWaiter(t, childPID)
|
||||||
require.NoError(t, group.Close())
|
require.NoError(t, group.Close())
|
||||||
require.Eventually(t, func() bool {
|
waitForExit()
|
||||||
return !processAlive(childPID)
|
|
||||||
}, 20*time.Second, 200*time.Millisecond, "closing the job must terminate the orphan")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestGroupNestsWithKiller: assigned to the group first and the step's Killer
|
// TestGroupNestsWithKiller: assigned to the group first and the step's Killer
|
||||||
@@ -53,10 +49,9 @@ func TestGroupNestsWithKiller(t *testing.T) {
|
|||||||
require.NoError(t, err, "the step's job object must nest inside the group's")
|
require.NoError(t, err, "the step's job object must nest inside the group's")
|
||||||
defer killer.Close()
|
defer killer.Close()
|
||||||
|
|
||||||
|
waitForExit := newProcessExitWaiter(t, cmd.Process.Pid)
|
||||||
require.NoError(t, killer.Kill())
|
require.NoError(t, killer.Kill())
|
||||||
require.Eventually(t, func() bool {
|
waitForExit()
|
||||||
return !processAlive(cmd.Process.Pid)
|
|
||||||
}, 20*time.Second, 200*time.Millisecond, "cancelling the step should still kill its tree")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestGroupNilSafe covers the fallback path: when the job object cannot be
|
// TestGroupNilSafe covers the fallback path: when the job object cannot be
|
||||||
|
|||||||
@@ -9,8 +9,6 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
"strings"
|
||||||
"syscall"
|
"syscall"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -46,23 +44,8 @@ func processAlive(pid int) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestKillerKillsTree verifies that a process group captured by the killer is
|
|
||||||
// terminated together with a child the process spawns afterwards. This mirrors
|
|
||||||
// a step or post-task script that launches a child which spawns further
|
|
||||||
// processes, where cancelling must take down the whole tree, not just the
|
|
||||||
// direct child.
|
|
||||||
func TestKillerKillsTree(t *testing.T) {
|
func TestKillerKillsTree(t *testing.T) {
|
||||||
dir := t.TempDir()
|
cmd, ready := newTestProcess(t, "parent", false)
|
||||||
pidFile := filepath.Join(dir, "child.pid")
|
|
||||||
|
|
||||||
// Parent shell backgrounds a long-lived child (writing its PID to a file)
|
|
||||||
// and then sleeps. With job control off (non-interactive sh) the backgrounded
|
|
||||||
// child stays in the parent's process group, so the group kill must reach it.
|
|
||||||
script := fmt.Sprintf(`sleep 600 & echo $! > %q; sleep 600`, pidFile)
|
|
||||||
cmd := exec.Command("/bin/sh", "-c", script)
|
|
||||||
// Launch as its own process-group leader, exactly like a real process does
|
|
||||||
// (see SysProcAttr), so the killer's PGID == the process PID.
|
|
||||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
|
||||||
require.NoError(t, cmd.Start())
|
require.NoError(t, cmd.Start())
|
||||||
t.Cleanup(func() {
|
t.Cleanup(func() {
|
||||||
_ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
|
_ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
|
||||||
@@ -73,25 +56,10 @@ func TestKillerKillsTree(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer killer.Close()
|
defer killer.Close()
|
||||||
|
|
||||||
// Wait for the backgrounded child PID to be reported.
|
childPID := ready()
|
||||||
var childPID int
|
require.True(t, processAlive(childPID), "child process should start")
|
||||||
require.Eventually(t, func() bool {
|
|
||||||
b, e := os.ReadFile(pidFile)
|
|
||||||
if e != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
s := strings.TrimSpace(string(b))
|
|
||||||
if s == "" {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
childPID, _ = strconv.Atoi(s)
|
|
||||||
return childPID > 0 && processAlive(childPID)
|
|
||||||
}, 20*time.Second, 100*time.Millisecond, "child process should start")
|
|
||||||
|
|
||||||
// Killing the group must terminate both the parent and the backgrounded child.
|
|
||||||
require.NoError(t, killer.Kill())
|
require.NoError(t, killer.Kill())
|
||||||
// Reap the parent so it does not linger as a zombie (which would still report
|
|
||||||
// as alive); SIGKILL makes Wait return promptly.
|
|
||||||
_ = cmd.Wait()
|
_ = cmd.Wait()
|
||||||
|
|
||||||
require.Eventually(t, func() bool {
|
require.Eventually(t, func() bool {
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ package process
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"golang.org/x/sys/windows"
|
"golang.org/x/sys/windows"
|
||||||
@@ -25,6 +24,23 @@ func processAlive(pid int) bool {
|
|||||||
return code == stillActive
|
return code == stillActive
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func newProcessExitWaiter(t *testing.T, pids ...int) func() {
|
||||||
|
t.Helper()
|
||||||
|
handles := make([]windows.Handle, 0, len(pids))
|
||||||
|
for _, pid := range pids {
|
||||||
|
handle, err := windows.OpenProcess(windows.SYNCHRONIZE, false, uint32(pid))
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Cleanup(func() { require.NoError(t, windows.CloseHandle(handle)) })
|
||||||
|
handles = append(handles, handle)
|
||||||
|
}
|
||||||
|
return func() {
|
||||||
|
t.Helper()
|
||||||
|
result, err := windows.WaitForMultipleObjects(handles, true, 20000)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.EqualValues(t, windows.WAIT_OBJECT_0, result, "processes should exit: %v", pids)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestKillerKillsTree(t *testing.T) {
|
func TestKillerKillsTree(t *testing.T) {
|
||||||
cmd, ready := newTestProcess(t, "parent", false)
|
cmd, ready := newTestProcess(t, "parent", false)
|
||||||
require.NoError(t, cmd.Start())
|
require.NoError(t, cmd.Start())
|
||||||
@@ -39,9 +55,7 @@ func TestKillerKillsTree(t *testing.T) {
|
|||||||
childPID := ready()
|
childPID := ready()
|
||||||
require.True(t, processAlive(childPID), "child process should start")
|
require.True(t, processAlive(childPID), "child process should start")
|
||||||
|
|
||||||
|
waitForExit := newProcessExitWaiter(t, cmd.Process.Pid, childPID)
|
||||||
require.NoError(t, killer.Kill())
|
require.NoError(t, killer.Kill())
|
||||||
|
waitForExit()
|
||||||
require.Eventually(t, func() bool {
|
|
||||||
return !processAlive(cmd.Process.Pid) && !processAlive(childPID)
|
|
||||||
}, 20*time.Second, 200*time.Millisecond, "parent and child should both be terminated")
|
|
||||||
}
|
}
|
||||||
@@ -691,22 +691,6 @@ func (r *Reporter) Close(lastWords string) error {
|
|||||||
logging.Errorf("No Response from RunDaemon for %s, continue best effort", r.daemonWait)
|
logging.Errorf("No Response from RunDaemon for %s, continue best effort", r.daemonWait)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Gitea's UpdateLog short-circuits on len(Rows)==0 before honoring NoMore,
|
|
||||||
// so a final empty request never runs TransferLogs and dbfs_data leaks.
|
|
||||||
// Inject a sentinel row after the daemon has exited so it can't be flushed
|
|
||||||
// before ReportLog(true).
|
|
||||||
// TODO: Remove after https://github.com/go-gitea/gitea/pull/37631 is in all
|
|
||||||
// supported branches, e.g. v1.28+.
|
|
||||||
r.stateMu.Lock()
|
|
||||||
if len(r.logRows) == 0 {
|
|
||||||
// Not appendLogRow: the sentinel is not job output and has no place in job.log.
|
|
||||||
r.logRows = append(r.logRows, &runnerv1.LogRow{
|
|
||||||
Time: timestamppb.Now(),
|
|
||||||
Content: "",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
r.stateMu.Unlock()
|
|
||||||
|
|
||||||
// Separate budgets so a slow ReportLog can't starve the ReportState that
|
// Separate budgets so a slow ReportLog can't starve the ReportState that
|
||||||
// carries the cancel acknowledgement.
|
// carries the cancel acknowledgement.
|
||||||
err := errors.Join(
|
err := errors.Join(
|
||||||
@@ -960,7 +944,7 @@ func logRow(entry logging.Record) *runnerv1.LogRow {
|
|||||||
|
|
||||||
// mask repairs the content first, so a secret the repair itself spells out is still caught.
|
// mask repairs the content first, so a secret the repair itself spells out is still caught.
|
||||||
func (r *Reporter) mask(content string) string {
|
func (r *Reporter) mask(content string) string {
|
||||||
return r.logReplacer.Replace(strings.ToValidUTF8(content, "?"))
|
return runner.MaskLog(strings.ToValidUTF8(content, "?"), r.logReplacer)
|
||||||
}
|
}
|
||||||
|
|
||||||
// AddMask deliberately leaves the process-wide masker alone. Its entries come from every live
|
// AddMask deliberately leaves the process-wide masker alone. Its entries come from every live
|
||||||
|
|||||||
@@ -173,11 +173,9 @@ func TestReporter_RunDaemonClose_Race(t *testing.T) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
ctx, cancel := context.WithCancel(t.Context())
|
ctx, cancel := context.WithCancel(t.Context())
|
||||||
taskCtx, err := structpb.NewStruct(map[string]any{})
|
|
||||||
require.NoError(t, err)
|
|
||||||
cfg, _ := config.LoadDefault("")
|
cfg, _ := config.LoadDefault("")
|
||||||
reporter := NewReporter(ctx, cancel, client, &runnerv1.Task{
|
reporter := NewReporter(ctx, cancel, client, &runnerv1.Task{
|
||||||
Context: taskCtx,
|
Context: &structpb.Struct{},
|
||||||
}, cfg)
|
}, cfg)
|
||||||
reporter.ResetSteps(newTestSteps(1))
|
reporter.ResetSteps(newTestSteps(1))
|
||||||
|
|
||||||
@@ -357,7 +355,7 @@ func TestReporter_StateNotifyFlush(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestReporter_CloseAlwaysSendsRowsWithNoMore(t *testing.T) {
|
func TestReporter_CloseFinalizesLogWithoutRows(t *testing.T) {
|
||||||
var lastReq atomic.Pointer[runnerv1.UpdateLogRequest]
|
var lastReq atomic.Pointer[runnerv1.UpdateLogRequest]
|
||||||
var noMoreCalls atomic.Int64
|
var noMoreCalls atomic.Int64
|
||||||
|
|
||||||
@@ -402,7 +400,7 @@ func TestReporter_CloseAlwaysSendsRowsWithNoMore(t *testing.T) {
|
|||||||
final := lastReq.Load()
|
final := lastReq.Load()
|
||||||
require.NotNil(t, final)
|
require.NotNil(t, final)
|
||||||
assert.True(t, final.NoMore, "final UpdateLog must carry NoMore=true")
|
assert.True(t, final.NoMore, "final UpdateLog must carry NoMore=true")
|
||||||
assert.NotEmpty(t, final.Rows, "final UpdateLog must carry at least one row")
|
assert.Empty(t, final.Rows, "a job without output must not get a log row")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestReporter_StateHeartbeat(t *testing.T) {
|
func TestReporter_StateHeartbeat(t *testing.T) {
|
||||||
@@ -474,10 +472,8 @@ func TestReporter_ServerCancelStillFlushesFinal(t *testing.T) {
|
|||||||
|
|
||||||
ctx, cancel := context.WithCancel(t.Context())
|
ctx, cancel := context.WithCancel(t.Context())
|
||||||
defer cancel()
|
defer cancel()
|
||||||
taskCtx, err := structpb.NewStruct(map[string]any{})
|
|
||||||
require.NoError(t, err)
|
|
||||||
cfg, _ := config.LoadDefault("")
|
cfg, _ := config.LoadDefault("")
|
||||||
reporter := NewReporter(ctx, cancel, client, &runnerv1.Task{Context: taskCtx}, cfg)
|
reporter := NewReporter(ctx, cancel, client, &runnerv1.Task{Context: &structpb.Struct{}}, cfg)
|
||||||
reporter.ResetSteps(newTestSteps(1))
|
reporter.ResetSteps(newTestSteps(1))
|
||||||
|
|
||||||
reporter.stateMu.Lock()
|
reporter.stateMu.Lock()
|
||||||
@@ -527,10 +523,8 @@ func TestReporter_CloseReportsCancelledOnCanceledCtx(t *testing.T) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
ctx, cancel := context.WithCancel(t.Context())
|
ctx, cancel := context.WithCancel(t.Context())
|
||||||
taskCtx, err := structpb.NewStruct(map[string]any{})
|
|
||||||
require.NoError(t, err)
|
|
||||||
cfg, _ := config.LoadDefault("")
|
cfg, _ := config.LoadDefault("")
|
||||||
reporter := NewReporter(ctx, cancel, client, &runnerv1.Task{Context: taskCtx}, cfg)
|
reporter := NewReporter(ctx, cancel, client, &runnerv1.Task{Context: &structpb.Struct{}}, cfg)
|
||||||
reporter.ResetSteps(newTestSteps(1))
|
reporter.ResetSteps(newTestSteps(1))
|
||||||
|
|
||||||
cancel()
|
cancel()
|
||||||
@@ -671,19 +665,15 @@ func TestReporter_SetOutputs(t *testing.T) {
|
|||||||
require.True(t, ok)
|
require.True(t, ok)
|
||||||
assert.Equal(t, "caf\xff", got.value)
|
assert.Equal(t, "caf\xff", got.value)
|
||||||
|
|
||||||
maxValue := strings.Repeat("v", maxOutputValueLen)
|
r.SetOutputs(map[string]string{"atlimit": longValue[:maxOutputValueLen]})
|
||||||
r.SetOutputs(map[string]string{"atlimit": maxValue})
|
|
||||||
got, ok = r.outputs["atlimit"]
|
got, ok = r.outputs["atlimit"]
|
||||||
require.True(t, ok)
|
require.True(t, ok)
|
||||||
assert.Len(t, got.value, maxOutputValueLen)
|
assert.Len(t, got.value, maxOutputValueLen)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestReporter_DebugSettingsAreNotMasked(t *testing.T) {
|
func TestReporter_DebugSettingsAreNotMasked(t *testing.T) {
|
||||||
taskCtx, err := structpb.NewStruct(map[string]any{})
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
reporter := NewReporter(t.Context(), nil, nil, &runnerv1.Task{
|
reporter := NewReporter(t.Context(), nil, nil, &runnerv1.Task{
|
||||||
Context: taskCtx,
|
Context: &structpb.Struct{},
|
||||||
Secrets: map[string]string{"ACTIONS_STEP_DEBUG": "true", "ACTIONS_RUNNER_DEBUG": "true", "TOKEN": "s3cr3t"},
|
Secrets: map[string]string{"ACTIONS_STEP_DEBUG": "true", "ACTIONS_RUNNER_DEBUG": "true", "TOKEN": "s3cr3t"},
|
||||||
}, &config.Config{})
|
}, &config.Config{})
|
||||||
defer deregisterGlobalMasks(reporter)
|
defer deregisterGlobalMasks(reporter)
|
||||||
@@ -760,8 +750,7 @@ func TestReporter_ParseResult(t *testing.T) {
|
|||||||
func TestReporter_masksEncodedSecrets(t *testing.T) {
|
func TestReporter_masksEncodedSecrets(t *testing.T) {
|
||||||
secret := `p@ss w"rd/1`
|
secret := `p@ss w"rd/1`
|
||||||
r := &Reporter{logReplacer: strings.NewReplacer()}
|
r := &Reporter{logReplacer: strings.NewReplacer()}
|
||||||
r.oldnew = runner.AppendSecretMasker(r.oldnew, secret)
|
r.AddMask(secret)
|
||||||
r.logReplacer = strings.NewReplacer(r.oldnew...)
|
|
||||||
|
|
||||||
for _, line := range []string{
|
for _, line := range []string{
|
||||||
"token: " + secret,
|
"token: " + secret,
|
||||||
@@ -773,6 +762,17 @@ func TestReporter_masksEncodedSecrets(t *testing.T) {
|
|||||||
assert.NotContains(t, masked, secret)
|
assert.NotContains(t, masked, secret)
|
||||||
assert.NotContains(t, masked, base64.StdEncoding.EncodeToString([]byte(secret)))
|
assert.NotContains(t, masked, base64.StdEncoding.EncodeToString([]byte(secret)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
r.AddMask("alpha beta;gamma]delta")
|
||||||
|
r.AddMask("%")
|
||||||
|
for _, test := range []struct{ line, want string }{
|
||||||
|
{"##[group]alpha beta%3Bgamma%5Ddelta", "##[group]***"},
|
||||||
|
{"##[group]alpha beta%3Bgamma]delta", "##[group]***"},
|
||||||
|
{"%", "***"},
|
||||||
|
} {
|
||||||
|
r.appendLogRow(logRow(logging.Record{Message: test.line}))
|
||||||
|
assert.Equal(t, test.want, r.logRows[len(r.logRows)-1].Content)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestReporter_AckIndexBeyondBuffer(t *testing.T) {
|
func TestReporter_AckIndexBeyondBuffer(t *testing.T) {
|
||||||
@@ -861,10 +861,8 @@ func TestReporter_ServerFailureResultIsReportedAsFailure(t *testing.T) {
|
|||||||
|
|
||||||
ctx, cancel := context.WithCancel(t.Context())
|
ctx, cancel := context.WithCancel(t.Context())
|
||||||
defer cancel()
|
defer cancel()
|
||||||
taskCtx, err := structpb.NewStruct(map[string]any{})
|
|
||||||
require.NoError(t, err)
|
|
||||||
cfg, _ := config.LoadDefault("")
|
cfg, _ := config.LoadDefault("")
|
||||||
reporter := NewReporter(ctx, cancel, client, &runnerv1.Task{Context: taskCtx}, cfg)
|
reporter := NewReporter(ctx, cancel, client, &runnerv1.Task{Context: &structpb.Struct{}}, cfg)
|
||||||
close(reporter.daemon)
|
close(reporter.daemon)
|
||||||
|
|
||||||
require.NoError(t, reporter.ReportState(false))
|
require.NoError(t, reporter.ReportState(false))
|
||||||
@@ -1031,9 +1029,7 @@ func newTestReporter(t *testing.T, client *mocks.Client, cfg *config.Config) *Re
|
|||||||
t.Helper()
|
t.Helper()
|
||||||
ctx, cancel := context.WithCancel(t.Context())
|
ctx, cancel := context.WithCancel(t.Context())
|
||||||
t.Cleanup(cancel)
|
t.Cleanup(cancel)
|
||||||
taskCtx, err := structpb.NewStruct(map[string]any{})
|
return NewReporter(ctx, cancel, client, &runnerv1.Task{Context: &structpb.Struct{}}, cfg)
|
||||||
require.NoError(t, err)
|
|
||||||
return NewReporter(ctx, cancel, client, &runnerv1.Task{Context: taskCtx}, cfg)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func newTestSteps(n int) []*model.Step {
|
func newTestSteps(n int) []*model.Step {
|
||||||
@@ -1051,11 +1047,9 @@ func TestReporter_ReportedSteps(t *testing.T) {
|
|||||||
return &runnerv1.UpdateLogResponse{AckIndex: req.Index + int64(len(req.Rows))}, nil
|
return &runnerv1.UpdateLogResponse{AckIndex: req.Index + int64(len(req.Rows))}, nil
|
||||||
})
|
})
|
||||||
client.On("UpdateTask", mock.Anything, mock.Anything).Maybe().Return(&runnerv1.UpdateTaskResponse{}, nil)
|
client.On("UpdateTask", mock.Anything, mock.Anything).Maybe().Return(&runnerv1.UpdateTaskResponse{}, nil)
|
||||||
taskCtx, err := structpb.NewStruct(map[string]any{})
|
|
||||||
require.NoError(t, err)
|
|
||||||
cfg, _ := config.LoadDefault("")
|
cfg, _ := config.LoadDefault("")
|
||||||
ctx, cancel := context.WithCancel(t.Context())
|
ctx, cancel := context.WithCancel(t.Context())
|
||||||
reporter := NewReporter(ctx, cancel, client, &runnerv1.Task{Context: taskCtx, Secrets: map[string]string{"TOKEN": "s3cr3t"}}, cfg)
|
reporter := NewReporter(ctx, cancel, client, &runnerv1.Task{Context: &structpb.Struct{}, Secrets: map[string]string{"TOKEN": "s3cr3t"}}, cfg)
|
||||||
reporter.RunDaemon()
|
reporter.RunDaemon()
|
||||||
reporter.Logf("runner version")
|
reporter.Logf("runner version")
|
||||||
reporter.ResetSteps(steps)
|
reporter.ResetSteps(steps)
|
||||||
|
|||||||
@@ -117,7 +117,8 @@ func TestExportRetriesAndMatchesOTLP(t *testing.T) {
|
|||||||
certificate := server.TLS.Certificates[0]
|
certificate := server.TLS.Certificates[0]
|
||||||
key, err := x509.MarshalPKCS8PrivateKey(certificate.PrivateKey)
|
key, err := x509.MarshalPKCS8PrivateKey(certificate.PrivateKey)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
certificatePath, keyPath := filepath.Join(t.TempDir(), "cert.pem"), filepath.Join(t.TempDir(), "key.pem")
|
dir := t.TempDir()
|
||||||
|
certificatePath, keyPath := filepath.Join(dir, "cert.pem"), filepath.Join(dir, "key.pem")
|
||||||
require.NoError(t, os.WriteFile(certificatePath, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certificate.Certificate[0]}), 0o600))
|
require.NoError(t, os.WriteFile(certificatePath, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certificate.Certificate[0]}), 0o600))
|
||||||
require.NoError(t, os.WriteFile(keyPath, pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: key}), 0o600))
|
require.NoError(t, os.WriteFile(keyPath, pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: key}), 0o600))
|
||||||
setEnv(t, map[string]string{
|
setEnv(t, map[string]string{
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ package ver
|
|||||||
// go build -ldflags "-X gitea.com/gitea/runner/internal/pkg/ver.version=1.2.3"
|
// go build -ldflags "-X gitea.com/gitea/runner/internal/pkg/ver.version=1.2.3"
|
||||||
var version = "dev"
|
var version = "dev"
|
||||||
|
|
||||||
|
const MinGiteaVersion = "1.26.2" // also in the README.md prerequisites
|
||||||
|
|
||||||
func Version() string {
|
func Version() string {
|
||||||
return version
|
return version
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user