refactor: remove dead code and consolidate tests (#1335)

Removes dead code and redundant logic and consolidates tests, so there is less to read and maintain. No behavior change.

1. **act/runner**: inline single-use wrappers, compile regexes once instead of per call, embed the trampoline as a string, drop the unused legacy result of `parseActionCommand`
2. **act/container**: `sanitizeConfig` and `mergeContainerConfigs` stop returning the configs they already mutate in place, drop never-set option fields and the unused `user` parameter of `ExecWithCmdLine`
3. **act/common and cache server**: drop the unused `Trace`/`Tracef` loggers and the short SHA of `FindGitRevision`, `RegisterJob` releases through `RevokeJob`
4. **daemon, poller and reporter**: one shutdown wait for both poll modes, drop an unreachable fetch timeout branch, guard the reporter's last report time with its existing lock instead of an atomic
5. **build**: drop unused Makefile and release workflow variables, collapse the goreleaser ignore list to the same build targets
6. **tests**: fold near-duplicate tests into tables with shared setup

Throughout, hand-rolled logic gives way to `cmp.Or`, `strings.Cut`, `slices` and `min`/`max`.

Reviewed-on: https://gitea.com/gitea/runner/pulls/1335
Reviewed-by: bircni <[email protected]>
Co-authored-by: silverwind <[email protected]>
This commit is contained in:
silverwind authored and silverwind committed 2026-10-22 19:41:10 +00:00
1 parent 923a0bfb59
commit d882311ec6
107 files changed
+2408 -6500

No files matched your search

+6 -23
View File
@@ -4,6 +4,7 @@
package cmd
import (
"cmp"
"errors"
"fmt"
@@ -33,32 +34,14 @@ func runCacheServer(configFile *string, cacheArgs *cacheServerArgs) func(cmd *co
return errors.New("cache-server does not support cache.s3; set cache.s3 on the runners instead")
}
var (
dir = cfg.Cache.Dir
host = cfg.Cache.Host
port = cfg.Cache.Port
)
// cacheArgs has higher priority
if cacheArgs.Dir != "" {
dir = cacheArgs.Dir
}
if cacheArgs.Host != "" {
host = cacheArgs.Host
}
if cacheArgs.Port != 0 {
port = cacheArgs.Port
}
secret := cfg.Cache.ExternalSecret
if secret == "" {
if cfg.Cache.ExternalSecret == "" {
return errors.New("cache.external_secret (or cache.external_secret_file) 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(artifactcache.Options{
Dir: dir,
OutboundIP: host,
Port: port,
InternalSecret: secret,
Dir: cmp.Or(cacheArgs.Dir, cfg.Cache.Dir),
OutboundIP: cmp.Or(cacheArgs.Host, cfg.Cache.Host),
Port: cmp.Or(cacheArgs.Port, cfg.Cache.Port),
InternalSecret: cfg.Cache.ExternalSecret,
Policy: run.CachePolicy(cfg),
Logger: logging.Default().With("module", "cache_request"),
})
+12 -28
View File
@@ -163,8 +163,8 @@ func runDaemon(ctx context.Context, daemArgs *daemonArgs, configFile *string) fu
}
}
if !slices.Equal(reg.Labels, ls.ToStrings()) {
reg.Labels = ls.ToStrings()
if labelStrings := ls.ToStrings(); !slices.Equal(reg.Labels, labelStrings) {
reg.Labels = labelStrings
if err := config.SaveRegistration(cfg.Runner.File, reg); err != nil {
return fmt.Errorf("failed to save runner config: %w", err)
}
@@ -244,26 +244,14 @@ func runDaemon(ctx context.Context, daemArgs *daemonArgs, configFile *string) fu
}
if daemArgs.Once || reg.Ephemeral {
done := make(chan struct{})
go func() {
defer close(done)
poller.PollOnce(func() { removeEphemeralRegistration(reg, cfg.Runner.File) })
}()
// shutdown when we complete a job or cancel is requested
select {
case <-ctx.Done():
case <-done:
}
go poller.PollOnce(func() { removeEphemeralRegistration(reg, cfg.Runner.File) })
} else {
go poller.Poll()
}
// Stop either on an external cancellation or when the poller shuts
// itself down (e.g. after the runner has been unregistered).
select {
case <-ctx.Done():
case <-poller.Done():
}
select {
case <-ctx.Done():
case <-poller.Done():
}
logging.Infof("runner: %s shutdown initiated, waiting %s for running jobs to complete before shutting down", resp.Runner.Name, cfg.Runner.ShutdownTimeout)
@@ -329,13 +317,8 @@ func splitLabels(s string) []string {
// initLogging sets up the process-wide logger.
func initLogging(cfg *config.Config) {
callPrettyfier := func(f *runtime.Frame) (string, string) {
// get function name
s := strings.Split(f.Function, ".")
funcname := "[" + s[len(s)-1] + "]"
// get file name and line number
_, filename := path.Split(f.File)
filename = "[" + filename + ":" + strconv.Itoa(f.Line) + "]"
return funcname, filename
return "[" + f.Function[strings.LastIndexByte(f.Function, '.')+1:] + "]", "[" + filename + ":" + strconv.Itoa(f.Line) + "]"
}
isTerm := term.IsTerminal(int(os.Stdout.Fd()))
@@ -407,11 +390,12 @@ func getDockerSocketPath(configDockerHost string) (string, error) {
}
for _, p := range commonSocketPaths {
if _, err := os.Lstat(os.ExpandEnv(p)); err == nil {
socket := os.ExpandEnv(p)
if _, err := os.Lstat(socket); err == nil {
if strings.HasPrefix(p, `\\.\`) {
return "npipe://" + filepath.ToSlash(os.ExpandEnv(p)), nil
return "npipe://" + filepath.ToSlash(socket), nil
}
return "unix://" + filepath.ToSlash(os.ExpandEnv(p)), nil
return "unix://" + filepath.ToSlash(socket), nil
}
}
+43 -75
View File
@@ -203,12 +203,8 @@ func parseKVAndFile(rawKVs []string, filePath string) map[string]string {
_ = readEnvs(filePath, result)
for _, raw := range rawKVs {
parts := strings.SplitN(raw, "=", 2)
if len(parts) == 2 {
result[parts[0]] = parts[1]
} else {
result[parts[0]] = ""
}
key, value, _ := strings.Cut(raw, "=")
result[key] = value
}
return result
}
@@ -265,39 +261,25 @@ func printList(plan *model.Plan) {
for i, stage := range plan.Stages {
for _, r := range stage.Runs {
jobID := r.JobID
line := lineInfoDef{
jobID: jobID,
jobID: r.JobID,
jobName: r.String(),
stage: strconv.Itoa(i),
wfName: r.Workflow.Name,
wfFile: r.Workflow.File,
events: strings.Join(r.Workflow.On(), `,`),
}
if _, ok := jobs[jobID]; ok {
if jobs[r.JobID] {
duplicateJobIDs = true
} else {
jobs[jobID] = true
}
jobs[r.JobID] = true
lineInfos = append(lineInfos, line)
if jobIDMaxWidth < len(line.jobID) {
jobIDMaxWidth = len(line.jobID)
}
if jobNameMaxWidth < len(line.jobName) {
jobNameMaxWidth = len(line.jobName)
}
if stageMaxWidth < len(line.stage) {
stageMaxWidth = len(line.stage)
}
if wfNameMaxWidth < len(line.wfName) {
wfNameMaxWidth = len(line.wfName)
}
if wfFileMaxWidth < len(line.wfFile) {
wfFileMaxWidth = len(line.wfFile)
}
if eventsMaxWidth < len(line.events) {
eventsMaxWidth = len(line.events)
}
jobIDMaxWidth = max(jobIDMaxWidth, len(line.jobID))
jobNameMaxWidth = max(jobNameMaxWidth, len(line.jobName))
stageMaxWidth = max(stageMaxWidth, len(line.stage))
wfNameMaxWidth = max(wfNameMaxWidth, len(line.wfName))
wfFileMaxWidth = max(wfFileMaxWidth, len(line.wfFile))
eventsMaxWidth = max(eventsMaxWidth, len(line.events))
}
}
@@ -356,21 +338,15 @@ func runExecList(planner model.WorkflowPlanner, execArgs *executeArgs) error {
case execArgs.job != "":
logging.Infof("Preparing plan with a job: %s", execArgs.job)
filterPlan, err = planner.PlanJob(execArgs.job)
if err != nil {
return err
}
case filterEventName != "":
logging.Infof("Preparing plan for a event: %s", filterEventName)
filterPlan, err = planner.PlanEvent(filterEventName)
if err != nil {
return err
}
default:
logging.Infof("Preparing plan with all jobs")
filterPlan, err = planner.PlanAll()
if err != nil {
return err
}
}
if err != nil {
return err
}
printList(filterPlan)
@@ -385,32 +361,31 @@ func (i *executeArgs) runnerConfig(eventName string, env, proxyEnv map[string]st
}
return &runner.Config{
Workdir: i.Workdir(),
BindWorkdir: false,
ForcePull: i.forcePull,
ForceRebuild: i.forceRebuild,
JSONLogger: i.jsonLogger,
Env: env,
ProxyEnv: proxyEnv,
Vars: i.LoadVars(),
Secrets: i.LoadSecrets(),
InsecureSecrets: i.insecureSecrets,
Privileged: i.privileged,
UsernsMode: i.usernsMode,
ContainerArchitecture: i.containerArchitecture,
ContainerDaemonSocket: i.containerDaemonSocket,
UseGitIgnore: i.useGitIgnore,
GitHubInstance: i.githubInstance,
ContainerCapAdd: i.containerCapAdd,
ContainerCapDrop: i.containerCapDrop,
ContainerOptions: i.containerOptions,
ArtifactServerPath: i.artifactServerPath,
ArtifactServerPort: i.artifactServerPort,
ArtifactServerAddr: i.artifactServerAddr,
NoSkipCheckout: i.noSkipCheckout,
EventName: eventName,
EventJSON: eventJSON,
// PresetGitHubContext: preset,
Workdir: i.Workdir(),
BindWorkdir: false,
ForcePull: i.forcePull,
ForceRebuild: i.forceRebuild,
JSONLogger: i.jsonLogger,
Env: env,
ProxyEnv: proxyEnv,
Vars: i.LoadVars(),
Secrets: i.LoadSecrets(),
InsecureSecrets: i.insecureSecrets,
Privileged: i.privileged,
UsernsMode: i.usernsMode,
ContainerArchitecture: i.containerArchitecture,
ContainerDaemonSocket: i.containerDaemonSocket,
UseGitIgnore: i.useGitIgnore,
GitHubInstance: i.githubInstance,
ContainerCapAdd: i.containerCapAdd,
ContainerCapDrop: i.containerCapDrop,
ContainerOptions: i.containerOptions,
ArtifactServerPath: i.artifactServerPath,
ArtifactServerPort: i.artifactServerPort,
ArtifactServerAddr: i.artifactServerAddr,
NoSkipCheckout: i.noSkipCheckout,
EventName: eventName,
EventJSON: eventJSON,
ContainerNamePrefix: "GITEA-ACTIONS-TASK-" + eventName,
ContainerMaxLifetime: maxLifetime,
ContainerNetworkMode: container.NetworkMode(i.network),
@@ -465,15 +440,12 @@ func runExec(ctx context.Context, execArgs *executeArgs) func(cmd *cobra.Command
if execArgs.job != "" {
logging.Infof("Planning job: %s", execArgs.job)
plan, err = planner.PlanJob(execArgs.job)
if err != nil {
return err
}
} else {
logging.Infof("Planning jobs for event: %s", eventName)
plan, err = planner.PlanEvent(eventName)
if err != nil {
return err
}
}
if err != nil {
return err
}
maxLifetime := 3 * time.Hour
@@ -548,11 +520,7 @@ func runExec(ctx context.Context, execArgs *executeArgs) func(cmd *cobra.Command
config.Env["ACT_EXEC"] = "true"
config.Secrets[actionsRuntimeTokenEnvName] = actionsRuntimeToken
if t := config.Secrets["GITEA_TOKEN"]; t != "" {
config.Token = t
} else if t := config.Secrets["GITHUB_TOKEN"]; t != "" {
config.Token = t
}
config.Token = cmp.Or(config.Secrets["GITEA_TOKEN"], config.Secrets["GITHUB_TOKEN"])
if !execArgs.debug {
logLevel := logging.LevelInfo
+6 -16
View File
@@ -117,10 +117,7 @@ func (r *registerInputs) validate() error {
if r.Token == "" {
return errors.New("token is empty")
}
if len(r.Labels) > 0 {
return validateLabels(r.Labels)
}
return nil
return validateLabels(r.Labels)
}
func validateLabels(ls []string) error {
@@ -141,20 +138,14 @@ func (r *registerInputs) stageValue(stage registerStage) string {
case StageInputRunnerName:
return r.RunnerName
case StageInputLabels:
if len(r.Labels) > 0 {
return strings.Join(r.Labels, ",")
}
return strings.Join(r.Labels, ",")
}
return ""
}
func (r *registerInputs) assignToNext(stage registerStage, value string, cfg *config.Config) registerStage {
// must set instance address and token.
// if empty, keep current stage.
if stage == StageInputInstance || stage == StageInputToken {
if value == "" {
return stage
}
if (stage == StageInputInstance || stage == StageInputToken) && value == "" {
return stage
}
// set hostname for runner name if empty
@@ -222,11 +213,10 @@ func initInputs(regArgs *registerArgs) (*registerInputs, error) {
case regArgs.Token != "":
token = regArgs.Token
default:
envToken, ok := os.LookupEnv(registerTokenEnvVar)
if !ok || envToken == "" {
token = os.Getenv(registerTokenEnvVar)
if token == "" {
return nil, fmt.Errorf("missing token, token-file argument, or %s environment variable", registerTokenEnvVar)
}
token = envToken
}
inputs := &registerInputs{
InstanceAddr: strings.TrimSpace(regArgs.InstanceAddr),
+54 -89
View File
@@ -65,7 +65,6 @@ func TestRegisterInputsValidate(t *testing.T) {
func TestValidateLabels(t *testing.T) {
require.NoError(t, validateLabels([]string{"ubuntu:host", "ubuntu:docker://node:18"}))
// a colon that is not a supported schema is part of the label name
require.NoError(t, validateLabels([]string{"pool:e57e18d4-10d4-406f-93bf-60f127221bdd"}))
require.Error(t, validateLabels([]string{"ubuntu:host", ""}))
}
@@ -149,97 +148,63 @@ func TestRegisterInputsAssignToNext(t *testing.T) {
}
func TestInitInputs(t *testing.T) {
t.Run("missing token", func(t *testing.T) {
_, err := initInputs(&registerArgs{
InstanceAddr: "http://localhost:3000",
RunnerName: "runner",
Ephemeral: true,
Labels: " ubuntu:host , ubuntu:docker://node:18 ",
for _, tc := range []struct {
name, tokenFile, wantErr string
env *string
}{
{name: "missing token", wantErr: "missing token, token-file argument, or GITEA_RUNNER_REGISTRATION_TOKEN environment variable"},
{name: "empty token", env: new(""), wantErr: "missing token, token-file argument, or GITEA_RUNNER_REGISTRATION_TOKEN environment variable"},
{
name: "invalid token file", tokenFile: "/tmp/nonexistent", env: new("from-env"),
wantErr: "cannot read the token file: /tmp/nonexistent, open /tmp/nonexistent: no such file or directory",
},
} {
t.Run(tc.name, func(t *testing.T) {
if tc.env != nil {
t.Setenv(registerTokenEnvVar, *tc.env)
}
_, err := initInputs(&registerArgs{
InstanceAddr: "http://localhost:3000",
TokenFile: tc.tokenFile,
RunnerName: "runner",
Ephemeral: true,
Labels: " ubuntu:host , ubuntu:docker://node:18 ",
})
require.EqualError(t, err, tc.wantErr)
})
require.EqualError(t, err, "missing token, token-file argument, or GITEA_RUNNER_REGISTRATION_TOKEN environment variable")
})
}
t.Run("empty token", func(t *testing.T) {
t.Setenv(registerTokenEnvVar, "")
_, err := initInputs(&registerArgs{
InstanceAddr: "http://localhost:3000",
Token: "",
TokenFile: "",
RunnerName: "runner",
Ephemeral: true,
Labels: " ubuntu:host , ubuntu:docker://node:18 ",
for _, tc := range []struct {
name, address, token, runnerName, wantToken string
file bool
}{
{name: "valid token", address: " http://localhost:3000 ", token: "from-plain-arg\n", runnerName: "runner\n", wantToken: "from-plain-arg"},
{name: "valid token file", address: "http://localhost:3000", runnerName: "runner", file: true, wantToken: "from-file"},
{name: "token from environment variable", address: "http://localhost:3000", runnerName: "runner", wantToken: "from-env"},
} {
t.Run(tc.name, func(t *testing.T) {
t.Setenv(registerTokenEnvVar, "from-env")
var tokenFile string
if tc.file {
tokenFile = filepath.Join(t.TempDir(), "from-file")
require.NoError(t, os.WriteFile(tokenFile, []byte("from-file\n"), 0o600))
}
inputs, err := initInputs(&registerArgs{
InstanceAddr: tc.address,
Token: tc.token,
TokenFile: tokenFile,
RunnerName: tc.runnerName,
Ephemeral: true,
Labels: " ubuntu:host , ubuntu:docker://node:18 ",
})
require.NoError(t, err)
require.Equal(t, "http://localhost:3000", inputs.InstanceAddr)
require.Equal(t, tc.wantToken, inputs.Token)
require.Equal(t, "runner", inputs.RunnerName)
require.True(t, inputs.Ephemeral)
require.Equal(t, []string{"ubuntu:host ", " ubuntu:docker://node:18"}, inputs.Labels)
})
require.EqualError(t, err, "missing token, token-file argument, or GITEA_RUNNER_REGISTRATION_TOKEN environment variable")
})
t.Run("invalid token file", func(t *testing.T) {
t.Setenv(registerTokenEnvVar, "from-env")
_, err := initInputs(&registerArgs{
InstanceAddr: "http://localhost:3000",
TokenFile: "/tmp/nonexistent",
RunnerName: "runner",
Ephemeral: true,
Labels: " ubuntu:host , ubuntu:docker://node:18 ",
})
require.EqualError(t, err, "cannot read the token file: /tmp/nonexistent, open /tmp/nonexistent: no such file or directory")
})
t.Run("valid token", func(t *testing.T) {
t.Setenv(registerTokenEnvVar, "from-env")
inputs, err := initInputs(&registerArgs{
InstanceAddr: " http://localhost:3000 ",
Token: "from-plain-arg\n",
RunnerName: "runner\n",
Ephemeral: true,
Labels: " ubuntu:host , ubuntu:docker://node:18 ",
})
require.NoError(t, err)
require.Equal(t, "http://localhost:3000", inputs.InstanceAddr)
require.Equal(t, "from-plain-arg", inputs.Token)
require.Equal(t, "runner", inputs.RunnerName)
require.True(t, inputs.Ephemeral)
require.Equal(t, []string{"ubuntu:host ", " ubuntu:docker://node:18"}, inputs.Labels)
})
t.Run("valid token file", func(t *testing.T) {
t.Setenv(registerTokenEnvVar, "from-env")
tokenFile, createErr := os.CreateTemp(t.TempDir(), "from-file")
require.NoError(t, createErr)
defer tokenFile.Close()
_, writeErr := tokenFile.WriteString("from-file\n")
require.NoError(t, writeErr)
_ = tokenFile.Sync()
inputs, err := initInputs(&registerArgs{
InstanceAddr: "http://localhost:3000",
TokenFile: tokenFile.Name(),
RunnerName: "runner",
Ephemeral: true,
Labels: " ubuntu:host , ubuntu:docker://node:18 ",
})
require.NoError(t, err)
require.Equal(t, "http://localhost:3000", inputs.InstanceAddr)
require.Equal(t, "from-file", inputs.Token)
require.Equal(t, "runner", inputs.RunnerName)
require.True(t, inputs.Ephemeral)
require.Equal(t, []string{"ubuntu:host ", " ubuntu:docker://node:18"}, inputs.Labels)
})
t.Run("token from environment variable", func(t *testing.T) {
t.Setenv(registerTokenEnvVar, "from-env")
inputs, err := initInputs(&registerArgs{
InstanceAddr: "http://localhost:3000",
RunnerName: "runner",
Ephemeral: true,
Labels: " ubuntu:host , ubuntu:docker://node:18 ",
})
require.NoError(t, err)
require.Equal(t, "http://localhost:3000", inputs.InstanceAddr)
require.Equal(t, "from-env", inputs.Token)
require.Equal(t, "runner", inputs.RunnerName)
require.True(t, inputs.Ephemeral)
require.Equal(t, []string{"ubuntu:host ", " ubuntu:docker://node:18"}, inputs.Labels)
})
}
t.Run("empty labels", func(t *testing.T) {
inputs, _ := initInputs(&registerArgs{
+17 -29
View File
@@ -83,8 +83,6 @@ func New(cfg *config.Config, client client.Client, runner TaskRunner) *Poller {
jobsCtx, shutdownJobs := context.WithCancel(context.Background())
done := make(chan struct{})
p := &Poller{
client: client,
runner: runner,
@@ -96,11 +94,11 @@ func New(cfg *config.Config, client client.Client, runner TaskRunner) *Poller {
jobsCtx: jobsCtx,
shutdownJobs: shutdownJobs,
done: done,
done: make(chan struct{}),
availabilityReady: true,
availabilityReason: "ok",
}
p.lastHealthyPoll.Store(time.Now().UnixNano())
p.availabilityReady = true
p.availabilityReason = "ok"
return p
}
@@ -132,8 +130,8 @@ func (p *Poller) Poll() {
continue
}
task, ok := p.fetchTask(p.pollingCtx, s)
if !ok {
task := p.fetchTask(p.pollingCtx, s)
if task == nil {
p.runIdleMaintenance()
<-sem
if !p.waitBackoff(s) {
@@ -166,8 +164,8 @@ func (p *Poller) PollOnce(onTaskFetched func()) {
}
continue
}
task, ok := p.fetchTask(p.pollingCtx, s)
if !ok {
task := p.fetchTask(p.pollingCtx, s)
if task == nil {
p.runIdleMaintenance()
if !p.waitBackoff(s) {
return
@@ -348,7 +346,7 @@ func (p *Poller) runTaskWithRecover(ctx context.Context, task *runnerv1.Task) {
}
}
func (p *Poller) fetchTask(ctx context.Context, s *workerState) (*runnerv1.Task, bool) {
func (p *Poller) fetchTask(ctx context.Context, s *workerState) *runnerv1.Task {
reqCtx, cancel := context.WithTimeout(ctx, p.cfg.Runner.FetchTimeout)
defer cancel()
@@ -370,7 +368,7 @@ func (p *Poller) fetchTask(ctx context.Context, s *workerState) (*runnerv1.Task,
}
s.consecutiveEmpty++
metrics.PollFetchTotal.WithLabelValues(metrics.LabelResultEmpty).Inc()
return nil, false
return nil
}
s.fetchTimedOut = false
metrics.PollFetchDuration.Observe(time.Since(start).Seconds())
@@ -385,22 +383,17 @@ func (p *Poller) fetchTask(ctx context.Context, s *workerState) (*runnerv1.Task,
p.unregistered.Store(true)
p.registrationRejected.Store(rejected)
p.shutdownPolling()
return nil, false
}
// Not a long poll, so a deadline can mean the server assigned a task
// this runner never received.
if errors.Is(err, context.DeadlineExceeded) {
logging.WithError(err).Errorf("fetching a task timed out after %s", p.cfg.Runner.FetchTimeout)
} else {
logging.WithError(err).Error("failed to fetch task")
return nil
}
logging.WithError(err).Error("failed to fetch task")
p.lastPollFailed.Store(true)
s.consecutiveErrors++
metrics.PollFetchTotal.WithLabelValues(metrics.LabelResultError).Inc()
metrics.ClientErrors.WithLabelValues(metrics.LabelMethodFetchTask, connect.CodeOf(err).String()).Inc()
return nil, false
return nil
}
p.markHealthyPoll()
p.lastHealthyPoll.Store(time.Now().UnixNano())
p.lastPollFailed.Store(false)
// Successful response — reset error counter.
s.consecutiveErrors = 0
@@ -408,7 +401,7 @@ func (p *Poller) fetchTask(ctx context.Context, s *workerState) (*runnerv1.Task,
if resp == nil {
s.consecutiveEmpty++
metrics.PollFetchTotal.WithLabelValues(metrics.LabelResultEmpty).Inc()
return nil, false
return nil
}
if resp.TasksVersion > v {
@@ -418,17 +411,12 @@ func (p *Poller) fetchTask(ctx context.Context, s *workerState) (*runnerv1.Task,
if resp.Task == nil {
s.consecutiveEmpty++
metrics.PollFetchTotal.WithLabelValues(metrics.LabelResultEmpty).Inc()
return nil, false
return nil
}
// got a task, set `tasksVersion` to zero to force query db in next request.
p.tasksVersion.CompareAndSwap(resp.TasksVersion, 0)
metrics.PollFetchTotal.WithLabelValues(metrics.LabelResultTask).Inc()
return resp.Task, true
}
func (p *Poller) markHealthyPoll() {
p.lastHealthyPoll.Store(time.Now().UnixNano())
p.lastPollFailed.Store(false)
return resp.Task
}
+42 -100
View File
@@ -22,14 +22,10 @@ import (
"github.com/stretchr/testify/require"
)
// TestPoller_WorkerStateCounters verifies that workerState correctly tracks
// consecutive empty responses independently per state instance, and that
// fetchTask increments only the relevant counter.
func TestPoller_WorkerStateCounters(t *testing.T) {
client := mocks.NewClient(t)
client.On("FetchTask", mock.Anything, mock.Anything).Return(
func(_ context.Context, _ *runnerv1.FetchTaskRequest) (*runnerv1.FetchTaskResponse, error) {
// Always return an empty response.
return &runnerv1.FetchTaskResponse{}, nil
},
)
@@ -42,24 +38,17 @@ func TestPoller_WorkerStateCounters(t *testing.T) {
s1 := &workerState{}
s2 := &workerState{}
// Each worker independently observes one empty response.
_, ok := p.fetchTask(ctx, s1)
require.False(t, ok)
_, ok = p.fetchTask(ctx, s2)
require.False(t, ok)
require.Nil(t, p.fetchTask(ctx, s1))
require.Nil(t, p.fetchTask(ctx, s2))
assert.Equal(t, int64(1), s1.consecutiveEmpty, "worker 1 should only count its own empty response")
assert.Equal(t, int64(1), s2.consecutiveEmpty, "worker 2 should only count its own empty response")
// Worker 1 sees a second empty; worker 2 stays at 1.
_, ok = p.fetchTask(ctx, s1)
require.False(t, ok)
require.Nil(t, p.fetchTask(ctx, s1))
assert.Equal(t, int64(2), s1.consecutiveEmpty)
assert.Equal(t, int64(1), s2.consecutiveEmpty, "worker 2's counter must not be affected by worker 1's empty fetches")
}
// TestPoller_FetchErrorIncrementsErrorsOnly verifies that a fetch error
// increments only the per-worker error counter, not the empty counter.
func TestPoller_FetchErrorIncrementsErrorsOnly(t *testing.T) {
client := mocks.NewClient(t)
client.On("FetchTask", mock.Anything, mock.Anything).Return(nil, errors.New("network unreachable"))
@@ -69,15 +58,11 @@ func TestPoller_FetchErrorIncrementsErrorsOnly(t *testing.T) {
p := &Poller{client: client, cfg: cfg}
s := &workerState{}
_, ok := p.fetchTask(t.Context(), s)
require.False(t, ok)
require.Nil(t, p.fetchTask(t.Context(), s))
assert.Equal(t, int64(1), s.consecutiveErrors)
assert.Equal(t, int64(0), s.consecutiveEmpty)
}
// A deadline is the idle path once the server holds the request open, and a
// symptom before then, so it must neither reset the error count nor assert that
// the server is reachable.
func TestPoller_FetchTimeoutIsNoSignal(t *testing.T) {
client := mocks.NewClient(t)
client.On("FetchTask", mock.Anything, mock.Anything).Return(nil, context.DeadlineExceeded)
@@ -90,20 +75,15 @@ func TestPoller_FetchTimeoutIsNoSignal(t *testing.T) {
hook := logtest.SetDefault(t)
s := &workerState{consecutiveErrors: 2}
_, ok := p.fetchTask(t.Context(), s)
require.False(t, ok)
require.Nil(t, p.fetchTask(t.Context(), s))
assert.Equal(t, int64(2), s.consecutiveErrors)
assert.Equal(t, int64(1), s.consecutiveEmpty)
assert.True(t, p.lastPollFailed.Load(), "a timeout must not clear a known failure")
// An idle runner against a server holding the request open must not flood.
_, _ = p.fetchTask(t.Context(), s)
p.fetchTask(t.Context(), s)
assert.Len(t, hook.AllRecords(), 1)
}
// TestPoller_FetchUnauthenticatedStopsPolling verifies that an Unauthenticated
// response marks the runner as unregistered and cancels the polling context so
// the daemon can exit instead of retrying forever.
func TestPoller_FetchUnauthenticatedStopsPolling(t *testing.T) {
for name, tc := range map[string]struct {
fetchErr error
@@ -120,8 +100,7 @@ func TestPoller_FetchUnauthenticatedStopsPolling(t *testing.T) {
require.NoError(t, err)
p := New(cfg, client, nil)
s := &workerState{}
_, ok := p.fetchTask(t.Context(), s)
require.False(t, ok)
require.Nil(t, p.fetchTask(t.Context(), s))
assert.True(t, p.Unregistered(), "runner should be marked unregistered")
assert.Equal(t, tc.rejected, p.RegistrationRejected())
@@ -135,8 +114,6 @@ func TestPoller_FetchUnauthenticatedStopsPolling(t *testing.T) {
}
}
// TestPoller_CalculateInterval verifies the exponential backoff math is
// correctly driven by the workerState counters.
func TestPoller_CalculateInterval(t *testing.T) {
cfg, err := config.LoadDefault("")
require.NoError(t, err)
@@ -165,7 +142,6 @@ func TestPoller_CalculateInterval(t *testing.T) {
}
}
// atomicMax atomically updates target to max(target, val).
func atomicMax(target *atomic.Int64, val int64) {
for {
old := target.Load()
@@ -205,7 +181,6 @@ func TestPollerReady(t *testing.T) {
require.NoError(t, err)
poller := New(cfg, nil, &availabilityRunner{})
// /readyz reuses the availability the poll loop last recorded.
ready, reason := poller.Ready(time.Second)
assert.True(t, ready)
assert.Equal(t, "ok", reason)
@@ -291,63 +266,45 @@ 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, _ *runnerv1.FetchTaskRequest) (*runnerv1.FetchTaskResponse, error) {
return &runnerv1.FetchTaskResponse{}, nil
},
)
func TestPollerCallsOnIdle(t *testing.T) {
for _, pollOnce := range []bool{false, true} {
name := "Poll"
if pollOnce {
name = "PollOnce"
}
t.Run(name, func(t *testing.T) {
cli := mocks.NewClient(t)
cli.On("FetchTask", mock.Anything, mock.Anything).Return(&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
cfg, err := config.LoadDefault("")
require.NoError(t, err)
if !pollOnce {
cfg.Runner.Capacity = 1
}
cfg.Runner.FetchInterval = 10 * time.Millisecond
cfg.Runner.FetchIntervalMax = 10 * time.Millisecond
runner := &idleAwareRunner{}
poller := New(cfg, cli, runner)
runner := &idleAwareRunner{}
poller := New(cfg, cli, runner)
var wg sync.WaitGroup
wg.Go(func() {
if pollOnce {
poller.PollOnce(func() {})
} else {
poller.Poll()
}
})
var wg sync.WaitGroup
wg.Go(poller.Poll)
require.Eventually(t, func() bool {
return runner.idleCalls.Load() > 0
}, time.Second, 10*time.Millisecond)
require.Eventually(t, func() bool {
return runner.idleCalls.Load() > 0
}, time.Second, 10*time.Millisecond)
ctx, cancel := context.WithTimeout(t.Context(), 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, _ *runnerv1.FetchTaskRequest) (*runnerv1.FetchTaskResponse, error) {
return &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(func() { poller.PollOnce(func() {}) })
require.Eventually(t, func() bool {
return runner.idleCalls.Load() > 0
}, time.Second, 10*time.Millisecond)
ctx, cancel := context.WithTimeout(t.Context(), time.Second)
defer cancel()
require.NoError(t, poller.Shutdown(ctx))
wg.Wait()
ctx, cancel := context.WithTimeout(t.Context(), time.Second)
defer cancel()
require.NoError(t, poller.Shutdown(ctx))
wg.Wait()
})
}
}
func TestPollerPollOnceCallsOnTaskFetchedBeforeRunningIt(t *testing.T) {
@@ -362,9 +319,6 @@ func TestPollerPollOnceCallsOnTaskFetchedBeforeRunningIt(t *testing.T) {
assert.Equal(t, int64(1), runner.totalCompleted.Load())
}
// 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).
func TestPoller_ConcurrencyLimitedByCapacity(t *testing.T) {
const (
capacity = 3
@@ -427,13 +381,6 @@ func TestPoller_ConcurrencyLimitedByCapacity(t *testing.T) {
"all tasks should have been executed")
}
// TestPoller_ShutdownForcesJobsOnTimeout locks in the fix for a
// pre-existing bug where Shutdown's timeout branch used a blocking
// `<-p.done` receive, leaving p.shutdownJobs() unreachable. With a
// task parked on jobsCtx and a Shutdown deadline shorter than the
// task's natural completion, Shutdown must force-cancel via
// shutdownJobs() and return ctx.Err() promptly — not block until the
// task would have finished on its own.
func TestPoller_ShutdownForcesJobsOnTimeout(t *testing.T) {
var served atomic.Bool
cli := mocks.NewClient(t)
@@ -448,8 +395,6 @@ func TestPoller_ShutdownForcesJobsOnTimeout(t *testing.T) {
},
)
// delay >> Shutdown timeout: Run only returns when jobsCtx is
// cancelled by shutdownJobs().
runner := &mockRunner{delay: 30 * time.Second}
cfg, err := config.LoadDefault("")
@@ -474,9 +419,6 @@ func TestPoller_ShutdownForcesJobsOnTimeout(t *testing.T) {
elapsed := time.Since(start)
require.ErrorIs(t, err, context.DeadlineExceeded)
// With the fix, Shutdown returns shortly after the deadline once
// the forced job unwinds. Without the fix, the blocking <-p.done
// would hang for the full 30s mockRunner delay.
assert.Less(t, elapsed, 5*time.Second,
"Shutdown must not block on the parked task; shutdownJobs() must run on timeout")
+1 -4
View File
@@ -33,7 +33,7 @@ func (r *Runner) checkConfiguredHealth(ctx context.Context) (bool, string) {
}
env := processEnvironment()
maps.Copy(env, r.cloneEnvs())
maps.Copy(env, r.envs)
env["GITEA_RUNNER_HEALTH_CHECK"] = "true"
env["GITEA_RUNNER_NAME"] = r.name
if r.client != nil {
@@ -84,9 +84,6 @@ func executeHealthCheck(ctx context.Context, script string, timeout time.Duratio
if errors.Is(checkCtx.Err(), context.DeadlineExceeded) {
return fmt.Errorf("timed out after %s", timeout)
}
if err == nil {
return nil
}
if exitErr, ok := errors.AsType[*exec.ExitError](err); ok {
return fmt.Errorf("exited with code %d", exitErr.ExitCode())
}
+5 -12
View File
@@ -6,7 +6,6 @@ package run
import (
"context"
"errors"
"fmt"
"io"
"os/exec"
"strconv"
@@ -69,15 +68,9 @@ func (r *Runner) runPostTaskScript(ctx context.Context, reporter *report.Reporte
// copiers have finished (cmd.Wait, bounded by WaitDelay above, guarantees it).
common.FlushWriter(stdout)
common.FlushWriter(stderr)
if err != nil {
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
logging.Warnf("post-task script %q for task %d: %v", script, task.Id, err)
return
}
if exitErr, ok := errors.AsType[*exec.ExitError](err); ok {
logging.Warnf("post-task script %q for task %d exited with code %d", script, task.Id, exitErr.ExitCode())
return
}
if exitErr, ok := errors.AsType[*exec.ExitError](err); ok {
logging.Warnf("post-task script %q for task %d exited with code %d", script, task.Id, exitErr.ExitCode())
} else if err != nil {
logging.Warnf("post-task script %q for task %d: %v", script, task.Id, err)
}
}
@@ -111,8 +104,8 @@ func (r *Runner) postTaskScriptEnv(reporter *report.Reporter, task *runnerv1.Tas
func envListFromMap(env map[string]string) []string {
envList := make([]string, 0, len(env))
for k, v := range env {
envList = append(envList, fmt.Sprintf("%s=%s", k, v))
for key, value := range env {
envList = append(envList, key+"="+value)
}
return envList
}
+2 -5
View File
@@ -120,7 +120,7 @@ func proxyPasswords() []string {
var passwords []string
for _, raw := range []string{cfg.HTTPProxy, cfg.HTTPSProxy} {
parsed, err := url.Parse(raw)
if err != nil || parsed.User == nil {
if err != nil {
continue
}
if password, ok := parsed.User.Password(); ok && password != "" && !slices.Contains(passwords, password) {
@@ -152,11 +152,8 @@ func appendNoProxy(noProxy string, hosts ...string) string {
// hostOf returns the host of a URL without its port, the form a no_proxy entry takes. It is
// empty for anything without a network host, such as a unix socket.
func hostOf(raw string) string {
if raw == "" {
return ""
}
parsed, err := url.Parse(strings.TrimSuffix(raw, "/"))
if err != nil || parsed.Hostname() == "" {
if err != nil {
return ""
}
return parsed.Hostname()
+10 -33
View File
@@ -99,7 +99,7 @@ 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 cacheEnabled(cfg) {
if cfg.Cache.Enabled == nil || *cfg.Cache.Enabled {
warnIgnoredCachePolicy(cfg)
warnIgnoredCacheSecret(cfg)
handler, err := artifactcache.StartHandler(artifactcache.Options{
@@ -121,8 +121,7 @@ func NewRunner(cfg *config.Config, reg *config.Registration, cli client.Client)
}
// set artifact gitea api
artifactGiteaAPI := strings.TrimSuffix(cli.Address(), "/") + "/api/actions_pipeline/"
envs["ACTIONS_RUNTIME_URL"] = artifactGiteaAPI
envs["ACTIONS_RUNTIME_URL"] = strings.TrimSuffix(cli.Address(), "/") + "/api/actions_pipeline/"
envs["ACTIONS_RESULTS_URL"] = strings.TrimSuffix(cli.Address(), "/")
// Set specific environments to distinguish between Gitea and GitHub
@@ -169,9 +168,7 @@ func (r *Runner) OnIdle(ctx context.Context) {
}
// Bind-workdir mode: reclaim stale per-task workspace dirs (numeric task IDs).
if r.cfg.Container.BindWorkdir {
workdirParent := strings.TrimLeft(r.cfg.Container.WorkdirParent, "/")
workdirRoot := filepath.FromSlash("/" + workdirParent)
r.cleanupStaleDirs(ctx, workdirRoot, isTaskIDDir)
r.cleanupStaleDirs(ctx, filepath.FromSlash("/"+strings.TrimLeft(r.cfg.Container.WorkdirParent, "/")), isTaskIDDir)
}
// Host mode: reclaim per-job scratch dirs left behind when HostEnvironment
// cleanup timed out (e.g. a delete stalled by an AV/EDR filter driver). They
@@ -270,10 +267,7 @@ func (r *Runner) cleanupStaleDirs(ctx context.Context, root string, match func(n
if err := ctx.Err(); err != nil {
return
}
if !entry.IsDir() {
continue
}
if !match(entry.Name()) {
if !entry.IsDir() || !match(entry.Name()) {
continue
}
info, err := entry.Info()
@@ -452,7 +446,9 @@ func (r *Runner) run(ctx context.Context, task *runnerv1.Task, reporter *report.
}()
workflowCallInputs := restoreCalledWorkflowContext(task)
r.reportSetup(reporter, task)
for _, line := range r.setupLines(task) {
reporter.Logf("%s", line)
}
workflow, jobID, err := generateWorkflow(task)
if err != nil {
@@ -511,11 +507,7 @@ func (r *Runner) run(ctx context.Context, task *runnerv1.Task, reporter *report.
telemetry.AddJobEnv(ctx, envs)
preset := model.GithubContextFromMap(task.Context.AsMap())
if t := task.Secrets["GITEA_TOKEN"]; t != "" {
preset.Token = t
} else if t := task.Secrets["GITHUB_TOKEN"]; t != "" {
preset.Token = t
}
preset.Token = cmp.Or(task.Secrets["GITEA_TOKEN"], task.Secrets["GITHUB_TOKEN"], preset.Token)
if actionsIDTokenRequestURL := taskContext["actions_id_token_request_url"].GetStringValue(); actionsIDTokenRequestURL != "" {
envs["ACTIONS_ID_TOKEN_REQUEST_URL"] = actionsIDTokenRequestURL
@@ -589,7 +581,6 @@ func (r *Runner) run(ctx context.Context, task *runnerv1.Task, reporter *report.
ForcePull: r.cfg.Container.ForcePull,
ForceRebuild: r.cfg.Container.ForceRebuild,
JSONLogger: false,
Env: envs,
ProxyEnv: proxyEnv,
Secrets: task.Secrets,
@@ -672,18 +663,8 @@ func (r *Runner) builtInCacheURL() string {
return r.envs["ACTIONS_CACHE_URL"]
}
func cacheEnabled(cfg *config.Config) bool {
return cfg.Cache.Enabled == nil || *cfg.Cache.Enabled
}
// cacheServiceV2 reports whether jobs are told the cache service speaks v2. It is all cache.v2
// turns off: the bundle edit that reaches it is what the artifact actions need too.
func (r *Runner) cacheServiceV2() bool {
return r.cfg.Cache.V2 == nil || *r.cfg.Cache.V2
}
func (r *Runner) setResultsService(envs map[string]string, resultsURL string) {
v2 := resultsURL != "" && r.cacheServiceV2()
v2 := resultsURL != "" && (r.cfg.Cache.V2 == nil || *r.cfg.Cache.V2) // all cache.v2 turns off, the bundle edit reaching it is what the artifact actions need too
if v2 {
envs[runner.CacheServiceV2Env] = "true"
} else {
@@ -902,11 +883,7 @@ func warnIgnoredCachePolicy(cfg *config.Config) {
// warnIgnoredCacheSecret flags an external cache server secret configured on a runner that uses the built-in cache server.
func warnIgnoredCacheSecret(cfg *config.Config) {
if cfg.Cache.ExternalServer != "" {
return
}
// Not using an external cache server, so any configured secret is ignored.
if cfg.Cache.ExternalSecret == "" {
if cfg.Cache.ExternalServer != "" || cfg.Cache.ExternalSecret == "" {
return
}
// LoadDefault resolves external_secret_file into ExternalSecret, so report whichever key the operator actually wrote.
+29 -58
View File
@@ -4,7 +4,6 @@
package run
import (
"bytes"
"encoding/json/v2"
"fmt"
"io"
@@ -22,53 +21,34 @@ import (
"github.com/stretchr/testify/require"
)
func emptyCfg() *config.Config { return &config.Config{} }
func TestRunner_registerCacheForTask_NoOps(t *testing.T) {
for token, handler := range map[string]*artifactcache.Handler{"tok": nil, "": {}} {
r := &Runner{cfg: emptyCfg(), cacheHandler: handler}
r := &Runner{cacheHandler: handler}
unregister, _ := r.registerCacheForTask(token, "owner/repo", "", nil)
require.NotNil(t, unregister, token)
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(artifactcache.Options{Dir: dir, OutboundIP: "127.0.0.1"})
require.NoError(t, err)
defer handler.Close()
r := &Runner{cfg: emptyCfg(), cacheHandler: handler, envs: map[string]string{"ACTIONS_RESULTS_URL": "https://gitea.example"}}
r := &Runner{cfg: &config.Config{}, cacheHandler: handler, envs: map[string]string{"ACTIONS_RESULTS_URL": "https://gitea.example"}}
const publicURL = "http://a1b2c3d4e5f6:8088"
token := "full-flow-token"
unregister, resultsURL := r.registerCacheForTask(token, "owner/repo", publicURL, nil)
assert.Equal(t, publicURL, resultsURL)
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", "",
resp := cacheRequest(t, token, 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 {
@@ -77,19 +57,16 @@ func TestRunner_CacheFullFlow_MatchesToolkit(t *testing.T) {
require.NoError(t, decodeJSON(resp, &reserved))
require.NotZero(t, reserved.CacheID)
// upload
resp = do(http.MethodPatch, fmt.Sprintf("%s/caches/%d", base, reserved.CacheID),
resp = cacheRequest(t, token, 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), "", "", "")
resp = cacheRequest(t, token, 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,
resp = cacheRequest(t, token, http.MethodGet,
fmt.Sprintf("%s/cache?keys=%s,fallback&version=%s", base, key, version), "", "", "")
require.Equal(t, http.StatusOK, resp.StatusCode)
var hit struct {
@@ -100,8 +77,6 @@ func TestRunner_CacheFullFlow_MatchesToolkit(t *testing.T) {
require.Equal(t, key, hit.CacheKey)
require.True(t, strings.HasPrefix(hit.ArchiveLocation, publicURL+"/"), hit.ArchiveLocation)
// download — toolkit does NOT attach Authorization here; the signature
// in the URL must be enough.
dl, err := http.Get(strings.Replace(hit.ArchiveLocation, publicURL, handler.ExternalURL(), 1))
require.NoError(t, err)
defer dl.Body.Close()
@@ -111,18 +86,32 @@ func TestRunner_CacheFullFlow_MatchesToolkit(t *testing.T) {
assert.Equal(t, body, string(got[:n]))
unregister()
resp = do(http.MethodGet, fmt.Sprintf("%s/cache?keys=%s&version=%s", base, key, version), "", "", "")
resp = cacheRequest(t, token, http.MethodGet, fmt.Sprintf("%s/cache?keys=%s&version=%s", base, key, version), "", "", "")
resp.Body.Close()
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
}
func cacheRequest(t *testing.T, token, method, url, contentType, contentRange, body string) *http.Response {
t.Helper()
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
}
func decodeJSON(resp *http.Response, v any) error {
defer resp.Body.Close()
return json.UnmarshalRead(resp.Body, v)
}
// End-to-end through a runner to 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) {
const secret = "shared-secret-for-tests"
remote, err := artifactcache.StartHandler(artifactcache.Options{Dir: t.TempDir(), OutboundIP: "127.0.0.1", InternalSecret: secret})
@@ -154,10 +143,7 @@ func TestRunner_ExternalCacheServer_RegisterRevoke(t *testing.T) {
repo := "owner/repoX"
base := r.envs["ACTIONS_CACHE_URL"] + "_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 := cacheRequest(t, token, http.MethodGet, base+"/cache?keys=k&version=v", "", "", "")
resp.Body.Close()
return resp.StatusCode
}
@@ -181,15 +167,9 @@ func TestRunner_ExternalCacheServer_RegisterRevoke(t *testing.T) {
require.NoError(t, decodeJSON(forwarded, &artifactResult))
assert.True(t, artifactResult["ok"])
// Full reserve→upload→commit→find→download cycle, identical to what
// @actions/cache does, through the runner to the remote 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)
resp := cacheRequest(t, token, http.MethodPost, base+"/caches", "application/json", "", string(reserveBody))
require.Equal(t, http.StatusOK, resp.StatusCode)
var reserved struct {
CacheID uint64 `json:"cacheId"`
@@ -197,25 +177,16 @@ func TestRunner_ExternalCacheServer_RegisterRevoke(t *testing.T) {
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 = cacheRequest(t, token, http.MethodPatch, fmt.Sprintf("%s/caches/%d", base, reserved.CacheID),
"", fmt.Sprintf("bytes 0-%d/*", len(body)-1), string(body))
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 = cacheRequest(t, token, http.MethodPost, fmt.Sprintf("%s/caches/%d", base, reserved.CacheID), "", "", "")
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)
resp = cacheRequest(t, token, http.MethodGet, base+"/cache?keys=ext-key&version=v", "", "", "")
require.Equal(t, http.StatusOK, resp.StatusCode)
var hit struct {
ArchiveLocation string `json:"archiveLocation"`
+5 -26
View File
@@ -8,31 +8,8 @@ import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestRunnerCloneEnvsReturnsTaskLocalCopy(t *testing.T) {
r := &Runner{
envs: map[string]string{
"ACTIONS_CACHE_URL": "http://cache.example",
"ACTIONS_RUNTIME_URL": "http://runner.example",
},
}
cloned := r.cloneEnvs()
require.Equal(t, r.envs, cloned)
cloned["ACTIONS_RUNTIME_TOKEN"] = "task-token"
cloned["ACTIONS_ID_TOKEN_REQUEST_URL"] = "http://oidc.example"
assert.NotContains(t, r.envs, "ACTIONS_RUNTIME_TOKEN")
assert.NotContains(t, r.envs, "ACTIONS_ID_TOKEN_REQUEST_URL")
assert.Equal(t, "http://cache.example", r.envs["ACTIONS_CACHE_URL"])
}
// Regression test for #958: concurrent tasks writing task-specific env keys
// used to race on the shared r.envs map and crash the runner with
// "fatal error: concurrent map writes". Each task must mutate its own clone.
func TestRunnerCloneEnvsConcurrentMutation(t *testing.T) {
r := &Runner{
envs: map[string]string{
@@ -48,6 +25,7 @@ func TestRunnerCloneEnvsConcurrentMutation(t *testing.T) {
go func() {
defer wg.Done()
envs := r.cloneEnvs()
assert.Equal(t, r.envs, envs)
envs["ACTIONS_RUNTIME_TOKEN"] = "task-token"
envs["ACTIONS_ID_TOKEN_REQUEST_URL"] = "http://oidc.example"
envs["ACTIONS_ID_TOKEN_REQUEST_TOKEN"] = "oidc-token"
@@ -55,7 +33,8 @@ func TestRunnerCloneEnvsConcurrentMutation(t *testing.T) {
}
wg.Wait()
assert.NotContains(t, r.envs, "ACTIONS_RUNTIME_TOKEN")
assert.NotContains(t, r.envs, "ACTIONS_ID_TOKEN_REQUEST_URL")
assert.NotContains(t, r.envs, "ACTIONS_ID_TOKEN_REQUEST_TOKEN")
assert.Equal(t, map[string]string{
"ACTIONS_CACHE_URL": "http://cache.example",
"ACTIONS_RUNTIME_URL": "http://runner.example",
}, r.envs)
}
+4 -20
View File
@@ -12,7 +12,6 @@ import (
"strconv"
"strings"
"gitea.com/gitea/runner/internal/pkg/report"
"gitea.com/gitea/runner/internal/pkg/ver"
runnerv1 "gitea.dev/actionslib/runner/v1"
@@ -23,14 +22,6 @@ import (
// falls back to the Go runtime alone. A var so tests can point it at a fixture.
var osReleasePath = "/etc/os-release"
// reportSetup opens the job log the way actions/runner opens its "Set up job" step. The action
// downloads and the closing job name are written later, as the job starts.
func (r *Runner) reportSetup(reporter *report.Reporter, task *runnerv1.Task) {
for _, line := range r.setupLines(task) {
reporter.Logf("%s", line)
}
}
// setupLines names the runner, then reports what it was asked to run and the host it runs on, each
// in its own group.
func (r *Runner) setupLines(task *runnerv1.Task) []string {
@@ -52,8 +43,10 @@ func (r *Runner) setupLines(task *runnerv1.Task) []string {
)
lines = append(lines, inputLines(fields)...)
lines = append(lines, "::group::Operating System")
lines = append(lines, osInfo()...)
return append(lines, "::endgroup::")
if name := prettyOSName(); name != "" {
lines = append(lines, name)
}
return append(lines, fmt.Sprintf("%s/%s", runtime.GOOS, runtime.GOARCH), "::endgroup::")
}
// inputLines lists the inputs of the job: those Gitea resolved for a called workflow's job,
@@ -75,15 +68,6 @@ func inputLines(fields map[string]*structpb.Value) []string {
return append(lines, "::endgroup::")
}
// osInfo describes the host the runner executes on.
func osInfo() []string {
lines := make([]string, 0, 2)
if name := prettyOSName(); name != "" {
lines = append(lines, name)
}
return append(lines, fmt.Sprintf("%s/%s", runtime.GOOS, runtime.GOARCH))
}
// prettyOSName reads PRETTY_NAME (e.g. "Ubuntu 24.04.4 LTS") from os-release, or "" when absent.
func prettyOSName() string {
data, err := os.ReadFile(osReleasePath)
+13 -31
View File
@@ -96,33 +96,23 @@ func TestSetupLinesInputs(t *testing.T) {
}
func TestPrettyOSName(t *testing.T) {
tests := map[string]struct {
for _, tt := range []struct {
name string
osRelease string
want string
missing bool
}{
"quoted value": {
osRelease: "NAME=\"Ubuntu\"\nVERSION_ID=\"24.04\"\nPRETTY_NAME=\"Ubuntu 24.04.4 LTS\"\n",
want: "Ubuntu 24.04.4 LTS",
},
"unquoted value": {
osRelease: "PRETTY_NAME=Alpine Linux v3.21\n",
want: "Alpine Linux v3.21",
},
"no pretty name": {
osRelease: "NAME=\"Ubuntu\"\nVERSION_ID=\"24.04\"\n",
want: "",
},
// A key that merely ends in PRETTY_NAME must not be mistaken for it.
"similar key": {
osRelease: "IMAGE_PRETTY_NAME=\"Ubuntu Core 24\"\n",
want: "",
},
}
for name, tt := range tests {
t.Run(name, func(t *testing.T) {
{"quoted value", "NAME=\"Ubuntu\"\nVERSION_ID=\"24.04\"\nPRETTY_NAME=\"Ubuntu 24.04.4 LTS\"\n", "Ubuntu 24.04.4 LTS", false},
{"unquoted value", "PRETTY_NAME=Alpine Linux v3.21\n", "Alpine Linux v3.21", false},
{"no pretty name", "NAME=\"Ubuntu\"\nVERSION_ID=\"24.04\"\n", "", false},
{"similar key", "IMAGE_PRETTY_NAME=\"Ubuntu Core 24\"\n", "", false},
{"missing file", "", "", true},
} {
t.Run(tt.name, func(t *testing.T) {
path := filepath.Join(t.TempDir(), "os-release")
require.NoError(t, os.WriteFile(path, []byte(tt.osRelease), 0o600))
if !tt.missing {
require.NoError(t, os.WriteFile(path, []byte(tt.osRelease), 0o600))
}
original := osReleasePath
osReleasePath = path
@@ -131,12 +121,4 @@ func TestPrettyOSName(t *testing.T) {
assert.Equal(t, tt.want, prettyOSName())
})
}
t.Run("missing file", func(t *testing.T) {
original := osReleasePath
osReleasePath = filepath.Join(t.TempDir(), "absent")
defer func() { osReleasePath = original }()
assert.Empty(t, prettyOSName())
})
}
+7 -13
View File
@@ -6,7 +6,7 @@ package run
import (
"bytes"
"fmt"
"sort"
"slices"
"strings"
"gitea.dev/actionslib/pkg/model"
@@ -26,27 +26,21 @@ func generateWorkflow(task *runnerv1.Task) (*model.Workflow, string, error) {
}
jobID := jobIDs[0]
needJobIDs := make([]string, 0, len(task.Needs))
rawNeeds := yaml.Node{
Kind: yaml.SequenceNode,
Content: make([]*yaml.Node, 0, len(task.Needs)),
}
for id, need := range task.Needs {
needJobIDs = append(needJobIDs, id)
needJob := &model.Job{
workflow.Jobs[id] = &model.Job{
Outputs: need.Outputs,
Result: strings.ToLower(strings.TrimPrefix(need.Result.String(), "RESULT_")),
}
workflow.Jobs[id] = needJob
}
sort.Strings(needJobIDs)
rawNeeds := yaml.Node{
Kind: yaml.SequenceNode,
Content: make([]*yaml.Node, 0, len(needJobIDs)),
}
for _, id := range needJobIDs {
rawNeeds.Content = append(rawNeeds.Content, &yaml.Node{
Kind: yaml.ScalarNode,
Value: id,
})
}
slices.SortFunc(rawNeeds.Content, func(left, right *yaml.Node) int { return strings.Compare(left.Value, right.Value) })
workflow.Jobs[jobID].RawNeeds = rawNeeds
+63 -112
View File
@@ -13,21 +13,17 @@ import (
)
func Test_generateWorkflow(t *testing.T) {
type args struct {
task *runnerv1.Task
}
tests := []struct {
name string
args args
payload string
needs map[string]*runnerv1.TaskNeed
assert func(t *testing.T, wf *model.Workflow)
want1 string
jobID string
wantErr bool
}{
{
name: "has needs",
args: args{
task: &runnerv1.Task{
WorkflowPayload: []byte(`
payload: `
name: Build and deploy
on: push
@@ -39,34 +35,29 @@ jobs:
- uses: actions/checkout@v3
- run: ./deploy --build ${{ needs.job1.outputs.output1 }}
- run: ./deploy --build ${{ needs.job2.outputs.output2 }}
`),
Needs: map[string]*runnerv1.TaskNeed{
"job1": {
Outputs: map[string]string{
"output1": "output1 value",
},
Result: runnerv1.Result_RESULT_SUCCESS,
},
"job2": {
Outputs: map[string]string{
"output2": "output2 value",
},
Result: runnerv1.Result_RESULT_SUCCESS,
},
`,
needs: map[string]*runnerv1.TaskNeed{
"job1": {
Outputs: map[string]string{
"output1": "output1 value",
},
Result: runnerv1.Result_RESULT_SUCCESS,
},
"job2": {
Outputs: map[string]string{
"output2": "output2 value",
},
Result: runnerv1.Result_RESULT_SUCCESS,
},
},
assert: func(t *testing.T, wf *model.Workflow) {
require.Equal(t, []string{"job1", "job2"}, wf.GetJob("job9").Needs())
},
want1: "job9",
wantErr: false,
jobID: "job9",
},
{
name: "no needs",
args: args{
task: &runnerv1.Task{
WorkflowPayload: []byte(`
payload: `
name: Simple workflow
on: push
@@ -76,23 +67,18 @@ jobs:
steps:
- uses: actions/checkout@v3
- run: echo "hello"
`),
Needs: map[string]*runnerv1.TaskNeed{},
},
},
`,
needs: map[string]*runnerv1.TaskNeed{},
assert: func(t *testing.T, wf *model.Workflow) {
job := wf.GetJob("test")
require.Equal(t, []string{}, job.Needs())
require.Len(t, job.Steps, 2)
},
want1: "test",
wantErr: false,
jobID: "test",
},
{
name: "needs list",
args: args{
task: &runnerv1.Task{
WorkflowPayload: []byte(`
payload: `
name: Workflow with list needs
on: push
@@ -102,40 +88,34 @@ jobs:
runs-on: ubuntu-latest
steps:
- run: echo "deploying"
`),
Needs: map[string]*runnerv1.TaskNeed{
"build": {
Outputs: map[string]string{},
Result: runnerv1.Result_RESULT_SUCCESS,
},
"test": {
Outputs: map[string]string{
"coverage": "80%",
},
Result: runnerv1.Result_RESULT_SUCCESS,
},
"lint": {
Outputs: map[string]string{},
Result: runnerv1.Result_RESULT_FAILURE,
},
`,
needs: map[string]*runnerv1.TaskNeed{
"build": {
Outputs: map[string]string{},
Result: runnerv1.Result_RESULT_SUCCESS,
},
"test": {
Outputs: map[string]string{
"coverage": "80%",
},
Result: runnerv1.Result_RESULT_SUCCESS,
},
"lint": {
Outputs: map[string]string{},
Result: runnerv1.Result_RESULT_FAILURE,
},
},
assert: func(t *testing.T, wf *model.Workflow) {
job := wf.GetJob("deploy")
needs := job.Needs()
require.Equal(t, []string{"build", "lint", "test"}, needs)
require.Equal(t, []string{"build", "lint", "test"}, job.Needs())
require.Equal(t, "80%", wf.Jobs["test"].Outputs["coverage"])
require.Equal(t, "failure", wf.Jobs["lint"].Result)
},
want1: "deploy",
wantErr: false,
jobID: "deploy",
},
{
name: "workflow env and defaults",
args: args{
task: &runnerv1.Task{
WorkflowPayload: []byte(`
payload: `
name: Complex workflow
on:
push:
@@ -159,25 +139,19 @@ jobs:
node-version: "18"
- run: npm ci
- run: npm run build
`),
Needs: map[string]*runnerv1.TaskNeed{},
},
},
`,
needs: map[string]*runnerv1.TaskNeed{},
assert: func(t *testing.T, wf *model.Workflow) {
require.Equal(t, "Complex workflow", wf.Name)
require.Equal(t, "production", wf.Env["NODE_ENV"])
require.Equal(t, "true", wf.Env["CI"])
job := wf.GetJob("build")
require.Len(t, job.Steps, 4)
require.Len(t, wf.GetJob("build").Steps, 4)
},
want1: "build",
wantErr: false,
jobID: "build",
},
{
name: "job with container and services",
args: args{
task: &runnerv1.Task{
WorkflowPayload: []byte(`
payload: `
name: Integration tests
on: push
@@ -192,24 +166,18 @@ jobs:
steps:
- uses: actions/checkout@v3
- run: npm test
`),
Needs: map[string]*runnerv1.TaskNeed{},
},
},
`,
needs: map[string]*runnerv1.TaskNeed{},
assert: func(t *testing.T, wf *model.Workflow) {
job := wf.GetJob("integration")
container := job.Container()
require.Equal(t, "node:18", container.Image)
require.Equal(t, "node:18", job.Container().Image)
require.Equal(t, "postgres:15", job.Services["postgres"].Image)
},
want1: "integration",
wantErr: false,
jobID: "integration",
},
{
name: "job with matrix strategy",
args: args{
task: &runnerv1.Task{
WorkflowPayload: []byte(`
payload: `
name: Matrix build
on: push
@@ -222,40 +190,28 @@ jobs:
steps:
- uses: actions/checkout@v3
- run: go test ./...
`),
Needs: map[string]*runnerv1.TaskNeed{},
},
},
`,
needs: map[string]*runnerv1.TaskNeed{},
assert: func(t *testing.T, wf *model.Workflow) {
job := wf.GetJob("test")
matrixes, err := job.GetMatrixes()
matrixes, err := wf.GetJob("test").GetMatrixes()
require.NoError(t, err)
require.Len(t, matrixes, 2)
},
want1: "test",
wantErr: false,
jobID: "test",
},
{
name: "special yaml characters in values",
args: args{
task: &runnerv1.Task{
WorkflowPayload: []byte("name: \"Special: characters & test\"\non: push\n\njobs:\n test:\n runs-on: ubuntu-latest\n steps:\n - run: 'echo \"hello: world\"'\n - run: 'echo \"quotes & ampersands\"'\n - run: |\n echo \"multiline\"\n echo \"script\"\n"),
Needs: map[string]*runnerv1.TaskNeed{},
},
},
name: "special yaml characters in values",
payload: "name: \"Special: characters & test\"\non: push\n\njobs:\n test:\n runs-on: ubuntu-latest\n steps:\n - run: 'echo \"hello: world\"'\n - run: 'echo \"quotes & ampersands\"'\n - run: |\n echo \"multiline\"\n echo \"script\"\n",
needs: map[string]*runnerv1.TaskNeed{},
assert: func(t *testing.T, wf *model.Workflow) {
require.Equal(t, "Special: characters & test", wf.Name)
job := wf.GetJob("test")
require.Len(t, job.Steps, 3)
require.Len(t, wf.GetJob("test").Steps, 3)
},
want1: "test",
wantErr: false,
jobID: "test",
},
{
name: "invalid yaml",
args: args{
task: &runnerv1.Task{
WorkflowPayload: []byte(`
payload: `
name: Bad workflow
on: push
jobs:
@@ -264,25 +220,21 @@ jobs:
steps:
- run: echo "ok"
bad-indent: true
`),
Needs: map[string]*runnerv1.TaskNeed{},
},
},
assert: nil,
want1: "",
`,
needs: map[string]*runnerv1.TaskNeed{},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, got1, err := generateWorkflow(tt.args.task)
got, got1, err := generateWorkflow(&runnerv1.Task{WorkflowPayload: []byte(tt.payload), Needs: tt.needs})
if tt.wantErr {
require.Error(t, err)
return
}
require.NoError(t, err)
tt.assert(t, got)
require.Equal(t, tt.want1, got1)
require.Equal(t, tt.jobID, got1)
})
}
}
@@ -321,7 +273,6 @@ func Test_yamlV4NodeRoundTrip(t *testing.T) {
})
t.Run("node kind constants", func(t *testing.T) {
// Verify yaml/v4 node kind constants are usable (same API as v3)
require.NotEqual(t, yaml.ScalarNode, yaml.SequenceNode)
require.NotEqual(t, yaml.SequenceNode, yaml.MappingNode)
})