Script 'mail_helper' called by obssrc Hello community, here is the log from the commit of package semaphore for openSUSE:Factory checked in at 2026-10-09 21:45:13 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Comparing /work/SRC/openSUSE:Factory/semaphore (Old) and /work/SRC/openSUSE:Factory/.semaphore.new.3698736 (New) ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Package is "semaphore" Fri Oct 9 21:45:13 2026 rev:58 rq:1383678 version:2.19.16 Changes: -------- --- /work/SRC/openSUSE:Factory/semaphore/semaphore.changes 2026-09-18 22:08:11.756761147 +0200 +++ /work/SRC/openSUSE:Factory/.semaphore.new.3698736/semaphore.changes 2026-10-09 21:45:19.892187084 +0200 @@ -1,0 +2,39 @@ +Fri Oct 09 05:17:38 UTC 2026 - Johannes Kastl <[email protected]> + +- Update to version 2.19.16 (no releases between .12 and this one): + * Configurable runner check interval + Runners can now be told how often to ask the server for new + jobs. Until now the interval was hard-coded to 1 second, which + on installations with many runners produced a noticeable amount + of request traffic. + New option in the runner section of the runner config: + + { + "runner": { + "check_interval_seconds": 5 + } + } + + Or via environment variable: + + SEMAPHORE_RUNNER_CHECK_INTERVAL_SECONDS=5 + + Default: 1 second, so existing installations behave exactly as + before. Values of 0, negative values, or values too large to + represent as a duration fall back to the default. + + What changes and what does not. Only the check for new jobs + honours the configured interval. The runner's progress report, + which also serves as its heartbeat, keeps running once per + second. Raising the check interval therefore does not affect + when the server marks a runner offline. Jobs will start up to + check_interval_seconds later than before, so pick a value that + matches how quickly you need new tasks to be picked up. + Affected components: runner (semaphore runner start), config + schema. No database migration, no changes to the server or the + web interface. + * refactor(config): remove redundant conf.Dirs nil checks in + GetSecretsPath + * fix: old secrets path in config + +------------------------------------------------------------------- Old: ---- semaphore-2.19.12.obscpio web-2.19.12.tar.gz New: ---- semaphore-2.19.16.obscpio web-2.19.16.tar.gz ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Other differences: ------------------ ++++++ semaphore.spec ++++++ --- /var/tmp/diff_new_pack.JM6mFS/_old 2026-10-09 21:45:27.096490237 +0200 +++ /var/tmp/diff_new_pack.JM6mFS/_new 2026-10-09 21:45:27.097490279 +0200 @@ -17,7 +17,7 @@ Name: semaphore -Version: 2.19.12 +Version: 2.19.16 Release: 0 Summary: Modern UI for Ansible License: MIT ++++++ _service ++++++ --- /var/tmp/diff_new_pack.JM6mFS/_old 2026-10-09 21:45:27.170493351 +0200 +++ /var/tmp/diff_new_pack.JM6mFS/_new 2026-10-09 21:45:27.176493603 +0200 @@ -4,8 +4,8 @@ <param name="scm">git</param> <param name="submodules">disable</param> <param name="exclude">.git</param> - <param name="revision">refs/tags/v2.19.12</param> - <param name="match-tag">v2.19.12</param> + <param name="revision">refs/tags/v2.19.16</param> + <param name="match-tag">v2.19.16</param> <param name="versionformat">@PARENT_TAG@</param> <param name="versionrewrite-pattern">v(.*)</param> <param name="changesgenerate">enable</param> ++++++ _servicedata ++++++ --- /var/tmp/diff_new_pack.JM6mFS/_old 2026-10-09 21:45:27.221495497 +0200 +++ /var/tmp/diff_new_pack.JM6mFS/_new 2026-10-09 21:45:27.226495707 +0200 @@ -3,6 +3,6 @@ <param name="url">https://github.com/ansible-semaphore/semaphore</param> <param name="changesrevision">8a4dcf0868af718aaa5871368a3247dd622521f4</param></service><service name="tar_scm"> <param name="url">https://github.com/ansible-semaphore/semaphore.git</param> - <param name="changesrevision">012ed06d3eccadaed594c73b93b3d8a2459b576f</param></service></servicedata> + <param name="changesrevision">f8cefe62da4d40e536323e3dd936eb2e04e82b1a</param></service></servicedata> (No newline at EOF) ++++++ semaphore-2.19.12.obscpio -> semaphore-2.19.16.obscpio ++++++ diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/semaphore-2.19.12/config.schema.yaml new/semaphore-2.19.16/config.schema.yaml --- old/semaphore-2.19.12/config.schema.yaml 2026-08-30 12:30:17.000000000 +0200 +++ new/semaphore-2.19.16/config.schema.yaml 2026-10-07 07:29:04.000000000 +0200 @@ -572,6 +572,14 @@ enabled: { type: boolean } project_id: type: [integer, "null"] + check_interval_seconds: + type: integer + minimum: 1 + default: 1 + description: > + How often the runner polls the server for new jobs. Raising it reduces + request volume on a server with many runners, at the cost of jobs + starting slightly later. connection: { $ref: "#/$defs/RunnerConnectionConfig" } executor: { $ref: "#/$defs/ExecutorConfig" } diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/semaphore-2.19.12/services/runners/job_pool.go new/semaphore-2.19.16/services/runners/job_pool.go --- old/semaphore-2.19.12/services/runners/job_pool.go 2026-08-30 12:30:17.000000000 +0200 +++ new/semaphore-2.19.16/services/runners/job_pool.go 2026-10-07 07:29:04.000000000 +0200 @@ -256,6 +256,11 @@ return } +// runnerProgressInterval is how often the runner reports progress. It is not +// configurable: the report is also the heartbeat the server uses to decide a +// runner is still alive. +const runnerProgressInterval = time.Second + func (p *JobPool) Run() { launched := false @@ -265,8 +270,20 @@ }).Panic("runner token is empty, cannot start the runner") } + checkInterval := util.Config.RunnerCheckInterval() + + log.WithFields(log.Fields{ + "context": "job_running", + "check_interval": checkInterval, + }).Debug("Runner poll interval") + + // The progress report doubles as the runner's heartbeat, and the server marks + // a runner offline after a fixed timeout, so its cadence is fixed. Only the + // job check honours the configured interval. + lastJobCheck := time.Time{} + queueTicker := time.NewTicker(5 * time.Second) - requestTimer := time.NewTicker(1 * time.Second) + requestTimer := time.NewTicker(runnerProgressInterval) p.resetRunningJobs() defer func() { @@ -402,7 +419,10 @@ os.Exit(0) } - p.checkNewJobs() + if time.Since(lastJobCheck) >= checkInterval { + lastJobCheck = time.Now() + p.checkNewJobs() + } }() } diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/semaphore-2.19.12/services/server/access_key_serializer_local.go new/semaphore-2.19.16/services/server/access_key_serializer_local.go --- old/semaphore-2.19.12/services/server/access_key_serializer_local.go 2026-08-30 12:30:17.000000000 +0200 +++ new/semaphore-2.19.16/services/server/access_key_serializer_local.go 2026-10-07 07:29:04.000000000 +0200 @@ -119,7 +119,7 @@ } } - secretsBasePath := filepath.Clean(util.Config.Dirs.Secrets) + secretsBasePath := filepath.Clean(util.Config.GetSecretsPath()) if !filepath.IsAbs(secretsBasePath) { err = common_errors.NewUserErrorS("secrets path must be absolute") return diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/semaphore-2.19.12/util/config.go new/semaphore-2.19.16/util/config.go --- old/semaphore-2.19.12/util/config.go 2026-08-30 12:30:17.000000000 +0200 +++ new/semaphore-2.19.16/util/config.go 2026-10-07 07:29:04.000000000 +0200 @@ -9,6 +9,7 @@ "errors" "fmt" "io" + "math" "net/url" "os" "os/exec" @@ -167,6 +168,10 @@ MaxParallelTasks int `json:"max_parallel_tasks,omitempty" default:"9999" env:"SEMAPHORE_RUNNER_MAX_PARALLEL_TASKS"` ProjectID *int `json:"project_id,omitempty" env:"SEMAPHORE_RUNNER_PROJECT_ID"` + // CheckIntervalSeconds is how often the runner polls the server for new jobs. + // Plain int, not time.Duration, for env-binding simplicity. + CheckIntervalSeconds int `json:"check_interval_seconds,omitempty" default:"1" env:"SEMAPHORE_RUNNER_CHECK_INTERVAL_SECONDS"` + Connection *RunnerConnectionConfig `json:"connection,omitempty"` Executor *ExecutorConfig `json:"executor,omitempty" env:"SEMAPHORE_RUNNER_EXECUTOR"` @@ -487,6 +492,10 @@ // semaphore stores ephemeral projects here TmpPath string `json:"tmp_path,omitempty" default:"/tmp/semaphore" env:"SEMAPHORE_TMP_PATH"` + // SecretsPath is a legacy top-level setting for backwards compatibility. + // Users should prefer configuring dirs.secrets instead. + SecretsPath string `json:"secrets_path,omitempty" env:"SEMAPHORE_SECRETS_PATH"` + // HomeDirMode controls how the HOME environment variable is set for tasks. // "template_home" (default) — HOME is set to a per-template directory, // isolating .ansible/ across parallel tasks. Repo is cloned into a @@ -628,6 +637,21 @@ defaultRunnersReconcileIntervalSec = 30 ) +// GetSecretsPath returns the secrets path from configuration. +// Used for backward compatibility with legacy top-level secrets_path. +func (conf *ConfigType) GetSecretsPath() string { + if conf.Dirs.Secrets != "" && conf.Dirs.Secrets != "/tmp/semaphore" { + return conf.Dirs.Secrets + } + if conf.SecretsPath != "" { + return conf.SecretsPath + } + if conf.Dirs.Secrets != "" { + return conf.Dirs.Secrets + } + return "/tmp/semaphore" +} + // RunnersOfflineTimeout returns the heartbeat staleness after which a runner // is considered offline (no new tasks; its "starting" tasks are reassigned). func (conf *ConfigType) RunnersOfflineTimeout() time.Duration { @@ -652,6 +676,25 @@ return res } +// Default poll interval for a runner asking the server for work. +const defaultRunnerCheckIntervalSec = 1 + +// Larger overflows time.Duration and panics time.NewTicker. Kept as int64: the +// value exceeds int on 32-bit release targets (386, arm). +const maxRunnerCheckIntervalSec int64 = int64(math.MaxInt64) / int64(time.Second) + +// RunnerCheckInterval returns how often this runner polls the server for new +// jobs. Out-of-range values fall back to the default: 0 is indistinguishable +// from "unset" after defaults are applied, and oversized values overflow. +func (conf *ConfigType) RunnerCheckInterval() time.Duration { + sec := int64(defaultRunnerCheckIntervalSec) + if configured := int64(conf.Runner.CheckIntervalSeconds); configured > 0 && + configured <= maxRunnerCheckIntervalSec { + sec = configured + } + return time.Duration(sec) * time.Second +} + // RunnersReconcileInterval returns how often the server reconciles dispatched // tasks against runner liveness. func (conf *ConfigType) RunnersReconcileInterval() time.Duration { @@ -927,10 +970,18 @@ } func loadConfigDefaults() { + legacySecretsPath := Config.SecretsPath + if Config.Dirs == nil { + Config.Dirs = &ConfigDirs{} + } err := loadDefaultsToObject(Config) if err != nil { panic(err) } + + if legacySecretsPath != "" && (Config.Dirs.Secrets == "/tmp/semaphore" || Config.Dirs.Secrets == "") { + Config.Dirs.Secrets = legacySecretsPath + } } func castStringToInt(value string) int { diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/semaphore-2.19.12/util/config_test.go new/semaphore-2.19.16/util/config_test.go --- old/semaphore-2.19.12/util/config_test.go 2026-08-30 12:30:17.000000000 +0200 +++ new/semaphore-2.19.16/util/config_test.go 2026-10-07 07:29:04.000000000 +0200 @@ -141,6 +141,36 @@ } } +func TestLoadEnvironmentToObject_RunnerExecutor(t *testing.T) { + var val RunnerConfig + + origExecutor, hadExecutor := os.LookupEnv("SEMAPHORE_RUNNER_EXECUTOR") + origNetwork, hadNetwork := os.LookupEnv("SEMAPHORE_RUNNER_DOCKER_NETWORK") + t.Cleanup(func() { + if hadExecutor { + _ = os.Setenv("SEMAPHORE_RUNNER_EXECUTOR", origExecutor) + } else { + _ = os.Unsetenv("SEMAPHORE_RUNNER_EXECUTOR") + } + if hadNetwork { + _ = os.Setenv("SEMAPHORE_RUNNER_DOCKER_NETWORK", origNetwork) + } else { + _ = os.Unsetenv("SEMAPHORE_RUNNER_DOCKER_NETWORK") + } + }) + require.NoError(t, os.Setenv("SEMAPHORE_RUNNER_EXECUTOR", `{"type":"docker","docker":{"image":"example.com/job:1"}}`)) + require.NoError(t, os.Setenv("SEMAPHORE_RUNNER_DOCKER_NETWORK", "host")) + + _, err := loadEnvironmentToObject(&val) + require.NoError(t, err) + + require.NotNil(t, val.Executor) + assert.Equal(t, ExecutorTypeDocker, val.Executor.Type) + assert.Equal(t, "example.com/job:1", val.Executor.Docker.Image) + // individual executor env vars still override fields of the JSON object + assert.Equal(t, "host", val.Executor.Docker.Network) +} + func TestLoadEnvironmentToObject_SensitiveEnvs(t *testing.T) { type sub struct { Field string `json:"field"` @@ -585,3 +615,53 @@ ensureConfigValidationFailure(t, "AccessKeyEncryption", Config.AccessKeyEncryption) Config.AccessKeyEncryption = testCookieHash } + +func TestGetSecretsPath_DirsSecrets(t *testing.T) { + Config = NewConfigType() + Config.Dirs = &ConfigDirs{Secrets: "/custom/dirs/secrets"} + loadConfigDefaults() + assert.Equal(t, "/custom/dirs/secrets", Config.GetSecretsPath()) +} + +func TestGetSecretsPath_LegacySecretsPath(t *testing.T) { + Config = NewConfigType() + Config.SecretsPath = "/legacy/secrets/path" + loadConfigDefaults() + assert.Equal(t, "/legacy/secrets/path", Config.GetSecretsPath()) + require.NotNil(t, Config.Dirs) + assert.Equal(t, "/legacy/secrets/path", Config.Dirs.Secrets) +} + +func TestGetSecretsPath_Precedence(t *testing.T) { + Config = NewConfigType() + Config.Dirs = &ConfigDirs{Secrets: "/custom/dirs/secrets"} + Config.SecretsPath = "/legacy/secrets/path" + loadConfigDefaults() + assert.Equal(t, "/custom/dirs/secrets", Config.GetSecretsPath()) +} + +func TestGetSecretsPath_Default(t *testing.T) { + Config = NewConfigType() + loadConfigDefaults() + assert.Equal(t, "/tmp/semaphore", Config.GetSecretsPath()) + require.NotNil(t, Config.Dirs) + assert.Equal(t, "/tmp/semaphore", Config.Dirs.SSHAgentSockets) +} + +func TestGetSecretsPath_Env(t *testing.T) { + Config = NewConfigType() + orig, existed := os.LookupEnv("SEMAPHORE_SECRETS_PATH") + t.Cleanup(func() { + if existed { + _ = os.Setenv("SEMAPHORE_SECRETS_PATH", orig) + } else { + _ = os.Unsetenv("SEMAPHORE_SECRETS_PATH") + } + }) + _ = os.Setenv("SEMAPHORE_SECRETS_PATH", "/env/secrets/path") + + loadConfigEnvironment() + loadConfigDefaults() + + assert.Equal(t, "/env/secrets/path", Config.GetSecretsPath()) +} diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/semaphore-2.19.12/util/runner_check_interval_test.go new/semaphore-2.19.16/util/runner_check_interval_test.go --- old/semaphore-2.19.12/util/runner_check_interval_test.go 1970-01-01 01:00:00.000000000 +0100 +++ new/semaphore-2.19.16/util/runner_check_interval_test.go 2026-10-07 07:29:04.000000000 +0200 @@ -0,0 +1,94 @@ +package util + +import ( + "math" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// intHoldsOverflowingInterval reports whether an int is wide enough to express a +// value past the duration limit. On 32-bit targets (386, arm) it is not, so +// those cases cannot occur there and are skipped. +const intHoldsOverflowingInterval = int64(math.MaxInt) > maxRunnerCheckIntervalSec + +// A variable, not a constant: int(maxIntervalSec) would be a constant +// conversion and fail to compile on 32-bit even inside a guarded branch. +var maxIntervalSec = maxRunnerCheckIntervalSec + +func TestRunnerCheckInterval(t *testing.T) { + tests := []struct { + name string + runner *RunnerConfig + expected time.Duration + }{ + {"unset field", &RunnerConfig{}, time.Second}, + {"zero falls back", &RunnerConfig{CheckIntervalSeconds: 0}, time.Second}, + {"negative falls back", &RunnerConfig{CheckIntervalSeconds: -5}, time.Second}, + {"configured", &RunnerConfig{CheckIntervalSeconds: 10}, 10 * time.Second}, + {"large", &RunnerConfig{CheckIntervalSeconds: 300}, 5 * time.Minute}, + {"largest int", &RunnerConfig{CheckIntervalSeconds: math.MaxInt}, + time.Duration(clampInterval(math.MaxInt)) * time.Second}, + } + + if intHoldsOverflowingInterval { + tests = append(tests, + struct { + name string + runner *RunnerConfig + expected time.Duration + }{"max representable", &RunnerConfig{CheckIntervalSeconds: int(maxIntervalSec)}, + time.Duration(maxRunnerCheckIntervalSec) * time.Second}, + struct { + name string + runner *RunnerConfig + expected time.Duration + }{"overflows falls back", &RunnerConfig{CheckIntervalSeconds: int(maxIntervalSec) + 1}, + time.Second}, + ) + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + conf := &ConfigType{Runner: tt.runner} + assert.Equal(t, tt.expected, conf.RunnerCheckInterval()) + }) + } +} + +func clampInterval(sec int) int64 { + v := int64(sec) + if v > 0 && v <= maxRunnerCheckIntervalSec { + return v + } + return int64(defaultRunnerCheckIntervalSec) +} + +// The result must always be safe for time.NewTicker. +func TestRunnerCheckIntervalNeverPanicsTicker(t *testing.T) { + secs := []int{0, -1, 1, 300, math.MaxInt} + if intHoldsOverflowingInterval { + secs = append(secs, int(maxIntervalSec), int(maxIntervalSec)+1) + } + + for _, sec := range secs { + conf := &ConfigType{Runner: &RunnerConfig{CheckIntervalSeconds: sec}} + d := conf.RunnerCheckInterval() + + assert.Positive(t, d, "interval must stay positive for CheckIntervalSeconds=%d", sec) + assert.NotPanics(t, func() { + time.NewTicker(d).Stop() + }, "time.NewTicker must accept the interval for CheckIntervalSeconds=%d", sec) + } +} + +// The default tag must match the constant. +func TestRunnerCheckIntervalDefaultMatchesTag(t *testing.T) { + conf := &ConfigType{Runner: &RunnerConfig{}} + require.NoError(t, loadDefaultsToObject(conf)) + + assert.Equal(t, defaultRunnerCheckIntervalSec, conf.Runner.CheckIntervalSeconds) + assert.Equal(t, time.Second, conf.RunnerCheckInterval()) +} ++++++ semaphore.obsinfo ++++++ --- /var/tmp/diff_new_pack.JM6mFS/_old 2026-10-09 21:45:28.413545658 +0200 +++ /var/tmp/diff_new_pack.JM6mFS/_new 2026-10-09 21:45:28.420545952 +0200 @@ -1,5 +1,5 @@ name: semaphore -version: 2.19.12 -mtime: 1788085817 -commit: 012ed06d3eccadaed594c73b93b3d8a2459b576f +version: 2.19.16 +mtime: 1791350944 +commit: f8cefe62da4d40e536323e3dd936eb2e04e82b1a ++++++ vendor.tar.gz ++++++ /work/SRC/openSUSE:Factory/semaphore/vendor.tar.gz /work/SRC/openSUSE:Factory/.semaphore.new.3698736/vendor.tar.gz differ: char 151, line 1 ++++++ web-2.19.12.tar.gz -> web-2.19.16.tar.gz ++++++ /work/SRC/openSUSE:Factory/semaphore/web-2.19.12.tar.gz /work/SRC/openSUSE:Factory/.semaphore.new.3698736/web-2.19.16.tar.gz differ: char 13, line 1
