5 Commits

Author SHA1 Message Date
Lunny Xiao
dafb880cae Merge branch 'main' into lunny/rename 2026-04-26 18:57:25 +00:00
silverwind
ded278da71 Use 'Gitea Runner' branding in user-facing strings
Co-Authored-By: Claude (Opus 4.7) <noreply@anthropic.com>
2026-04-26 02:31:40 +02:00
silverwind
fe73bf9a96 Fix missed renames from act_runner to runner
- registration warning string
- root cobra command Short
- README title and content
- Makefile Go version error message
- Kubernetes example resource names
- s6 service directory

Co-Authored-By: Claude (Opus 4.7) <noreply@anthropic.com>
2026-04-26 02:26:05 +02:00
Lunny Xiao
f9bfeb85d9 fix lint 2026-04-25 16:18:57 -07:00
Lunny Xiao
81f3d3ef3f Rename act_runner to runner 2026-04-25 15:47:25 -07:00
91 changed files with 446 additions and 3054 deletions

View File

@@ -40,7 +40,7 @@ cpu.out
*.db
*.log
/gitea-runner
/runner
/debug
/bin

View File

@@ -17,7 +17,7 @@ jobs:
go-version-file: "go.mod"
- name: Import GPG key
id: import_gpg
uses: crazy-max/ghaction-import-gpg@v7
uses: crazy-max/ghaction-import-gpg@v6
with:
gpg_private_key: ${{ secrets.GPG_PRIVATE_KEY }}
passphrase: ${{ secrets.PASSPHRASE }}
@@ -71,12 +71,17 @@ jobs:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Repo Meta
id: repo_meta
run: |
echo REPO_NAME=$(echo ${GITHUB_REPOSITORY} | awk -F"/" '{print $2}') >> $GITHUB_OUTPUT
- name: "Docker meta"
id: docker_meta
uses: https://github.com/docker/metadata-action@v5
with:
images: |
${{ env.DOCKER_ORG }}/runner
${{ env.DOCKER_ORG }}/${{ steps.repo_meta.outputs.REPO_NAME }}
tags: |
type=semver,pattern={{major}}.{{minor}}.{{patch}}
type=semver,pattern={{major}}.{{minor}}

View File

@@ -1,9 +1,7 @@
name: checks
on:
push:
branches:
- main
pull_request:
- push
- pull_request
jobs:
lint:
@@ -19,4 +17,4 @@ jobs:
- name: build
run: make build
- name: test
run: make test
run: make test

2
.gitignore vendored
View File

@@ -1,4 +1,4 @@
/gitea-runner
/runner
.env
.runner
coverage.txt

View File

@@ -1,7 +1,5 @@
version: 2
project_name: gitea-runner
before:
hooks:
- go mod tidy
@@ -88,7 +86,7 @@ blobs:
provider: s3
bucket: "{{ .Env.S3_BUCKET }}"
region: "{{ .Env.S3_REGION }}"
directory: "gitea-runner/{{.Version}}"
directory: "runner/{{.Version}}"
extra_files:
- glob: ./**.xz
- glob: ./**.sha256

View File

@@ -21,7 +21,7 @@ FROM docker:28-dind AS dind
RUN apk add --no-cache s6 bash git tzdata
COPY --from=builder /opt/src/runner/gitea-runner /usr/local/bin/gitea-runner
COPY --from=builder /opt/src/runner/runner /usr/local/bin/runner
COPY scripts/run.sh /usr/local/bin/run.sh
COPY scripts/s6 /etc/s6
@@ -37,7 +37,7 @@ FROM docker:28-dind-rootless AS dind-rootless
USER root
RUN apk add --no-cache s6 bash git tzdata
COPY --from=builder /opt/src/runner/gitea-runner /usr/local/bin/gitea-runner
COPY --from=builder /opt/src/runner/runner /usr/local/bin/runner
COPY scripts/run.sh /usr/local/bin/run.sh
COPY scripts/s6 /etc/s6
@@ -56,7 +56,7 @@ ENTRYPOINT ["s6-svscan","/etc/s6"]
FROM alpine AS basic
RUN 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/runner /usr/local/bin/runner
COPY scripts/run.sh /usr/local/bin/run.sh
VOLUME /data

View File

@@ -1,5 +1,5 @@
DIST := dist
EXECUTABLE := gitea-runner
EXECUTABLE := runner
DIST_DIRS := $(DIST)/binaries $(DIST)/release
GO ?= go
SHASUM ?= shasum -a 256

View File

@@ -8,7 +8,7 @@ Docker Engine Community version is required for docker mode. To install Docker C
### Download pre-built binary
Visit [here](https://dl.gitea.com/gitea-runner/) and download the right version for your platform.
Visit [here](https://dl.gitea.com/runner/) and download the right version for your platform.
### Build from source
@@ -34,7 +34,7 @@ ENABLED=true
### Register
```bash
./gitea-runner register
./runner register
```
And you will be asked to input:
@@ -66,7 +66,7 @@ INFO Runner registered successfully.
You can also register with command line arguments.
```bash
./gitea-runner register --instance http://192.168.8.8:3000 --token <my_runner_token> --no-interactive
./runner register --instance http://192.168.8.8:3000 --token <my_runner_token> --no-interactive
```
If the registry succeed, it will run immediately. Next time, you could run the runner directly.
@@ -74,7 +74,7 @@ If the registry succeed, it will run immediately. Next time, you could run the r
### Run
```bash
./gitea-runner daemon
./runner daemon
```
### Run with docker
@@ -83,60 +83,23 @@ If the registry succeed, it will run immediately. Next time, you could run the r
docker run -e GITEA_INSTANCE_URL=https://your_gitea.com -e GITEA_RUNNER_REGISTRATION_TOKEN=<your_token> -v /var/run/docker.sock:/var/run/docker.sock --name my_runner gitea/runner:nightly
```
Mount a volume on `/data` if you want the registration file and optional config to survive container recreation (see [scripts/run.sh](scripts/run.sh)).
### Configuration
The runner is configured with a YAML file. Generate a starting point (this matches what ships in the tree):
You can also configure the runner with a configuration file.
The configuration file is a YAML file, you can generate a sample configuration file with `./runner generate-config`.
```bash
./gitea-runner generate-config > config.yaml
./runner generate-config > config.yaml
```
Pass it with `-c` / `--config` on any command that loads configuration (`register`, `daemon`, `cache-server`):
You can specify the configuration file path with `-c`/`--config` argument.
```bash
./gitea-runner -c config.yaml register
./gitea-runner -c config.yaml daemon
./gitea-runner -c config.yaml cache-server
./runner -c config.yaml register # register with config file
./runner -c config.yaml daemon # run with config file
```
Every option is described in [config.example.yaml](internal/pkg/config/config.example.yaml) (the same content `generate-config` prints).
#### Without a config file
If you omit `-c`, built-in defaults apply (same as an empty YAML document). A small set of **deprecated** environment variables can still override parts of that default config, but **only when no `-c` path was given**; they are ignored if you use a config file:
| Variable | Effect |
| --- | --- |
| `GITEA_DEBUG` | If true, sets log level to `debug` |
| `GITEA_TRACE` | If true, sets log level to `trace` |
| `GITEA_RUNNER_CAPACITY` | Concurrent jobs (integer) |
| `GITEA_RUNNER_FILE` | Registration state file path (default `.runner`) |
| `GITEA_RUNNER_ENVIRON` | Extra job env vars as comma-separated `KEY:VALUE` pairs |
| `GITEA_RUNNER_ENV_FILE` | Path to an env file merged into job env (same idea as `runner.env_file` in YAML) |
Prefer a YAML file for all settings.
#### Registration vs config labels
If `runner.labels` is set in the YAML file, those labels are used during `register` and the `--labels` CLI flag is ignored.
#### External cache (`actions/cache`)
If `cache.external_server` is set, you must set `cache.external_secret` to the same value on this runner and on the standalone cache server. Run the server with `gitea-runner cache-server` using a config that defines `cache.external_secret` (and matching `cache.dir` / host / port as needed). Flags `--dir`, `--host`, and `--port` on `cache-server` override the file.
#### Official Docker image
Besides `GITEA_INSTANCE_URL` and `GITEA_RUNNER_REGISTRATION_TOKEN`, the image entrypoint supports optional variables such as `CONFIG_FILE` (passed through as `-c`), `GITEA_RUNNER_LABELS`, `GITEA_RUNNER_EPHEMERAL`, `GITEA_RUNNER_ONCE`, `GITEA_RUNNER_NAME`, `GITEA_MAX_REG_ATTEMPTS`, `RUNNER_STATE_FILE`, and `GITEA_RUNNER_REGISTRATION_TOKEN_FILE`. See [scripts/run.sh](scripts/run.sh) for exact behavior.
For a fuller container-oriented walkthrough, see [examples/docker](examples/docker/README.md).
When `container.bind_workdir` is enabled, stale task workspace directories can be cleaned while the runner is idle:
- directories older than `runner.workdir_cleanup_age` are removed (default: `24h`; set `0` to disable)
- cleanup runs every `runner.idle_cleanup_interval` (default: `10m`; set `0` to disable)
- only purely numeric subdirectories under `container.workdir_parent` are treated as task workspaces and may be removed
- cleanup assumes `container.workdir_parent` is not shared across multiple runners
You can read the latest version of the configuration file online at [config.example.yaml](internal/pkg/config/config.example.yaml).
### Example Deployments

View File

@@ -5,24 +5,17 @@
package artifactcache
import (
"context"
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/url"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
@@ -35,36 +28,9 @@ import (
)
const (
apiPath = "/_apis/artifactcache"
internalPath = "/_internal"
// artifactURLTTL bounds how long a signed artifactLocation URL stays valid.
// Short enough that a leaked URL is near-worthless; long enough to let the
// @actions/cache client download a big blob that was returned from /cache.
artifactURLTTL = 10 * time.Minute
urlBase = "/_apis/artifactcache"
)
type credKey struct{}
// JobCredential ties a per-job bearer token (ACTIONS_RUNTIME_TOKEN) to the
// repository that owns it. Every cache entry is stamped with Repo on
// reserve/commit and checked on read/write so one repo can never observe or
// poison another repo's cache, even from inside a container that reaches the
// cache server over the docker bridge network.
type JobCredential struct {
Repo string
}
// credEntry holds a registered job's credential along with an active
// registration count. RegisterJob is reference-counted so that if two tasks
// briefly share an ACTIONS_RUNTIME_TOKEN — e.g. a runner that retries a task
// after a crash before the old registration is revoked — the first task's
// revoker does not cut the second task's auth out from under it.
type credEntry struct {
cred JobCredential
refs int
}
type Handler struct {
dir string
storage *Storage
@@ -77,36 +43,10 @@ type Handler struct {
gcAt time.Time
outboundIP string
// internalSecret guards /_internal/{register,revoke}. When set, a remote
// runner can use these endpoints to pre-register per-job
// ACTIONS_RUNTIME_TOKENs against this server, enabling the same
// per-job auth and repo scoping as the embedded handler over the
// network. Empty disables the control-plane entirely.
internalSecret string
// secret signs short-lived artifact download URLs. The @actions/cache
// toolkit does not send Authorization on the download request, so blob
// GETs authenticate via a per-URL HMAC signature with expiry rather than
// via the bearer token used for management endpoints.
secret []byte
credMu sync.RWMutex
creds map[string]*credEntry
}
// StartHandler opens the on-disk cache store and starts the HTTP server.
//
// internalSecret, when non-empty, enables a control-plane API at
// /_internal/{register,revoke} that lets a remote runner pre-register the
// per-job ACTIONS_RUNTIME_TOKENs it expects this server to honor. The
// embedded in-process handler leaves it empty and registers tokens via the
// in-process RegisterJob method directly.
func StartHandler(dir, outboundIP string, port uint16, internalSecret string, logger logrus.FieldLogger) (*Handler, error) {
h := &Handler{
creds: make(map[string]*credEntry),
internalSecret: internalSecret,
}
func StartHandler(dir, outboundIP string, port uint16, logger logrus.FieldLogger) (*Handler, error) {
h := &Handler{}
if logger == nil {
discard := logrus.New()
@@ -143,37 +83,19 @@ func StartHandler(dir, outboundIP string, port uint16, internalSecret string, lo
h.outboundIP = ip.String()
}
secret, err := loadOrCreateSecret(dir)
if err != nil {
return nil, err
}
h.secret = secret
router := httprouter.New()
router.GET(apiPath+"/cache", h.bearerAuth(h.find))
router.POST(apiPath+"/caches", h.bearerAuth(h.reserve))
router.PATCH(apiPath+"/caches/:id", h.bearerAuth(h.upload))
router.POST(apiPath+"/caches/:id", h.bearerAuth(h.commit))
router.POST(apiPath+"/clean", h.bearerAuth(h.clean))
// Artifact GET is signed via query-string HMAC because @actions/cache
// does not attach Authorization when downloading archiveLocation.
router.GET(apiPath+"/artifacts/:id", h.signedURLAuth(h.get))
// Control-plane: a remote runner registers/revokes per-job tokens so the
// cache API can authenticate them. Always wired so the routes exist; the
// handlers themselves 401 when internalSecret is unset.
router.POST(internalPath+"/register", h.internalAuth(h.internalRegister))
router.POST(internalPath+"/revoke", h.internalAuth(h.internalRevoke))
router.GET(urlBase+"/cache", h.middleware(h.find))
router.POST(urlBase+"/caches", h.middleware(h.reserve))
router.PATCH(urlBase+"/caches/:id", h.middleware(h.upload))
router.POST(urlBase+"/caches/:id", h.middleware(h.commit))
router.GET(urlBase+"/artifacts/:id", h.middleware(h.get))
router.POST(urlBase+"/clean", h.middleware(h.clean))
h.router = router
h.gcCache()
// Listen on all interfaces. Binding to outboundIP only would give no real
// security benefit (it is the LAN/internet-facing address either way) and
// can break Docker Desktop variants where the host's outbound IP is not
// routable from inside the container network. Authentication is enforced
// by the bearer middleware and per-repo scoping, not by reachability.
listener, err := net.Listen("tcp", fmt.Sprintf(":%d", port))
listener, err := net.Listen("tcp", fmt.Sprintf(":%d", port)) // listen on all interfaces
if err != nil {
return nil, err
}
@@ -199,91 +121,6 @@ func (h *Handler) ExternalURL() string {
h.listener.Addr().(*net.TCPAddr).Port)
}
// RegisterJob makes token a valid bearer credential for cache requests from
// the given repository and returns a function that removes it. The runner
// calls this at job start and defers the returned func so that the credential
// is only accepted while the job is running.
//
// Registrations are reference-counted: if a token is already registered, the
// existing repo is kept and the refcount is incremented. The entry is
// removed only when every revoker returned by RegisterJob has been called.
// This keeps a stray re-registration from silently revoking a live job.
func (h *Handler) RegisterJob(token, repo string) func() {
if h == nil || token == "" {
return func() {}
}
h.credMu.Lock()
if existing, ok := h.creds[token]; ok {
existing.refs++
} else {
h.creds[token] = &credEntry{
cred: JobCredential{Repo: repo},
refs: 1,
}
}
h.credMu.Unlock()
return func() {
h.credMu.Lock()
if entry, ok := h.creds[token]; ok {
entry.refs--
if entry.refs <= 0 {
delete(h.creds, token)
}
}
h.credMu.Unlock()
}
}
// RevokeJob explicitly revokes one registration of token, mirroring one call
// of the closure returned by RegisterJob. Used by the control-plane endpoint
// so a remote runner can revoke without holding the closure.
func (h *Handler) RevokeJob(token string) {
if h == nil || token == "" {
return
}
h.credMu.Lock()
if entry, ok := h.creds[token]; ok {
entry.refs--
if entry.refs <= 0 {
delete(h.creds, token)
}
}
h.credMu.Unlock()
}
func (h *Handler) lookupCredential(token string) (JobCredential, bool) {
h.credMu.RLock()
entry, ok := h.creds[token]
h.credMu.RUnlock()
if !ok {
return JobCredential{}, false
}
return entry.cred, true
}
// loadOrCreateSecret returns the 32-byte HMAC signing key for artifact URLs,
// persisted in dir/.secret so signed URLs handed out before a restart stay
// valid across the restart and so the standalone cache-server can be pointed
// at by config.Cache.ExternalServer without the URL rotating.
func loadOrCreateSecret(dir string) ([]byte, error) {
path := filepath.Join(dir, ".secret")
if data, err := os.ReadFile(path); err == nil {
if secret, err := hex.DecodeString(strings.TrimSpace(string(data))); err == nil && len(secret) >= 32 {
return secret, nil
}
} else if !os.IsNotExist(err) {
return nil, fmt.Errorf("read cache secret: %w", err)
}
secret := make([]byte, 32)
if _, err := rand.Read(secret); err != nil {
return nil, fmt.Errorf("generate cache secret: %w", err)
}
if err := os.WriteFile(path, []byte(hex.EncodeToString(secret)), 0o600); err != nil {
return nil, fmt.Errorf("write cache secret: %w", err)
}
return secret, nil
}
func (h *Handler) Close() error {
if h == nil {
return nil
@@ -323,7 +160,6 @@ func (h *Handler) openDB() (*bolthold.Store, error) {
// GET /_apis/artifactcache/cache
func (h *Handler) find(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
cred := credFromContext(r.Context())
keys := strings.Split(r.URL.Query().Get("keys"), ",")
// cache keys are case insensitive
for i, key := range keys {
@@ -338,7 +174,7 @@ func (h *Handler) find(w http.ResponseWriter, r *http.Request, _ httprouter.Para
}
defer db.Close()
cache, err := findCache(db, cred.Repo, keys, version)
cache, err := findCache(db, keys, version)
if err != nil {
h.responseJSON(w, r, 500, err)
return
@@ -358,14 +194,13 @@ func (h *Handler) find(w http.ResponseWriter, r *http.Request, _ httprouter.Para
}
h.responseJSON(w, r, 200, map[string]any{
"result": "hit",
"archiveLocation": h.signedArtifactURL(cache.ID, time.Now().Add(artifactURLTTL)),
"archiveLocation": fmt.Sprintf("%s%s/artifacts/%d", h.ExternalURL(), urlBase, cache.ID),
"cacheKey": cache.Key,
})
}
// POST /_apis/artifactcache/caches
func (h *Handler) reserve(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
cred := credFromContext(r.Context())
api := &Request{}
if err := json.NewDecoder(r.Body).Decode(api); err != nil {
h.responseJSON(w, r, 400, err)
@@ -375,7 +210,6 @@ func (h *Handler) reserve(w http.ResponseWriter, r *http.Request, _ httprouter.P
api.Key = strings.ToLower(api.Key)
cache := api.ToCache()
cache.Repo = cred.Repo
db, err := h.openDB()
if err != nil {
h.responseJSON(w, r, 500, err)
@@ -397,7 +231,6 @@ func (h *Handler) reserve(w http.ResponseWriter, r *http.Request, _ httprouter.P
// PATCH /_apis/artifactcache/caches/:id
func (h *Handler) upload(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
cred := credFromContext(r.Context())
id, err := strconv.ParseInt(params.ByName("id"), 10, 64)
if err != nil {
h.responseJSON(w, r, 400, err)
@@ -420,11 +253,6 @@ func (h *Handler) upload(w http.ResponseWriter, r *http.Request, params httprout
return
}
if cache.Repo != cred.Repo {
h.responseJSON(w, r, 403, fmt.Errorf("cache %d: forbidden", id))
return
}
if cache.Complete {
h.responseJSON(w, r, 400, fmt.Errorf("cache %v %q: already complete", cache.ID, cache.Key))
return
@@ -444,7 +272,6 @@ func (h *Handler) upload(w http.ResponseWriter, r *http.Request, params httprout
// POST /_apis/artifactcache/caches/:id
func (h *Handler) commit(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
cred := credFromContext(r.Context())
id, err := strconv.ParseInt(params.ByName("id"), 10, 64)
if err != nil {
h.responseJSON(w, r, 400, err)
@@ -467,11 +294,6 @@ func (h *Handler) commit(w http.ResponseWriter, r *http.Request, params httprout
return
}
if cache.Repo != cred.Repo {
h.responseJSON(w, r, 403, fmt.Errorf("cache %d: forbidden", id))
return
}
if cache.Complete {
h.responseJSON(w, r, 400, fmt.Errorf("cache %v %q: already complete", cache.ID, cache.Key))
return
@@ -504,10 +326,6 @@ func (h *Handler) commit(w http.ResponseWriter, r *http.Request, params httprout
}
// GET /_apis/artifactcache/artifacts/:id
// Authenticated via signed URL (see signedURLAuth), not bearer, because the
// @actions/cache toolkit downloads archiveLocation without Authorization.
// Repository scoping is already enforced at find() time; the signature binds
// the URL to the specific cache ID and an expiry.
func (h *Handler) get(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
id, err := strconv.ParseInt(params.ByName("id"), 10, 64)
if err != nil {
@@ -526,158 +344,21 @@ func (h *Handler) clean(w http.ResponseWriter, r *http.Request, _ httprouter.Par
h.responseJSON(w, r, 200)
}
// bearerAuth resolves ACTIONS_RUNTIME_TOKEN against the set of currently
// registered jobs. A match attaches the job's JobCredential to the request
// context; a miss returns 401 before the handler body runs.
func (h *Handler) bearerAuth(handler httprouter.Handle) httprouter.Handle {
func (h *Handler) middleware(handler httprouter.Handle) httprouter.Handle {
return func(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
h.logger.Debugf("%s %s", r.Method, r.URL.Path)
token := bearerToken(r)
if token == "" {
h.responseJSON(w, r, http.StatusUnauthorized, errors.New("missing bearer token"))
return
}
cred, ok := h.lookupCredential(token)
if !ok {
h.responseJSON(w, r, http.StatusUnauthorized, errors.New("unknown bearer token"))
return
}
ctx := context.WithValue(r.Context(), credKey{}, cred)
handler(w, r.WithContext(ctx), params)
go h.gcCache()
}
}
func (h *Handler) signedURLAuth(handler httprouter.Handle) httprouter.Handle {
return func(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
h.logger.Debugf("%s %s", r.Method, r.URL.Path)
id, err := strconv.ParseInt(params.ByName("id"), 10, 64)
if err != nil {
h.responseJSON(w, r, 400, err)
return
}
expStr := r.URL.Query().Get("exp")
sig := r.URL.Query().Get("sig")
if expStr == "" || sig == "" {
h.responseJSON(w, r, http.StatusUnauthorized, errors.New("missing signature"))
return
}
exp, err := strconv.ParseInt(expStr, 10, 64)
if err != nil {
h.responseJSON(w, r, http.StatusUnauthorized, errors.New("invalid expiry"))
return
}
if time.Now().Unix() > exp {
h.responseJSON(w, r, http.StatusUnauthorized, errors.New("signature expired"))
return
}
expected := h.computeSignature(id, exp)
if !hmac.Equal([]byte(sig), []byte(expected)) {
h.responseJSON(w, r, http.StatusUnauthorized, errors.New("bad signature"))
return
}
h.logger.Debugf("%s %s", r.Method, r.RequestURI)
handler(w, r, params)
go h.gcCache()
}
}
// internalAuth gates the control-plane endpoints. The bearer must
// constant-time-equal the configured internalSecret. If the secret is empty,
// the control-plane is disabled and every request gets 404 — which matches
// the upstream nektos/act behavior of "the route does not exist".
func (h *Handler) internalAuth(handler httprouter.Handle) httprouter.Handle {
return func(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
if h.internalSecret == "" {
http.NotFound(w, r)
return
}
token := bearerToken(r)
if token == "" || !hmac.Equal([]byte(token), []byte(h.internalSecret)) {
h.responseJSON(w, r, http.StatusUnauthorized, errors.New("internal: bad secret"))
return
}
handler(w, r, params)
}
}
type internalRegisterBody struct {
Token string `json:"token"`
Repo string `json:"repo"`
}
type internalRevokeBody struct {
Token string `json:"token"`
}
// POST /_internal/register
func (h *Handler) internalRegister(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
var body internalRegisterBody
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
h.responseJSON(w, r, http.StatusBadRequest, err)
return
}
if body.Token == "" {
h.responseJSON(w, r, http.StatusBadRequest, errors.New("token is required"))
return
}
h.RegisterJob(body.Token, body.Repo)
h.responseJSON(w, r, http.StatusOK)
}
// POST /_internal/revoke
func (h *Handler) internalRevoke(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
var body internalRevokeBody
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
h.responseJSON(w, r, http.StatusBadRequest, err)
return
}
if body.Token == "" {
h.responseJSON(w, r, http.StatusBadRequest, errors.New("token is required"))
return
}
h.RevokeJob(body.Token)
h.responseJSON(w, r, http.StatusOK)
}
func bearerToken(r *http.Request) string {
auth := r.Header.Get("Authorization")
const prefix = "Bearer "
if len(auth) > len(prefix) && strings.EqualFold(auth[:len(prefix)], prefix) {
return auth[len(prefix):]
}
return ""
}
func credFromContext(ctx context.Context) JobCredential {
if cred, ok := ctx.Value(credKey{}).(JobCredential); ok {
return cred
}
return JobCredential{}
}
func (h *Handler) computeSignature(cacheID, exp int64) string {
mac := hmac.New(sha256.New, h.secret)
fmt.Fprintf(mac, "%d:%d", cacheID, exp)
return hex.EncodeToString(mac.Sum(nil))
}
func (h *Handler) signedArtifactURL(cacheID uint64, exp time.Time) string {
expUnix := exp.Unix()
sig := h.computeSignature(int64(cacheID), expUnix)
q := url.Values{}
q.Set("exp", strconv.FormatInt(expUnix, 10))
q.Set("sig", sig)
return fmt.Sprintf("%s%s/artifacts/%d?%s", h.ExternalURL(), apiPath, cacheID, q.Encode())
}
// if not found, return (nil, nil) instead of an error.
func findCache(db *bolthold.Store, repo string, keys []string, version string) (*Cache, error) {
func findCache(db *bolthold.Store, keys []string, version string) (*Cache, error) {
cache := &Cache{}
for _, prefix := range keys {
// if a key in the list matches exactly, don't return partial matches
if err := db.FindOne(cache,
bolthold.Where("Repo").Eq(repo).
And("Key").Eq(prefix).
bolthold.Where("Key").Eq(prefix).
And("Version").Eq(version).
And("Complete").Eq(true).
SortBy("CreatedAt").Reverse()); err == nil || !errors.Is(err, bolthold.ErrNotFound) {
@@ -692,8 +373,7 @@ func findCache(db *bolthold.Store, repo string, keys []string, version string) (
continue
}
if err := db.FindOne(cache,
bolthold.Where("Repo").Eq(repo).
And("Key").RegExp(re).
bolthold.Where("Key").RegExp(re).
And("Version").Eq(version).
And("Complete").Eq(true).
SortBy("CreatedAt").Reverse()); err != nil {
@@ -739,6 +419,7 @@ const (
keepOld = 5 * time.Minute
)
//nolint:gocyclo // function handles many cases
func (h *Handler) gcCache() {
if h.gcing.Load() {
return
@@ -813,16 +494,12 @@ func (h *Handler) gcCache() {
}
}
// Remove the old caches with the same key and version within the same
// repository, keep the latest one. Aggregation must include Repo so two
// repos that happen to share a (key, version) do not evict each other —
// otherwise per-repo scoping holds for reads but one repo can age
// another out after keepOld.
// Remove the old caches with the same key and version, keep the latest one.
// Also keep the olds which have been used recently for a while in case of the cache is still in use.
if results, err := db.FindAggregate(
&Cache{},
bolthold.Where("Complete").Eq(true),
"Repo", "Key", "Version",
"Key", "Version",
); err != nil {
h.logger.Warnf("find aggregate caches: %v", err)
} else {
@@ -856,7 +533,7 @@ func (h *Handler) responseJSON(w http.ResponseWriter, r *http.Request, code int,
if len(v) == 0 || v[0] == nil {
data, _ = json.Marshal(struct{}{})
} else if err, ok := v[0].(error); ok {
h.logger.Errorf("%v %v: %v", r.Method, r.URL.Path, err)
h.logger.Errorf("%v %v: %v", r.Method, r.RequestURI, err)
data, _ = json.Marshal(map[string]any{
"error": err.Error(),
})

View File

@@ -22,38 +22,12 @@ import (
"go.etcd.io/bbolt"
)
// testToken is registered with the cache server in every test that needs to
// make authenticated requests; testClient then attaches it as the
// Authorization: Bearer header. testRepo is the repository scope used when
// registering it; cross-repo isolation is exercised in its own test.
const (
testToken = "test-runtime-token"
testRepo = "owner/repo"
)
type bearerTransport struct{ token string }
func (b *bearerTransport) RoundTrip(r *http.Request) (*http.Response, error) {
r.Header.Set("Authorization", "Bearer "+b.token)
return http.DefaultTransport.RoundTrip(r)
}
var testClient = &http.Client{Transport: &bearerTransport{token: testToken}}
// signArtifactURL builds a signed download URL the same way the server does;
// tests use it to reach the get handler directly without going through a
// find/cache-hit round trip.
func signArtifactURL(h *Handler, id int64) string {
return h.signedArtifactURL(uint64(id), time.Now().Add(artifactURLTTL))
}
func TestHandler(t *testing.T) {
dir := filepath.Join(t.TempDir(), "artifactcache")
handler, err := StartHandler(dir, "", 0, "", nil)
handler, err := StartHandler(dir, "", 0, nil)
require.NoError(t, err)
handler.RegisterJob(testToken, testRepo)
base := fmt.Sprintf("%s%s", handler.ExternalURL(), apiPath)
base := fmt.Sprintf("%s%s", handler.ExternalURL(), urlBase)
defer func() {
t.Run("inpect db", func(t *testing.T) {
@@ -71,10 +45,7 @@ func TestHandler(t *testing.T) {
require.NoError(t, handler.Close())
assert.Nil(t, handler.server)
assert.Nil(t, handler.listener)
resp, err := testClient.Post(fmt.Sprintf("%s/caches/%d", base, 1), "", nil)
if err == nil {
resp.Body.Close()
}
_, err := http.Post(fmt.Sprintf("%s/caches/%d", base, 1), "", nil) //nolint:bodyclose // pre-existing issue from nektos/act
assert.Error(t, err)
})
}()
@@ -82,9 +53,8 @@ func TestHandler(t *testing.T) {
t.Run("get not exist", func(t *testing.T) {
key := strings.ToLower(t.Name())
version := "c19da02a2bd7e77277f1ac29ab45c09b7d46a4ee758284e26bb3045ad11d9d20"
resp, err := testClient.Get(fmt.Sprintf("%s/cache?keys=%s&version=%s", base, key, version))
resp, err := http.Get(fmt.Sprintf("%s/cache?keys=%s&version=%s", base, key, version)) //nolint:bodyclose // pre-existing issue from nektos/act
require.NoError(t, err)
defer resp.Body.Close()
require.Equal(t, 204, resp.StatusCode)
})
@@ -98,18 +68,16 @@ func TestHandler(t *testing.T) {
})
t.Run("clean", func(t *testing.T) {
resp, err := testClient.Post(base+"/clean", "", nil)
resp, err := http.Post(base+"/clean", "", nil) //nolint:bodyclose // pre-existing issue from nektos/act
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, 200, resp.StatusCode)
})
t.Run("reserve with bad request", func(t *testing.T) {
body := []byte(`invalid json`)
require.NoError(t, err)
resp, err := testClient.Post(base+"/caches", "application/json", bytes.NewReader(body))
resp, err := http.Post(base+"/caches", "application/json", bytes.NewReader(body)) //nolint:bodyclose // pre-existing issue from nektos/act
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, 400, resp.StatusCode)
})
@@ -126,9 +94,8 @@ func TestHandler(t *testing.T) {
Size: 100,
})
require.NoError(t, err)
resp, err := testClient.Post(base+"/caches", "application/json", bytes.NewReader(body))
resp, err := http.Post(base+"/caches", "application/json", bytes.NewReader(body)) //nolint:bodyclose // pre-existing issue from nektos/act
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, 200, resp.StatusCode)
require.NoError(t, json.NewDecoder(resp.Body).Decode(&first))
@@ -141,9 +108,8 @@ func TestHandler(t *testing.T) {
Size: 100,
})
require.NoError(t, err)
resp, err := testClient.Post(base+"/caches", "application/json", bytes.NewReader(body))
resp, err := http.Post(base+"/caches", "application/json", bytes.NewReader(body)) //nolint:bodyclose // pre-existing issue from nektos/act
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, 200, resp.StatusCode)
require.NoError(t, json.NewDecoder(resp.Body).Decode(&second))
@@ -159,9 +125,8 @@ func TestHandler(t *testing.T) {
require.NoError(t, err)
req.Header.Set("Content-Type", "application/octet-stream")
req.Header.Set("Content-Range", "bytes 0-99/*")
resp, err := testClient.Do(req)
resp, err := http.DefaultClient.Do(req) //nolint:bodyclose // pre-existing issue from nektos/act
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, 400, resp.StatusCode)
})
@@ -171,9 +136,8 @@ func TestHandler(t *testing.T) {
require.NoError(t, err)
req.Header.Set("Content-Type", "application/octet-stream")
req.Header.Set("Content-Range", "bytes 0-99/*")
resp, err := testClient.Do(req)
resp, err := http.DefaultClient.Do(req) //nolint:bodyclose // pre-existing issue from nektos/act
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, 400, resp.StatusCode)
})
@@ -191,9 +155,8 @@ func TestHandler(t *testing.T) {
Size: 100,
})
require.NoError(t, err)
resp, err := testClient.Post(base+"/caches", "application/json", bytes.NewReader(body))
resp, err := http.Post(base+"/caches", "application/json", bytes.NewReader(body)) //nolint:bodyclose // pre-existing issue from nektos/act
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, 200, resp.StatusCode)
got := struct {
@@ -208,15 +171,13 @@ func TestHandler(t *testing.T) {
require.NoError(t, err)
req.Header.Set("Content-Type", "application/octet-stream")
req.Header.Set("Content-Range", "bytes 0-99/*")
resp, err := testClient.Do(req)
resp, err := http.DefaultClient.Do(req) //nolint:bodyclose // pre-existing issue from nektos/act
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, 200, resp.StatusCode)
}
{
resp, err := testClient.Post(fmt.Sprintf("%s/caches/%d", base, id), "", nil)
resp, err := http.Post(fmt.Sprintf("%s/caches/%d", base, id), "", nil) //nolint:bodyclose // pre-existing issue from nektos/act
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, 200, resp.StatusCode)
}
{
@@ -225,9 +186,8 @@ func TestHandler(t *testing.T) {
require.NoError(t, err)
req.Header.Set("Content-Type", "application/octet-stream")
req.Header.Set("Content-Range", "bytes 0-99/*")
resp, err := testClient.Do(req)
resp, err := http.DefaultClient.Do(req) //nolint:bodyclose // pre-existing issue from nektos/act
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, 400, resp.StatusCode)
}
})
@@ -246,9 +206,8 @@ func TestHandler(t *testing.T) {
Size: 100,
})
require.NoError(t, err)
resp, err := testClient.Post(base+"/caches", "application/json", bytes.NewReader(body))
resp, err := http.Post(base+"/caches", "application/json", bytes.NewReader(body)) //nolint:bodyclose // pre-existing issue from nektos/act
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, 200, resp.StatusCode)
got := struct {
@@ -263,27 +222,24 @@ func TestHandler(t *testing.T) {
require.NoError(t, err)
req.Header.Set("Content-Type", "application/octet-stream")
req.Header.Set("Content-Range", "bytes xx-99/*")
resp, err := testClient.Do(req)
resp, err := http.DefaultClient.Do(req) //nolint:bodyclose // pre-existing issue from nektos/act
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, 400, resp.StatusCode)
}
})
t.Run("commit with bad id", func(t *testing.T) {
{
resp, err := testClient.Post(base+"/caches/invalid_id", "", nil)
resp, err := http.Post(base+"/caches/invalid_id", "", nil) //nolint:bodyclose // pre-existing issue from nektos/act
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, 400, resp.StatusCode)
}
})
t.Run("commit with not exist id", func(t *testing.T) {
{
resp, err := testClient.Post(fmt.Sprintf("%s/caches/%d", base, 100), "", nil)
resp, err := http.Post(fmt.Sprintf("%s/caches/%d", base, 100), "", nil) //nolint:bodyclose // pre-existing issue from nektos/act
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, 400, resp.StatusCode)
}
})
@@ -302,9 +258,8 @@ func TestHandler(t *testing.T) {
Size: 100,
})
require.NoError(t, err)
resp, err := testClient.Post(base+"/caches", "application/json", bytes.NewReader(body))
resp, err := http.Post(base+"/caches", "application/json", bytes.NewReader(body)) //nolint:bodyclose // pre-existing issue from nektos/act
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, 200, resp.StatusCode)
got := struct {
@@ -319,21 +274,18 @@ func TestHandler(t *testing.T) {
require.NoError(t, err)
req.Header.Set("Content-Type", "application/octet-stream")
req.Header.Set("Content-Range", "bytes 0-99/*")
resp, err := testClient.Do(req)
resp, err := http.DefaultClient.Do(req) //nolint:bodyclose // pre-existing issue from nektos/act
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, 200, resp.StatusCode)
}
{
resp, err := testClient.Post(fmt.Sprintf("%s/caches/%d", base, id), "", nil)
resp, err := http.Post(fmt.Sprintf("%s/caches/%d", base, id), "", nil) //nolint:bodyclose // pre-existing issue from nektos/act
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, 200, resp.StatusCode)
}
{
resp, err := testClient.Post(fmt.Sprintf("%s/caches/%d", base, id), "", nil)
resp, err := http.Post(fmt.Sprintf("%s/caches/%d", base, id), "", nil) //nolint:bodyclose // pre-existing issue from nektos/act
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, 400, resp.StatusCode)
}
})
@@ -352,9 +304,8 @@ func TestHandler(t *testing.T) {
Size: 100,
})
require.NoError(t, err)
resp, err := testClient.Post(base+"/caches", "application/json", bytes.NewReader(body))
resp, err := http.Post(base+"/caches", "application/json", bytes.NewReader(body)) //nolint:bodyclose // pre-existing issue from nektos/act
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, 200, resp.StatusCode)
got := struct {
@@ -369,37 +320,32 @@ func TestHandler(t *testing.T) {
require.NoError(t, err)
req.Header.Set("Content-Type", "application/octet-stream")
req.Header.Set("Content-Range", "bytes 0-59/*")
resp, err := testClient.Do(req)
resp, err := http.DefaultClient.Do(req) //nolint:bodyclose // pre-existing issue from nektos/act
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, 200, resp.StatusCode)
}
{
resp, err := testClient.Post(fmt.Sprintf("%s/caches/%d", base, id), "", nil)
resp, err := http.Post(fmt.Sprintf("%s/caches/%d", base, id), "", nil) //nolint:bodyclose // pre-existing issue from nektos/act
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, 500, resp.StatusCode)
}
})
t.Run("get with bad id", func(t *testing.T) {
resp, err := testClient.Get(base + "/artifacts/invalid_id")
resp, err := http.Get(base + "/artifacts/invalid_id") //nolint:bodyclose // pre-existing issue from nektos/act
require.NoError(t, err)
defer resp.Body.Close()
require.Equal(t, 400, resp.StatusCode)
})
t.Run("get with not exist id", func(t *testing.T) {
resp, err := testClient.Get(signArtifactURL(handler, 100))
resp, err := http.Get(fmt.Sprintf("%s/artifacts/%d", base, 100)) //nolint:bodyclose // pre-existing issue from nektos/act
require.NoError(t, err)
defer resp.Body.Close()
require.Equal(t, 404, resp.StatusCode)
})
t.Run("get with not exist id", func(t *testing.T) {
resp, err := testClient.Get(signArtifactURL(handler, 100))
resp, err := http.Get(fmt.Sprintf("%s/artifacts/%d", base, 100)) //nolint:bodyclose // pre-existing issue from nektos/act
require.NoError(t, err)
defer resp.Body.Close()
require.Equal(t, 404, resp.StatusCode)
})
@@ -429,9 +375,8 @@ func TestHandler(t *testing.T) {
key + "_a",
}, ",")
resp, err := testClient.Get(fmt.Sprintf("%s/cache?keys=%s&version=%s", base, reqKeys, version))
resp, err := http.Get(fmt.Sprintf("%s/cache?keys=%s&version=%s", base, reqKeys, version)) //nolint:bodyclose // pre-existing issue from nektos/act
require.NoError(t, err)
defer resp.Body.Close()
require.Equal(t, 200, resp.StatusCode)
/*
@@ -450,9 +395,8 @@ func TestHandler(t *testing.T) {
assert.Equal(t, "hit", got.Result)
assert.Equal(t, keys[except], got.CacheKey)
contentResp, err := testClient.Get(got.ArchiveLocation)
contentResp, err := http.Get(got.ArchiveLocation) //nolint:bodyclose // pre-existing issue from nektos/act
require.NoError(t, err)
defer contentResp.Body.Close()
require.Equal(t, 200, contentResp.StatusCode)
content, err := io.ReadAll(contentResp.Body)
require.NoError(t, err)
@@ -469,9 +413,8 @@ func TestHandler(t *testing.T) {
{
reqKey := key + "_aBc"
resp, err := testClient.Get(fmt.Sprintf("%s/cache?keys=%s&version=%s", base, reqKey, version))
resp, err := http.Get(fmt.Sprintf("%s/cache?keys=%s&version=%s", base, reqKey, version)) //nolint:bodyclose // pre-existing issue from nektos/act
require.NoError(t, err)
defer resp.Body.Close()
require.Equal(t, 200, resp.StatusCode)
got := struct {
Result string `json:"result"`
@@ -509,9 +452,8 @@ func TestHandler(t *testing.T) {
key + "_a_b",
}, ",")
resp, err := testClient.Get(fmt.Sprintf("%s/cache?keys=%s&version=%s", base, reqKeys, version))
resp, err := http.Get(fmt.Sprintf("%s/cache?keys=%s&version=%s", base, reqKeys, version)) //nolint:bodyclose // pre-existing issue from nektos/act
require.NoError(t, err)
defer resp.Body.Close()
require.Equal(t, 200, resp.StatusCode)
/*
@@ -528,9 +470,8 @@ func TestHandler(t *testing.T) {
require.NoError(t, json.NewDecoder(resp.Body).Decode(&got))
assert.Equal(t, keys[expect], got.CacheKey)
contentResp, err := testClient.Get(got.ArchiveLocation)
contentResp, err := http.Get(got.ArchiveLocation) //nolint:bodyclose // pre-existing issue from nektos/act
require.NoError(t, err)
defer contentResp.Body.Close()
require.Equal(t, 200, contentResp.StatusCode)
content, err := io.ReadAll(contentResp.Body)
require.NoError(t, err)
@@ -563,9 +504,8 @@ func TestHandler(t *testing.T) {
key + "_a_b",
}, ",")
resp, err := testClient.Get(fmt.Sprintf("%s/cache?keys=%s&version=%s", base, reqKeys, version))
resp, err := http.Get(fmt.Sprintf("%s/cache?keys=%s&version=%s", base, reqKeys, version)) //nolint:bodyclose // pre-existing issue from nektos/act
require.NoError(t, err)
defer resp.Body.Close()
require.Equal(t, 200, resp.StatusCode)
/*
@@ -583,9 +523,8 @@ func TestHandler(t *testing.T) {
require.NoError(t, json.NewDecoder(resp.Body).Decode(&got))
assert.Equal(t, keys[expect], got.CacheKey)
contentResp, err := testClient.Get(got.ArchiveLocation)
contentResp, err := http.Get(got.ArchiveLocation) //nolint:bodyclose // pre-existing issue from nektos/act
require.NoError(t, err)
defer contentResp.Body.Close()
require.Equal(t, 200, contentResp.StatusCode)
content, err := io.ReadAll(contentResp.Body)
require.NoError(t, err)
@@ -602,9 +541,8 @@ func uploadCacheNormally(t *testing.T, base, key, version string, content []byte
Size: int64(len(content)),
})
require.NoError(t, err)
resp, err := testClient.Post(base+"/caches", "application/json", bytes.NewReader(body))
resp, err := http.Post(base+"/caches", "application/json", bytes.NewReader(body)) //nolint:bodyclose // pre-existing issue from nektos/act
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, 200, resp.StatusCode)
got := struct {
@@ -619,22 +557,19 @@ func uploadCacheNormally(t *testing.T, base, key, version string, content []byte
require.NoError(t, err)
req.Header.Set("Content-Type", "application/octet-stream")
req.Header.Set("Content-Range", "bytes 0-99/*")
resp, err := testClient.Do(req)
resp, err := http.DefaultClient.Do(req) //nolint:bodyclose // pre-existing issue from nektos/act
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, 200, resp.StatusCode)
}
{
resp, err := testClient.Post(fmt.Sprintf("%s/caches/%d", base, id), "", nil)
resp, err := http.Post(fmt.Sprintf("%s/caches/%d", base, id), "", nil) //nolint:bodyclose // pre-existing issue from nektos/act
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, 200, resp.StatusCode)
}
var archiveLocation string
{
resp, err := testClient.Get(fmt.Sprintf("%s/cache?keys=%s&version=%s", base, key, version))
resp, err := http.Get(fmt.Sprintf("%s/cache?keys=%s&version=%s", base, key, version)) //nolint:bodyclose // pre-existing issue from nektos/act
require.NoError(t, err)
defer resp.Body.Close()
require.Equal(t, 200, resp.StatusCode)
got := struct {
Result string `json:"result"`
@@ -647,9 +582,8 @@ func uploadCacheNormally(t *testing.T, base, key, version string, content []byte
archiveLocation = got.ArchiveLocation
}
{
resp, err := testClient.Get(archiveLocation)
resp, err := http.Get(archiveLocation) //nolint:bodyclose // pre-existing issue from nektos/act
require.NoError(t, err)
defer resp.Body.Close()
require.Equal(t, 200, resp.StatusCode)
got, err := io.ReadAll(resp.Body)
require.NoError(t, err)
@@ -659,7 +593,7 @@ func uploadCacheNormally(t *testing.T, base, key, version string, content []byte
func TestHandler_gcCache(t *testing.T) {
dir := filepath.Join(t.TempDir(), "artifactcache")
handler, err := StartHandler(dir, "", 0, "", nil)
handler, err := StartHandler(dir, "", 0, nil)
require.NoError(t, err)
defer func() {
@@ -765,421 +699,3 @@ func TestHandler_gcCache(t *testing.T) {
}
require.NoError(t, db.Close())
}
// TestHandler_RejectsMissingBearer covers the advisory's root cause:
// unauthenticated access to management endpoints is now refused with 401.
func TestHandler_RejectsMissingBearer(t *testing.T) {
dir := filepath.Join(t.TempDir(), "artifactcache")
handler, err := StartHandler(dir, "", 0, "", nil)
require.NoError(t, err)
defer handler.Close()
base := handler.ExternalURL() + apiPath
for _, tc := range []struct {
name string
method string
path string
body string
}{
{"find", http.MethodGet, "/cache?keys=x&version=y", ""},
{"reserve", http.MethodPost, "/caches", "{}"},
{"upload", http.MethodPatch, "/caches/1", ""},
{"commit", http.MethodPost, "/caches/1", ""},
{"clean", http.MethodPost, "/clean", ""},
} {
t.Run(tc.name, func(t *testing.T) {
req, err := http.NewRequest(tc.method, base+tc.path, strings.NewReader(tc.body))
require.NoError(t, err)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
resp.Body.Close()
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
})
}
}
// TestHandler_RejectsUnknownBearer verifies that a bearer token is only
// accepted after RegisterJob; stale/forged tokens cannot be replayed.
func TestHandler_RejectsUnknownBearer(t *testing.T) {
dir := filepath.Join(t.TempDir(), "artifactcache")
handler, err := StartHandler(dir, "", 0, "", nil)
require.NoError(t, err)
defer handler.Close()
base := handler.ExternalURL() + apiPath
req, err := http.NewRequest(http.MethodGet, base+"/cache?keys=x&version=y", nil)
require.NoError(t, err)
req.Header.Set("Authorization", "Bearer not-a-registered-token")
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
resp.Body.Close()
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
}
// TestHandler_UnregisterRevokes ensures that the function returned by
// RegisterJob invalidates the credential, so a token leaked at job time stops
// working the moment the job ends instead of living for the runner's lifetime.
func TestHandler_UnregisterRevokes(t *testing.T) {
dir := filepath.Join(t.TempDir(), "artifactcache")
handler, err := StartHandler(dir, "", 0, "", nil)
require.NoError(t, err)
defer handler.Close()
unregister := handler.RegisterJob("tmp-token", testRepo)
base := handler.ExternalURL() + apiPath
req, err := http.NewRequest(http.MethodGet, base+"/cache?keys=x&version=y", nil)
require.NoError(t, err)
req.Header.Set("Authorization", "Bearer tmp-token")
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
resp.Body.Close()
assert.NotEqual(t, http.StatusUnauthorized, resp.StatusCode)
unregister()
resp, err = http.DefaultClient.Do(req)
require.NoError(t, err)
resp.Body.Close()
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
}
// TestHandler_CrossRepoIsolation addresses the intra-runner poisoning vector
// raised in GHSA-82g9-637c-2fx2: job containers can reach the cache server
// over the docker bridge, so IP allowlisting alone does not stop a malicious
// PR run from another repo. A cache entry created under repoA must be
// invisible to queries scoped to repoB.
func TestHandler_CrossRepoIsolation(t *testing.T) {
dir := filepath.Join(t.TempDir(), "artifactcache")
handler, err := StartHandler(dir, "", 0, "", nil)
require.NoError(t, err)
defer handler.Close()
handler.RegisterJob("token-a", "owner/repoA")
handler.RegisterJob("token-b", "owner/repoB")
base := handler.ExternalURL() + apiPath
key := "shared-key"
version := "c19da02a2bd7e77277f1ac29ab45c09b7d46a4ee758284e26bb3045ad11d9d20"
content := []byte("repoA-payload")
clientA := &http.Client{Transport: &bearerTransport{token: "token-a"}}
clientB := &http.Client{Transport: &bearerTransport{token: "token-b"}}
// repoA reserves + uploads + commits.
reserveBody, err := json.Marshal(&Request{Key: key, Version: version, Size: int64(len(content))})
require.NoError(t, err)
resp, err := clientA.Post(base+"/caches", "application/json", bytes.NewReader(reserveBody))
require.NoError(t, err)
var reserved struct {
CacheID uint64 `json:"cacheId"`
}
require.NoError(t, json.NewDecoder(resp.Body).Decode(&reserved))
resp.Body.Close()
require.NotZero(t, reserved.CacheID)
req, err := http.NewRequest(http.MethodPatch, fmt.Sprintf("%s/caches/%d", base, reserved.CacheID), bytes.NewReader(content))
require.NoError(t, err)
req.Header.Set("Content-Range", fmt.Sprintf("bytes 0-%d/*", len(content)-1))
resp, err = clientA.Do(req)
require.NoError(t, err)
resp.Body.Close()
require.Equal(t, http.StatusOK, resp.StatusCode)
resp, err = clientA.Post(fmt.Sprintf("%s/caches/%d", base, reserved.CacheID), "", nil)
require.NoError(t, err)
resp.Body.Close()
require.Equal(t, http.StatusOK, resp.StatusCode)
// repoB with a matching key and version must NOT see repoA's cache.
resp, err = clientB.Get(fmt.Sprintf("%s/cache?keys=%s&version=%s", base, key, version))
require.NoError(t, err)
resp.Body.Close()
assert.Equal(t, http.StatusNoContent, resp.StatusCode)
// repoA still sees its own cache.
resp, err = clientA.Get(fmt.Sprintf("%s/cache?keys=%s&version=%s", base, key, version))
require.NoError(t, err)
resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode)
// repoB cannot upload to repoA's reserved id either (forbidden, not 401).
req, err = http.NewRequest(http.MethodPatch, fmt.Sprintf("%s/caches/%d", base, reserved.CacheID), bytes.NewReader([]byte("poison")))
require.NoError(t, err)
req.Header.Set("Content-Range", "bytes 0-5/*")
resp, err = clientB.Do(req)
require.NoError(t, err)
resp.Body.Close()
assert.Equal(t, http.StatusForbidden, resp.StatusCode)
}
// TestHandler_ArtifactSignature verifies that archive downloads reject
// missing / tampered / expired signatures, so a leaked archiveLocation stops
// working after artifactURLTTL even if the bearer token is still registered.
func TestHandler_ArtifactSignature(t *testing.T) {
dir := filepath.Join(t.TempDir(), "artifactcache")
handler, err := StartHandler(dir, "", 0, "", nil)
require.NoError(t, err)
defer handler.Close()
handler.RegisterJob(testToken, testRepo)
base := handler.ExternalURL() + apiPath
t.Run("missing signature", func(t *testing.T) {
resp, err := testClient.Get(fmt.Sprintf("%s/artifacts/%d", base, 1))
require.NoError(t, err)
resp.Body.Close()
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
})
t.Run("tampered signature", func(t *testing.T) {
good := handler.signedArtifactURL(1, time.Now().Add(artifactURLTTL))
bad := good[:len(good)-4] + "dead"
resp, err := testClient.Get(bad)
require.NoError(t, err)
resp.Body.Close()
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
})
t.Run("expired signature", func(t *testing.T) {
expired := handler.signedArtifactURL(1, time.Now().Add(-time.Second))
resp, err := testClient.Get(expired)
require.NoError(t, err)
resp.Body.Close()
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
})
t.Run("signature from a different server", func(t *testing.T) {
dir2 := filepath.Join(t.TempDir(), "artifactcache2")
other, err := StartHandler(dir2, "", 0, "", nil)
require.NoError(t, err)
defer other.Close()
otherURL := other.signedArtifactURL(1, time.Now().Add(artifactURLTTL))
// Rewrite the host so the request still lands on our handler, but
// the signature was computed with a different secret.
parts := strings.SplitN(otherURL, apiPath, 2)
forged := base + parts[1]
resp, err := testClient.Get(forged)
require.NoError(t, err)
resp.Body.Close()
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
})
}
// TestHandler_SecretPersistsAcrossRestarts is the property that lets
// gitea-runner cache-server be pointed at via cfg.Cache.ExternalServer: a
// restart must not invalidate signed URLs the handler has already issued
// (within their expiry window).
func TestHandler_SecretPersistsAcrossRestarts(t *testing.T) {
dir := filepath.Join(t.TempDir(), "artifactcache")
first, err := StartHandler(dir, "127.0.0.1", 0, "", nil)
require.NoError(t, err)
exp := time.Now().Add(artifactURLTTL).Unix()
sig := first.computeSignature(42, exp)
require.NoError(t, first.Close())
second, err := StartHandler(dir, "127.0.0.1", 0, "", nil)
require.NoError(t, err)
defer second.Close()
assert.Equal(t, sig, second.computeSignature(42, exp))
}
// TestHandler_ArtifactSignatureDownload is a happy-path round trip that
// ensures a real reserve/upload/commit/find/download flow still works after
// the auth refactor.
func TestHandler_ArtifactSignatureDownload(t *testing.T) {
dir := filepath.Join(t.TempDir(), "artifactcache")
handler, err := StartHandler(dir, "", 0, "", nil)
require.NoError(t, err)
defer handler.Close()
handler.RegisterJob(testToken, testRepo)
base := handler.ExternalURL() + apiPath
key := "download-key"
version := "c19da02a2bd7e77277f1ac29ab45c09b7d46a4ee758284e26bb3045ad11d9d20"
content := []byte("hello")
uploadCacheNormally(t, base, key, version, content)
resp, err := testClient.Get(fmt.Sprintf("%s/cache?keys=%s&version=%s", base, key, version))
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
var hit struct {
ArchiveLocation string `json:"archiveLocation"`
}
require.NoError(t, json.NewDecoder(resp.Body).Decode(&hit))
resp.Body.Close()
require.Contains(t, hit.ArchiveLocation, "sig=")
require.Contains(t, hit.ArchiveLocation, "exp=")
// Download without any Authorization header — the signature alone must
// be enough, because @actions/cache downloads archiveLocation unauth'd.
dl, err := http.Get(hit.ArchiveLocation)
require.NoError(t, err)
body, err := io.ReadAll(dl.Body)
dl.Body.Close()
require.NoError(t, err)
assert.Equal(t, http.StatusOK, dl.StatusCode)
assert.Equal(t, content, body)
}
// TestHandler_RegisterJob_RefCounted verifies that a duplicate RegisterJob
// for the same token does not silently revoke the first registration on the
// first revoker call. This matters if a runner ever re-registers a token
// (restart mid-task, retry), which must not kill the live job's auth.
func TestHandler_RegisterJob_RefCounted(t *testing.T) {
dir := filepath.Join(t.TempDir(), "artifactcache")
handler, err := StartHandler(dir, "", 0, "", nil)
require.NoError(t, err)
defer handler.Close()
first := handler.RegisterJob("shared", testRepo)
second := handler.RegisterJob("shared", testRepo)
base := handler.ExternalURL() + apiPath
probe := func() int {
req, err := http.NewRequest(http.MethodGet, base+"/cache?keys=x&version=v", nil)
require.NoError(t, err)
req.Header.Set("Authorization", "Bearer shared")
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
resp.Body.Close()
return resp.StatusCode
}
require.NotEqual(t, http.StatusUnauthorized, probe())
first()
assert.NotEqual(t, http.StatusUnauthorized, probe(),
"token must stay valid while another registration holds the refcount")
second()
assert.Equal(t, http.StatusUnauthorized, probe(),
"token is revoked only after every revoker has run")
}
// TestHandler_GC_PerRepoDedup ensures duplicate-pruning does not evict
// another repo's entry. Two repos reserve the same (key, version); after the
// keepOld window, GC must keep the one from each repo.
func TestHandler_GC_PerRepoDedup(t *testing.T) {
dir := filepath.Join(t.TempDir(), "artifactcache")
handler, err := StartHandler(dir, "", 0, "", nil)
require.NoError(t, err)
defer handler.Close()
handler.RegisterJob("tok-a", "owner/repoA")
handler.RegisterJob("tok-b", "owner/repoB")
key := "shared-dedup-key"
version := "c19da02a2bd7e77277f1ac29ab45c09b7d46a4ee758284e26bb3045ad11d9d20"
// Seed one completed cache per repo directly via the DB, bypassing the
// HTTP round trip so we can precisely control UsedAt.
db, err := handler.openDB()
require.NoError(t, err)
now := time.Now().Unix()
stale := time.Now().Add(-keepOld - time.Minute).Unix()
a := &Cache{Repo: "owner/repoA", Key: key, Version: version, Complete: true, CreatedAt: stale, UsedAt: stale, Size: 1}
b := &Cache{Repo: "owner/repoB", Key: key, Version: version, Complete: true, CreatedAt: now, UsedAt: now, Size: 1}
require.NoError(t, insertCache(db, a))
require.NoError(t, insertCache(db, b))
// Write the backing blobs so the dedup deletion has something to remove.
require.NoError(t, handler.storage.Write(a.ID, 0, strings.NewReader("a")))
_, err = handler.storage.Commit(a.ID, 1)
require.NoError(t, err)
require.NoError(t, handler.storage.Write(b.ID, 0, strings.NewReader("b")))
_, err = handler.storage.Commit(b.ID, 1)
require.NoError(t, err)
require.NoError(t, db.Close())
// Force GC to run regardless of the cooldown.
handler.gcAt = time.Time{}
handler.gcCache()
db, err = handler.openDB()
require.NoError(t, err)
defer db.Close()
var after []Cache
require.NoError(t, db.Find(&after, bolthold.Where("Key").Eq(key).And("Version").Eq(version)))
repos := make(map[string]bool)
for _, c := range after {
repos[c.Repo] = true
}
assert.True(t, repos["owner/repoA"], "repoA's cache must survive dedup against repoB")
assert.True(t, repos["owner/repoB"], "repoB's cache must survive dedup against repoA")
}
// TestHandler_InternalAPI_Disabled verifies that without an internalSecret
// the control-plane routes are 404 — operators can't accidentally hit
// register/revoke when the feature is off.
func TestHandler_InternalAPI_Disabled(t *testing.T) {
dir := filepath.Join(t.TempDir(), "artifactcache")
handler, err := StartHandler(dir, "", 0, "", nil)
require.NoError(t, err)
defer handler.Close()
for _, ep := range []string{"/_internal/register", "/_internal/revoke"} {
resp, err := http.Post(handler.ExternalURL()+ep, "application/json", strings.NewReader(`{}`))
require.NoError(t, err)
resp.Body.Close()
assert.Equal(t, http.StatusNotFound, resp.StatusCode, ep)
}
}
// TestHandler_InternalAPI_AuthAndUsage covers the control-plane: bad/missing
// secret → 401, malformed body → 400, happy path round-trips a token through
// register → cache-API accepts it → revoke → cache-API rejects it.
func TestHandler_InternalAPI_AuthAndUsage(t *testing.T) {
dir := filepath.Join(t.TempDir(), "artifactcache")
const secret = "internal-secret"
handler, err := StartHandler(dir, "", 0, secret, nil)
require.NoError(t, err)
defer handler.Close()
base := handler.ExternalURL()
post := func(path, bearer, body string) int {
req, err := http.NewRequest(http.MethodPost, base+path, strings.NewReader(body))
require.NoError(t, err)
if bearer != "" {
req.Header.Set("Authorization", "Bearer "+bearer)
}
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
resp.Body.Close()
return resp.StatusCode
}
t.Run("missing secret 401", func(t *testing.T) {
assert.Equal(t, http.StatusUnauthorized, post("/_internal/register", "", `{"token":"x","repo":"r"}`))
})
t.Run("wrong secret 401", func(t *testing.T) {
assert.Equal(t, http.StatusUnauthorized, post("/_internal/register", "wrong", `{"token":"x","repo":"r"}`))
})
t.Run("malformed body 400", func(t *testing.T) {
assert.Equal(t, http.StatusBadRequest, post("/_internal/register", secret, `not json`))
})
t.Run("missing token 400", func(t *testing.T) {
assert.Equal(t, http.StatusBadRequest, post("/_internal/register", secret, `{"repo":"r"}`))
})
t.Run("register then revoke round-trip", func(t *testing.T) {
probe := func(token string) int {
req, _ := http.NewRequest(http.MethodGet, base+apiPath+"/cache?keys=k&version=v", nil)
req.Header.Set("Authorization", "Bearer "+token)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
resp.Body.Close()
return resp.StatusCode
}
assert.Equal(t, http.StatusUnauthorized, probe("via-internal-api"))
assert.Equal(t, http.StatusOK, post("/_internal/register", secret, `{"token":"via-internal-api","repo":"owner/repo"}`))
assert.NotEqual(t, http.StatusUnauthorized, probe("via-internal-api"))
assert.Equal(t, http.StatusOK, post("/_internal/revoke", secret, `{"token":"via-internal-api"}`))
assert.Equal(t, http.StatusUnauthorized, probe("via-internal-api"))
})
}

View File

@@ -29,7 +29,6 @@ func (c *Request) ToCache() *Cache {
type Cache struct {
ID uint64 `json:"id" boltholdKey:"ID"`
Repo string `json:"repo" boltholdIndex:"Repo"`
Key string `json:"key" boltholdIndex:"Key"`
Version string `json:"version" boltholdIndex:"Version"`
Size int64 `json:"cacheSize"`

View File

@@ -202,7 +202,7 @@ func TestListArtifactContainer(t *testing.T) {
panic(err)
}
assert.Len(response.Value, 1)
assert.Equal(1, len(response.Value)) //nolint:testifylint // pre-existing issue from nektos/act
assert.Equal("some/file", response.Value[0].Path)
assert.Equal("file", response.Value[0].ItemType)
assert.Equal("http://localhost/artifact/1/some/file/.", response.Value[0].ContentLocation)
@@ -283,7 +283,7 @@ func runTestJobFile(ctx context.Context, t *testing.T, tjfi TestJobFileInfo) {
}
workdir, err := filepath.Abs(tjfi.workdir)
assert.NoError(t, err, workdir) //nolint:testifylint // pre-existing issue from nektos/act
assert.Nil(t, err, workdir) //nolint:testifylint // pre-existing issue from nektos/act
fullWorkflowPath := filepath.Join(workdir, tjfi.workflowPath)
runnerConfig := &runner.Config{
Workdir: workdir,
@@ -299,16 +299,16 @@ func runTestJobFile(ctx context.Context, t *testing.T, tjfi TestJobFileInfo) {
}
runner, err := runner.New(runnerConfig)
assert.NoError(t, err, tjfi.workflowPath) //nolint:testifylint // pre-existing issue from nektos/act
assert.Nil(t, err, tjfi.workflowPath) //nolint:testifylint // pre-existing issue from nektos/act
planner, err := model.NewWorkflowPlanner(fullWorkflowPath, true)
assert.NoError(t, err, fullWorkflowPath) //nolint:testifylint // pre-existing issue from nektos/act
assert.Nil(t, err, fullWorkflowPath) //nolint:testifylint // pre-existing issue from nektos/act
plan, err := planner.PlanEvent(tjfi.eventName)
if err == nil {
err = runner.NewPlanExecutor(plan)(ctx)
if tjfi.errorMessage == "" {
assert.NoError(t, err, fullWorkflowPath) //nolint:testifylint // pre-existing issue from nektos/act
assert.Nil(t, err, fullWorkflowPath) //nolint:testifylint // pre-existing issue from nektos/act
} else {
assert.Error(t, err, tjfi.errorMessage) //nolint:testifylint // pre-existing issue from nektos/act
}

View File

@@ -35,9 +35,9 @@ func TestCartesianProduct(t *testing.T) {
"baz": {false, true},
}
output = CartesianProduct(input)
assert.Empty(output)
assert.Len(output, 0) //nolint:testifylint // pre-existing issue from nektos/act
input = map[string][]any{}
output = CartesianProduct(input)
assert.Empty(output)
assert.Len(output, 0) //nolint:testifylint // pre-existing issue from nektos/act
}

View File

@@ -21,11 +21,11 @@ func TestNewWorkflow(t *testing.T) {
// empty
emptyWorkflow := NewPipelineExecutor()
assert.NoError(emptyWorkflow(ctx)) //nolint:testifylint // pre-existing issue from nektos/act
assert.Nil(emptyWorkflow(ctx)) //nolint:testifylint // pre-existing issue from nektos/act
// error case
errorWorkflow := NewErrorExecutor(errors.New("test error"))
assert.Error(errorWorkflow(ctx)) //nolint:testifylint // pre-existing issue from nektos/act
assert.NotNil(errorWorkflow(ctx)) //nolint:testifylint // pre-existing issue from nektos/act
// multiple success case
runcount := 0
@@ -38,7 +38,7 @@ func TestNewWorkflow(t *testing.T) {
runcount++
return nil
})
assert.NoError(successWorkflow(ctx)) //nolint:testifylint // pre-existing issue from nektos/act
assert.Nil(successWorkflow(ctx)) //nolint:testifylint // pre-existing issue from nektos/act
assert.Equal(2, runcount)
}
@@ -60,7 +60,7 @@ func TestNewConditionalExecutor(t *testing.T) {
return nil
})(ctx)
assert.NoError(err) //nolint:testifylint // pre-existing issue from nektos/act
assert.Nil(err) //nolint:testifylint // pre-existing issue from nektos/act
assert.Equal(0, trueCount)
assert.Equal(1, falseCount)
@@ -74,7 +74,7 @@ func TestNewConditionalExecutor(t *testing.T) {
return nil
})(ctx)
assert.NoError(err) //nolint:testifylint // pre-existing issue from nektos/act
assert.Nil(err) //nolint:testifylint // pre-existing issue from nektos/act
assert.Equal(1, trueCount)
assert.Equal(1, falseCount)
}
@@ -105,7 +105,7 @@ func TestNewParallelExecutor(t *testing.T) {
assert.Equal(int32(3), count.Load(), "should run all 3 executors")
assert.Equal(int32(2), maxCount.Load(), "should run at most 2 executors in parallel")
assert.NoError(err) //nolint:testifylint // pre-existing issue from nektos/act
assert.Nil(err) //nolint:testifylint // pre-existing issue from nektos/act
// Reset to test running the executor with 0 parallelism
count.Store(0)
@@ -116,7 +116,7 @@ func TestNewParallelExecutor(t *testing.T) {
assert.Equal(int32(3), count.Load(), "should run all 3 executors")
assert.Equal(int32(1), maxCount.Load(), "should run at most 1 executors in parallel")
assert.NoError(errSingle)
assert.Nil(errSingle) //nolint:testifylint // pre-existing issue from nektos/act
}
func TestNewParallelExecutorFailed(t *testing.T) {

View File

@@ -32,21 +32,12 @@ var (
githubHTTPRegex = regexp.MustCompile(`^https?://.*github.com.*/(.+)/(.+?)(?:.git)?$`)
githubSSHRegex = regexp.MustCompile(`github.com[:/](.+)/(.+?)(?:.git)?$`)
cloneLocks sync.Map // key: clone target directory; value: *sync.Mutex
cloneLock sync.Mutex
ErrShortRef = errors.New("short SHA references are not supported")
ErrNoRepo = errors.New("unable to find git repo")
)
// acquireCloneLock returns an unlock function after locking the per-directory mutex for dir.
// Only concurrent operations targeting the same directory are erialized; clones into different directories run in parallel.
func acquireCloneLock(dir string) func() {
v, _ := cloneLocks.LoadOrStore(dir, &sync.Mutex{})
mu := v.(*sync.Mutex)
mu.Lock()
return mu.Unlock
}
type Error struct {
err error
commit string
@@ -302,13 +293,16 @@ func gitOptions(token string) (fetchOptions git.FetchOptions, pullOptions git.Pu
}
// NewGitCloneExecutor creates an executor to clone git repos
//
//nolint:gocyclo // function handles many cases
func NewGitCloneExecutor(input NewGitCloneExecutorInput) common.Executor {
return func(ctx context.Context) error {
logger := common.Logger(ctx)
logger.Infof(" \u2601 git clone '%s' # ref=%s", input.URL, input.Ref)
logger.Debugf(" cloning %s to %s", input.URL, input.Dir)
defer acquireCloneLock(input.Dir)()
cloneLock.Lock()
defer cloneLock.Unlock()
refName := plumbing.ReferenceName("refs/heads/" + input.Ref)
r, err := CloneIfRequired(ctx, refName, input, logger)

View File

@@ -11,10 +11,8 @@ import (
"os/exec"
"path/filepath"
"strings"
"sync"
"syscall"
"testing"
"time"
log "github.com/sirupsen/logrus"
"github.com/stretchr/testify/assert"
@@ -305,61 +303,3 @@ func gitCmd(args ...string) error {
}
return nil
}
func TestAcquireCloneLock(t *testing.T) {
t.Run("same directory serializes", func(t *testing.T) {
dir := t.TempDir()
unlock1 := acquireCloneLock(dir)
secondAcquired := make(chan struct{})
go func() {
unlock := acquireCloneLock(dir)
close(secondAcquired)
unlock()
}()
select {
case <-secondAcquired:
t.Fatal("second acquire should block while first holds the lock")
case <-time.After(50 * time.Millisecond):
}
unlock1()
select {
case <-secondAcquired:
case <-time.After(time.Second):
t.Fatal("second acquire should proceed after first releases the lock")
}
})
t.Run("different directories do not block", func(t *testing.T) {
dirA := t.TempDir()
dirB := t.TempDir()
unlockA := acquireCloneLock(dirA)
defer unlockA()
done := make(chan struct{})
go func() {
unlock := acquireCloneLock(dirB)
unlock()
close(done)
}()
select {
case <-done:
case <-time.After(time.Second):
t.Fatal("acquire on a different directory must not block")
}
})
t.Run("same directory reuses the same mutex", func(t *testing.T) {
dir := t.TempDir()
v1, _ := cloneLocks.LoadOrStore(dir, &sync.Mutex{})
v2, _ := cloneLocks.LoadOrStore(dir, &sync.Mutex{})
require.Same(t, v1, v2)
})
}

View File

@@ -6,7 +6,6 @@ package container
import (
"context"
"fmt"
"io"
"gitea.com/gitea/runner/act/common"
@@ -14,13 +13,6 @@ import (
"github.com/docker/go-connections/nat"
)
// ExitCodeError reports a non-zero process exit code from a container command.
type ExitCodeError int
func (e ExitCodeError) Error() string {
return fmt.Sprintf("Process completed with exit code %d.", int(e))
}
// NewContainerInput the input for the New function
type NewContainerInput struct {
Image string

View File

@@ -324,6 +324,8 @@ type containerConfig struct {
// parse parses the args for the specified command and generates a Config,
// a HostConfig and returns them with the specified command.
// If the specified args are not valid, it will return an error.
//
//nolint:gocyclo // function handles many cases
func parse(flags *pflag.FlagSet, copts *containerOptions, serverOS string) (*containerConfig, error) {
var (
attachStdin = copts.attach.Get("stdin")

View File

@@ -194,6 +194,7 @@ func TestParseRunWithInvalidArgs(t *testing.T) {
}
}
//nolint:gocyclo // function handles many cases
func TestParseWithVolumes(t *testing.T) {
// A single volume
arr, tryit := setupPlatformVolume([]string{`/tmp`}, []string{`c:\tmp`})
@@ -631,7 +632,7 @@ func TestParseModes(t *testing.T) {
}
// uts ko
_, _, _, err = parseRun([]string{"--uts=container:", "img", "cmd"})
_, _, _, err = parseRun([]string{"--uts=container:", "img", "cmd"}) //nolint:dogsled // ignoring multiple returns in test helpers
assert.ErrorContains(t, err, "--uts: invalid UTS mode")
// uts ok
@@ -692,7 +693,7 @@ func TestParseRestartPolicy(t *testing.T) {
func TestParseRestartPolicyAutoRemove(t *testing.T) {
expected := "Conflicting options: --restart and --rm"
_, _, _, err := parseRun([]string{"--rm", "--restart=always", "img", "cmd"})
_, _, _, err := parseRun([]string{"--rm", "--restart=always", "img", "cmd"}) //nolint:dogsled // ignoring multiple returns in test helpers
if err == nil || err.Error() != expected {
t.Fatalf("Expected error %v, but got none", expected)
}

View File

@@ -29,17 +29,17 @@ func TestImageExistsLocally(t *testing.T) {
// Test if image exists with specific tag
invalidImageTag, err := ImageExistsLocally(ctx, "library/alpine:this-random-tag-will-never-exist", "linux/amd64")
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
assert.False(t, invalidImageTag)
assert.Nil(t, err) //nolint:testifylint // pre-existing issue from nektos/act
assert.Equal(t, false, invalidImageTag) //nolint:testifylint // pre-existing issue from nektos/act
// Test if image exists with specific architecture (image platform)
invalidImagePlatform, err := ImageExistsLocally(ctx, "alpine:latest", "windows/amd64")
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
assert.False(t, invalidImagePlatform)
assert.Nil(t, err) //nolint:testifylint // pre-existing issue from nektos/act
assert.Equal(t, false, invalidImagePlatform) //nolint:testifylint // pre-existing issue from nektos/act
// pull an image
cli, err := client.NewClientWithOpts(client.FromEnv)
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
assert.Nil(t, err) //nolint:testifylint // pre-existing issue from nektos/act
cli.NegotiateAPIVersion(context.Background())
// Chose alpine latest because it's so small
@@ -47,25 +47,25 @@ func TestImageExistsLocally(t *testing.T) {
readerDefault, err := cli.ImagePull(ctx, "node:16-buster-slim", types.ImagePullOptions{
Platform: "linux/amd64",
})
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
assert.Nil(t, err) //nolint:testifylint // pre-existing issue from nektos/act
defer readerDefault.Close()
_, err = io.ReadAll(readerDefault)
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
assert.Nil(t, err) //nolint:testifylint // pre-existing issue from nektos/act
imageDefaultArchExists, err := ImageExistsLocally(ctx, "node:16-buster-slim", "linux/amd64")
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
assert.True(t, imageDefaultArchExists)
assert.Nil(t, err) //nolint:testifylint // pre-existing issue from nektos/act
assert.Equal(t, true, imageDefaultArchExists) //nolint:testifylint // pre-existing issue from nektos/act
// Validate if another architecture platform can be pulled
readerArm64, err := cli.ImagePull(ctx, "node:16-buster-slim", types.ImagePullOptions{
Platform: "linux/arm64",
})
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
assert.Nil(t, err) //nolint:testifylint // pre-existing issue from nektos/act
defer readerArm64.Close()
_, err = io.ReadAll(readerArm64)
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
assert.Nil(t, err) //nolint:testifylint // pre-existing issue from nektos/act
imageArm64Exists, err := ImageExistsLocally(ctx, "node:16-buster-slim", "linux/arm64")
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
assert.True(t, imageArm64Exists)
assert.Nil(t, err) //nolint:testifylint // pre-existing issue from nektos/act
assert.Equal(t, true, imageArm64Exists) //nolint:testifylint // pre-existing issue from nektos/act
}

View File

@@ -43,7 +43,7 @@ func TestGetImagePullOptions(t *testing.T) {
config.SetDir("/non-existent/docker")
options, err := getImagePullOptions(ctx, NewDockerPullExecutorInput{})
assert.NoError(t, err, "Failed to create ImagePullOptions") //nolint:testifylint // pre-existing issue from nektos/act
assert.Nil(t, err, "Failed to create ImagePullOptions") //nolint:testifylint // pre-existing issue from nektos/act
assert.Equal(t, "", options.RegistryAuth, "RegistryAuth should be empty if no username or password is set") //nolint:testifylint // pre-existing issue from nektos/act
options, err = getImagePullOptions(ctx, NewDockerPullExecutorInput{
@@ -51,7 +51,7 @@ func TestGetImagePullOptions(t *testing.T) {
Username: "username",
Password: "password",
})
assert.NoError(t, err, "Failed to create ImagePullOptions") //nolint:testifylint // pre-existing issue from nektos/act
assert.Nil(t, err, "Failed to create ImagePullOptions") //nolint:testifylint // pre-existing issue from nektos/act
assert.Equal(t, "eyJ1c2VybmFtZSI6InVzZXJuYW1lIiwicGFzc3dvcmQiOiJwYXNzd29yZCJ9", options.RegistryAuth, "Username and Password should be provided")
config.SetDir("testdata/docker-pull-options")
@@ -59,6 +59,6 @@ func TestGetImagePullOptions(t *testing.T) {
options, err = getImagePullOptions(ctx, NewDockerPullExecutorInput{
Image: "nektos/act",
})
assert.NoError(t, err, "Failed to create ImagePullOptions") //nolint:testifylint // pre-existing issue from nektos/act
assert.Nil(t, err, "Failed to create ImagePullOptions") //nolint:testifylint // pre-existing issue from nektos/act
assert.Equal(t, "eyJ1c2VybmFtZSI6InVzZXJuYW1lIiwicGFzc3dvcmQiOiJwYXNzd29yZFxuIiwic2VydmVyYWRkcmVzcyI6Imh0dHBzOi8vaW5kZXguZG9ja2VyLmlvL3YxLyJ9", options.RegistryAuth, "RegistryAuth should be taken from local docker config")
}

View File

@@ -633,10 +633,14 @@ func (cr *containerReference) exec(cmd []string, env map[string]string, user, wo
return fmt.Errorf("failed to inspect exec: %w", err)
}
if inspectResp.ExitCode == 0 {
switch inspectResp.ExitCode {
case 0:
return nil
case 127:
return fmt.Errorf("exitcode '%d': command not found, please refer to https://github.com/nektos/act/issues/107 for more information", inspectResp.ExitCode)
default:
return fmt.Errorf("exitcode '%d': failure", inspectResp.ExitCode)
}
return ExitCodeError(inspectResp.ExitCode)
}
}
@@ -926,7 +930,7 @@ func (cr *containerReference) wait() common.Executor {
return nil
}
return ExitCodeError(statusCode)
return fmt.Errorf("exit with `FAILURE`: %v", statusCode)
}
}

View File

@@ -23,7 +23,6 @@ import (
"github.com/sirupsen/logrus/hooks/test"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
)
func TestDocker(t *testing.T) {
@@ -86,11 +85,6 @@ func (m *mockDockerClient) ContainerExecInspect(ctx context.Context, execID stri
return args.Get(0).(types.ContainerExecInspect), args.Error(1)
}
func (m *mockDockerClient) ContainerWait(ctx context.Context, containerID string, condition container.WaitCondition) (<-chan container.WaitResponse, <-chan error) {
args := m.Called(ctx, containerID, condition)
return args.Get(0).(<-chan container.WaitResponse), args.Get(1).(<-chan error)
}
func (m *mockDockerClient) CopyToContainer(ctx context.Context, id, path string, content io.Reader, options types.CopyToContainerOptions) error {
args := m.Called(ctx, id, path, content, options)
return args.Error(0)
@@ -180,43 +174,12 @@ func TestDockerExecFailure(t *testing.T) {
}
err := cr.exec([]string{""}, map[string]string{}, "user", "workdir")(ctx)
var exitErr ExitCodeError
require.ErrorAs(t, err, &exitErr)
assert.Equal(t, ExitCodeError(1), exitErr)
assert.Equal(t, "Process completed with exit code 1.", err.Error())
assert.Error(t, err, "exit with `FAILURE`: 1") //nolint:testifylint // pre-existing issue from nektos/act
conn.AssertExpectations(t)
client.AssertExpectations(t)
}
func TestDockerWaitFailure(t *testing.T) {
ctx := context.Background()
statusCh := make(chan container.WaitResponse, 1)
statusCh <- container.WaitResponse{StatusCode: 2}
errCh := make(chan error, 1)
client := &mockDockerClient{}
client.On("ContainerWait", ctx, "123", container.WaitConditionNotRunning).
Return((<-chan container.WaitResponse)(statusCh), (<-chan error)(errCh))
cr := &containerReference{
id: "123",
cli: client,
input: &NewContainerInput{
Image: "image",
},
}
err := cr.wait()(ctx)
var exitErr ExitCodeError
require.ErrorAs(t, err, &exitErr)
assert.Equal(t, ExitCodeError(2), exitErr)
assert.Equal(t, "Process completed with exit code 2.", err.Error())
client.AssertExpectations(t)
}
func TestDockerCopyTarStream(t *testing.T) {
ctx := context.Background()

View File

@@ -29,7 +29,7 @@ func TestGetSocketAndHostWithSocket(t *testing.T) {
ret, err := GetSocketAndHost(socketURI)
// Assert
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
assert.Nil(t, err) //nolint:testifylint // pre-existing issue from nektos/act
assert.Equal(t, SocketAndHost{socketURI, dockerHost}, ret)
}
@@ -42,7 +42,7 @@ func TestGetSocketAndHostNoSocket(t *testing.T) {
ret, err := GetSocketAndHost("")
// Assert
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
assert.Nil(t, err) //nolint:testifylint // pre-existing issue from nektos/act
assert.Equal(t, SocketAndHost{dockerHost, dockerHost}, ret)
}
@@ -57,8 +57,8 @@ func TestGetSocketAndHostOnlySocket(t *testing.T) {
ret, err := GetSocketAndHost(socketURI)
// Assert
assert.NoError(t, err, "Expected no error from GetSocketAndHost") //nolint:testifylint // pre-existing issue from nektos/act
assert.True(t, defaultSocketFound, "Expected to find default socket")
assert.NoError(t, err, "Expected no error from GetSocketAndHost") //nolint:testifylint // pre-existing issue from nektos/act
assert.Equal(t, true, defaultSocketFound, "Expected to find default socket") //nolint:testifylint // pre-existing issue from nektos/act
assert.Equal(t, socketURI, ret.Socket, "Expected socket to match common location")
assert.Equal(t, defaultSocket, ret.Host, "Expected ret.Host to match default socket location")
}
@@ -73,7 +73,7 @@ func TestGetSocketAndHostDontMount(t *testing.T) {
ret, err := GetSocketAndHost("-")
// Assert
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
assert.Nil(t, err) //nolint:testifylint // pre-existing issue from nektos/act
assert.Equal(t, SocketAndHost{"-", dockerHost}, ret)
}
@@ -87,8 +87,8 @@ func TestGetSocketAndHostNoHostNoSocket(t *testing.T) {
ret, err := GetSocketAndHost("")
// Assert
assert.True(t, found, "Expected a default socket to be found")
assert.NoError(t, err, "Expected no error from GetSocketAndHost") //nolint:testifylint // pre-existing issue from nektos/act
assert.Equal(t, true, found, "Expected a default socket to be found") //nolint:testifylint // pre-existing issue from nektos/act
assert.Nil(t, err, "Expected no error from GetSocketAndHost") //nolint:testifylint // pre-existing issue from nektos/act
assert.Equal(t, SocketAndHost{defaultSocket, defaultSocket}, ret, "Expected to match default socket location")
}
@@ -112,8 +112,8 @@ func TestGetSocketAndHostNoHostNoSocketDefaultLocation(t *testing.T) {
// Assert
assert.Equal(t, unixSocket, defaultSocket, "Expected default socket to match common socket location")
assert.True(t, found, "Expected default socket to be found")
assert.NoError(t, err, "Expected no error from GetSocketAndHost") //nolint:testifylint // pre-existing issue from nektos/act
assert.Equal(t, true, found, "Expected default socket to be found") //nolint:testifylint // pre-existing issue from nektos/act
assert.Nil(t, err, "Expected no error from GetSocketAndHost") //nolint:testifylint // pre-existing issue from nektos/act
assert.Equal(t, SocketAndHost{unixSocket, unixSocket}, ret, "Expected to match default socket location")
}
@@ -128,7 +128,7 @@ func TestGetSocketAndHostNoHostInvalidSocket(t *testing.T) {
ret, err := GetSocketAndHost(mySocket)
// Assert
assert.False(t, found, "Expected no default socket to be found")
assert.Equal(t, false, found, "Expected no default socket to be found") //nolint:testifylint // pre-existing issue from nektos/act
assert.Equal(t, "", defaultSocket, "Expected no default socket to be found") //nolint:testifylint // pre-existing issue from nektos/act
assert.Equal(t, SocketAndHost{}, ret, "Expected to match default socket location")
assert.Error(t, err, "Expected an error in invalid state")
@@ -147,8 +147,8 @@ func TestGetSocketAndHostOnlySocketValidButUnusualLocation(t *testing.T) {
// Assert
// Default socket locations
assert.Equal(t, "", defaultSocket, "Expect default socket location to be empty") //nolint:testifylint // pre-existing issue from nektos/act
assert.False(t, found, "Expected no default socket to be found")
assert.Equal(t, false, found, "Expected no default socket to be found") //nolint:testifylint // pre-existing issue from nektos/act
// Sane default
assert.NoError(t, err, "Expect no error from GetSocketAndHost") //nolint:testifylint // pre-existing issue from nektos/act
assert.Nil(t, err, "Expect no error from GetSocketAndHost") //nolint:testifylint // pre-existing issue from nektos/act
assert.Equal(t, socketURI, ret.Host, "Expect host to default to unusual socket")
}

View File

@@ -16,9 +16,7 @@ import (
"os/exec"
"path/filepath"
"runtime"
"strconv"
"strings"
"sync"
"time"
"gitea.com/gitea/runner/act/common"
@@ -36,15 +34,9 @@ type HostEnvironment struct {
TmpDir string
ToolCache string
Workdir string
// BindWorkdir is true when the app runner mounts the workspace on the host and
// deletes the task directory after the job; host teardown must not remove Workdir.
BindWorkdir bool
ActPath string
CleanUp func()
StdOut io.Writer
mu sync.Mutex
runningPIDs map[int]struct{}
ActPath string
CleanUp func()
StdOut io.Writer
}
func (e *HostEnvironment) Create(_, _ []string) common.Executor {
@@ -352,30 +344,8 @@ func (e *HostEnvironment) exec(ctx context.Context, command []string, cmdline st
if ppty != nil {
go writeKeepAlive(ppty)
}
// Split Start/Wait so the PID can be registered before the process can exit;
// cmd.Run() would block until exit, by which time the PID may have been reused.
if err := cmd.Start(); err != nil {
return err
}
if cmd.Process != nil {
e.mu.Lock()
if e.runningPIDs == nil {
e.runningPIDs = map[int]struct{}{}
}
e.runningPIDs[cmd.Process.Pid] = struct{}{}
e.mu.Unlock()
defer func(pid int) {
e.mu.Lock()
delete(e.runningPIDs, pid)
e.mu.Unlock()
}(cmd.Process.Pid)
}
err = cmd.Wait()
err = cmd.Run()
if err != nil {
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
return ExitCodeError(exitErr.ExitCode())
}
return err
}
if tty != nil {
@@ -415,83 +385,12 @@ func (e *HostEnvironment) UpdateFromEnv(srcPath string, env *map[string]string)
return parseEnvFile(e, srcPath, env)
}
func removePathWithRetry(ctx context.Context, path string) error {
if path == "" {
return nil
}
attempts := 1
delay := time.Duration(0)
if runtime.GOOS == "windows" {
attempts = 5
delay = 200 * time.Millisecond
}
var lastErr error
for i := 0; i < attempts; i++ {
if i > 0 {
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(delay):
}
}
lastErr = os.RemoveAll(path)
if lastErr == nil {
return nil
}
}
return lastErr
}
func (e *HostEnvironment) terminateRunningProcesses(ctx context.Context) {
if runtime.GOOS != "windows" {
return
}
e.mu.Lock()
pids := make([]int, 0, len(e.runningPIDs))
for pid := range e.runningPIDs {
pids = append(pids, pid)
}
e.mu.Unlock()
if len(pids) == 0 {
return
}
logger := common.Logger(ctx)
for _, pid := range pids {
// Best-effort: forcibly terminate process tree to release file handles
// so that workspace cleanup can succeed on Windows.
cmd := exec.CommandContext(ctx, "taskkill", "/PID", strconv.Itoa(pid), "/T", "/F")
out, err := cmd.CombinedOutput()
if err != nil {
logger.Debugf("taskkill failed for pid=%d: %v output=%s", pid, err, strings.TrimSpace(string(out)))
}
}
}
func (e *HostEnvironment) Remove() common.Executor {
return func(ctx context.Context) error {
// Ensure any lingering child processes are ended before attempting
// to remove the workspace (Windows file locks otherwise prevent cleanup).
e.terminateRunningProcesses(ctx)
// Only removes per-job misc state. Must not remove the cache/toolcache root.
if e.CleanUp != nil {
e.CleanUp()
}
logger := common.Logger(ctx)
var errs []error
if err := removePathWithRetry(ctx, e.Path); err != nil {
logger.Warnf("failed to remove host misc state %s: %v", e.Path, err)
errs = append(errs, err)
}
if !e.BindWorkdir && e.Workdir != "" {
if err := removePathWithRetry(ctx, e.Workdir); err != nil {
logger.Warnf("failed to remove host workspace %s: %v", e.Workdir, err)
errs = append(errs, err)
}
}
return errors.Join(errs...)
return os.RemoveAll(e.Path)
}
}

View File

@@ -11,14 +11,9 @@ import (
"os"
"path"
"path/filepath"
"runtime"
"testing"
"gitea.com/gitea/runner/act/common"
"github.com/sirupsen/logrus"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// Type assert HostEnvironment implements ExecutionsEnvironment
@@ -74,76 +69,3 @@ func TestGetContainerArchive(t *testing.T) {
_, err = reader.Next()
assert.ErrorIs(t, err, io.EOF)
}
func TestHostEnvironmentExecExitCode(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("uses POSIX shell")
}
dir := t.TempDir()
ctx := context.Background()
e := &HostEnvironment{
Path: filepath.Join(dir, "path"),
TmpDir: filepath.Join(dir, "tmp"),
ToolCache: filepath.Join(dir, "tool_cache"),
ActPath: filepath.Join(dir, "act_path"),
StdOut: io.Discard,
Workdir: filepath.Join(dir, "path"),
}
for _, p := range []string{e.Path, e.TmpDir, e.ToolCache, e.ActPath} {
assert.NoError(t, os.MkdirAll(p, 0o700)) //nolint:testifylint // test setup
}
err := e.Exec([]string{"sh", "-c", "exit 3"}, map[string]string{"PATH": os.Getenv("PATH")}, "", "")(ctx)
var exitErr ExitCodeError
require.ErrorAs(t, err, &exitErr)
assert.Equal(t, ExitCodeError(3), exitErr)
assert.Equal(t, "Process completed with exit code 3.", err.Error())
}
func TestHostEnvironmentRemoveCleansWorkdir(t *testing.T) {
logger := logrus.New()
ctx := common.WithLogger(context.Background(), logrus.NewEntry(logger))
base := t.TempDir()
miscRoot := filepath.Join(base, "misc")
path := filepath.Join(miscRoot, "hostexecutor")
require.NoError(t, os.MkdirAll(path, 0o700))
workdir := filepath.Join(base, "workspace", "owner", "repo")
require.NoError(t, os.MkdirAll(workdir, 0o700))
e := &HostEnvironment{
Path: path,
Workdir: workdir,
BindWorkdir: false,
CleanUp: func() {
_ = os.RemoveAll(miscRoot)
},
StdOut: os.Stdout,
}
require.NoError(t, e.Remove()(ctx))
_, err := os.Stat(workdir)
assert.ErrorIs(t, err, os.ErrNotExist)
}
func TestHostEnvironmentRemoveSkipsWorkdirWhenBindWorkdir(t *testing.T) {
logger := logrus.New()
ctx := common.WithLogger(context.Background(), logrus.NewEntry(logger))
base := t.TempDir()
miscRoot := filepath.Join(base, "misc")
path := filepath.Join(miscRoot, "hostexecutor")
require.NoError(t, os.MkdirAll(path, 0o700))
workdir := filepath.Join(base, "workspace", "123", "owner", "repo")
require.NoError(t, os.MkdirAll(workdir, 0o700))
e := &HostEnvironment{
Path: path,
Workdir: workdir,
BindWorkdir: true,
CleanUp: func() {
_ = os.RemoveAll(miscRoot)
},
StdOut: os.Stdout,
}
require.NoError(t, e.Remove()(ctx))
_, err := os.Stat(workdir)
require.NoError(t, err)
}

View File

@@ -43,7 +43,7 @@ func TestFunctionContains(t *testing.T) {
for _, tt := range table {
t.Run(tt.name, func(t *testing.T) {
output, err := NewInterpeter(env, Config{}).Evaluate(tt.input, DefaultStatusCheckNone)
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
assert.Nil(t, err) //nolint:testifylint // pre-existing issue from nektos/act
assert.Equal(t, tt.expected, output)
})
@@ -72,7 +72,7 @@ func TestFunctionStartsWith(t *testing.T) {
for _, tt := range table {
t.Run(tt.name, func(t *testing.T) {
output, err := NewInterpeter(env, Config{}).Evaluate(tt.input, DefaultStatusCheckNone)
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
assert.Nil(t, err) //nolint:testifylint // pre-existing issue from nektos/act
assert.Equal(t, tt.expected, output)
})
@@ -101,7 +101,7 @@ func TestFunctionEndsWith(t *testing.T) {
for _, tt := range table {
t.Run(tt.name, func(t *testing.T) {
output, err := NewInterpeter(env, Config{}).Evaluate(tt.input, DefaultStatusCheckNone)
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
assert.Nil(t, err) //nolint:testifylint // pre-existing issue from nektos/act
assert.Equal(t, tt.expected, output)
})
@@ -128,7 +128,7 @@ func TestFunctionJoin(t *testing.T) {
for _, tt := range table {
t.Run(tt.name, func(t *testing.T) {
output, err := NewInterpeter(env, Config{}).Evaluate(tt.input, DefaultStatusCheckNone)
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
assert.Nil(t, err) //nolint:testifylint // pre-existing issue from nektos/act
assert.Equal(t, tt.expected, output)
})
@@ -154,7 +154,7 @@ func TestFunctionToJSON(t *testing.T) {
for _, tt := range table {
t.Run(tt.name, func(t *testing.T) {
output, err := NewInterpeter(env, Config{}).Evaluate(tt.input, DefaultStatusCheckNone)
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
assert.Nil(t, err) //nolint:testifylint // pre-existing issue from nektos/act
assert.Equal(t, tt.expected, output)
})
@@ -177,7 +177,7 @@ func TestFunctionFromJSON(t *testing.T) {
for _, tt := range table {
t.Run(tt.name, func(t *testing.T) {
output, err := NewInterpeter(env, Config{}).Evaluate(tt.input, DefaultStatusCheckNone)
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
assert.Nil(t, err) //nolint:testifylint // pre-existing issue from nektos/act
assert.Equal(t, tt.expected, output)
})
@@ -205,9 +205,9 @@ func TestFunctionHashFiles(t *testing.T) {
for _, tt := range table {
t.Run(tt.name, func(t *testing.T) {
workdir, err := filepath.Abs("testdata")
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
assert.Nil(t, err) //nolint:testifylint // pre-existing issue from nektos/act
output, err := NewInterpeter(env, Config{WorkingDir: workdir}).Evaluate(tt.input, DefaultStatusCheckNone)
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
assert.Nil(t, err) //nolint:testifylint // pre-existing issue from nektos/act
assert.Equal(t, tt.expected, output)
})
@@ -248,7 +248,7 @@ func TestFunctionFormat(t *testing.T) {
if tt.error != nil {
assert.Equal(t, tt.error, err.Error())
} else {
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
assert.Nil(t, err) //nolint:testifylint // pre-existing issue from nektos/act
assert.Equal(t, tt.expected, output)
}
})

View File

@@ -156,6 +156,7 @@ func (impl *interperterImpl) evaluateNode(exprNode actionlint.ExprNode) (any, er
}
}
//nolint:gocyclo // function handles many cases
func (impl *interperterImpl) evaluateVariable(variableNode *actionlint.VariableNode) (any, error) {
switch strings.ToLower(variableNode.Name) {
case "github":
@@ -583,6 +584,7 @@ func (impl *interperterImpl) evaluateLogicalCompare(compareNode *actionlint.Logi
return nil, fmt.Errorf("Unable to compare incompatibles types '%s' and '%s'", leftValue.Kind(), rightValue.Kind())
}
//nolint:gocyclo // function handles many cases
func (impl *interperterImpl) evaluateFuncCall(funcCallNode *actionlint.FuncCallNode) (any, error) {
args := make([]reflect.Value, 0)

View File

@@ -35,7 +35,7 @@ func TestLiterals(t *testing.T) {
for _, tt := range table {
t.Run(tt.name, func(t *testing.T) {
output, err := NewInterpeter(env, Config{}).Evaluate(tt.input, DefaultStatusCheckNone)
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
assert.Nil(t, err) //nolint:testifylint // pre-existing issue from nektos/act
assert.Equal(t, tt.expected, output)
})
@@ -105,10 +105,10 @@ func TestOperators(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
output, err := NewInterpeter(env, Config{}).Evaluate(tt.input, DefaultStatusCheckNone)
if tt.error != "" {
assert.Error(t, err) //nolint:testifylint // pre-existing issue from nektos/act
assert.NotNil(t, err) //nolint:testifylint // pre-existing issue from nektos/act
assert.Equal(t, tt.error, err.Error())
} else {
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
assert.Nil(t, err) //nolint:testifylint // pre-existing issue from nektos/act
}
assert.Equal(t, tt.expected, output)
@@ -157,7 +157,7 @@ func TestOperatorsCompare(t *testing.T) {
for _, tt := range table {
t.Run(tt.name, func(t *testing.T) {
output, err := NewInterpeter(env, Config{}).Evaluate(tt.input, DefaultStatusCheckNone)
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
assert.Nil(t, err) //nolint:testifylint // pre-existing issue from nektos/act
assert.Equal(t, tt.expected, output)
})
@@ -520,7 +520,7 @@ func TestOperatorsBooleanEvaluation(t *testing.T) {
for _, tt := range table {
t.Run(tt.name, func(t *testing.T) {
output, err := NewInterpeter(env, Config{}).Evaluate(tt.input, DefaultStatusCheckNone)
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
assert.Nil(t, err) //nolint:testifylint // pre-existing issue from nektos/act
if expected, ok := tt.expected.(float64); ok && math.IsNaN(expected) {
assert.True(t, math.IsNaN(output.(float64)))
@@ -624,7 +624,7 @@ func TestContexts(t *testing.T) {
for _, tt := range table {
t.Run(tt.name, func(t *testing.T) {
output, err := NewInterpeter(env, Config{}).Evaluate(tt.input, DefaultStatusCheckNone)
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
assert.Nil(t, err) //nolint:testifylint // pre-existing issue from nektos/act
assert.Equal(t, tt.expected, output)
})

View File

@@ -128,6 +128,7 @@ func (*DefaultFs) Readlink(path string) (string, error) {
return os.Readlink(path)
}
//nolint:gocyclo // function handles many cases
func (fc *FileCollector) CollectFiles(ctx context.Context, submodulePath []string) filepath.WalkFunc {
i, _ := fc.Fs.OpenGitIndex(path.Join(fc.SrcPath, path.Join(submodulePath...)))
return func(file string, fi os.FileInfo, err error) error {

View File

@@ -61,6 +61,8 @@ type WorkflowFiles struct {
}
// NewWorkflowPlanner will load a specific workflow, all workflows from a directory or all workflows from a directory and its subdirectories
//
//nolint:gocyclo // function handles many cases
func NewWorkflowPlanner(path string, noWorkflowRecurse bool) (WorkflowPlanner, error) {
path, err := filepath.Abs(path)
if err != nil {

View File

@@ -57,11 +57,11 @@ func TestWorkflow(t *testing.T) {
// Check that an invalid job id returns error
result, err := createStages(&workflow, "invalid_job_id")
assert.Error(t, err) //nolint:testifylint // pre-existing issue from nektos/act
assert.NotNil(t, err) //nolint:testifylint // pre-existing issue from nektos/act
assert.Nil(t, result)
// Check that an valid job id returns non-error
result, err = createStages(&workflow, "valid_job")
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
assert.Nil(t, err) //nolint:testifylint // pre-existing issue from nektos/act
assert.NotNil(t, result)
}

View File

@@ -440,6 +440,8 @@ func (j *Job) Matrix() map[string][]any {
// GetMatrixes returns the matrix cross product
// It skips includes and hard fails excludes for non-existing keys
//
//nolint:gocyclo // function handles many cases
func (j *Job) GetMatrixes() ([]map[string]any, error) {
matrixes := make([]map[string]any, 0)
if j.Strategy != nil {

View File

@@ -56,7 +56,7 @@ jobs:
assert.NoError(t, err, "read workflow should succeed") //nolint:testifylint // pre-existing issue from nektos/act
newSchedules = workflow.OnSchedule()
assert.Empty(t, newSchedules)
assert.Len(t, newSchedules, 0) //nolint:testifylint // pre-existing issue from nektos/act
yaml = `
name: local-action-docker-url
@@ -74,7 +74,7 @@ jobs:
assert.NoError(t, err, "read workflow should succeed") //nolint:testifylint // pre-existing issue from nektos/act
newSchedules = workflow.OnSchedule()
assert.Empty(t, newSchedules)
assert.Len(t, newSchedules, 0) //nolint:testifylint // pre-existing issue from nektos/act
yaml = `
name: local-action-docker-url
@@ -91,7 +91,7 @@ jobs:
assert.NoError(t, err, "read workflow should succeed") //nolint:testifylint // pre-existing issue from nektos/act
newSchedules = workflow.OnSchedule()
assert.Empty(t, newSchedules)
assert.Len(t, newSchedules, 0) //nolint:testifylint // pre-existing issue from nektos/act
}
func TestReadWorkflow_StringEvent(t *testing.T) {
@@ -870,7 +870,7 @@ jobs:
assert.Nil(t, matrix, "matrix should be nil for jobs without strategy")
} else {
assert.NotNil(t, matrix, "matrix should not be nil")
assert.Len(t, matrix, tt.wantLen, "matrix should have expected number of keys")
assert.Equal(t, tt.wantLen, len(matrix), "matrix should have expected number of keys") //nolint:testifylint // pre-existing issue from nektos/act
if tt.checkFn != nil {
tt.checkFn(t, matrix)
}

View File

@@ -265,6 +265,8 @@ func removeGitIgnore(ctx context.Context, directory string) error {
}
// TODO: break out parts of function to reduce complexicity
//
//nolint:gocyclo // function handles many cases
func execAsDocker(ctx context.Context, step actionStep, actionName, basedir string, localAction bool) error {
logger := common.Logger(ctx)
rc := step.getRunContext()
@@ -427,7 +429,7 @@ func newStepContainer(ctx context.Context, step step, image string, cmd, entrypo
Image: image,
Username: rc.Config.Secrets["DOCKER_USERNAME"],
Password: rc.Config.Secrets["DOCKER_PASSWORD"],
Name: createContainerName(rc.jobContainerName(), "STEP-"+stepModel.ID),
Name: createSimpleContainerName(rc.jobContainerName(), "STEP-"+stepModel.ID),
Env: envList,
Mounts: mounts,
NetworkMode: networkMode,

View File

@@ -137,7 +137,7 @@ runs:
action, err := readActionImpl(context.Background(), tt.step, "actionDir", "actionPath", readFile, writeFile)
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
assert.Nil(t, err) //nolint:testifylint // pre-existing issue from nektos/act
assert.Equal(t, tt.expected, action)
closerMock.AssertExpectations(t)
@@ -247,7 +247,7 @@ func TestActionRunner(t *testing.T) {
err := runActionImpl(tt.step, "dir", newRemoteAction("org/repo/path@ref"))(ctx)
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
assert.Nil(t, err) //nolint:testifylint // pre-existing issue from nektos/act
cm.AssertExpectations(t)
})
}

View File

@@ -405,6 +405,7 @@ func escapeFormatString(in string) string {
return strings.ReplaceAll(strings.ReplaceAll(in, "{", "{{"), "}", "}}")
}
//nolint:gocyclo // function handles many cases
func rewriteSubExpression(ctx context.Context, in string, forceFormat bool) (string, error) { //nolint:unparam // pre-existing issue from nektos/act
if !strings.Contains(in, "${{") || !strings.Contains(in, "}}") {
return in, nil
@@ -471,6 +472,7 @@ func rewriteSubExpression(ctx context.Context, in string, forceFormat bool) (str
return out, nil
}
//nolint:gocyclo // function handles many cases
func getEvaluatorInputs(ctx context.Context, rc *RunContext, step step, ghc *model.GithubContext) map[string]any {
inputs := map[string]any{}

View File

@@ -24,13 +24,7 @@ type jobInfo interface {
result(result string)
}
// reportStepError emits the GitHub Actions ##[error] annotation and records
// the error against the job so the job is reported as failed.
func reportStepError(ctx context.Context, err error) {
common.Logger(ctx).Errorf("##[error]%v", err)
common.SetJobError(ctx, err)
}
//nolint:contextcheck,gocyclo // composes many step executors
func newJobExecutor(info jobInfo, sf stepFactory, rc *RunContext) common.Executor {
steps := make([]common.Executor, 0)
preSteps := make([]common.Executor, 0)
@@ -39,7 +33,7 @@ func newJobExecutor(info jobInfo, sf stepFactory, rc *RunContext) common.Executo
steps = append(steps, func(ctx context.Context) error {
logger := common.Logger(ctx)
if len(info.matrix()) > 0 {
logger.Infof("Matrix: %v", info.matrix())
logger.Infof("\U0001F9EA Matrix: %v", info.matrix())
}
return nil
})
@@ -82,36 +76,33 @@ func newJobExecutor(info jobInfo, sf stepFactory, rc *RunContext) common.Executo
preExec := step.pre()
preSteps = append(preSteps, useStepLogger(rc, stepModel, stepStagePre, func(ctx context.Context) error {
logger := common.Logger(ctx)
preErr := preExec(ctx)
if preErr != nil {
reportStepError(ctx, preErr)
logger.Errorf("%v", preErr)
common.SetJobError(ctx, preErr)
} else if ctx.Err() != nil {
reportStepError(ctx, ctx.Err())
logger.Errorf("%v", ctx.Err())
common.SetJobError(ctx, ctx.Err())
}
return preErr
}))
stepExec := step.main()
steps = append(steps, useStepLogger(rc, stepModel, stepStageMain, func(ctx context.Context) error {
logger := common.Logger(ctx)
err := stepExec(ctx)
if err != nil {
reportStepError(ctx, err)
logger.Errorf("%v", err)
common.SetJobError(ctx, err)
} else if ctx.Err() != nil {
reportStepError(ctx, ctx.Err())
logger.Errorf("%v", ctx.Err())
common.SetJobError(ctx, ctx.Err())
}
return nil
}))
postFn := step.post()
postExec := useStepLogger(rc, stepModel, stepStagePost, func(ctx context.Context) error {
err := postFn(ctx)
if err != nil {
reportStepError(ctx, err)
} else if ctx.Err() != nil {
reportStepError(ctx, ctx.Err())
}
return err
})
postExec := useStepLogger(rc, stepModel, stepStagePost, step.post())
if postExecutor != nil {
// run the post executor in reverse order
postExecutor = postExec.Finally(postExecutor)
@@ -166,7 +157,7 @@ func newJobExecutor(info jobInfo, sf stepFactory, rc *RunContext) common.Executo
pipeline = append(pipeline, steps...)
return common.NewPipelineExecutor(info.startContainer(), common.NewPipelineExecutor(pipeline...).
Finally(func(ctx context.Context) error {
Finally(func(ctx context.Context) error { //nolint:contextcheck // intentionally detaches from canceled parent
var cancel context.CancelFunc
if ctx.Err() == context.Canceled {
// in case of an aborted run, we still should execute the
@@ -206,7 +197,7 @@ func setJobResult(ctx context.Context, info jobInfo, rc *RunContext, success boo
jobResultMessage = "failed"
}
logger.WithField("jobResult", jobResult).Infof("Job %s", jobResultMessage)
logger.WithField("jobResult", jobResult).Infof("\U0001F3C1 Job %s", jobResultMessage)
}
func setJobOutputs(ctx context.Context, rc *RunContext) {

View File

@@ -331,7 +331,7 @@ func TestNewJobExecutor(t *testing.T) {
executor := newJobExecutor(jim, sfm, rc)
err := executor(ctx)
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
assert.Nil(t, err) //nolint:testifylint // pre-existing issue from nektos/act
assert.Equal(t, tt.executedSteps, executorOrder)
jim.AssertExpectations(t)

View File

@@ -30,11 +30,6 @@ const (
gray = 37
)
const (
rawOutputField = "raw_output"
scriptLineCyanField = "script_line_cyan"
)
var (
colors []int
nextColor int
@@ -166,8 +161,6 @@ func withStepLogger(ctx context.Context, stepNumber int, stepID, stepName, stage
type entryProcessor func(entry *logrus.Entry) *logrus.Entry
// valueMasker applies secrets and ::add-mask:: patterns to every log entry, including
// raw_output (command/stream) lines; there is no bypass by field.
func valueMasker(insecureSecrets bool, secrets map[string]string) entryProcessor {
return func(entry *logrus.Entry) *logrus.Entry {
if insecureSecrets {
@@ -234,12 +227,8 @@ func (f *jobLogFormatter) printColored(b *bytes.Buffer, entry *logrus.Entry) {
debugFlag = "[DEBUG] "
}
if entry.Data[rawOutputField] == true {
if entry.Data[scriptLineCyanField] == true {
fmt.Fprintf(b, "\x1b[%dm|\x1b[0m \x1b[36;1m%s\x1b[0m", f.color, entry.Message)
} else {
fmt.Fprintf(b, "\x1b[%dm|\x1b[0m %s", f.color, entry.Message)
}
if entry.Data["raw_output"] == true {
fmt.Fprintf(b, "\x1b[%dm|\x1b[0m %s", f.color, entry.Message)
} else if entry.Data["dryrun"] == true {
fmt.Fprintf(b, "\x1b[1m\x1b[%dm\x1b[7m*DRYRUN*\x1b[0m \x1b[%dm[%s] \x1b[0m%s%s", gray, f.color, job, debugFlag, entry.Message)
} else {
@@ -262,7 +251,7 @@ func (f *jobLogFormatter) print(b *bytes.Buffer, entry *logrus.Entry) {
debugFlag = "[DEBUG] "
}
if entry.Data[rawOutputField] == true {
if entry.Data["raw_output"] == true {
fmt.Fprintf(b, "[%s] | %s", job, entry.Message)
} else if entry.Data["dryrun"] == true {
fmt.Fprintf(b, "*DRYRUN* [%s] %s%s", job, debugFlag, entry.Message)

View File

@@ -60,7 +60,7 @@ func TestMaxParallelStrategy(t *testing.T) {
matrixes, err := job.GetMatrixes()
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
assert.NotNil(t, matrixes)
assert.Len(t, matrixes, 5)
assert.Equal(t, 5, len(matrixes)) //nolint:testifylint // pre-existing issue from nektos/act
assert.Equal(t, tt.expectedMaxParallel, job.Strategy.MaxParallel)
})
}

View File

@@ -101,7 +101,8 @@ func (rc *RunContext) jobContainerName() string {
if rc.caller != nil {
nameParts = append(nameParts, "CALLED-BY-"+rc.caller.runContext.JobName)
}
return createContainerName(nameParts...) // For Gitea
// return createSimpleContainerName(rc.Config.ContainerNamePrefix, "WORKFLOW-"+rc.Run.Workflow.Name, "JOB-"+rc.Name)
return createSimpleContainerName(nameParts...) // For Gitea
}
// networkNameForGitea return the name of the network
@@ -220,12 +221,11 @@ func (rc *RunContext) startHostEnvironment() common.Executor {
}
toolCache := filepath.Join(cacheDir, "tool_cache")
rc.JobContainer = &container.HostEnvironment{
Path: path,
TmpDir: runnerTmp,
ToolCache: toolCache,
Workdir: rc.Config.Workdir,
BindWorkdir: rc.Config.BindWorkdir,
ActPath: actPath,
Path: path,
TmpDir: runnerTmp,
ToolCache: toolCache,
Workdir: rc.Config.Workdir,
ActPath: actPath,
CleanUp: func() {
os.RemoveAll(miscpath)
},
@@ -260,6 +260,7 @@ func (rc *RunContext) startHostEnvironment() common.Executor {
}
}
//nolint:gocyclo // function handles many cases
func (rc *RunContext) startJobContainer() common.Executor {
return func(ctx context.Context) error {
logger := common.Logger(ctx)
@@ -730,7 +731,7 @@ func (rc *RunContext) isEnabled(ctx context.Context) (bool, error) {
jobType, jobTypeErr := job.Type()
if runJobErr != nil {
return false, fmt.Errorf("if-expression %q evaluation failed: %s", job.If.Value, runJobErr)
return false, fmt.Errorf(" \u274C Error in if-expression: \"if: %s\" (%s)", job.If.Value, runJobErr)
}
if jobType == model.JobTypeInvalid {
@@ -768,6 +769,7 @@ func mergeMaps(maps ...map[string]string) map[string]string {
return rtnMap
}
// Deprecated: use createSimpleContainerName
func createContainerName(parts ...string) string {
name := strings.Join(parts, "-")
pattern := regexp.MustCompile("[^a-zA-Z0-9]")
@@ -781,6 +783,22 @@ func createContainerName(parts ...string) string {
return fmt.Sprintf("%s-%x", trimmedName, hash)
}
func createSimpleContainerName(parts ...string) string {
pattern := regexp.MustCompile("[^a-zA-Z0-9-]")
name := make([]string, 0, len(parts))
for _, v := range parts {
v = pattern.ReplaceAllString(v, "-")
v = strings.Trim(v, "-")
for strings.Contains(v, "--") {
v = strings.ReplaceAll(v, "--", "-")
}
if v != "" {
name = append(name, v)
}
}
return strings.Join(name, "_")
}
func trimToLen(s string, l int) string {
if l < 0 {
l = 0
@@ -808,6 +826,7 @@ func (rc *RunContext) getStepsContext() map[string]*model.StepResult {
return rc.StepResults
}
//nolint:gocyclo // function handles many cases
func (rc *RunContext) getGithubContext(ctx context.Context) *model.GithubContext {
logger := common.Logger(ctx)
ghc := &model.GithubContext{

View File

@@ -282,7 +282,7 @@ func TestGetGitHubContext(t *testing.T) {
log.SetLevel(log.DebugLevel)
cwd, err := os.Getwd()
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
assert.Nil(t, err) //nolint:testifylint // pre-existing issue from nektos/act
rc := &RunContext{
Config: &Config{
@@ -622,16 +622,23 @@ func TestRunContextGetEnv(t *testing.T) {
}
}
func TestCreateContainerNameBoundedForLongMatrixInput(t *testing.T) {
longMatrixValue := strings.Repeat("os=ubuntu-latest-go=1.24-node=22-", 20)
name := createContainerName(
"gitea",
"WORKFLOW-super-long-workflow-name",
"JOB-build-matrix-"+longMatrixValue,
)
assert.LessOrEqual(t, len(name), 128)
assert.LessOrEqual(t, len(name+"-env"), 255)
assert.LessOrEqual(t, len(name+"-network"), 255)
assert.LessOrEqual(t, len(name+"-job1234567890"), 255)
func Test_createSimpleContainerName(t *testing.T) {
tests := []struct {
parts []string
want string
}{
{
parts: []string{"a--a", "BB正", "c-C"},
want: "a-a_BB_c-C",
},
{
parts: []string{"a-a", "", "-"},
want: "a-a",
},
}
for _, tt := range tests {
t.Run(strings.Join(tt.parts, " "), func(t *testing.T) {
assert.Equalf(t, tt.want, createSimpleContainerName(tt.parts...), "createSimpleContainerName(%v)", tt.parts)
})
}
}

View File

@@ -137,6 +137,8 @@ func (runner *runnerImpl) configure() (Runner, error) {
}
// NewPlanExecutor ...
//
//nolint:gocyclo // function handles many cases
func (runner *runnerImpl) NewPlanExecutor(plan *model.Plan) common.Executor {
maxJobNameLen := 0

View File

@@ -87,7 +87,7 @@ func TestGraphMissingEvent(t *testing.T) {
plan, err := planner.PlanEvent("push")
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
assert.NotNil(t, plan)
assert.Empty(t, plan.Stages)
assert.Equal(t, 0, len(plan.Stages)) //nolint:testifylint // pre-existing issue from nektos/act
assert.Contains(t, buf.String(), "no events found for workflow: no-event.yml")
log.SetOutput(out)
@@ -100,7 +100,7 @@ func TestGraphMissingFirst(t *testing.T) {
plan, err := planner.PlanEvent("push")
assert.EqualError(t, err, "unable to build dependency graph for no first (no-first.yml)") //nolint:testifylint // pre-existing issue from nektos/act
assert.NotNil(t, plan)
assert.Empty(t, plan.Stages)
assert.Equal(t, 0, len(plan.Stages)) //nolint:testifylint // pre-existing issue from nektos/act
}
func TestGraphWithMissing(t *testing.T) {
@@ -114,7 +114,7 @@ func TestGraphWithMissing(t *testing.T) {
plan, err := planner.PlanEvent("push")
assert.NotNil(t, plan)
assert.Empty(t, plan.Stages)
assert.Equal(t, 0, len(plan.Stages)) //nolint:testifylint // pre-existing issue from nektos/act
assert.EqualError(t, err, "unable to build dependency graph for missing (missing.yml)") //nolint:testifylint // pre-existing issue from nektos/act
assert.Contains(t, buf.String(), "unable to build dependency graph for missing (missing.yml)")
log.SetOutput(out)
@@ -134,7 +134,7 @@ func TestGraphWithSomeMissing(t *testing.T) {
plan, err := planner.PlanAll()
assert.Error(t, err, "unable to build dependency graph for no first (no-first.yml)") //nolint:testifylint // pre-existing issue from nektos/act
assert.NotNil(t, plan)
assert.Len(t, plan.Stages, 1)
assert.Equal(t, 1, len(plan.Stages)) //nolint:testifylint // pre-existing issue from nektos/act
assert.Contains(t, buf.String(), "unable to build dependency graph for missing (missing.yml)")
assert.Contains(t, buf.String(), "unable to build dependency graph for no first (no-first.yml)")
log.SetOutput(out)
@@ -159,7 +159,7 @@ func TestGraphEvent(t *testing.T) {
plan, err = planner.PlanEvent("release")
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
assert.NotNil(t, plan)
assert.Empty(t, plan.Stages)
assert.Equal(t, 0, len(plan.Stages)) //nolint:testifylint // pre-existing issue from nektos/act
}
type TestJobFileInfo struct {
@@ -177,7 +177,7 @@ func (j *TestJobFileInfo) runTest(ctx context.Context, t *testing.T, cfg *Config
log.SetLevel(logLevel)
workdir, err := filepath.Abs(j.workdir)
assert.NoError(t, err, workdir) //nolint:testifylint // pre-existing issue from nektos/act
assert.Nil(t, err, workdir) //nolint:testifylint // pre-existing issue from nektos/act
fullWorkflowPath := filepath.Join(workdir, j.workflowPath)
runnerConfig := &Config{
@@ -197,17 +197,17 @@ func (j *TestJobFileInfo) runTest(ctx context.Context, t *testing.T, cfg *Config
}
runner, err := New(runnerConfig)
assert.NoError(t, err, j.workflowPath) //nolint:testifylint // pre-existing issue from nektos/act
assert.Nil(t, err, j.workflowPath) //nolint:testifylint // pre-existing issue from nektos/act
planner, err := model.NewWorkflowPlanner(fullWorkflowPath, true)
assert.NoError(t, err, fullWorkflowPath) //nolint:testifylint // pre-existing issue from nektos/act
assert.Nil(t, err, fullWorkflowPath) //nolint:testifylint // pre-existing issue from nektos/act
plan, err := planner.PlanEvent(j.eventName)
assert.True(t, (err == nil) != (plan == nil), "PlanEvent should return either a plan or an error") //nolint:testifylint // pre-existing issue from nektos/act
if err == nil && plan != nil {
err = runner.NewPlanExecutor(plan)(ctx)
if j.errorMessage == "" {
assert.NoError(t, err, fullWorkflowPath) //nolint:testifylint // pre-existing issue from nektos/act
assert.Nil(t, err, fullWorkflowPath) //nolint:testifylint // pre-existing issue from nektos/act
} else {
assert.Error(t, err, j.errorMessage) //nolint:testifylint // pre-existing issue from nektos/act
}

View File

@@ -107,7 +107,7 @@ func runStepExecutor(step step, stage stepStage, executor common.Executor) commo
if strings.Contains(stepString, "::add-mask::") {
stepString = "add-mask command"
}
logger.Infof("Run %s %s", stage, stepString)
logger.Infof("\u2B50 Run %s %s", stage, stepString)
// Prepare and clean Runner File Commands
actPath := rc.JobContainer.GetActPath()
@@ -158,7 +158,7 @@ func runStepExecutor(step step, stage stepStage, executor common.Executor) commo
err = executor(timeoutctx)
if err == nil {
logger.WithField("stepResult", stepResult.Outcome).Infof("Success - %s %s", stage, stepString)
logger.WithField("stepResult", stepResult.Outcome).Infof(" \u2705 Success - %s %s", stage, stepString)
} else {
stepResult.Outcome = model.StepStatusFailure
@@ -169,7 +169,6 @@ func runStepExecutor(step step, stage stepStage, executor common.Executor) commo
}
if continueOnError {
logger.Errorf("##[error]%v", err)
logger.Infof("Failed but continue next step")
err = nil
stepResult.Conclusion = model.StepStatusSuccess
@@ -177,9 +176,7 @@ func runStepExecutor(step step, stage stepStage, executor common.Executor) commo
stepResult.Conclusion = model.StepStatusFailure
}
// Infof: Errorf entries are promoted to the user log by the reporter,
// which would duplicate the ##[error] annotation emitted elsewhere.
logger.WithField("stepResult", stepResult.Outcome).Infof("Failure - %s %s", stage, stepString)
logger.WithField("stepResult", stepResult.Outcome).Errorf(" \u274C Failure - %s %s", stage, stepString)
}
// Process Runner File Commands
orgerr := err
@@ -271,7 +268,7 @@ func isStepEnabled(ctx context.Context, expr string, step step, stage stepStage)
runStep, err := EvalBool(ctx, rc.NewStepExpressionEvaluator(ctx, step), expr, defaultStatusCheck)
if err != nil {
return false, fmt.Errorf("if-expression %q evaluation failed: %s", expr, err)
return false, fmt.Errorf(" \u274C Error in if-expression: \"if: %s\" (%s)", expr, err)
}
return runStep, nil
@@ -287,7 +284,7 @@ func isContinueOnError(ctx context.Context, expr string, step step, _ stepStage)
continueOnError, err := EvalBool(ctx, rc.NewStepExpressionEvaluator(ctx, step), expr, exprparser.DefaultStatusCheckNone)
if err != nil {
return false, fmt.Errorf("continue-on-error expression %q evaluation failed: %s", expr, err)
return false, fmt.Errorf(" \u274C Error in continue-on-error-expression: \"continue-on-error: %s\" (%s)", expr, err)
}
return continueOnError, nil

View File

@@ -44,10 +44,6 @@ func (sal *stepActionLocal) main() common.Executor {
return nil
}
printRunActionHeader(ctx, sal.Step, sal.env, sal.getRunContext())
rawLogger := common.Logger(ctx).WithField(rawOutputField, true)
defer rawLogger.Infof("::endgroup::")
actionDir := filepath.Join(sal.getRunContext().Config.Workdir, sal.Step.Uses)
localReader := func(ctx context.Context) actionYamlReader {

View File

@@ -97,10 +97,10 @@ func TestStepActionLocalTest(t *testing.T) {
})
err := sal.pre()(ctx)
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
assert.Nil(t, err) //nolint:testifylint // pre-existing issue from nektos/act
err = sal.main()(ctx)
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
assert.Nil(t, err) //nolint:testifylint // pre-existing issue from nektos/act
cm.AssertExpectations(t)
salm.AssertExpectations(t)

View File

@@ -39,6 +39,7 @@ type stepActionRemote struct {
var stepActionRemoteNewCloneExecutor = git.NewGitCloneExecutor
//nolint:gocyclo // function handles many cases
func (sar *stepActionRemote) prepareActionExecutor() common.Executor {
return func(ctx context.Context) error {
if sar.remoteAction != nil && sar.action != nil {
@@ -165,10 +166,6 @@ func (sar *stepActionRemote) main() common.Executor {
return common.NewPipelineExecutor(
sar.prepareActionExecutor(),
runStepExecutor(sar, stepStageMain, func(ctx context.Context) error {
printRunActionHeader(ctx, sar.Step, sar.env, sar.RunContext)
rawLogger := common.Logger(ctx).WithField(rawOutputField, true)
defer rawLogger.Infof("::endgroup::")
github := sar.getGithubContext(ctx)
if sar.remoteAction.IsCheckout() && isLocalCheckout(github, sar.Step) && !sar.RunContext.Config.NoSkipCheckout {
if sar.RunContext.Config.BindWorkdir {

View File

@@ -272,8 +272,8 @@ func TestStepActionRemotePre(t *testing.T) {
err := sar.pre()(ctx)
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
assert.True(t, clonedAction)
assert.Nil(t, err) //nolint:testifylint // pre-existing issue from nektos/act
assert.Equal(t, true, clonedAction) //nolint:testifylint // pre-existing issue from nektos/act
sarm.AssertExpectations(t)
})
@@ -343,8 +343,8 @@ func TestStepActionRemotePreThroughAction(t *testing.T) {
err := sar.pre()(ctx)
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
assert.True(t, clonedAction)
assert.Nil(t, err) //nolint:testifylint // pre-existing issue from nektos/act
assert.Equal(t, true, clonedAction) //nolint:testifylint // pre-existing issue from nektos/act
sarm.AssertExpectations(t)
})
@@ -419,7 +419,7 @@ func TestStepActionRemotePreThroughActionToken(t *testing.T) {
err := sar.pre()(ctx)
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
assert.Nil(t, err) //nolint:testifylint // pre-existing issue from nektos/act
// Verify that the clone was called (URL should be redirected to github.com)
assert.True(t, actualURL != "", "Expected clone to be called") //nolint:testifylint // pre-existing issue from nektos/act
assert.Equal(t, "https://github.com/org/repo", actualURL, "URL should be redirected to github.com")

View File

@@ -116,10 +116,6 @@ func (sd *stepDocker) newStepContainer(ctx context.Context, image string, cmd, e
envList = append(envList, fmt.Sprintf("%s=%s", "RUNNER_TEMP", "/tmp"))
binds, mounts := rc.GetBindsAndMounts()
networkMode := "container:" + rc.jobContainerName()
if rc.IsHostEnv(ctx) {
networkMode = "default"
}
stepContainer := ContainerNewContainer(&container.NewContainerInput{
Cmd: cmd,
Entrypoint: entrypoint,
@@ -127,10 +123,10 @@ func (sd *stepDocker) newStepContainer(ctx context.Context, image string, cmd, e
Image: image,
Username: rc.Config.Secrets["DOCKER_USERNAME"],
Password: rc.Config.Secrets["DOCKER_PASSWORD"],
Name: createContainerName(rc.jobContainerName(), "STEP-"+step.ID),
Name: createSimpleContainerName(rc.jobContainerName(), "STEP-"+step.ID),
Env: envList,
Mounts: mounts,
NetworkMode: networkMode,
NetworkMode: "container:" + rc.jobContainerName(),
Binds: binds,
Stdout: logWriter,
Stderr: logWriter,

View File

@@ -8,7 +8,6 @@ import (
"bytes"
"context"
"io"
"strings"
"testing"
"gitea.com/gitea/runner/act/container"
@@ -102,7 +101,7 @@ func TestStepDockerMain(t *testing.T) {
cm.On("GetContainerArchive", ctx, "/var/run/act/workflow/pathcmd.txt").Return(io.NopCloser(&bytes.Buffer{}), nil)
err := sd.main()(ctx)
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
assert.Nil(t, err) //nolint:testifylint // pre-existing issue from nektos/act
assert.Equal(t, "node:14", input.Image)
@@ -114,86 +113,8 @@ func TestStepDockerPrePost(t *testing.T) {
sd := &stepDocker{}
err := sd.pre()(ctx)
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
assert.Nil(t, err) //nolint:testifylint // pre-existing issue from nektos/act
err = sd.post()(ctx)
assert.NoError(t, err)
}
func TestStepDockerNewStepContainerNetworkMode(t *testing.T) {
cases := []struct {
name string
platform string
expectDefault bool
}{
{
name: "docker mode attaches to job container network",
platform: "node:14",
expectDefault: false,
},
{
name: "host mode uses default network",
platform: "-self-hosted",
expectDefault: true,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
cm := &containerMock{}
var captured *container.NewContainerInput
origContainerNewContainer := ContainerNewContainer
ContainerNewContainer = func(input *container.NewContainerInput) container.ExecutionsEnvironment {
captured = input
return cm
}
defer func() {
ContainerNewContainer = origContainerNewContainer
}()
ctx := context.Background()
platform := tc.platform
sd := &stepDocker{
RunContext: &RunContext{
StepResults: map[string]*model.StepResult{},
Config: &Config{
PlatformPicker: func(_ []string) string {
return platform
},
},
Run: &model.Run{
JobID: "1",
Workflow: &model.Workflow{
Jobs: map[string]*model.Job{
"1": {},
},
},
},
JobContainer: cm,
},
Step: &model.Step{
ID: "1",
Uses: "docker://alpine:3.20",
},
}
sd.RunContext.ExprEval = sd.RunContext.NewExpressionEvaluator(ctx)
assert.Equal(t, tc.expectDefault, sd.RunContext.IsHostEnv(ctx),
"IsHostEnv mismatch for platform %q", tc.platform)
_ = sd.newStepContainer(ctx, "alpine:3.20", []string{"echo", "hello"}, nil)
if tc.expectDefault {
assert.Equal(t, "default", captured.NetworkMode,
"host-mode step container must use 'default' network, got %q",
captured.NetworkMode)
} else {
assert.True(t, strings.HasPrefix(captured.NetworkMode, "container:"),
"docker-mode step container must attach to job container network, got %q",
captured.NetworkMode)
}
})
}
assert.Nil(t, err) //nolint:testifylint // pre-existing issue from nektos/act
}

View File

@@ -67,7 +67,7 @@ func TestStepFactoryNewStep(t *testing.T) {
step, err := sf.newStep(tt.model, &RunContext{})
assert.True(t, tt.check((step)))
assert.NoError(t, err)
assert.Nil(t, err) //nolint:testifylint // pre-existing issue from nektos/act
})
}
}

View File

@@ -9,7 +9,6 @@ import (
"fmt"
"maps"
"runtime"
"slices"
"strings"
"gitea.com/gitea/runner/act/common"
@@ -18,18 +17,15 @@ import (
"gitea.com/gitea/runner/act/model"
"github.com/kballard/go-shellquote"
yaml "go.yaml.in/yaml/v4"
)
type stepRun struct {
Step *model.Step
RunContext *RunContext
cmd []string
cmdline string
env map[string]string
WorkingDirectory string
interpolatedScript string
shellCommand string
Step *model.Step
RunContext *RunContext
cmd []string
cmdline string
env map[string]string
WorkingDirectory string
}
func (sr *stepRun) pre() common.Executor {
@@ -43,154 +39,15 @@ func (sr *stepRun) main() common.Executor {
return runStepExecutor(sr, stepStageMain, common.NewPipelineExecutor(
sr.setupShellCommandExecutor(),
func(ctx context.Context) error {
rc := sr.getRunContext()
// Apply ::add-path:: effects before printing so PATH is accurate in the env: block.
rc.ApplyExtraPath(ctx, &sr.env)
sr.printRunScriptActionDetails(ctx)
if he, ok := rc.JobContainer.(*container.HostEnvironment); ok && he != nil {
sr.getRunContext().ApplyExtraPath(ctx, &sr.env)
if he, ok := sr.getRunContext().JobContainer.(*container.HostEnvironment); ok && he != nil {
return he.ExecWithCmdLine(sr.cmd, sr.cmdline, sr.env, "", sr.WorkingDirectory)(ctx)
}
return rc.JobContainer.Exec(sr.cmd, sr.env, "", sr.WorkingDirectory)(ctx)
return sr.getRunContext().JobContainer.Exec(sr.cmd, sr.env, "", sr.WorkingDirectory)(ctx)
},
))
}
// printRunScriptActionDetails mirrors actions/runner ScriptHandler.PrintActionDetails
// for script steps.
func (sr *stepRun) printRunScriptActionDetails(ctx context.Context) {
rawLogger := common.Logger(ctx).WithField(rawOutputField, true)
scriptLineLogger := rawLogger.WithField(scriptLineCyanField, true)
normalized := strings.TrimRight(strings.ReplaceAll(sr.interpolatedScript, "\r\n", "\n"), "\n")
rawLogger.Infof("::group::Run %s", sr.runScriptGroupTitle(normalized))
if normalized != "" {
for line := range strings.SplitSeq(normalized, "\n") {
scriptLineLogger.Info(line)
}
}
rawLogger.Infof("shell: %s", sr.shellCommand)
printStepEnvBlock(ctx, sr.Step, sr.env, sr.getRunContext())
rawLogger.Infof("::endgroup::")
}
// printRunActionHeader mirrors actions/runner's "Run <action>" header for `uses:` steps,
// including the with: inputs and the step-level env: block. The caller is responsible
// for emitting ::endgroup:: after the action finishes.
func printRunActionHeader(ctx context.Context, step *model.Step, env map[string]string, rc *RunContext) {
if step == nil {
return
}
rawLogger := common.Logger(ctx).WithField(rawOutputField, true)
title := step.Uses
if step.Name != "" {
title = step.Name
}
rawLogger.Infof("::group::Run %s", title)
if len(step.With) > 0 {
rawLogger.Infof("with:")
for _, k := range slices.Sorted(maps.Keys(step.With)) {
rawLogger.Infof(" %s: %s", k, step.With[k])
}
}
printStepEnvBlock(ctx, step, env, rc)
}
// printStepEnvBlock emits the declared-env block (YAML order, internal vars filtered)
// shared by the run: and uses: "Run" headers.
func printStepEnvBlock(ctx context.Context, step *model.Step, env map[string]string, rc *RunContext) {
rawLogger := common.Logger(ctx).WithField(rawOutputField, true)
caseInsensitive := rc != nil && rc.JobContainer != nil && rc.JobContainer.IsEnvironmentCaseInsensitive()
var visible []string
for _, k := range stepDeclaredEnvKeysInOrder(step) {
if !isInternalEnvKey(k, caseInsensitive) {
visible = append(visible, k)
}
}
if len(visible) == 0 {
return
}
rawLogger.Infof("env:")
envLookup := env
if caseInsensitive {
envLookup = make(map[string]string, len(env))
for k, v := range env {
envLookup[strings.ToUpper(k)] = v
}
}
for _, k := range visible {
lookupKey := k
if caseInsensitive {
lookupKey = strings.ToUpper(k)
}
rawLogger.Infof(" %s: %s", k, envLookup[lookupKey])
}
}
// isInternalEnvKey matches actions/runner's filtered set of vars that are hidden
// from the "Run" header's env: block because they are injected by the runner itself.
func isInternalEnvKey(k string, caseInsensitive bool) bool {
upper := k
if caseInsensitive {
upper = strings.ToUpper(k)
}
switch upper {
case "PATH", "HOME", "CI":
return true
}
return strings.HasPrefix(upper, "GITHUB_") ||
strings.HasPrefix(upper, "GITEA_") ||
strings.HasPrefix(upper, "RUNNER_") ||
strings.HasPrefix(upper, "INPUT_")
}
func (sr *stepRun) runScriptGroupTitle(normalizedScript string) string {
trimmed := strings.TrimLeft(normalizedScript, " \t\r\n")
if idx := strings.IndexAny(trimmed, "\r\n"); idx >= 0 {
trimmed = trimmed[:idx]
}
if trimmed != "" {
return trimmed
}
if sr.Step != nil {
if sr.Step.Name != "" {
return sr.Step.Name
}
return sr.Step.ID
}
return ""
}
// stepDeclaredEnvKeysInOrder walks the raw YAML Env mapping so keys are emitted in
// the order the workflow author wrote them; step.Environment() decodes into a Go map
// and loses ordering.
func stepDeclaredEnvKeysInOrder(step *model.Step) []string {
if step == nil || step.Env.Kind != yaml.MappingNode {
return nil
}
content := step.Env.Content
keys := make([]string, 0, len(content)/2)
seen := make(map[string]struct{}, len(content)/2)
for i := 0; i+1 < len(content); i += 2 {
k := content[i]
if k.Kind != yaml.ScalarNode || k.Tag == "!!merge" || k.Value == "<<" {
continue
}
if _, dup := seen[k.Value]; dup {
continue
}
seen[k.Value] = struct{}{}
keys = append(keys, k.Value)
}
return keys
}
func (sr *stepRun) post() common.Executor {
return func(ctx context.Context) error {
return nil
@@ -254,10 +111,8 @@ func (sr *stepRun) setupShellCommand(ctx context.Context) (name, script string,
step := sr.Step
script = sr.RunContext.NewStepExpressionEvaluator(ctx, sr).Interpolate(ctx, step.Run)
sr.interpolatedScript = script
scCmd := step.ShellCommand()
sr.shellCommand = scCmd
name = getScriptName(sr.RunContext, step)

View File

@@ -1,182 +0,0 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// Copyright 2026 The nektos/act Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package runner
import (
"bytes"
"context"
"strings"
"testing"
"gitea.com/gitea/runner/act/common"
"gitea.com/gitea/runner/act/model"
"github.com/sirupsen/logrus"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
yaml "go.yaml.in/yaml/v4"
)
func TestRunScriptGroupTitle(t *testing.T) {
sr := &stepRun{Step: &model.Step{Name: "Build"}}
assert.Equal(t, "make build", sr.runScriptGroupTitle("make build"))
assert.Equal(t, "echo one", sr.runScriptGroupTitle(" \techo one\necho two"))
assert.Equal(t, "Build", sr.runScriptGroupTitle(""))
sr = &stepRun{Step: &model.Step{ID: "s1"}}
assert.Equal(t, "s1", sr.runScriptGroupTitle("\n \n"))
}
func TestStepDeclaredEnvOrderPreservesYAML(t *testing.T) {
raw := `id: s1
run: "echo 1"
env:
GITHUB_TOKEN: tok
PATH: /custom/bin
MY_VAR: hello
`
var step model.Step
require.NoError(t, yaml.Unmarshal([]byte(raw), &step))
assert.Equal(t, []string{"GITHUB_TOKEN", "PATH", "MY_VAR"}, stepDeclaredEnvKeysInOrder(&step))
}
func TestStepDeclaredEnvKeysInOrderEmpty(t *testing.T) {
assert.Nil(t, stepDeclaredEnvKeysInOrder(nil))
assert.Empty(t, stepDeclaredEnvKeysInOrder(&model.Step{}))
}
func TestStepDeclaredEnvKeysIgnoreYAMLMergeKey(t *testing.T) {
doc := `
common: &common
COMMON_A: a
COMMON_B: b
step:
env:
LOCAL_BEFORE: before
<<: *common
COMMON_B: overridden
LOCAL_AFTER: after
`
var root struct {
Step model.Step `yaml:"step"`
}
require.NoError(t, yaml.Unmarshal([]byte(doc), &root))
keys := stepDeclaredEnvKeysInOrder(&root.Step)
assert.Equal(t, []string{"LOCAL_BEFORE", "COMMON_B", "LOCAL_AFTER"}, keys)
}
func TestPrintRunScriptActionDetailsGolden(t *testing.T) {
raw := `id: s1
name: Build
run: |
echo one
echo two
shell: pwsh
env:
PATH_PREFIX: /custom/bin
GITHUB_TOKEN: tok
GREETING: hello
`
var step model.Step
require.NoError(t, yaml.Unmarshal([]byte(raw), &step))
buf := &bytes.Buffer{}
logger := logrus.New()
logger.SetOutput(buf)
logger.SetLevel(logrus.InfoLevel)
logger.SetFormatter(&jobLogFormatter{color: cyan})
entry := logger.WithFields(logrus.Fields{"job": "j1"})
ctx := common.WithLogger(context.Background(), entry)
sr := &stepRun{
Step: &step,
RunContext: &RunContext{},
shellCommand: "pwsh -command . '{0}'",
interpolatedScript: "echo one\necho two\n",
env: map[string]string{
"PATH_PREFIX": "/custom/bin",
"GITHUB_TOKEN": "tok",
"GREETING": "hello",
},
}
sr.printRunScriptActionDetails(ctx)
want := strings.Join([]string{
"[j1] | ::group::Run echo one",
"[j1] | echo one",
"[j1] | echo two",
"[j1] | shell: pwsh -command . '{0}'",
"[j1] | env:",
"[j1] | PATH_PREFIX: /custom/bin",
"[j1] | GREETING: hello",
"[j1] | ::endgroup::",
"",
}, "\n")
assert.Equal(t, want, buf.String())
}
func TestPrintRunActionHeaderGolden(t *testing.T) {
raw := `id: s1
uses: actions/checkout@v4
with:
fetch-depth: "0"
token: secret
env:
CUSTOM: value
GITHUB_TOKEN: tok
`
var step model.Step
require.NoError(t, yaml.Unmarshal([]byte(raw), &step))
buf := &bytes.Buffer{}
logger := logrus.New()
logger.SetOutput(buf)
logger.SetLevel(logrus.InfoLevel)
logger.SetFormatter(&jobLogFormatter{color: cyan})
entry := logger.WithFields(logrus.Fields{"job": "j1"})
ctx := common.WithLogger(context.Background(), entry)
printRunActionHeader(ctx, &step, map[string]string{"CUSTOM": "value", "GITHUB_TOKEN": "tok"}, &RunContext{})
want := strings.Join([]string{
"[j1] | ::group::Run actions/checkout@v4",
"[j1] | with:",
"[j1] | fetch-depth: 0",
"[j1] | token: secret",
"[j1] | env:",
"[j1] | CUSTOM: value",
"",
}, "\n")
assert.Equal(t, want, buf.String())
}
func TestIsInternalEnvKey(t *testing.T) {
for _, k := range []string{"PATH", "HOME", "CI", "GITHUB_TOKEN", "GITEA_ACTIONS", "RUNNER_OS", "INPUT_FOO"} {
assert.True(t, isInternalEnvKey(k, false), k)
}
for _, k := range []string{"PATH_PREFIX", "MY_VAR", "GREETING", "HOMEPAGE"} {
assert.False(t, isInternalEnvKey(k, false), k)
}
assert.True(t, isInternalEnvKey("path", true))
assert.False(t, isInternalEnvKey("path", false))
}
func TestPrintColoredScriptLineCyan(t *testing.T) {
f := &jobLogFormatter{color: cyan}
entry := &logrus.Entry{
Level: logrus.InfoLevel,
Message: "echo one",
Data: logrus.Fields{
"job": "j1",
rawOutputField: true,
scriptLineCyanField: true,
},
}
buf := &bytes.Buffer{}
f.printColored(buf, entry)
assert.Equal(t, "\x1b[36m|\x1b[0m \x1b[36;1mecho one\x1b[0m", buf.String())
}

View File

@@ -81,7 +81,7 @@ func TestStepRun(t *testing.T) {
cm.On("GetContainerArchive", ctx, "/var/run/act/workflow/pathcmd.txt").Return(io.NopCloser(&bytes.Buffer{}), nil)
err := sr.main()(ctx)
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
assert.Nil(t, err) //nolint:testifylint // pre-existing issue from nektos/act
cm.AssertExpectations(t)
}
@@ -91,8 +91,8 @@ func TestStepRunPrePost(t *testing.T) {
sr := &stepRun{}
err := sr.pre()(ctx)
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
assert.Nil(t, err) //nolint:testifylint // pre-existing issue from nektos/act
err = sr.post()(ctx)
assert.NoError(t, err)
assert.Nil(t, err) //nolint:testifylint // pre-existing issue from nektos/act
}

View File

@@ -156,7 +156,7 @@ func TestSetupEnv(t *testing.T) {
sm.On("getEnv").Return(&env)
err := setupEnv(context.Background(), sm)
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
assert.Nil(t, err) //nolint:testifylint // pre-existing issue from nektos/act
// These are commit or system specific
delete((env), "GITHUB_REF")
@@ -318,35 +318,35 @@ func TestIsContinueOnError(t *testing.T) {
step := createTestStep(t, "name: test")
continueOnError, err := isContinueOnError(context.Background(), step.getStepModel().RawContinueOnError, step, stepStageMain)
assertObject.False(continueOnError)
assertObject.NoError(err) //nolint:testifylint // pre-existing issue from nektos/act
assertObject.Nil(err) //nolint:testifylint // pre-existing issue from nektos/act
// explcit true
step = createTestStep(t, "continue-on-error: true")
continueOnError, err = isContinueOnError(context.Background(), step.getStepModel().RawContinueOnError, step, stepStageMain)
assertObject.True(continueOnError)
assertObject.NoError(err) //nolint:testifylint // pre-existing issue from nektos/act
assertObject.Nil(err) //nolint:testifylint // pre-existing issue from nektos/act
// explicit false
step = createTestStep(t, "continue-on-error: false")
continueOnError, err = isContinueOnError(context.Background(), step.getStepModel().RawContinueOnError, step, stepStageMain)
assertObject.False(continueOnError)
assertObject.NoError(err) //nolint:testifylint // pre-existing issue from nektos/act
assertObject.Nil(err) //nolint:testifylint // pre-existing issue from nektos/act
// expression true
step = createTestStep(t, "continue-on-error: ${{ 'test' == 'test' }}")
continueOnError, err = isContinueOnError(context.Background(), step.getStepModel().RawContinueOnError, step, stepStageMain)
assertObject.True(continueOnError)
assertObject.NoError(err) //nolint:testifylint // pre-existing issue from nektos/act
assertObject.Nil(err) //nolint:testifylint // pre-existing issue from nektos/act
// expression false
step = createTestStep(t, "continue-on-error: ${{ 'test' != 'test' }}")
continueOnError, err = isContinueOnError(context.Background(), step.getStepModel().RawContinueOnError, step, stepStageMain)
assertObject.False(continueOnError)
assertObject.NoError(err) //nolint:testifylint // pre-existing issue from nektos/act
assertObject.Nil(err) //nolint:testifylint // pre-existing issue from nektos/act
// expression parse error
step = createTestStep(t, "continue-on-error: ${{ 'test' != test }}")
continueOnError, err = isContinueOnError(context.Background(), step.getStepModel().RawContinueOnError, step, stepStageMain)
assertObject.False(continueOnError)
assertObject.Error(err)
assertObject.NotNil(err) //nolint:testifylint // pre-existing issue from nektos/act
}

View File

@@ -13,29 +13,19 @@
"@actions/github": "^4.0.0"
},
"devDependencies": {
"@vercel/ncc": "^0.38.0"
"@vercel/ncc": "^0.24.1"
},
"engines": {
"node": ">=12"
}
},
"node_modules/@actions/core": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@actions/core/-/core-1.11.1.tgz",
"integrity": "sha512-hXJCSrkwfA46Vd9Z3q4cpEpHB1rL5NG04+/rbqW9d3+CSvtB1tYe8UTpAlixa1vj0m/ULglfEK2UKxMGxCxv5A==",
"license": "MIT",
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@actions/core/-/core-1.10.0.tgz",
"integrity": "sha512-2aZDDa3zrrZbP5ZYg159sNoLRb61nQ7awl5pSvIq5Qpj81vwDzdMRKzkWJGJuwVvWpvZKx7vspJALyvaaIQyug==",
"dependencies": {
"@actions/exec": "^1.1.1",
"@actions/http-client": "^2.0.1"
}
},
"node_modules/@actions/exec": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@actions/exec/-/exec-1.1.1.tgz",
"integrity": "sha512-+sCcHHbVdk93a0XT19ECtO/gIXoxvdsgQLzb2fE2/5sIZmWQuluYyjPQtrtTHdU1YzTZ7bAPN4sITq2xi1679w==",
"license": "MIT",
"dependencies": {
"@actions/io": "^1.0.1"
"@actions/http-client": "^2.0.1",
"uuid": "^8.3.2"
}
},
"node_modules/@actions/github": {
@@ -65,12 +55,6 @@
"tunnel": "^0.0.6"
}
},
"node_modules/@actions/io": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@actions/io/-/io-1.1.3.tgz",
"integrity": "sha512-wi9JjgKLYS7U/z8PPbco+PvTb/nRWjeoFlJ1Qer83k/3C5PHQi28hiVdeE2kHXmIL99mQFawx8qt/JPjZilJ8Q==",
"license": "MIT"
},
"node_modules/@octokit/auth-token": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/@octokit/auth-token/-/auth-token-2.5.0.tgz",
@@ -173,11 +157,10 @@
}
},
"node_modules/@vercel/ncc": {
"version": "0.38.4",
"resolved": "https://registry.npmjs.org/@vercel/ncc/-/ncc-0.38.4.tgz",
"integrity": "sha512-8LwjnlP39s08C08J5NstzriPvW1SP8Zfpp1BvC2sI35kPeZnHfxVkCwu4/+Wodgnd60UtT1n8K8zw+Mp7J9JmQ==",
"version": "0.24.1",
"resolved": "https://registry.npmjs.org/@vercel/ncc/-/ncc-0.24.1.tgz",
"integrity": "sha512-r9m7brz2hNmq5TF3sxrK4qR/FhXn44XIMglQUir4sT7Sh5GOaYXlMYikHFwJStf8rmQGTlvOoBXt4yHVonRG8A==",
"dev": true,
"license": "MIT",
"bin": {
"ncc": "dist/ncc/cli.js"
}
@@ -245,6 +228,14 @@
"resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-6.0.0.tgz",
"integrity": "sha512-isyNax3wXoKaulPDZWHQqbmIx1k2tb9fb3GGDBRxCscfYV2Ch7WxPArBsFEG8s/safwXTT7H4QGhaIkTp9447w=="
},
"node_modules/uuid": {
"version": "8.3.2",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
"bin": {
"uuid": "dist/bin/uuid"
}
},
"node_modules/webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",

View File

@@ -15,7 +15,7 @@
"@actions/github": "^4.0.0"
},
"devDependencies": {
"@vercel/ncc": "^0.38.0"
"@vercel/ncc": "^0.24.1"
},
"engines": {
"node": ">=12"

View File

@@ -13,29 +13,19 @@
"@actions/github": "^4.0.0"
},
"devDependencies": {
"@vercel/ncc": "^0.38.0"
"@vercel/ncc": "^0.24.1"
},
"engines": {
"node": ">=16"
}
},
"node_modules/@actions/core": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@actions/core/-/core-1.11.1.tgz",
"integrity": "sha512-hXJCSrkwfA46Vd9Z3q4cpEpHB1rL5NG04+/rbqW9d3+CSvtB1tYe8UTpAlixa1vj0m/ULglfEK2UKxMGxCxv5A==",
"license": "MIT",
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@actions/core/-/core-1.10.0.tgz",
"integrity": "sha512-2aZDDa3zrrZbP5ZYg159sNoLRb61nQ7awl5pSvIq5Qpj81vwDzdMRKzkWJGJuwVvWpvZKx7vspJALyvaaIQyug==",
"dependencies": {
"@actions/exec": "^1.1.1",
"@actions/http-client": "^2.0.1"
}
},
"node_modules/@actions/exec": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@actions/exec/-/exec-1.1.1.tgz",
"integrity": "sha512-+sCcHHbVdk93a0XT19ECtO/gIXoxvdsgQLzb2fE2/5sIZmWQuluYyjPQtrtTHdU1YzTZ7bAPN4sITq2xi1679w==",
"license": "MIT",
"dependencies": {
"@actions/io": "^1.0.1"
"@actions/http-client": "^2.0.1",
"uuid": "^8.3.2"
}
},
"node_modules/@actions/github": {
@@ -65,12 +55,6 @@
"tunnel": "^0.0.6"
}
},
"node_modules/@actions/io": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@actions/io/-/io-1.1.3.tgz",
"integrity": "sha512-wi9JjgKLYS7U/z8PPbco+PvTb/nRWjeoFlJ1Qer83k/3C5PHQi28hiVdeE2kHXmIL99mQFawx8qt/JPjZilJ8Q==",
"license": "MIT"
},
"node_modules/@octokit/auth-token": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/@octokit/auth-token/-/auth-token-2.5.0.tgz",
@@ -173,11 +157,10 @@
}
},
"node_modules/@vercel/ncc": {
"version": "0.38.4",
"resolved": "https://registry.npmjs.org/@vercel/ncc/-/ncc-0.38.4.tgz",
"integrity": "sha512-8LwjnlP39s08C08J5NstzriPvW1SP8Zfpp1BvC2sI35kPeZnHfxVkCwu4/+Wodgnd60UtT1n8K8zw+Mp7J9JmQ==",
"version": "0.24.1",
"resolved": "https://registry.npmjs.org/@vercel/ncc/-/ncc-0.24.1.tgz",
"integrity": "sha512-r9m7brz2hNmq5TF3sxrK4qR/FhXn44XIMglQUir4sT7Sh5GOaYXlMYikHFwJStf8rmQGTlvOoBXt4yHVonRG8A==",
"dev": true,
"license": "MIT",
"bin": {
"ncc": "dist/ncc/cli.js"
}
@@ -245,6 +228,14 @@
"resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-6.0.0.tgz",
"integrity": "sha512-isyNax3wXoKaulPDZWHQqbmIx1k2tb9fb3GGDBRxCscfYV2Ch7WxPArBsFEG8s/safwXTT7H4QGhaIkTp9447w=="
},
"node_modules/uuid": {
"version": "8.3.2",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
"bin": {
"uuid": "dist/bin/uuid"
}
},
"node_modules/webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",

View File

@@ -15,7 +15,7 @@
"@actions/github": "^4.0.0"
},
"devDependencies": {
"@vercel/ncc": "^0.38.0"
"@vercel/ncc": "^0.24.1"
},
"engines": {
"node": ">=16"

View File

@@ -1,11 +1,11 @@
{
"name": "node20",
"name": "node16",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "node20",
"name": "node16",
"version": "1.0.0",
"license": "ISC",
"dependencies": {
@@ -13,29 +13,19 @@
"@actions/github": "^4.0.0"
},
"devDependencies": {
"@vercel/ncc": "^0.38.0"
"@vercel/ncc": "^0.24.1"
},
"engines": {
"node": ">=20"
}
},
"node_modules/@actions/core": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@actions/core/-/core-1.11.1.tgz",
"integrity": "sha512-hXJCSrkwfA46Vd9Z3q4cpEpHB1rL5NG04+/rbqW9d3+CSvtB1tYe8UTpAlixa1vj0m/ULglfEK2UKxMGxCxv5A==",
"license": "MIT",
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@actions/core/-/core-1.10.0.tgz",
"integrity": "sha512-2aZDDa3zrrZbP5ZYg159sNoLRb61nQ7awl5pSvIq5Qpj81vwDzdMRKzkWJGJuwVvWpvZKx7vspJALyvaaIQyug==",
"dependencies": {
"@actions/exec": "^1.1.1",
"@actions/http-client": "^2.0.1"
}
},
"node_modules/@actions/exec": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@actions/exec/-/exec-1.1.1.tgz",
"integrity": "sha512-+sCcHHbVdk93a0XT19ECtO/gIXoxvdsgQLzb2fE2/5sIZmWQuluYyjPQtrtTHdU1YzTZ7bAPN4sITq2xi1679w==",
"license": "MIT",
"dependencies": {
"@actions/io": "^1.0.1"
"@actions/http-client": "^2.0.1",
"uuid": "^8.3.2"
}
},
"node_modules/@actions/github": {
@@ -65,12 +55,6 @@
"tunnel": "^0.0.6"
}
},
"node_modules/@actions/io": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@actions/io/-/io-1.1.3.tgz",
"integrity": "sha512-wi9JjgKLYS7U/z8PPbco+PvTb/nRWjeoFlJ1Qer83k/3C5PHQi28hiVdeE2kHXmIL99mQFawx8qt/JPjZilJ8Q==",
"license": "MIT"
},
"node_modules/@octokit/auth-token": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/@octokit/auth-token/-/auth-token-2.5.0.tgz",
@@ -173,11 +157,10 @@
}
},
"node_modules/@vercel/ncc": {
"version": "0.38.4",
"resolved": "https://registry.npmjs.org/@vercel/ncc/-/ncc-0.38.4.tgz",
"integrity": "sha512-8LwjnlP39s08C08J5NstzriPvW1SP8Zfpp1BvC2sI35kPeZnHfxVkCwu4/+Wodgnd60UtT1n8K8zw+Mp7J9JmQ==",
"version": "0.24.1",
"resolved": "https://registry.npmjs.org/@vercel/ncc/-/ncc-0.24.1.tgz",
"integrity": "sha512-r9m7brz2hNmq5TF3sxrK4qR/FhXn44XIMglQUir4sT7Sh5GOaYXlMYikHFwJStf8rmQGTlvOoBXt4yHVonRG8A==",
"dev": true,
"license": "MIT",
"bin": {
"ncc": "dist/ncc/cli.js"
}
@@ -245,6 +228,14 @@
"resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-6.0.0.tgz",
"integrity": "sha512-isyNax3wXoKaulPDZWHQqbmIx1k2tb9fb3GGDBRxCscfYV2Ch7WxPArBsFEG8s/safwXTT7H4QGhaIkTp9447w=="
},
"node_modules/uuid": {
"version": "8.3.2",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
"bin": {
"uuid": "dist/bin/uuid"
}
},
"node_modules/webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",

View File

@@ -15,7 +15,7 @@
"@actions/github": "^4.0.0"
},
"devDependencies": {
"@vercel/ncc": "^0.38.0"
"@vercel/ncc": "^0.24.1"
},
"engines": {
"node": ">=20"

View File

@@ -32,7 +32,7 @@ runs:
shell: bash
- uses: ./localdockerimagetest_
# Also test a remote docker action here
- uses: actions/hello-world-docker-action@v2
- uses: actions/hello-world-docker-action@v1
with:
who-to-greet: 'Mona the Octocat'
# Test if GITHUB_ACTION_PATH is set correctly after all steps

View File

@@ -38,6 +38,7 @@ func CompilePattern(rawpattern string) (*WorkflowPattern, error) {
}, nil
}
//nolint:gocyclo // function handles many cases
func PatternToRegex(pattern string) (string, error) {
var rpattern strings.Builder
rpattern.WriteString("^")

View File

@@ -1,4 +1,4 @@
# Usage Examples for `gitea-runner`
# Usage Examples for `runner`
Welcome to our collection of usage and deployment examples specifically designed for Gitea setups. Whether you're a beginner or an experienced user, you'll find practical resources here that you can directly apply to enhance your Gitea experience. We encourage you to contribute your own insights and knowledge to make this collection even more comprehensive and valuable.

View File

@@ -1,4 +1,4 @@
### Running `gitea-runner` using `docker-compose`
### Running `runner` using `docker-compose`
```yml
...
@@ -38,7 +38,7 @@
- GITEA_RUNNER_REGISTRATION_TOKEN=<registration token>
```
### Running `gitea-runner` using Docker-in-Docker (DIND)
### Running `runner` using Docker-in-Docker (DIND)
```yml
...

View File

@@ -1,4 +1,4 @@
### Run `gitea-runner` in a Docker Container
### Run `runner` in a Docker Container
```sh
docker run -e GITEA_INSTANCE_URL=http://192.168.8.18:3000 -e GITEA_RUNNER_REGISTRATION_TOKEN=<runner_token> -v /var/run/docker.sock:/var/run/docker.sock -v $PWD/data:/data --name my_runner gitea/runner:nightly

View File

@@ -1,4 +1,4 @@
## Kubernetes Docker in Docker Deployment with `gitea-runner`
## Kubernetes Docker in Docker Deployment with `runner`
NOTE: Docker in Docker (dind) requires elevated privileges on Kubernetes. The current way to achieve this is to set the pod `SecurityContext` to `privileged`. Keep in mind that this is a potential security issue that has the potential for a malicious application to break out of the container context.

View File

@@ -1,4 +1,4 @@
## `gitea-runner` on Virtual or Physical Servers
## `runner` on Virtual or Physical Servers
Files in this directory:

View File

@@ -1,12 +1,12 @@
## Using Rootless Docker with`gitea-runner`
## Using Rootless Docker with`runner`
Here is a simple example of how to set up `gitea-runner` with rootless Docker. It has been created with Debian, but other Linux should work the same way.
Here is a simple example of how to set up `runner` with rootless Docker. It has been created with Debian, but other Linux should work the same way.
Note: This procedure needs a real login shell -- using `sudo su` or other method of accessing the account will fail some of the steps below.
As `root`:
- Create a user to run both `docker` and `gitea-runner`. In this example, we use a non-privileged account called `rootless`.
- Create a user to run both `docker` and `runner`. In this example, we use a non-privileged account called `rootless`.
```bash
useradd -m rootless
@@ -38,24 +38,24 @@ export DOCKER_HOST=unix:///run/user/$(id -u)/docker.sock
```
- Reboot. Ensure that the Docker process is working.
- Create a directory for saving `gitea-runner` data between restarts
- Create a directory for saving `runner` data between restarts
`mkdir /home/rootless/gitea-runner`
`mkdir /home/rootless/runner`
- Register the runner from the data directory
```bash
cd /home/rootless/gitea-runner
gitea-runner register
cd /home/rootless/runner
runner register
```
- Generate a `gitea-runner` configuration file in the data directory. Edit the file to adjust for the system.
- Generate a `runner` configuration file in the data directory. Edit the file to adjust for the system.
```bash
gitea-runner generate-config >/home/rootless/gitea-runner/config
runner generate-config >/home/rootless/runner/config
```
- Create a new user-level`systemd` unit file as `/home/rootless/.config/systemd/user/gitea-runner.service` with the following contents:
- Create a new user-level`systemd` unit file as `/home/rootless/.config/systemd/user/runner.service` with the following contents:
```bash
Description=Gitea Actions runner
@@ -65,9 +65,9 @@ export DOCKER_HOST=unix:///run/user/$(id -u)/docker.sock
[Service]
Environment=PATH=/home/rootless/bin:/sbin:/usr/sbin:/home/rootless/bin:/home/rootless/bin:/home/rootless/bin:/usr/local/bin:/usr/bin:/bin:/usr/local/games:/usr/games
Environment=DOCKER_HOST=unix:///run/user/1001/docker.sock
ExecStart=/usr/bin/gitea-runner daemon -c /home/rootless/gitea-runner/config
ExecStart=/usr/bin/runner daemon -c /home/rootless/runner/config
ExecReload=/bin/kill -s HUP $MAINPID
WorkingDirectory=/home/rootless/gitea-runner
WorkingDirectory=/home/rootless/runner
TimeoutSec=0
RestartSec=2
Restart=always
@@ -88,8 +88,8 @@ export DOCKER_HOST=unix:///run/user/$(id -u)/docker.sock
- Reboot
After the system restarts, check that the`gitea-runner` is working and that the runner is connected to Gitea.
After the system restarts, check that the`runner` is working and that the runner is connected to Gitea.
````bash
systemctl --user status gitea-runner
journalctl --user -xeu gitea-runner
systemctl --user status runner
journalctl --user -xeu runner

30
go.mod
View File

@@ -4,16 +4,16 @@ go 1.26.0
require (
code.gitea.io/actions-proto-go v0.4.1
connectrpc.com/connect v1.19.2
connectrpc.com/connect v1.19.1
github.com/avast/retry-go/v4 v4.7.0
github.com/docker/docker v25.0.15+incompatible
github.com/docker/docker v25.0.13+incompatible
github.com/joho/godotenv v1.5.1
github.com/mattn/go-isatty v0.0.22
github.com/mattn/go-isatty v0.0.20
github.com/sirupsen/logrus v1.9.4
github.com/spf13/cobra v1.10.2
github.com/stretchr/testify v1.11.1
go.yaml.in/yaml/v4 v4.0.0-rc.3
golang.org/x/term v0.42.0
golang.org/x/term v0.40.0
golang.org/x/time v0.14.0 // indirect
google.golang.org/protobuf v1.36.11
gopkg.in/yaml.v3 v3.0.1 // indirect
@@ -24,7 +24,7 @@ require (
github.com/Masterminds/semver v1.5.0
github.com/creack/pty v1.1.24
github.com/distribution/reference v0.6.0
github.com/docker/cli v25.0.7+incompatible
github.com/docker/cli v25.0.3+incompatible
github.com/docker/go-connections v0.6.0
github.com/go-git/go-billy/v5 v5.8.0
github.com/go-git/go-git/v5 v5.18.0
@@ -33,12 +33,12 @@ require (
github.com/julienschmidt/httprouter v1.3.0
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51
github.com/moby/buildkit v0.13.2
github.com/moby/patternmatcher v0.6.1
github.com/moby/patternmatcher v0.6.0
github.com/opencontainers/image-spec v1.1.1
github.com/opencontainers/selinux v1.13.1
github.com/pkg/errors v0.9.1
github.com/prometheus/client_golang v1.23.2
github.com/rhysd/actionlint v1.7.12
github.com/rhysd/actionlint v1.7.11
github.com/spf13/pflag v1.0.10
github.com/timshannon/bolthold v0.0.0-20240314194003-30aac6950928
go.etcd.io/bbolt v1.4.3
@@ -62,7 +62,7 @@ require (
github.com/docker/docker-credential-helpers v0.9.5 // indirect
github.com/docker/go-units v0.5.0 // indirect
github.com/emirpasic/gods v1.18.1 // indirect
github.com/fatih/color v1.19.0 // indirect
github.com/fatih/color v1.18.0 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
github.com/go-logr/logr v1.4.3 // indirect
@@ -77,7 +77,7 @@ require (
github.com/klauspost/compress v1.18.4 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-runewidth v0.0.21 // indirect
github.com/mattn/go-runewidth v0.0.20 // indirect
github.com/mattn/go-shellwords v1.0.12 // indirect
github.com/mitchellh/mapstructure v1.1.2 // indirect
github.com/moby/sys/sequential v0.6.0 // indirect
@@ -105,12 +105,16 @@ require (
go.opentelemetry.io/otel/metric v1.40.0 // indirect
go.opentelemetry.io/otel/trace v1.40.0 // indirect
go.yaml.in/yaml/v2 v2.4.2 // indirect
golang.org/x/crypto v0.49.0 // indirect
golang.org/x/net v0.52.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.43.0 // indirect
golang.org/x/crypto v0.48.0 // indirect
golang.org/x/net v0.50.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.41.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1 // indirect
google.golang.org/grpc v1.67.0 // indirect
gopkg.in/warnings.v0 v0.1.2 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
)
// Remove after github.com/docker/distribution is updated to support distribution/reference v0.6.0
// (pulled in via moby/buildkit, breaks on undefined: reference.SplitHostname)
replace github.com/distribution/reference v0.6.0 => github.com/distribution/reference v0.5.0

40
go.sum
View File

@@ -1,7 +1,7 @@
code.gitea.io/actions-proto-go v0.4.1 h1:l0EYhjsgpUe/1VABo2eK7zcoNX2W44WOnb0MSLrKfls=
code.gitea.io/actions-proto-go v0.4.1/go.mod h1:mn7Wkqz6JbnTOHQpot3yDeHx+O5C9EGhMEE+htvHBas=
connectrpc.com/connect v1.19.2 h1:McQ83FGdzL+t60peksi0gXC7MQ/iLKgLduAnThbM0mo=
connectrpc.com/connect v1.19.2/go.mod h1:tN20fjdGlewnSFeZxLKb0xwIZ6ozc3OQs2hTXy4du9w=
connectrpc.com/connect v1.19.1 h1:R5M57z05+90EfEvCY1b7hBxDVOUl45PrtXtAV2fOC14=
connectrpc.com/connect v1.19.1/go.mod h1:tN20fjdGlewnSFeZxLKb0xwIZ6ozc3OQs2hTXy4du9w=
cyphar.com/go-pathrs v0.2.3 h1:0pH8gep37wB0BgaXrEaN1OtZhUMeS7VvaejSr6i822o=
cyphar.com/go-pathrs v0.2.3/go.mod h1:y8f1EMG7r+hCuFf/rXsKqMJrJAUoADZGNh5/vZPKcGc=
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
@@ -49,16 +49,12 @@ github.com/cyphar/filepath-securejoin v0.6.1/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/docker/cli v25.0.7+incompatible h1:scW/AbGafKmANsonsFckFHTwpz2QypoPA/zpoLnDs/E=
github.com/docker/cli v25.0.7+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
github.com/distribution/reference v0.5.0 h1:/FUIFXtfc/x2gpa5/VGfiGLuOIdYa1t65IKK2OFGvA0=
github.com/distribution/reference v0.5.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/docker/cli v25.0.3+incompatible h1:KLeNs7zws74oFuVhgZQ5ONGZiXUUdgsdy6/EsX/6284=
github.com/docker/cli v25.0.3+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
github.com/docker/docker v25.0.13+incompatible h1:YeBrkUd3q0ZoRDNoEzuopwCLU+uD8GZahDHwBdsTnkU=
github.com/docker/docker v25.0.13+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/docker/docker v25.0.14+incompatible h1:+HNue3fKbqiDHYFAriyiMjfS5u25zB0E2/R8f42lOMc=
github.com/docker/docker v25.0.14+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/docker/docker v25.0.15+incompatible h1:JhRD6vZdk0Ms3SEMztefBISJL13NbxudQnGix6l+T5M=
github.com/docker/docker v25.0.15+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/docker/docker-credential-helpers v0.9.5 h1:EFNN8DHvaiK8zVqFA2DT6BjXE0GzfLOZ38ggPTKePkY=
github.com/docker/docker-credential-helpers v0.9.5/go.mod h1:v1S+hepowrQXITkEfw6o4+BMbGot02wiKpzWhGUZK6c=
github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94=
@@ -71,8 +67,6 @@ github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc
github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w=
github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
@@ -137,12 +131,8 @@ github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHP
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
github.com/mattn/go-runewidth v0.0.20 h1:WcT52H91ZUAwy8+HUkdM3THM6gXqXuLJi9O3rjcQQaQ=
github.com/mattn/go-runewidth v0.0.20/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/mattn/go-runewidth v0.0.21 h1:jJKAZiQH+2mIinzCJIaIG9Be1+0NR+5sz/lYEEjdM8w=
github.com/mattn/go-runewidth v0.0.21/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/mattn/go-shellwords v1.0.12 h1:M2zGm7EW6UQJvDeQxo4T51eKPurbeFbe8WtebGE2xrk=
github.com/mattn/go-shellwords v1.0.12/go.mod h1:EZzvwXDESEeg03EKmM+RmDnNOPKG4lLtQsUlTZDWQ8Y=
github.com/mitchellh/mapstructure v1.1.2 h1:fmNYVwqnSfB9mZU6OS2O6GsXM+wcskZDuKQzvN1EDeE=
@@ -151,8 +141,6 @@ github.com/moby/buildkit v0.13.2 h1:nXNszM4qD9E7QtG7bFWPnDI1teUQFQglBzon/IU3SzI=
github.com/moby/buildkit v0.13.2/go.mod h1:2cyVOv9NoHM7arphK9ZfHIWKn9YVZRFd1wXB8kKmEzY=
github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk=
github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc=
github.com/moby/patternmatcher v0.6.1 h1:qlhtafmr6kgMIJjKJMDmMWq7WLkKIo23hsrpR3x084U=
github.com/moby/patternmatcher v0.6.1/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc=
github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU=
github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko=
github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs=
@@ -189,8 +177,6 @@ github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzM
github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
github.com/rhysd/actionlint v1.7.11 h1:m+aSuCpCIClS8X02xMG4Z8s87fCHPsAtYkAoWGQZgEE=
github.com/rhysd/actionlint v1.7.11/go.mod h1:8n50YougV9+50niD7oxgDTZ1KbN/ZnKiQ2xpLFeVhsI=
github.com/rhysd/actionlint v1.7.12 h1:vQ4GeJN86C0QH+gTUQcs8McmK62OLT3kmakPMtEWYnY=
github.com/rhysd/actionlint v1.7.12/go.mod h1:krOUhujIsJusovkaYzQ/VNH8PFexjNKqU0q5XI/4w+g=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
@@ -271,8 +257,6 @@ golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPh
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8=
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
@@ -284,15 +268,11 @@ golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwY
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -307,17 +287,9 @@ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY=
golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=

View File

@@ -4,7 +4,6 @@
package cmd
import (
"errors"
"fmt"
"os"
"os/signal"
@@ -48,15 +47,10 @@ func runCacheServer(configFile *string, cacheArgs *cacheServerArgs) func(cmd *co
port = cacheArgs.Port
}
secret := cfg.Cache.ExternalSecret
if secret == "" {
return errors.New("cache.external_secret must be set for cache-server; configure the same value on each runner that points at this server via cache.external_server")
}
cacheHandler, err := artifactcache.StartHandler(
dir,
host,
port,
secret,
log.StandardLogger().WithField("module", "cache_request"),
)
if err != nil {

View File

@@ -15,17 +15,18 @@ import (
)
func Execute(ctx context.Context) {
// ./gitea-runner
// ./runner
rootCmd := &cobra.Command{
Use: "gitea-runner",
Use: "runner [event name to run]\nIf no event name passed, will default to \"on: push\"",
Short: "Gitea Runner",
Args: cobra.MaximumNArgs(1),
Version: ver.Version(),
SilenceUsage: true,
}
configFile := ""
rootCmd.PersistentFlags().StringVarP(&configFile, "config", "c", "", "Config file path")
// ./gitea-runner register
// ./runner register
var regArgs registerArgs
registerCmd := &cobra.Command{
Use: "register",
@@ -41,7 +42,7 @@ func Execute(ctx context.Context) {
registerCmd.Flags().BoolVar(&regArgs.Ephemeral, "ephemeral", false, "Configure the runner to be ephemeral and only ever be able to pick a single job (stricter than --once)")
rootCmd.AddCommand(registerCmd)
// ./gitea-runner daemon
// ./runner daemon
var daemArgs daemonArgs
daemonCmd := &cobra.Command{
Use: "daemon",
@@ -52,10 +53,10 @@ func Execute(ctx context.Context) {
daemonCmd.Flags().BoolVar(&daemArgs.Once, "once", false, "Run one job then exit")
rootCmd.AddCommand(daemonCmd)
// ./gitea-runner exec
// ./runner exec
rootCmd.AddCommand(loadExecCmd(ctx))
// ./gitea-runner config
// ./runner config
rootCmd.AddCommand(&cobra.Command{
Use: "generate-config",
Short: "Generate an example config file",
@@ -65,7 +66,7 @@ func Execute(ctx context.Context) {
},
})
// ./gitea-runner cache-server
// ./runner cache-server
var cacheArgs cacheServerArgs
cacheCmd := &cobra.Command{
Use: "cache-server",

View File

@@ -6,8 +6,6 @@ package cmd
import (
"context"
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"maps"
@@ -370,7 +368,7 @@ func runExec(ctx context.Context, execArgs *executeArgs) func(cmd *cobra.Command
}
// init a cache server
handler, err := artifactcache.StartHandler("", "", 0, "", log.StandardLogger().WithField("module", "cache_request"))
handler, err := artifactcache.StartHandler("", "", 0, log.StandardLogger().WithField("module", "cache_request"))
if err != nil {
return err
}
@@ -395,25 +393,6 @@ func runExec(ctx context.Context, execArgs *executeArgs) func(cmd *cobra.Command
execArgs.artifactServerPath = tempDir
}
// Register ACTIONS_RUNTIME_TOKEN against local cache server
env := execArgs.LoadEnvs()
const actionsRuntimeTokenEnvName = "ACTIONS_RUNTIME_TOKEN"
actionsRuntimeToken := env[actionsRuntimeTokenEnvName]
if actionsRuntimeToken == "" {
actionsRuntimeToken = os.Getenv(actionsRuntimeTokenEnvName)
}
if actionsRuntimeToken == "" {
tmpBranch := make([]byte, 12)
if _, err := rand.Read(tmpBranch); err != nil {
actionsRuntimeToken = "token"
} else {
actionsRuntimeToken = hex.EncodeToString(tmpBranch)
}
env[actionsRuntimeTokenEnvName] = actionsRuntimeToken
os.Setenv(actionsRuntimeTokenEnvName, actionsRuntimeToken)
}
handler.RegisterJob(actionsRuntimeToken, "__local/__exec")
// run the plan
config := &runner.Config{
Workdir: execArgs.Workdir(),
@@ -423,7 +402,7 @@ func runExec(ctx context.Context, execArgs *executeArgs) func(cmd *cobra.Command
ForceRebuild: execArgs.forceRebuild,
LogOutput: true,
JSONLogger: execArgs.jsonLogger,
Env: env,
Env: execArgs.LoadEnvs(),
Vars: execArgs.LoadVars(),
Secrets: execArgs.LoadSecrets(),
InsecureSecrets: execArgs.insecureSecrets,

View File

@@ -27,11 +27,6 @@ type TaskRunner interface {
Run(ctx context.Context, task *runnerv1.Task) error
}
// IdleRunner can run maintenance while the poller is idle.
type IdleRunner interface {
OnIdle(ctx context.Context)
}
type Poller struct {
client client.Client
runner TaskRunner
@@ -100,7 +95,6 @@ func (p *Poller) Poll() {
task, ok := p.fetchTask(p.pollingCtx, s)
if !ok {
p.runIdleMaintenance()
<-sem
if !p.waitBackoff(s) {
return
@@ -125,7 +119,6 @@ func (p *Poller) PollOnce() {
for {
task, ok := p.fetchTask(p.pollingCtx, s)
if !ok {
p.runIdleMaintenance()
if !p.waitBackoff(s) {
return
}
@@ -137,12 +130,6 @@ func (p *Poller) PollOnce() {
}
}
func (p *Poller) runIdleMaintenance() {
if idleRunner, ok := p.runner.(IdleRunner); ok {
idleRunner.OnIdle(p.jobsCtx)
}
}
func (p *Poller) Shutdown(ctx context.Context) error {
p.shutdownPolling()

View File

@@ -125,11 +125,6 @@ type mockRunner struct {
totalCompleted atomic.Int64
}
type idleAwareRunner struct {
mockRunner
idleCalls atomic.Int64
}
func (m *mockRunner) Run(ctx context.Context, _ *runnerv1.Task) error {
atomicMax(&m.maxConcurrent, m.running.Add(1))
select {
@@ -141,78 +136,6 @@ func (m *mockRunner) Run(ctx context.Context, _ *runnerv1.Task) error {
return nil
}
func TestPollerRunIdleMaintenance(t *testing.T) {
runner := &idleAwareRunner{}
p := &Poller{runner: runner, jobsCtx: context.Background()}
p.runIdleMaintenance()
assert.Equal(t, int64(1), runner.idleCalls.Load())
}
func (m *idleAwareRunner) OnIdle(_ context.Context) {
m.idleCalls.Add(1)
}
func TestPollerPollCallsOnIdle(t *testing.T) {
cli := mocks.NewClient(t)
cli.On("FetchTask", mock.Anything, mock.Anything).Return(
func(_ context.Context, _ *connect_go.Request[runnerv1.FetchTaskRequest]) (*connect_go.Response[runnerv1.FetchTaskResponse], error) {
return connect_go.NewResponse(&runnerv1.FetchTaskResponse{}), nil
},
)
cfg, err := config.LoadDefault("")
require.NoError(t, err)
cfg.Runner.Capacity = 1
cfg.Runner.FetchInterval = 10 * time.Millisecond
cfg.Runner.FetchIntervalMax = 10 * time.Millisecond
runner := &idleAwareRunner{}
poller := New(cfg, cli, runner)
var wg sync.WaitGroup
wg.Go(poller.Poll)
require.Eventually(t, func() bool {
return runner.idleCalls.Load() > 0
}, time.Second, 10*time.Millisecond)
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
require.NoError(t, poller.Shutdown(ctx))
wg.Wait()
}
func TestPollerPollOnceCallsOnIdle(t *testing.T) {
cli := mocks.NewClient(t)
cli.On("FetchTask", mock.Anything, mock.Anything).Return(
func(_ context.Context, _ *connect_go.Request[runnerv1.FetchTaskRequest]) (*connect_go.Response[runnerv1.FetchTaskResponse], error) {
return connect_go.NewResponse(&runnerv1.FetchTaskResponse{}), nil
},
)
cfg, err := config.LoadDefault("")
require.NoError(t, err)
cfg.Runner.FetchInterval = 10 * time.Millisecond
cfg.Runner.FetchIntervalMax = 10 * time.Millisecond
runner := &idleAwareRunner{}
poller := New(cfg, cli, runner)
var wg sync.WaitGroup
wg.Go(poller.PollOnce)
require.Eventually(t, func() bool {
return runner.idleCalls.Load() > 0
}, time.Second, 10*time.Millisecond)
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
require.NoError(t, poller.Shutdown(ctx))
wg.Wait()
}
// TestPoller_ConcurrencyLimitedByCapacity verifies that with capacity=3 and
// 6 available tasks, at most 3 tasks run concurrently, and FetchTask is
// never called concurrently (single poller).

View File

@@ -4,17 +4,12 @@
package run
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"maps"
"net/http"
"os"
"path/filepath"
"runtime"
"strconv"
"strings"
"sync"
"sync/atomic"
@@ -43,15 +38,12 @@ type Runner struct {
cfg *config.Config
client client.Client
labels labels.Labels
envs map[string]string
cacheHandler *artifactcache.Handler
client client.Client
labels labels.Labels
envs map[string]string
runningTasks sync.Map
runningCount atomic.Int64
lastIdleCleanupUnixNano atomic.Int64
now func() time.Time
runningTasks sync.Map
runningCount atomic.Int64
}
func NewRunner(cfg *config.Config, reg *config.Registration, cli client.Client) *Runner {
@@ -63,24 +55,21 @@ func NewRunner(cfg *config.Config, reg *config.Registration, cli client.Client)
}
envs := make(map[string]string, len(cfg.Runner.Envs))
maps.Copy(envs, cfg.Runner.Envs)
var cacheHandler *artifactcache.Handler
if cfg.Cache.Enabled == nil || *cfg.Cache.Enabled {
if cfg.Cache.ExternalServer != "" {
envs["ACTIONS_CACHE_URL"] = cfg.Cache.ExternalServer
} else {
handler, err := artifactcache.StartHandler(
cacheHandler, err := artifactcache.StartHandler(
cfg.Cache.Dir,
cfg.Cache.Host,
cfg.Cache.Port,
"",
log.StandardLogger().WithField("module", "cache_request"),
)
if err != nil {
log.Errorf("cannot init cache server, it will be disabled: %v", err)
// go on
} else {
cacheHandler = handler
envs["ACTIONS_CACHE_URL"] = handler.ExternalURL() + "/"
envs["ACTIONS_CACHE_URL"] = cacheHandler.ExternalURL() + "/"
}
}
}
@@ -94,94 +83,12 @@ func NewRunner(cfg *config.Config, reg *config.Registration, cli client.Client)
envs["GITEA_ACTIONS"] = "true"
envs["GITEA_ACTIONS_RUNNER_VERSION"] = ver.Version()
runner := &Runner{
name: reg.Name,
cfg: cfg,
client: cli,
labels: ls,
envs: envs,
cacheHandler: cacheHandler,
now: time.Now,
}
return runner
}
// OnIdle performs lightweight maintenance during polling idle windows.
// It runs synchronously on the poller goroutine; shouldRunIdleCleanup
// throttles invocations to runner.idle_cleanup_interval so the impact on
// poll cadence is bounded even when the workdir root is large.
func (r *Runner) OnIdle(ctx context.Context) {
if !r.shouldRunIdleCleanup() {
return
}
workdirParent := strings.TrimLeft(r.cfg.Container.WorkdirParent, "/")
workdirRoot := filepath.FromSlash("/" + workdirParent)
r.cleanupStaleTaskDirs(ctx, workdirRoot)
}
func (r *Runner) shouldRunIdleCleanup() bool {
if !r.cfg.Container.BindWorkdir {
return false
}
if r.cfg.Runner.WorkdirCleanupAge <= 0 || r.cfg.Runner.IdleCleanupInterval <= 0 {
return false
}
if r.RunningCount() != 0 {
return false
}
now := r.now()
interval := r.cfg.Runner.IdleCleanupInterval
for {
last := r.lastIdleCleanupUnixNano.Load()
if last != 0 && now.Sub(time.Unix(0, last)) < interval {
return false
}
if r.lastIdleCleanupUnixNano.CompareAndSwap(last, now.UnixNano()) {
return true
}
}
}
func (r *Runner) cleanupStaleTaskDirs(ctx context.Context, workdirRoot string) {
entries, err := os.ReadDir(workdirRoot)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return
}
log.Warnf("failed to list task workspace root %s for stale cleanup: %v", workdirRoot, err)
return
}
// A task may begin between shouldRunIdleCleanup's running-count check and
// the loop below. That is safe because new task dirs are created with the
// current mtime and therefore fall on the keep side of cutoff.
cutoff := r.now().Add(-r.cfg.Runner.WorkdirCleanupAge)
for _, entry := range entries {
if err := ctx.Err(); err != nil {
return
}
if !entry.IsDir() {
continue
}
// Task workspaces are indexed by numeric task IDs; skip any other
// directories to avoid deleting operator-managed data under workdir_root.
if _, err := strconv.ParseUint(entry.Name(), 10, 64); err != nil {
continue
}
info, err := entry.Info()
if err != nil {
log.Warnf("failed to stat task workspace %s: %v", filepath.Join(workdirRoot, entry.Name()), err)
continue
}
if info.ModTime().After(cutoff) {
continue
}
taskDir := filepath.Join(workdirRoot, entry.Name())
if err := os.RemoveAll(taskDir); err != nil {
log.Warnf("failed to clean stale task workspace %s: %v", taskDir, err)
continue
}
log.Infof("cleaned stale task workspace %s", taskDir)
return &Runner{
name: reg.Name,
cfg: cfg,
client: cli,
labels: ls,
envs: envs,
}
}
@@ -292,21 +199,6 @@ func (r *Runner) run(ctx context.Context, task *runnerv1.Task, reporter *report.
giteaRuntimeToken = preset.Token
}
r.envs["ACTIONS_RUNTIME_TOKEN"] = giteaRuntimeToken
// Mask the runtime token so it cannot be echoed in user step output; it is
// now also the cache server's bearer credential and leaking it would let
// any reader of the log impersonate this job against the cache.
if giteaRuntimeToken != "" {
task.Secrets["ACTIONS_RUNTIME_TOKEN"] = giteaRuntimeToken
}
// Register this job's runtime token with the local cache server so that
// cache requests from the job container can authenticate. The credential
// is removed when the task finishes, so a leaked token stops working as
// soon as the job ends rather than remaining valid for the runner's
// lifetime. Only applies to the embedded cache server; when the operator
// points the runner at an external cache via cfg.Cache.ExternalServer, it
// is that server's responsibility to authenticate requests.
defer r.registerCacheForTask(giteaRuntimeToken, preset.Repository, reporter)()
eventJSON, err := json.Marshal(preset.Event)
if err != nil {
@@ -324,13 +216,6 @@ func (r *Runner) run(ctx context.Context, task *runnerv1.Task, reporter *report.
workdirParent = fmt.Sprintf("%s/%d", workdirParent, task.Id)
}
workdir := filepath.FromSlash(fmt.Sprintf("/%s/%s", workdirParent, preset.Repository))
if runtime.GOOS == "windows" {
if abs, err := filepath.Abs(workdir); err == nil {
workdir = abs
}
}
// Without bind_workdir, the workspace path omits the task id; concurrent host-mode jobs
// for the same repository would share this directory and can race with per-job cleanup.
runnerConfig := &runner.Config{
// On Linux, Workdir will be like "/<parent_directory>/<owner>/<repo>"
@@ -393,82 +278,6 @@ func (r *Runner) run(ctx context.Context, task *runnerv1.Task, reporter *report.
return execErr
}
// registerCacheForTask tells the cache server to accept requests authenticated
// with the given runtime token for the duration of this task. Returns a
// function the caller must invoke (typically via defer) to revoke the
// credential when the task finishes.
//
// Three modes:
// - Embedded handler: register in-process via RegisterJob.
// - external_server + external_secret: POST to the remote server's
// /_internal/register, defer a POST to /_internal/revoke. This is what
// enables full per-job auth and repo scoping over the network.
// - external_server alone (no secret): no-op revoker. The remote server is
// in legacy openMode and ignores the runtime token; trust is at the
// network layer.
//
// Safe with an empty token (older Gitea did not issue one).
func (r *Runner) registerCacheForTask(token, repo string, reporter *report.Reporter) func() {
if token == "" {
return func() {}
}
if r.cacheHandler != nil {
return r.cacheHandler.RegisterJob(token, repo)
}
if r.cfg.Cache.ExternalServer != "" && r.cfg.Cache.ExternalSecret != "" {
return r.registerExternalCacheJob(token, repo, reporter)
}
return func() {}
}
// registerExternalCacheJob POSTs to the remote cache-server's control-plane.
// Failures are logged but not fatal: if registration fails, the cache will
// 401 the job's requests — better than failing the whole task for a cache
// outage. The warning is mirrored to the job log so users can see why their
// cache calls 401, instead of having to read the runner daemon's stderr.
func (r *Runner) registerExternalCacheJob(token, repo string, reporter *report.Reporter) func() {
base := strings.TrimRight(r.cfg.Cache.ExternalServer, "/")
if err := postInternalCache(base+"/_internal/register", r.cfg.Cache.ExternalSecret,
map[string]string{"token": token, "repo": repo}); err != nil {
log.Warnf("cache external_server register failed (%s): %v", base, err)
if reporter != nil {
reporter.Logf("::warning::cache external_server register failed (%s): %v — cache requests from this job will be unauthenticated and likely return 401", base, err)
}
}
return func() {
if err := postInternalCache(base+"/_internal/revoke", r.cfg.Cache.ExternalSecret,
map[string]string{"token": token}); err != nil {
log.Warnf("cache external_server revoke failed (%s): %v", base, err)
if reporter != nil {
reporter.Logf("::warning::cache external_server revoke failed (%s): %v", base, err)
}
}
}
}
func postInternalCache(url, secret string, body map[string]string) error {
buf, err := json.Marshal(body)
if err != nil {
return err
}
req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(buf))
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+secret)
req.Header.Set("Content-Type", "application/json")
client := &http.Client{Timeout: 5 * time.Second}
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode/100 != 2 {
return fmt.Errorf("status %d", resp.StatusCode)
}
return nil
}
func (r *Runner) RunningCount() int64 {
return r.runningCount.Load()
}

View File

@@ -1,239 +0,0 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package run
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"path/filepath"
"strings"
"testing"
"gitea.com/gitea/runner/act/artifactcache"
"gitea.com/gitea/runner/internal/pkg/config"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func emptyCfg() *config.Config { return &config.Config{} }
func TestRunner_registerCacheForTask(t *testing.T) {
dir := filepath.Join(t.TempDir(), "artifactcache")
handler, err := artifactcache.StartHandler(dir, "127.0.0.1", 0, "", nil)
require.NoError(t, err)
defer handler.Close()
r := &Runner{cfg: emptyCfg(), cacheHandler: handler}
token := "run-token-123"
unregister := r.registerCacheForTask(token, "owner/repo", nil)
base := handler.ExternalURL() + "/_apis/artifactcache"
probe := func() int {
req, err := http.NewRequest(http.MethodGet, base+"/cache?keys=x&version=v", nil)
require.NoError(t, err)
req.Header.Set("Authorization", "Bearer "+token)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
resp.Body.Close()
return resp.StatusCode
}
assert.NotEqual(t, http.StatusUnauthorized, probe(),
"token should be accepted while task is registered")
unregister()
assert.Equal(t, http.StatusUnauthorized, probe(),
"token must be rejected after the revoker runs")
}
func TestRunner_registerCacheForTask_NoOps(t *testing.T) {
t.Run("nil cacheHandler", func(t *testing.T) {
r := &Runner{cfg: emptyCfg()}
unregister := r.registerCacheForTask("tok", "owner/repo", nil)
require.NotNil(t, unregister)
unregister()
})
t.Run("empty token", func(t *testing.T) {
dir := filepath.Join(t.TempDir(), "artifactcache")
handler, err := artifactcache.StartHandler(dir, "127.0.0.1", 0, "", nil)
require.NoError(t, err)
defer handler.Close()
r := &Runner{cfg: emptyCfg(), cacheHandler: handler}
unregister := r.registerCacheForTask("", "owner/repo", nil)
require.NotNil(t, unregister)
unregister()
})
}
// Locks in @actions/cache's wire protocol: bearer on reserve/upload/commit
// /find, no auth on the signed archiveLocation download.
func TestRunner_CacheFullFlow_MatchesToolkit(t *testing.T) {
dir := filepath.Join(t.TempDir(), "artifactcache")
handler, err := artifactcache.StartHandler(dir, "127.0.0.1", 0, "", nil)
require.NoError(t, err)
defer handler.Close()
r := &Runner{cfg: emptyCfg(), cacheHandler: handler}
token := "full-flow-token"
unregister := r.registerCacheForTask(token, "owner/repo", nil)
defer unregister()
base := handler.ExternalURL() + "/_apis/artifactcache"
do := func(method, url, contentType, contentRange, body string) *http.Response {
req, err := http.NewRequest(method, url, strings.NewReader(body))
require.NoError(t, err)
req.Header.Set("Authorization", "Bearer "+token)
if contentType != "" {
req.Header.Set("Content-Type", contentType)
}
if contentRange != "" {
req.Header.Set("Content-Range", contentRange)
}
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
return resp
}
key := "toolkit-flow"
version := "c19da02a2bd7e77277f1ac29ab45c09b7d46a4ee758284e26bb3045ad11d9d20"
body := `hello-cache-body`
// reserve
resp := do(http.MethodPost, base+"/caches", "application/json", "",
fmt.Sprintf(`{"key":"%s","version":"%s","cacheSize":%d}`, key, version, len(body)))
require.Equal(t, http.StatusOK, resp.StatusCode)
var reserved struct {
CacheID uint64 `json:"cacheId"`
}
require.NoError(t, decodeJSON(resp, &reserved))
require.NotZero(t, reserved.CacheID)
// upload
resp = do(http.MethodPatch, fmt.Sprintf("%s/caches/%d", base, reserved.CacheID),
"application/octet-stream", fmt.Sprintf("bytes 0-%d/*", len(body)-1), body)
require.Equal(t, http.StatusOK, resp.StatusCode)
resp.Body.Close()
// commit
resp = do(http.MethodPost, fmt.Sprintf("%s/caches/%d", base, reserved.CacheID), "", "", "")
require.Equal(t, http.StatusOK, resp.StatusCode)
resp.Body.Close()
// find — @actions/cache always sends comma-separated keys here
resp = do(http.MethodGet,
fmt.Sprintf("%s/cache?keys=%s,fallback&version=%s", base, key, version), "", "", "")
require.Equal(t, http.StatusOK, resp.StatusCode)
var hit struct {
ArchiveLocation string `json:"archiveLocation"`
CacheKey string `json:"cacheKey"`
}
require.NoError(t, decodeJSON(resp, &hit))
require.Equal(t, key, hit.CacheKey)
require.NotEmpty(t, hit.ArchiveLocation)
// download — toolkit does NOT attach Authorization here; the signature
// in the URL must be enough.
dl, err := http.Get(hit.ArchiveLocation)
require.NoError(t, err)
defer dl.Body.Close()
require.Equal(t, http.StatusOK, dl.StatusCode)
got := make([]byte, 64)
n, _ := dl.Body.Read(got)
assert.Equal(t, body, string(got[:n]))
}
func decodeJSON(resp *http.Response, v any) error {
defer resp.Body.Close()
return json.NewDecoder(resp.Body).Decode(v)
}
// End-to-end against a remote cache-server: token unknown → 401, register →
// reserve/upload/commit/find/download all OK, revoke → 401 again.
func TestRunner_ExternalCacheServer_RegisterRevoke(t *testing.T) {
dir := filepath.Join(t.TempDir(), "remote-cache")
const secret = "shared-secret-for-tests"
remote, err := artifactcache.StartHandler(dir, "127.0.0.1", 0, secret, nil)
require.NoError(t, err)
defer remote.Close()
r := &Runner{cfg: &config.Config{Cache: config.Cache{
ExternalServer: remote.ExternalURL(),
ExternalSecret: secret,
}}}
token := "external-task-token"
repo := "owner/repoX"
base := remote.ExternalURL() + "/_apis/artifactcache"
probe := func() int {
req, _ := http.NewRequest(http.MethodGet, base+"/cache?keys=k&version=v", nil)
req.Header.Set("Authorization", "Bearer "+token)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
resp.Body.Close()
return resp.StatusCode
}
require.Equal(t, http.StatusUnauthorized, probe(),
"token must be unknown to the remote server before registration")
unregister := r.registerCacheForTask(token, repo, nil)
require.NotEqual(t, http.StatusUnauthorized, probe(),
"token must be accepted after registerCacheForTask")
// Full reserve→upload→commit→find→download cycle, identical to what
// @actions/cache does, against the remote (external) server.
body := []byte("payload-from-task")
reserveBody, _ := json.Marshal(&artifactcache.Request{Key: "ext-key", Version: "v", Size: int64(len(body))})
req, _ := http.NewRequest(http.MethodPost, base+"/caches", bytes.NewReader(reserveBody))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
var reserved struct {
CacheID uint64 `json:"cacheId"`
}
require.NoError(t, decodeJSON(resp, &reserved))
require.NotZero(t, reserved.CacheID)
req, _ = http.NewRequest(http.MethodPatch, fmt.Sprintf("%s/caches/%d", base, reserved.CacheID), bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Range", fmt.Sprintf("bytes 0-%d/*", len(body)-1))
resp, err = http.DefaultClient.Do(req)
require.NoError(t, err)
resp.Body.Close()
require.Equal(t, http.StatusOK, resp.StatusCode)
req, _ = http.NewRequest(http.MethodPost, fmt.Sprintf("%s/caches/%d", base, reserved.CacheID), nil)
req.Header.Set("Authorization", "Bearer "+token)
resp, err = http.DefaultClient.Do(req)
require.NoError(t, err)
resp.Body.Close()
require.Equal(t, http.StatusOK, resp.StatusCode)
req, _ = http.NewRequest(http.MethodGet, base+"/cache?keys=ext-key&version=v", nil)
req.Header.Set("Authorization", "Bearer "+token)
resp, err = http.DefaultClient.Do(req)
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
var hit struct {
ArchiveLocation string `json:"archiveLocation"`
}
require.NoError(t, decodeJSON(resp, &hit))
require.NotEmpty(t, hit.ArchiveLocation)
dl, err := http.Get(hit.ArchiveLocation)
require.NoError(t, err)
defer dl.Body.Close()
require.Equal(t, http.StatusOK, dl.StatusCode)
unregister()
assert.Equal(t, http.StatusUnauthorized, probe(),
"token must be rejected after the revoker runs")
}

View File

@@ -1,247 +0,0 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package run
import (
"context"
"os"
"path/filepath"
"strconv"
"testing"
"time"
"gitea.com/gitea/runner/internal/pkg/config"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestRunnerCleanupStaleTaskDirs(t *testing.T) {
now := time.Date(2026, time.April, 29, 20, 0, 0, 0, time.UTC)
workdirRoot := filepath.Join(t.TempDir(), "workspace")
require.NoError(t, os.MkdirAll(workdirRoot, 0o700))
oldTask := filepath.Join(workdirRoot, "1001")
freshTask := filepath.Join(workdirRoot, "1002")
nonTask := filepath.Join(workdirRoot, "shared")
alphaNumericTask := filepath.Join(workdirRoot, "123abc")
for _, path := range []string{oldTask, freshTask, nonTask, alphaNumericTask} {
require.NoError(t, os.MkdirAll(path, 0o700))
}
require.NoError(t, os.Chtimes(oldTask, now.Add(-3*time.Hour), now.Add(-3*time.Hour)))
require.NoError(t, os.Chtimes(freshTask, now.Add(-30*time.Minute), now.Add(-30*time.Minute)))
require.NoError(t, os.Chtimes(nonTask, now.Add(-5*time.Hour), now.Add(-5*time.Hour)))
require.NoError(t, os.Chtimes(alphaNumericTask, now.Add(-5*time.Hour), now.Add(-5*time.Hour)))
r := &Runner{
cfg: &config.Config{
Runner: config.Runner{
WorkdirCleanupAge: 2 * time.Hour,
},
},
now: func() time.Time { return now },
}
r.cleanupStaleTaskDirs(context.Background(), workdirRoot)
assert.NoDirExists(t, oldTask)
assert.DirExists(t, freshTask)
assert.DirExists(t, nonTask)
assert.DirExists(t, alphaNumericTask)
}
func TestRunnerCleanupStaleTaskDirsMissingRoot(t *testing.T) {
r := &Runner{
cfg: &config.Config{
Runner: config.Runner{WorkdirCleanupAge: time.Hour},
},
now: time.Now,
}
// Must be a silent no-op rather than a warning or panic when the root
// has not yet been created (e.g. the runner has never executed a task).
r.cleanupStaleTaskDirs(context.Background(), filepath.Join(t.TempDir(), "missing"))
}
func TestRunnerCleanupStaleTaskDirsHonorsContext(t *testing.T) {
now := time.Date(2026, time.April, 29, 20, 0, 0, 0, time.UTC)
workdirRoot := filepath.Join(t.TempDir(), "workspace")
require.NoError(t, os.MkdirAll(workdirRoot, 0o700))
for i := 1001; i <= 1003; i++ {
dir := filepath.Join(workdirRoot, strconv.Itoa(i))
require.NoError(t, os.MkdirAll(dir, 0o700))
require.NoError(t, os.Chtimes(dir, now.Add(-3*time.Hour), now.Add(-3*time.Hour)))
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
r := &Runner{
cfg: &config.Config{
Runner: config.Runner{WorkdirCleanupAge: time.Hour},
},
now: func() time.Time { return now },
}
r.cleanupStaleTaskDirs(ctx, workdirRoot)
for i := 1001; i <= 1003; i++ {
assert.DirExists(t, filepath.Join(workdirRoot, strconv.Itoa(i)))
}
}
func TestRunnerShouldRunIdleCleanupThrottles(t *testing.T) {
now := time.Date(2026, time.April, 29, 20, 0, 0, 0, time.UTC)
r := &Runner{
cfg: &config.Config{
Container: config.Container{
BindWorkdir: true,
},
Runner: config.Runner{
WorkdirCleanupAge: 24 * time.Hour,
IdleCleanupInterval: time.Hour,
},
},
now: func() time.Time { return now },
}
assert.True(t, r.shouldRunIdleCleanup())
now = now.Add(30 * time.Minute)
assert.False(t, r.shouldRunIdleCleanup())
now = now.Add(31 * time.Minute)
assert.True(t, r.shouldRunIdleCleanup())
}
func TestRunnerShouldRunIdleCleanupSkipsWhenJobRunning(t *testing.T) {
r := &Runner{
cfg: &config.Config{
Container: config.Container{
BindWorkdir: true,
},
Runner: config.Runner{
WorkdirCleanupAge: 24 * time.Hour,
IdleCleanupInterval: time.Minute,
},
},
now: time.Now,
}
r.runningCount.Store(1)
assert.False(t, r.shouldRunIdleCleanup())
}
func TestRunnerShouldRunIdleCleanupSkipsWhenBindWorkdirDisabled(t *testing.T) {
r := &Runner{
cfg: &config.Config{
Runner: config.Runner{
WorkdirCleanupAge: 24 * time.Hour,
IdleCleanupInterval: time.Minute,
},
},
now: time.Now,
}
assert.False(t, r.shouldRunIdleCleanup())
}
func TestRunnerShouldRunIdleCleanupSkipsWhenDisabled(t *testing.T) {
now := time.Date(2026, time.April, 29, 20, 0, 0, 0, time.UTC)
t.Run("cleanup age disabled", func(t *testing.T) {
r := &Runner{
cfg: &config.Config{
Container: config.Container{
BindWorkdir: true,
},
Runner: config.Runner{
WorkdirCleanupAge: -1,
IdleCleanupInterval: time.Minute,
},
},
now: func() time.Time { return now },
}
assert.False(t, r.shouldRunIdleCleanup())
})
t.Run("idle interval disabled", func(t *testing.T) {
r := &Runner{
cfg: &config.Config{
Container: config.Container{
BindWorkdir: true,
},
Runner: config.Runner{
WorkdirCleanupAge: 24 * time.Hour,
IdleCleanupInterval: -1,
},
},
now: func() time.Time { return now },
}
assert.False(t, r.shouldRunIdleCleanup())
})
}
// TestRunnerOnIdleIntegratesCleanup wires the full OnIdle entry point and
// confirms it walks workdir_parent (after the leading-slash trim that
// matches the production path construction) and removes stale numeric dirs.
func TestRunnerOnIdleIntegratesCleanup(t *testing.T) {
now := time.Date(2026, time.April, 29, 20, 0, 0, 0, time.UTC)
root := t.TempDir()
stale := filepath.Join(root, "1234")
require.NoError(t, os.MkdirAll(stale, 0o700))
require.NoError(t, os.Chtimes(stale, now.Add(-48*time.Hour), now.Add(-48*time.Hour)))
r := &Runner{
cfg: &config.Config{
Container: config.Container{
BindWorkdir: true,
WorkdirParent: root, // leading slash absent, OnIdle reattaches it
},
Runner: config.Runner{
WorkdirCleanupAge: 24 * time.Hour,
IdleCleanupInterval: time.Minute,
},
},
now: func() time.Time { return now },
}
r.OnIdle(context.Background())
assert.NoDirExists(t, stale)
}
// TestRunnerOnIdleSkipsWhenAlreadyCancelled verifies a pre-cancelled ctx
// short-circuits cleanup before any directory entry is touched.
func TestRunnerOnIdleSkipsWhenAlreadyCancelled(t *testing.T) {
now := time.Date(2026, time.April, 29, 20, 0, 0, 0, time.UTC)
root := t.TempDir()
stale := filepath.Join(root, "1234")
require.NoError(t, os.MkdirAll(stale, 0o700))
require.NoError(t, os.Chtimes(stale, now.Add(-48*time.Hour), now.Add(-48*time.Hour)))
r := &Runner{
cfg: &config.Config{
Container: config.Container{
BindWorkdir: true,
WorkdirParent: root,
},
Runner: config.Runner{
WorkdirCleanupAge: 24 * time.Hour,
IdleCleanupInterval: time.Minute,
},
},
now: func() time.Time { return now },
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
r.OnIdle(ctx)
assert.DirExists(t, stale)
}

View File

@@ -1,14 +1,10 @@
# Example configuration file, it's safe to copy this as the default config file without any modification.
# You don't have to copy this file to your instance,
# just run `./gitea-runner generate-config > config.yaml` to generate a config file.
# just run `./runner generate-config > config.yaml` to generate a config file.
# Logging for the runner process itself (messages printed to stderr).
# This does not control how workflow step output is streamed to the Gitea UI;
# tune that with runner.log_report_* below.
log:
# logrus severity: trace, debug, info, warn, error, fatal, panic.
# trace and debug turn on caller/file:line in log lines. Default if omitted: info.
# The level of logging, can be trace, debug, info, warn, error, fatal
level: info
runner:
@@ -39,13 +35,7 @@ runner:
# The maximum interval for fetching the job from the Gitea instance.
# The runner uses exponential backoff when idle, increasing the interval up to this maximum.
# Set to 0 or same as fetch_interval to disable backoff.
fetch_interval_max: 5s
# While idle, remove stale bind-workdir task directories older than this duration.
# Setting either workdir_cleanup_age or idle_cleanup_interval to 0 (or any
# non-positive value) disables workdir cleanup entirely.
workdir_cleanup_age: 24h
# Cadence for the idle stale bind-workdir cleanup pass.
idle_cleanup_interval: 10m
fetch_interval_max: 60s
# The base interval for periodic log flush to the Gitea instance.
# Logs may be sent earlier if the buffer reaches log_report_batch_size
# or if log_report_max_latency expires after the first buffered row.
@@ -91,18 +81,12 @@ cache:
# The external cache server URL. Valid only when enable is true.
# If it's specified, runner will use this URL as the ACTIONS_CACHE_URL rather than start a server by itself.
# The URL should generally end with "/".
# Requires external_secret below to be set to the same value on both this runner and the cache-server.
external_server: ""
# Shared secret between this runner and the external `gitea-runner cache-server`. Required when external_server
# (or `gitea-runner cache-server`) is in use: the runner pre-registers each job's ACTIONS_RUNTIME_TOKEN with the
# cache-server, and the cache-server enforces bearer auth + per-repo cache isolation.
external_secret: ""
container:
# Specifies the network to which the container will connect.
# Could be host, bridge or the name of a custom network.
# If it's empty, runner will create a network automatically.
# Deprecated: `network_mode` is still accepted for old configs; use `network` instead.
network: ""
# Whether to use privileged mode or not when launching task containers (privileged mode is required for Docker-in-Docker).
privileged: false
@@ -113,7 +97,6 @@ container:
# If the path starts with '/', the '/' will be trimmed.
# For example, if the parent directory is /path/to/my/dir, workdir_parent should be path/to/my/dir
# If it's empty, /workspace will be used.
# Purely numeric subdirectories under this path are reserved for task workspaces and may be removed by idle cleanup.
workdir_parent:
# Volumes (including bind mounts) can be mounted to containers. Glob syntax is supported, see https://github.com/gobwas/glob
# You can specify multiple volumes. If the sequence is empty, no volumes can be mounted.

View File

@@ -4,7 +4,6 @@
package config
import (
"errors"
"fmt"
"maps"
"os"
@@ -33,8 +32,6 @@ type Runner struct {
FetchTimeout time.Duration `yaml:"fetch_timeout"` // FetchTimeout specifies the timeout duration for fetching resources.
FetchInterval time.Duration `yaml:"fetch_interval"` // FetchInterval specifies the interval duration for fetching resources.
FetchIntervalMax time.Duration `yaml:"fetch_interval_max"` // FetchIntervalMax specifies the maximum backoff interval when idle.
WorkdirCleanupAge time.Duration `yaml:"workdir_cleanup_age"` // WorkdirCleanupAge removes stale bind-workdir task directories older than this duration during idle cleanup.
IdleCleanupInterval time.Duration `yaml:"idle_cleanup_interval"` // IdleCleanupInterval runs stale bind-workdir cleanup periodically while the runner is idle. Set to 0 to disable cleanup cadence.
LogReportInterval time.Duration `yaml:"log_report_interval"` // LogReportInterval specifies the base interval for periodic log flush.
LogReportMaxLatency time.Duration `yaml:"log_report_max_latency"` // LogReportMaxLatency specifies the max time a log row can wait before being sent.
LogReportBatchSize int `yaml:"log_report_batch_size"` // LogReportBatchSize triggers immediate log flush when buffer reaches this size.
@@ -50,7 +47,6 @@ type Cache struct {
Host string `yaml:"host"` // Host specifies the caching host.
Port uint16 `yaml:"port"` // Port specifies the caching port.
ExternalServer string `yaml:"external_server"` // ExternalServer specifies the URL of external cache server
ExternalSecret string `yaml:"external_secret"` // ExternalSecret is a shared secret between this runner and an external gitea-runner cache-server, enabling per-job ACTIONS_RUNTIME_TOKEN authentication and repo scoping over the network. Leave empty to keep the legacy unauthenticated behavior.
}
// Container represents the configuration for the container.
@@ -94,7 +90,6 @@ type Config struct {
// If file is not empty, it will be used to load the configuration.
func LoadDefault(file string) (*Config, error) {
cfg := &Config{}
definedRunnerKeys := map[string]bool{}
if file != "" {
content, err := os.ReadFile(file)
if err != nil {
@@ -103,10 +98,6 @@ func LoadDefault(file string) (*Config, error) {
if err := yaml.Unmarshal(content, cfg); err != nil {
return nil, fmt.Errorf("parse config file %q: %w", file, err)
}
definedRunnerKeys, err = definedRunnerConfigKeys(content)
if err != nil {
return nil, fmt.Errorf("parse config file %q for defaults metadata: %w", file, err)
}
}
compatibleWithOldEnvs(file != "", cfg)
@@ -144,9 +135,6 @@ func LoadDefault(file string) (*Config, error) {
home, _ := os.UserHomeDir()
cfg.Cache.Dir = filepath.Join(home, ".cache", "actcache")
}
if cfg.Cache.ExternalServer != "" && cfg.Cache.ExternalSecret == "" {
return nil, errors.New("cache.external_server is set but cache.external_secret is empty; configure the same external_secret on this runner and the gitea-runner cache-server")
}
}
if cfg.Container.WorkdirParent == "" {
cfg.Container.WorkdirParent = "workspace"
@@ -162,13 +150,7 @@ func LoadDefault(file string) (*Config, error) {
cfg.Runner.FetchInterval = 2 * time.Second
}
if cfg.Runner.FetchIntervalMax <= 0 {
cfg.Runner.FetchIntervalMax = 5 * time.Second
}
if cfg.Runner.WorkdirCleanupAge == 0 && !definedRunnerKeys["workdir_cleanup_age"] {
cfg.Runner.WorkdirCleanupAge = 24 * time.Hour
}
if cfg.Runner.IdleCleanupInterval == 0 && !definedRunnerKeys["idle_cleanup_interval"] {
cfg.Runner.IdleCleanupInterval = 10 * time.Minute
cfg.Runner.FetchIntervalMax = 60 * time.Second
}
if cfg.Runner.LogReportInterval <= 0 {
cfg.Runner.LogReportInterval = 5 * time.Second
@@ -212,30 +194,3 @@ func LoadDefault(file string) (*Config, error) {
return cfg, nil
}
func definedRunnerConfigKeys(content []byte) (map[string]bool, error) {
var root yaml.Node
if err := yaml.Unmarshal(content, &root); err != nil {
return nil, err
}
defined := map[string]bool{}
if len(root.Content) == 0 {
return defined, nil
}
doc := root.Content[0]
for i := 0; i+1 < len(doc.Content); i += 2 {
key := doc.Content[i]
value := doc.Content[i+1]
if key.Value != "runner" || value.Kind != yaml.MappingNode {
continue
}
for j := 0; j+1 < len(value.Content); j += 2 {
defined[value.Content[j].Value] = true
}
break
}
return defined, nil
}

View File

@@ -1,119 +0,0 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package config
import (
"os"
"path/filepath"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestLoadDefault_RejectsExternalServerWithoutSecret(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.yaml")
require.NoError(t, os.WriteFile(path, []byte(`
cache:
enabled: true
external_server: "http://cache.invalid/"
`), 0o600))
_, err := LoadDefault(path)
require.Error(t, err)
assert.Contains(t, err.Error(), "external_secret")
}
func TestLoadDefault_AcceptsExternalServerWithSecret(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.yaml")
require.NoError(t, os.WriteFile(path, []byte(`
cache:
enabled: true
external_server: "http://cache.invalid/"
external_secret: "shh"
`), 0o600))
_, err := LoadDefault(path)
require.NoError(t, err)
}
func TestLoadDefault_DefaultsWorkdirCleanupAge(t *testing.T) {
cfg, err := LoadDefault("")
require.NoError(t, err)
assert.Equal(t, 24*time.Hour, cfg.Runner.WorkdirCleanupAge)
assert.Equal(t, 10*time.Minute, cfg.Runner.IdleCleanupInterval)
}
func TestLoadDefault_UsesConfiguredWorkdirCleanupAge(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.yaml")
require.NoError(t, os.WriteFile(path, []byte(`
runner:
workdir_cleanup_age: 2h30m
`), 0o600))
cfg, err := LoadDefault(path)
require.NoError(t, err)
assert.Equal(t, 150*time.Minute, cfg.Runner.WorkdirCleanupAge)
}
func TestLoadDefault_UsesConfiguredIdleCleanupInterval(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.yaml")
require.NoError(t, os.WriteFile(path, []byte(`
runner:
idle_cleanup_interval: 45m
`), 0o600))
cfg, err := LoadDefault(path)
require.NoError(t, err)
assert.Equal(t, 45*time.Minute, cfg.Runner.IdleCleanupInterval)
}
func TestLoadDefault_AllowsDisablingWorkdirCleanup(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.yaml")
require.NoError(t, os.WriteFile(path, []byte(`
runner:
workdir_cleanup_age: 0s
idle_cleanup_interval: 0s
`), 0o600))
cfg, err := LoadDefault(path)
require.NoError(t, err)
assert.Equal(t, time.Duration(0), cfg.Runner.WorkdirCleanupAge)
assert.Equal(t, time.Duration(0), cfg.Runner.IdleCleanupInterval)
}
func TestLoadDefault_AllowsNegativeWorkdirCleanupValues(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.yaml")
require.NoError(t, os.WriteFile(path, []byte(`
runner:
workdir_cleanup_age: -1s
idle_cleanup_interval: -1s
`), 0o600))
cfg, err := LoadDefault(path)
require.NoError(t, err)
assert.Equal(t, -1*time.Second, cfg.Runner.WorkdirCleanupAge)
assert.Equal(t, -1*time.Second, cfg.Runner.IdleCleanupInterval)
}
// TestLoadDefault_MalformedYAMLReturnsParseError pins the error surfaced for
// invalid YAML to the canonical "parse config file" message rather than the
// "for defaults metadata" variant — i.e. the main yaml.Unmarshal runs first.
func TestLoadDefault_MalformedYAMLReturnsParseError(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.yaml")
require.NoError(t, os.WriteFile(path, []byte("runner:\n capacity: [unterminated\n"), 0o600))
_, err := LoadDefault(path)
require.Error(t, err)
assert.Contains(t, err.Error(), "parse config file")
assert.NotContains(t, err.Error(), "defaults metadata")
}

View File

@@ -12,8 +12,8 @@ import (
"github.com/prometheus/client_golang/prometheus/collectors"
)
// Namespace is the Prometheus namespace for all gitea-runner metrics.
const Namespace = "gitea_runner"
// Namespace is the Prometheus namespace for all runner metrics.
const Namespace = "runner"
// Label value constants for Prometheus metrics.
// Using constants prevents typos from silently creating new time-series.

View File

@@ -10,7 +10,6 @@ import (
"regexp"
"strings"
"sync"
"sync/atomic"
"time"
"gitea.com/gitea/runner/internal/pkg/client"
@@ -49,10 +48,6 @@ type Reporter struct {
outputs sync.Map
daemon chan struct{}
// Unix-nanos of the last successful UpdateTask. Atomic so the heartbeat
// guard in ReportState reads it without contending stateMu.
lastReportedAtNanos atomic.Int64
// Adaptive batching control
logReportInterval time.Duration
logReportMaxLatency time.Duration
@@ -494,12 +489,8 @@ func (r *Reporter) ReportState(reportResult bool) error {
// Consume stateChanged atomically with the snapshot; restored on error
// below so a concurrent Fire() during UpdateTask isn't silently lost.
// Heartbeat at stateReportInterval even when nothing changed, so the server
// doesn't time out long-running silent jobs as orphaned (#826).
last := r.lastReportedAtNanos.Load()
withinHeartbeatInterval := last != 0 && time.Since(time.Unix(0, last)) < r.stateReportInterval
r.stateMu.Lock()
if !reportResult && !r.stateChanged && len(outputs) == 0 && withinHeartbeatInterval {
if !reportResult && !r.stateChanged && len(outputs) == 0 {
r.stateMu.Unlock()
return nil
}
@@ -526,7 +517,6 @@ func (r *Reporter) ReportState(reportResult bool) error {
return err
}
metrics.ReportStateTotal.WithLabelValues(metrics.LabelResultSuccess).Inc()
r.lastReportedAtNanos.Store(time.Now().UnixNano())
for _, k := range resp.Msg.SentOutputs {
r.outputs.Store(k, struct{}{})

View File

@@ -597,45 +597,3 @@ func TestReporter_StateNotifyFlush(t *testing.T) {
}, 500*time.Millisecond, 10*time.Millisecond,
"step transition should have triggered immediate state flush via stateNotify")
}
// TestReporter_StateHeartbeat verifies that ReportState sends a heartbeat
// UpdateTask once stateReportInterval has elapsed since the last successful
// report, even when nothing has changed. Without this, long-running silent
// jobs (no log output, no step transitions) cause the server to time the
// task out and cancel it (#826).
func TestReporter_StateHeartbeat(t *testing.T) {
var updateTaskCalls atomic.Int64
client := mocks.NewClient(t)
client.On("UpdateTask", mock.Anything, mock.Anything).Return(
func(_ context.Context, _ *connect_go.Request[runnerv1.UpdateTaskRequest]) (*connect_go.Response[runnerv1.UpdateTaskResponse], error) {
updateTaskCalls.Add(1)
return connect_go.NewResponse(&runnerv1.UpdateTaskResponse{}), nil
},
)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
taskCtx, err := structpb.NewStruct(map[string]any{})
require.NoError(t, err)
cfg, _ := config.LoadDefault("")
cfg.Runner.StateReportInterval = 50 * time.Millisecond
reporter := NewReporter(ctx, cancel, client, &runnerv1.Task{Context: taskCtx}, cfg)
reporter.ResetSteps(1)
// First call has no prior report — sends to seed lastReportedAt.
reporter.stateMu.Lock()
reporter.stateChanged = true
reporter.stateMu.Unlock()
require.NoError(t, reporter.ReportState(false))
require.Equal(t, int64(1), updateTaskCalls.Load())
// Second call immediately after with nothing changed — must skip.
require.NoError(t, reporter.ReportState(false))
assert.Equal(t, int64(1), updateTaskCalls.Load(), "no-op ReportState within stateReportInterval must skip")
// After stateReportInterval elapses, a heartbeat must fire even with no changes.
time.Sleep(2 * cfg.Runner.StateReportInterval)
require.NoError(t, reporter.ReportState(false))
assert.Equal(t, int64(2), updateTaskCalls.Load(), "ReportState must heartbeat after stateReportInterval even with no state change")
}

View File

@@ -41,7 +41,7 @@ if [[ ! -s "$RUNNER_STATE_FILE" ]]; then
# the context of a single docker-compose, something similar could be done via healthchecks, but
# this is more flexible.
while [[ $success -eq 0 ]] && [[ $try -lt ${GITEA_MAX_REG_ATTEMPTS:-10} ]]; do
gitea-runner register \
runner register \
--instance "${GITEA_INSTANCE_URL}" \
--token "${GITEA_RUNNER_REGISTRATION_TOKEN}" \
--name "${GITEA_RUNNER_NAME:-`hostname`}" \
@@ -57,8 +57,8 @@ if [[ ! -s "$RUNNER_STATE_FILE" ]]; then
fi
done
fi
# Prevent reading the token from the gitea-runner process
# Prevent reading the token from the runner process
unset GITEA_RUNNER_REGISTRATION_TOKEN
unset GITEA_RUNNER_REGISTRATION_TOKEN_FILE
exec gitea-runner daemon ${CONFIG_ARG} ${RUN_ARGS}
exec runner daemon ${CONFIG_ARG} ${RUN_ARGS}

View File

@@ -1,9 +0,0 @@
#!/usr/bin/env bash
while [ ! -d /etc/s6/docker/supervise ]; do
sleep 0.1
done
s6-svwait -U /etc/s6/docker
exec run.sh

5
scripts/s6/runner/run Executable file
View File

@@ -0,0 +1,5 @@
#!/usr/bin/env bash
s6-svwait -U /etc/s6/docker
exec run.sh