mirror of
https://gitea.com/gitea/act_runner.git
synced 2026-08-06 08:54:21 +02:00
Compare commits
3 Commits
v2.3.0
...
0192861155
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0192861155 | ||
|
|
e6c7ba3a15 | ||
|
|
61f0cfa951 |
@@ -33,6 +33,8 @@ jobs:
|
|||||||
done
|
done
|
||||||
- name: lint
|
- name: lint
|
||||||
run: make lint
|
run: make lint
|
||||||
|
- name: checks
|
||||||
|
run: make checks
|
||||||
- name: build
|
- name: build
|
||||||
run: make build
|
run: make build
|
||||||
- name: test
|
- name: test
|
||||||
|
|||||||
11
Makefile
11
Makefile
@@ -21,6 +21,8 @@ DOCKER_ROOTLESS_REF := $(DOCKER_IMAGE):$(DOCKER_TAG)-dind-rootless
|
|||||||
GOLANGCI_LINT_PACKAGE ?= github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.12.2
|
GOLANGCI_LINT_PACKAGE ?= github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.12.2
|
||||||
GOVULNCHECK_PACKAGE ?= golang.org/x/vuln/cmd/govulncheck@v1.3.0
|
GOVULNCHECK_PACKAGE ?= golang.org/x/vuln/cmd/govulncheck@v1.3.0
|
||||||
|
|
||||||
|
GOTEST_FLAGS ?= -race -timeout 20m -parallel 8
|
||||||
|
|
||||||
STATIC ?=
|
STATIC ?=
|
||||||
EXTLDFLAGS ?=
|
EXTLDFLAGS ?=
|
||||||
ifneq ($(STATIC),)
|
ifneq ($(STATIC),)
|
||||||
@@ -110,6 +112,9 @@ deps-tools: ## install tool dependencies
|
|||||||
$(GO) install $(GOVULNCHECK_PACKAGE) & \
|
$(GO) install $(GOVULNCHECK_PACKAGE) & \
|
||||||
wait
|
wait
|
||||||
|
|
||||||
|
.PHONY: checks
|
||||||
|
checks: tidy-check fmt-check security-check ## run the non-lint source checks
|
||||||
|
|
||||||
.PHONY: lint
|
.PHONY: lint
|
||||||
lint: lint-go lint-go-windows ## lint everything
|
lint: lint-go lint-go-windows ## lint everything
|
||||||
|
|
||||||
@@ -131,7 +136,7 @@ lint-pr-title: ## lint PR title against Conventional Commits (set PR_TITLE=...)
|
|||||||
@node ./tools/lint-pr-title.ts
|
@node ./tools/lint-pr-title.ts
|
||||||
|
|
||||||
.PHONY: security-check
|
.PHONY: security-check
|
||||||
security-check: deps-tools
|
security-check:
|
||||||
GOEXPERIMENT= $(GO) run $(GOVULNCHECK_PACKAGE) -show color ./... || true
|
GOEXPERIMENT= $(GO) run $(GOVULNCHECK_PACKAGE) -show color ./... || true
|
||||||
|
|
||||||
.PHONY: tidy
|
.PHONY: tidy
|
||||||
@@ -148,8 +153,8 @@ tidy-check: tidy
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
.PHONY: test
|
.PHONY: test
|
||||||
test: fmt-check security-check ## test everything (integration tests self-skip without docker/network)
|
test: ## test everything (integration tests self-skip without docker/network)
|
||||||
@$(GO) test -race -timeout 20m -v -cover -coverprofile coverage.txt ./... && echo "\n==>\033[32m Ok\033[m\n" || exit 1
|
@$(GO) test $(GOTEST_FLAGS) -cover -coverprofile coverage.txt ./... && echo "\n==>\033[32m Ok\033[m\n" || exit 1
|
||||||
|
|
||||||
.PHONY: coverage-report
|
.PHONY: coverage-report
|
||||||
coverage-report: ## turn coverage.txt from `make test` into .tmp/coverage.md
|
coverage-report: ## turn coverage.txt from `make test` into .tmp/coverage.md
|
||||||
|
|||||||
10
README.md
10
README.md
@@ -270,6 +270,16 @@ On Windows, use `.exe`, `.bat`, or `.cmd` paths; **PowerShell (`.ps1`) is not su
|
|||||||
|
|
||||||
See **[docs/post-task-script.md](docs/post-task-script.md)** for lifecycle details, environment variables, timeout interaction, and platform notes.
|
See **[docs/post-task-script.md](docs/post-task-script.md)** for lifecycle details, environment variables, timeout interaction, and platform notes.
|
||||||
|
|
||||||
|
#### Job hooks (`runner.hooks.job_started`, `runner.hooks.job_completed`)
|
||||||
|
|
||||||
|
Optional scripts that run **inside the job environment** (the job container, or the host in host mode), before the job's first step and after its last one. They are the equivalent of GitHub's `ACTIONS_RUNNER_HOOK_JOB_STARTED` / `ACTIONS_RUNNER_HOOK_JOB_COMPLETED`, which are read when the settings are unset.
|
||||||
|
|
||||||
|
Because they run where the steps run and see the job's environment, they are the place for per-job setup no workflow should have to carry: registry logins, mirror configuration, or masking runner-wide secrets with `::add-mask::`. Their output is part of the job log and is scanned for workflow commands, and they can export to the job through `$GITHUB_ENV` and `$GITHUB_PATH`.
|
||||||
|
|
||||||
|
Both hooks are synchronous and block the job while they run. Either one exiting non-zero fails the job, and there is no per-hook timeout.
|
||||||
|
|
||||||
|
See **[docs/job-hooks.md](docs/job-hooks.md)** for the execution order, environment, and platform notes.
|
||||||
|
|
||||||
### Example Deployments
|
### Example Deployments
|
||||||
|
|
||||||
Check out the [examples](examples) directory for sample deployment types.
|
Check out the [examples](examples) directory for sample deployment types.
|
||||||
|
|||||||
@@ -445,13 +445,6 @@ func TestHandler(t *testing.T) {
|
|||||||
require.Equal(t, 404, resp.StatusCode)
|
require.Equal(t, 404, resp.StatusCode)
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("get with not exist id", func(t *testing.T) {
|
|
||||||
resp, err := testClient.Get(signArtifactURL(handler, 100))
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer resp.Body.Close()
|
|
||||||
require.Equal(t, 404, resp.StatusCode)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("get with multiple keys", func(t *testing.T) {
|
t.Run("get with multiple keys", func(t *testing.T) {
|
||||||
version := "c19da02a2bd7e77277f1ac29ab45c09b7d46a4ee758284e26bb3045ad11d9d20"
|
version := "c19da02a2bd7e77277f1ac29ab45c09b7d46a4ee758284e26bb3045ad11d9d20"
|
||||||
key := strings.ToLower(t.Name())
|
key := strings.ToLower(t.Name())
|
||||||
@@ -469,7 +462,8 @@ func TestHandler(t *testing.T) {
|
|||||||
_, err := rand.Read(contents[i])
|
_, err := rand.Read(contents[i])
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
uploadCacheNormally(t, base, keys[i], version, contents[i])
|
uploadCacheNormally(t, base, keys[i], version, contents[i])
|
||||||
time.Sleep(time.Second) // ensure CreatedAt of caches are different
|
// ensure CreatedAt of caches are different, in upload order
|
||||||
|
backdateCache(t, handler, keys[i], time.Duration(len(contents)-i)*time.Second)
|
||||||
}
|
}
|
||||||
|
|
||||||
reqKeys := strings.Join([]string{
|
reqKeys := strings.Join([]string{
|
||||||
@@ -554,7 +548,8 @@ func TestHandler(t *testing.T) {
|
|||||||
_, err := rand.Read(contents[i])
|
_, err := rand.Read(contents[i])
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
uploadCacheNormally(t, base, keys[i], version, contents[i])
|
uploadCacheNormally(t, base, keys[i], version, contents[i])
|
||||||
time.Sleep(time.Second) // ensure CreatedAt of caches are different
|
// ensure CreatedAt of caches are different, in upload order
|
||||||
|
backdateCache(t, handler, keys[i], time.Duration(len(contents)-i)*time.Second)
|
||||||
}
|
}
|
||||||
|
|
||||||
reqKeys := strings.Join([]string{
|
reqKeys := strings.Join([]string{
|
||||||
@@ -607,7 +602,8 @@ func TestHandler(t *testing.T) {
|
|||||||
_, err := rand.Read(contents[i])
|
_, err := rand.Read(contents[i])
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
uploadCacheNormally(t, base, keys[i], version, contents[i])
|
uploadCacheNormally(t, base, keys[i], version, contents[i])
|
||||||
time.Sleep(time.Second) // ensure CreatedAt of caches are different
|
// ensure CreatedAt of caches are different, in upload order
|
||||||
|
backdateCache(t, handler, keys[i], time.Duration(len(contents)-i)*time.Second)
|
||||||
}
|
}
|
||||||
|
|
||||||
reqKeys := strings.Join([]string{
|
reqKeys := strings.Join([]string{
|
||||||
@@ -646,6 +642,20 @@ func TestHandler(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// backdateCache rewrites a cache's CreatedAt. It has one-second resolution, so age-ordering
|
||||||
|
// tests set it directly instead of sleeping a second between uploads.
|
||||||
|
func backdateCache(t *testing.T, handler *Handler, key string, age time.Duration) {
|
||||||
|
db, err := handler.openDB()
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
var caches []*Cache
|
||||||
|
require.NoError(t, db.Find(&caches, bolthold.Where("Key").Eq(key)))
|
||||||
|
require.Len(t, caches, 1)
|
||||||
|
caches[0].CreatedAt = time.Now().Add(-age).Unix()
|
||||||
|
require.NoError(t, db.Update(caches[0].ID, caches[0]))
|
||||||
|
}
|
||||||
|
|
||||||
func uploadCacheNormally(t *testing.T, base, key, version string, content []byte) { //nolint:unparam // pre-existing issue from nektos/act
|
func uploadCacheNormally(t *testing.T, base, key, version string, content []byte) { //nolint:unparam // pre-existing issue from nektos/act
|
||||||
var id uint64
|
var id uint64
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,89 +0,0 @@
|
|||||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
|
||||||
// SPDX-License-Identifier: MIT
|
|
||||||
|
|
||||||
package common
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"sync"
|
|
||||||
"sync/atomic"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Simple fast test that verifies max-parallel: 2 limits concurrency
|
|
||||||
func TestMaxParallel2Quick(t *testing.T) {
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
var currentRunning atomic.Int32
|
|
||||||
var maxSimultaneous atomic.Int32
|
|
||||||
|
|
||||||
executors := make([]Executor, 4)
|
|
||||||
for i := range 4 {
|
|
||||||
executors[i] = func(ctx context.Context) error {
|
|
||||||
current := currentRunning.Add(1)
|
|
||||||
|
|
||||||
// Update max if needed
|
|
||||||
for {
|
|
||||||
maxValue := maxSimultaneous.Load()
|
|
||||||
if current <= maxValue || maxSimultaneous.CompareAndSwap(maxValue, current) {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
time.Sleep(10 * time.Millisecond)
|
|
||||||
currentRunning.Add(-1)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
err := NewParallelExecutor(2, executors...)(ctx)
|
|
||||||
|
|
||||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
|
||||||
assert.LessOrEqual(t, maxSimultaneous.Load(), int32(2),
|
|
||||||
"Should not exceed max-parallel: 2")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test that verifies max-parallel: 1 enforces sequential execution
|
|
||||||
func TestMaxParallel1Sequential(t *testing.T) {
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
var currentRunning atomic.Int32
|
|
||||||
var maxSimultaneous atomic.Int32
|
|
||||||
var executionOrder []int
|
|
||||||
var orderMutex sync.Mutex
|
|
||||||
|
|
||||||
executors := make([]Executor, 5)
|
|
||||||
for i := range 5 {
|
|
||||||
taskID := i
|
|
||||||
executors[i] = func(ctx context.Context) error {
|
|
||||||
current := currentRunning.Add(1)
|
|
||||||
|
|
||||||
// Track execution order
|
|
||||||
orderMutex.Lock()
|
|
||||||
executionOrder = append(executionOrder, taskID)
|
|
||||||
orderMutex.Unlock()
|
|
||||||
|
|
||||||
// Update max if needed
|
|
||||||
for {
|
|
||||||
maxValue := maxSimultaneous.Load()
|
|
||||||
if current <= maxValue || maxSimultaneous.CompareAndSwap(maxValue, current) {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
time.Sleep(20 * time.Millisecond)
|
|
||||||
currentRunning.Add(-1)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
err := NewParallelExecutor(1, executors...)(ctx)
|
|
||||||
|
|
||||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
|
||||||
assert.Equal(t, int32(1), maxSimultaneous.Load(),
|
|
||||||
"max-parallel: 1 should only run 1 task at a time")
|
|
||||||
assert.Len(t, executionOrder, 5, "All 5 tasks should have executed")
|
|
||||||
}
|
|
||||||
@@ -1,221 +0,0 @@
|
|||||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
|
||||||
// SPDX-License-Identifier: MIT
|
|
||||||
|
|
||||||
package common
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"sync"
|
|
||||||
"sync/atomic"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TestMaxParallelJobExecution tests actual job execution with max-parallel
|
|
||||||
func TestMaxParallelJobExecution(t *testing.T) {
|
|
||||||
t.Run("MaxParallel=1 Sequential", func(t *testing.T) {
|
|
||||||
var currentRunning atomic.Int32
|
|
||||||
var maxConcurrent int32
|
|
||||||
var executionOrder []int
|
|
||||||
var mu sync.Mutex
|
|
||||||
|
|
||||||
executors := make([]Executor, 5)
|
|
||||||
for i := range 5 {
|
|
||||||
taskID := i
|
|
||||||
executors[i] = func(ctx context.Context) error {
|
|
||||||
current := currentRunning.Add(1)
|
|
||||||
|
|
||||||
// Track max concurrent
|
|
||||||
for {
|
|
||||||
maxValue := atomic.LoadInt32(&maxConcurrent)
|
|
||||||
if current <= maxValue || atomic.CompareAndSwapInt32(&maxConcurrent, maxValue, current) {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
mu.Lock()
|
|
||||||
executionOrder = append(executionOrder, taskID)
|
|
||||||
mu.Unlock()
|
|
||||||
|
|
||||||
time.Sleep(10 * time.Millisecond)
|
|
||||||
currentRunning.Add(-1)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
err := NewParallelExecutor(1, executors...)(ctx)
|
|
||||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
|
||||||
|
|
||||||
assert.Equal(t, int32(1), maxConcurrent, "Should never exceed 1 concurrent execution")
|
|
||||||
assert.Len(t, executionOrder, 5, "All tasks should execute")
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("MaxParallel=3 Limited", func(t *testing.T) {
|
|
||||||
var currentRunning atomic.Int32
|
|
||||||
var maxConcurrent int32
|
|
||||||
|
|
||||||
executors := make([]Executor, 10)
|
|
||||||
for i := range 10 {
|
|
||||||
executors[i] = func(ctx context.Context) error {
|
|
||||||
current := currentRunning.Add(1)
|
|
||||||
|
|
||||||
for {
|
|
||||||
maxValue := atomic.LoadInt32(&maxConcurrent)
|
|
||||||
if current <= maxValue || atomic.CompareAndSwapInt32(&maxConcurrent, maxValue, current) {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
time.Sleep(20 * time.Millisecond)
|
|
||||||
currentRunning.Add(-1)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
err := NewParallelExecutor(3, executors...)(ctx)
|
|
||||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
|
||||||
|
|
||||||
assert.LessOrEqual(t, int(maxConcurrent), 3, "Should never exceed 3 concurrent executions")
|
|
||||||
assert.GreaterOrEqual(t, int(maxConcurrent), 1, "Should have at least 1 concurrent execution")
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("MaxParallel=0 Uses1Worker", func(t *testing.T) {
|
|
||||||
var maxConcurrent int32
|
|
||||||
var currentRunning atomic.Int32
|
|
||||||
|
|
||||||
executors := make([]Executor, 5)
|
|
||||||
for i := range 5 {
|
|
||||||
executors[i] = func(ctx context.Context) error {
|
|
||||||
current := currentRunning.Add(1)
|
|
||||||
|
|
||||||
for {
|
|
||||||
maxValue := atomic.LoadInt32(&maxConcurrent)
|
|
||||||
if current <= maxValue || atomic.CompareAndSwapInt32(&maxConcurrent, maxValue, current) {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
time.Sleep(10 * time.Millisecond)
|
|
||||||
currentRunning.Add(-1)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
// When maxParallel is 0 or negative, it defaults to 1
|
|
||||||
err := NewParallelExecutor(0, executors...)(ctx)
|
|
||||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
|
||||||
|
|
||||||
assert.Equal(t, int32(1), maxConcurrent, "Should use 1 worker when max-parallel is 0")
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestMaxParallelWithErrors tests error handling with max-parallel
|
|
||||||
func TestMaxParallelWithErrors(t *testing.T) {
|
|
||||||
t.Run("OneTaskFailsOthersContinue", func(t *testing.T) {
|
|
||||||
var successCount int32
|
|
||||||
|
|
||||||
executors := make([]Executor, 5)
|
|
||||||
for i := range 5 {
|
|
||||||
taskID := i
|
|
||||||
executors[i] = func(ctx context.Context) error {
|
|
||||||
if taskID == 2 {
|
|
||||||
return assert.AnError
|
|
||||||
}
|
|
||||||
atomic.AddInt32(&successCount, 1)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
err := NewParallelExecutor(2, executors...)(ctx)
|
|
||||||
|
|
||||||
// Should return the error from task 2
|
|
||||||
assert.Error(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
|
||||||
|
|
||||||
// Other tasks should still execute
|
|
||||||
assert.Equal(t, int32(4), successCount, "4 tasks should succeed")
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("ContextCancellation", func(t *testing.T) {
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
|
||||||
|
|
||||||
var startedCount int32
|
|
||||||
executors := make([]Executor, 10)
|
|
||||||
for i := range 10 {
|
|
||||||
executors[i] = func(ctx context.Context) error {
|
|
||||||
atomic.AddInt32(&startedCount, 1)
|
|
||||||
time.Sleep(100 * time.Millisecond)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cancel after a short delay
|
|
||||||
go func() {
|
|
||||||
time.Sleep(30 * time.Millisecond)
|
|
||||||
cancel()
|
|
||||||
}()
|
|
||||||
|
|
||||||
err := NewParallelExecutor(3, executors...)(ctx)
|
|
||||||
assert.Error(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
|
||||||
assert.ErrorIs(t, err, context.Canceled) //nolint:testifylint // pre-existing issue from nektos/act
|
|
||||||
|
|
||||||
// Not all tasks should start due to cancellation (but timing may vary)
|
|
||||||
// Just verify cancellation occurred
|
|
||||||
t.Logf("Started %d tasks before cancellation", startedCount)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestMaxParallelResourceSharing tests resource sharing scenarios
|
|
||||||
func TestMaxParallelResourceSharing(t *testing.T) {
|
|
||||||
t.Run("SharedResourceWithMutex", func(t *testing.T) {
|
|
||||||
var sharedCounter int
|
|
||||||
var mu sync.Mutex
|
|
||||||
|
|
||||||
executors := make([]Executor, 100)
|
|
||||||
for i := range 100 {
|
|
||||||
executors[i] = func(ctx context.Context) error {
|
|
||||||
mu.Lock()
|
|
||||||
sharedCounter++
|
|
||||||
mu.Unlock()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
err := NewParallelExecutor(10, executors...)(ctx)
|
|
||||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
|
||||||
|
|
||||||
assert.Equal(t, 100, sharedCounter, "All tasks should increment counter")
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("ChannelCommunication", func(t *testing.T) {
|
|
||||||
resultChan := make(chan int, 50)
|
|
||||||
|
|
||||||
executors := make([]Executor, 50)
|
|
||||||
for i := range 50 {
|
|
||||||
taskID := i
|
|
||||||
executors[i] = func(ctx context.Context) error {
|
|
||||||
resultChan <- taskID
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
err := NewParallelExecutor(5, executors...)(ctx)
|
|
||||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
|
||||||
|
|
||||||
close(resultChan)
|
|
||||||
|
|
||||||
results := make(map[int]bool)
|
|
||||||
for result := range resultChan {
|
|
||||||
results[result] = true
|
|
||||||
}
|
|
||||||
|
|
||||||
assert.Len(t, results, 50, "All task IDs should be received")
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -9,9 +9,9 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"reflect"
|
"reflect"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
@@ -82,44 +82,45 @@ func TestNewConditionalExecutor(t *testing.T) {
|
|||||||
assert.Equal(1, falseCount)
|
assert.Equal(1, falseCount)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestNewParallelExecutor(t *testing.T) {
|
// concurrencyProbe returns an executor recording the peak number of concurrent copies. Copies
|
||||||
assert := assert.New(t)
|
// block until wantActive are in flight so the peak is exact without sleeping, and later copies
|
||||||
|
// find the gate already open so the last one still finishes with no partner left.
|
||||||
|
func concurrencyProbe(wantActive int32) (exec Executor, count, maxActive *atomic.Int32) {
|
||||||
|
var counted, active, peak atomic.Int32
|
||||||
|
var once sync.Once
|
||||||
|
reached := make(chan struct{})
|
||||||
|
|
||||||
ctx := context.Background()
|
return func(ctx context.Context) error {
|
||||||
|
counted.Add(1)
|
||||||
var count, activeCount, maxCount atomic.Int32
|
running := active.Add(1)
|
||||||
emptyWorkflow := NewPipelineExecutor(func(ctx context.Context) error {
|
|
||||||
count.Add(1)
|
|
||||||
|
|
||||||
active := activeCount.Add(1)
|
|
||||||
for {
|
for {
|
||||||
m := maxCount.Load()
|
seen := peak.Load()
|
||||||
if active <= m || maxCount.CompareAndSwap(m, active) {
|
if running <= seen || peak.CompareAndSwap(seen, running) {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
time.Sleep(2 * time.Second)
|
if running >= wantActive {
|
||||||
activeCount.Add(-1)
|
once.Do(func() { close(reached) })
|
||||||
|
}
|
||||||
|
<-reached
|
||||||
|
active.Add(-1)
|
||||||
return nil
|
return nil
|
||||||
})
|
}, &counted, &peak
|
||||||
|
}
|
||||||
|
|
||||||
err := NewParallelExecutor(2, emptyWorkflow, emptyWorkflow, emptyWorkflow)(ctx)
|
func TestNewParallelExecutor(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
assert.Equal(int32(3), count.Load(), "should run all 3 executors")
|
exec, count, maxActive := concurrencyProbe(2)
|
||||||
assert.Equal(int32(2), maxCount.Load(), "should run at most 2 executors in parallel")
|
require.NoError(t, NewParallelExecutor(2, exec, exec, exec)(ctx))
|
||||||
assert.NoError(err) //nolint:testifylint // pre-existing issue from nektos/act
|
assert.Equal(t, int32(3), count.Load(), "should run all 3 executors")
|
||||||
|
assert.Equal(t, int32(2), maxActive.Load(), "should run at most 2 executors in parallel")
|
||||||
|
|
||||||
// Reset to test running the executor with 0 parallelism
|
// parallelism below 1 falls back to a single worker
|
||||||
count.Store(0)
|
exec, count, maxActive = concurrencyProbe(1)
|
||||||
activeCount.Store(0)
|
require.NoError(t, NewParallelExecutor(0, exec, exec, exec)(ctx))
|
||||||
maxCount.Store(0)
|
assert.Equal(t, int32(3), count.Load(), "should run all 3 executors")
|
||||||
|
assert.Equal(t, int32(1), maxActive.Load(), "should run at most 1 executor in parallel")
|
||||||
errSingle := NewParallelExecutor(0, emptyWorkflow, emptyWorkflow, emptyWorkflow)(ctx)
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestNewParallelExecutorEmpty(t *testing.T) {
|
func TestNewParallelExecutorEmpty(t *testing.T) {
|
||||||
@@ -173,6 +174,23 @@ func TestNewParallelExecutorCanceled(t *testing.T) {
|
|||||||
assert.Error(errExpected, err) //nolint:testifylint // pre-existing issue from nektos/act
|
assert.Error(errExpected, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestNewParallelExecutorRunsRemainingAfterFailure(t *testing.T) {
|
||||||
|
var successCount atomic.Int32
|
||||||
|
executors := make([]Executor, 5)
|
||||||
|
for i := range executors {
|
||||||
|
executors[i] = func(ctx context.Context) error {
|
||||||
|
if i == 2 {
|
||||||
|
return errors.New("fake error")
|
||||||
|
}
|
||||||
|
successCount.Add(1)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
require.Error(t, NewParallelExecutor(2, executors...)(context.Background()))
|
||||||
|
assert.Equal(t, int32(4), successCount.Load(), "a failing executor must not stop the others")
|
||||||
|
}
|
||||||
|
|
||||||
func TestExecutorConditionalsAndFinally(t *testing.T) {
|
func TestExecutorConditionalsAndFinally(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
var calls []string
|
var calls []string
|
||||||
|
|||||||
@@ -471,8 +471,7 @@ func (cr *containerReference) mergeContainerConfigs(ctx context.Context, config
|
|||||||
|
|
||||||
logger.Debugf("Custom container.HostConfig from options ==> %+v", containerConfig.HostConfig)
|
logger.Debugf("Custom container.HostConfig from options ==> %+v", containerConfig.HostConfig)
|
||||||
|
|
||||||
hostConfig.Binds = append(hostConfig.Binds, containerConfig.HostConfig.Binds...)
|
overlayVolumes(hostConfig, containerConfig.HostConfig)
|
||||||
hostConfig.Mounts = append(hostConfig.Mounts, containerConfig.HostConfig.Mounts...)
|
|
||||||
binds := hostConfig.Binds
|
binds := hostConfig.Binds
|
||||||
mounts := hostConfig.Mounts
|
mounts := hostConfig.Mounts
|
||||||
networkMode := hostConfig.NetworkMode
|
networkMode := hostConfig.NetworkMode
|
||||||
@@ -1108,6 +1107,34 @@ func (cr *containerReference) sanitizeConfig(ctx context.Context, config *contai
|
|||||||
return config, hostConfig
|
return config, hostConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// bindTarget returns the container path a bind mounts onto, empty if it cannot be parsed.
|
||||||
|
func bindTarget(bind string) string {
|
||||||
|
parsed, err := loader.ParseVolume(bind)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return parsed.Target
|
||||||
|
}
|
||||||
|
|
||||||
|
// overlayVolumes appends src's volumes to dst, dropping the dst ones they mount over. Docker
|
||||||
|
// rejects two mounts on one target, so the volumes declared last have to win.
|
||||||
|
func overlayVolumes(dst, src *container.HostConfig) {
|
||||||
|
claimed := map[string]bool{}
|
||||||
|
for _, bind := range src.Binds {
|
||||||
|
if target := bindTarget(bind); target != "" {
|
||||||
|
claimed[target] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, mt := range src.Mounts {
|
||||||
|
claimed[mt.Target] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
dst.Binds = append(slices.DeleteFunc(slices.Clone(dst.Binds),
|
||||||
|
func(bind string) bool { return claimed[bindTarget(bind)] }), src.Binds...)
|
||||||
|
dst.Mounts = append(slices.DeleteFunc(slices.Clone(dst.Mounts),
|
||||||
|
func(mt mount.Mount) bool { return claimed[mt.Target] }), src.Mounts...)
|
||||||
|
}
|
||||||
|
|
||||||
type validVolumeMatcher struct {
|
type validVolumeMatcher struct {
|
||||||
allowAll bool
|
allowAll bool
|
||||||
named []glob.Glob
|
named []glob.Glob
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import (
|
|||||||
cerrdefs "github.com/containerd/errdefs"
|
cerrdefs "github.com/containerd/errdefs"
|
||||||
"github.com/moby/moby/api/pkg/stdcopy"
|
"github.com/moby/moby/api/pkg/stdcopy"
|
||||||
"github.com/moby/moby/api/types/container"
|
"github.com/moby/moby/api/types/container"
|
||||||
|
"github.com/moby/moby/api/types/mount"
|
||||||
mobyclient "github.com/moby/moby/client"
|
mobyclient "github.com/moby/moby/client"
|
||||||
"github.com/sirupsen/logrus/hooks/test"
|
"github.com/sirupsen/logrus/hooks/test"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
@@ -660,3 +661,22 @@ func TestCheckVolumesRejectsEscapingHostPaths(t *testing.T) {
|
|||||||
})
|
})
|
||||||
assert.Empty(t, hostConf.Binds)
|
assert.Empty(t, hostConf.Binds)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestMergeContainerConfigsVolumesReplaceRunnerMounts(t *testing.T) {
|
||||||
|
logger, _ := test.NewNullLogger()
|
||||||
|
ctx := common.WithLogger(context.Background(), logger)
|
||||||
|
cr := &containerReference{
|
||||||
|
input: &NewContainerInput{
|
||||||
|
NetworkMode: "bridge",
|
||||||
|
Options: "--volume /host/tools:/opt/hostedtoolcache",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
_, hostConf, err := cr.mergeContainerConfigs(ctx, &container.Config{}, &container.HostConfig{
|
||||||
|
Binds: []string{"/var/run/docker.sock:/var/run/docker.sock"},
|
||||||
|
Mounts: []mount.Mount{{Type: mount.TypeVolume, Source: "act-toolcache", Target: "/opt/hostedtoolcache"}},
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, []string{"/var/run/docker.sock:/var/run/docker.sock", "/host/tools:/opt/hostedtoolcache"}, hostConf.Binds)
|
||||||
|
assert.Empty(t, hostConf.Mounts)
|
||||||
|
}
|
||||||
|
|||||||
@@ -330,6 +330,10 @@ func (e *HostEnvironment) exec(ctx context.Context, command []string, cmdline st
|
|||||||
} else {
|
} else {
|
||||||
wd = e.Path
|
wd = e.Path
|
||||||
}
|
}
|
||||||
|
// Flush any buffered, not-yet-newline-terminated trailing line, as the docker backend
|
||||||
|
// does in waitForCommand, so the final line of a command's output is not lost.
|
||||||
|
defer common.FlushWriter(e.StdOut)
|
||||||
|
|
||||||
f, err := lookupPathHost(command[0], env, e.StdOut)
|
f, err := lookupPathHost(command[0], env, e.StdOut)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -66,12 +66,15 @@ func (*LinuxContainerEnvironmentExtensions) JoinPathVariable(paths ...string) st
|
|||||||
return strings.Join(paths, ":")
|
return strings.Join(paths, ":")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DefaultToolCache is where the runner mounts the tool cache inside job containers.
|
||||||
|
const DefaultToolCache = "/opt/hostedtoolcache"
|
||||||
|
|
||||||
func (*LinuxContainerEnvironmentExtensions) GetRunnerContext(ctx context.Context) map[string]any {
|
func (*LinuxContainerEnvironmentExtensions) GetRunnerContext(ctx context.Context) map[string]any {
|
||||||
return map[string]any{
|
return map[string]any{
|
||||||
"os": "Linux",
|
"os": "Linux",
|
||||||
"arch": RunnerArch(ctx),
|
"arch": RunnerArch(ctx),
|
||||||
"temp": "/tmp",
|
"temp": "/tmp",
|
||||||
"tool_cache": "/opt/hostedtoolcache",
|
"tool_cache": DefaultToolCache,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -472,10 +472,7 @@ func newStepContainer(ctx context.Context, step step, image string, cmd, entrypo
|
|||||||
envList = append(envList, fmt.Sprintf("%s=%s", k, v))
|
envList = append(envList, fmt.Sprintf("%s=%s", k, v))
|
||||||
}
|
}
|
||||||
|
|
||||||
envList = append(envList, fmt.Sprintf("%s=%s", "RUNNER_TOOL_CACHE", "/opt/hostedtoolcache"))
|
envList = append(envList, rc.runnerEnv(ctx)...)
|
||||||
envList = append(envList, fmt.Sprintf("%s=%s", "RUNNER_OS", "Linux"))
|
|
||||||
envList = append(envList, fmt.Sprintf("%s=%s", "RUNNER_ARCH", container.RunnerArch(ctx)))
|
|
||||||
envList = append(envList, fmt.Sprintf("%s=%s", "RUNNER_TEMP", "/tmp"))
|
|
||||||
|
|
||||||
binds, mounts := rc.GetBindsAndMounts()
|
binds, mounts := rc.GetBindsAndMounts()
|
||||||
networkMode := "container:" + rc.jobContainerName()
|
networkMode := "container:" + rc.jobContainerName()
|
||||||
|
|||||||
@@ -5,11 +5,9 @@ package runner
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"net"
|
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"runtime"
|
"runtime"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/container"
|
"gitea.com/gitea/runner/act/container"
|
||||||
|
|
||||||
@@ -42,18 +40,6 @@ func requireDocker(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// requireNetwork skips the test unless github.com is reachable. A few tests exercise behaviour
|
|
||||||
// that inherently needs the network (force-pulling an image, resolving a remote short-sha ref);
|
|
||||||
// gating lets the rest of the suite run offline without these failing.
|
|
||||||
func requireNetwork(t *testing.T) {
|
|
||||||
t.Helper()
|
|
||||||
conn, err := net.DialTimeout("tcp", "github.com:443", 3*time.Second)
|
|
||||||
if err != nil {
|
|
||||||
t.Skipf("skipping: network unavailable: %v", err)
|
|
||||||
}
|
|
||||||
_ = conn.Close()
|
|
||||||
}
|
|
||||||
|
|
||||||
// requireHostTools skips the test unless every named executable is on PATH. Used by the
|
// requireHostTools skips the test unless every named executable is on PATH. Used by the
|
||||||
// self-hosted (host environment) suite, which runs steps directly on the host.
|
// self-hosted (host environment) suite, which runs steps directly on the host.
|
||||||
func requireHostTools(t *testing.T, tools ...string) {
|
func requireHostTools(t *testing.T, tools ...string) {
|
||||||
|
|||||||
@@ -226,11 +226,16 @@ func newJobExecutor(info jobInfo, sf stepFactory, rc *RunContext) common.Executo
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The setup section of the job log: download the actions, run the pre steps, then name the job.
|
// The setup section of the job log. The started hook goes first, so what it sets up is
|
||||||
|
// in place for the first action download and the first step.
|
||||||
|
preSteps = append(preSteps, rc.runJobStartedHook)
|
||||||
preSteps = append(preSteps, printPrepareActions(rc, preparers))
|
preSteps = append(preSteps, printPrepareActions(rc, preparers))
|
||||||
preSteps = append(preSteps, stepPreSteps...)
|
preSteps = append(preSteps, stepPreSteps...)
|
||||||
preSteps = append(preSteps, printCompleteJobName(rc))
|
preSteps = append(preSteps, printCompleteJobName(rc))
|
||||||
|
|
||||||
|
// Ahead of the teardown below, while the job environment is still up.
|
||||||
|
postExecutor = postExecutor.Finally(rc.runJobCompletedHook)
|
||||||
|
|
||||||
postExecutor = postExecutor.Finally(func(ctx context.Context) error {
|
postExecutor = postExecutor.Finally(func(ctx context.Context) error {
|
||||||
jobError := common.JobError(ctx)
|
jobError := common.JobError(ctx)
|
||||||
var err error
|
var err error
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func TestJobExecutor(t *testing.T) {
|
func TestJobExecutor(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
// Dryrun only checks syntax/planning; all cases resolve locally, so this runs offline.
|
// Dryrun only checks syntax/planning; all cases resolve locally, so this runs offline.
|
||||||
tables := []TestJobFileInfo{
|
tables := []TestJobFileInfo{
|
||||||
{workdir, "uses-and-run-in-one-step", "push", "Invalid run/uses syntax for job:test step:Test", platforms, secrets},
|
{workdir, "uses-and-run-in-one-step", "push", "Invalid run/uses syntax for job:test step:Test", platforms, secrets},
|
||||||
@@ -46,6 +47,7 @@ func TestJobExecutor(t *testing.T) {
|
|||||||
ctx := common.WithDryrun(context.Background(), true)
|
ctx := common.WithDryrun(context.Background(), true)
|
||||||
for _, table := range tables {
|
for _, table := range tables {
|
||||||
t.Run(table.workflowPath, func(t *testing.T) {
|
t.Run(table.workflowPath, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
table.runTest(ctx, t, &Config{})
|
table.runTest(ctx, t, &Config{})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
115
act/runner/job_hooks.go
Normal file
115
act/runner/job_hooks.go
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package runner
|
||||||
|
|
||||||
|
import (
|
||||||
|
"cmp"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"maps"
|
||||||
|
"path"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gitea.com/gitea/runner/act/common"
|
||||||
|
"gitea.com/gitea/runner/act/container"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GitHub's job-hook variables, read as a fallback when the settings are unset.
|
||||||
|
const (
|
||||||
|
jobStartedHookEnv = "ACTIONS_RUNNER_HOOK_JOB_STARTED"
|
||||||
|
jobCompletedHookEnv = "ACTIONS_RUNNER_HOOK_JOB_COMPLETED"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Kept apart from the per-step file-command files, which are truncated on every step.
|
||||||
|
const (
|
||||||
|
hookEnvFileCommand = "workflow/hook-envs.txt"
|
||||||
|
hookPathFileCommand = "workflow/hook-path.txt"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (rc *RunContext) runJobStartedHook(ctx context.Context) error {
|
||||||
|
return rc.runJobHook(ctx, cmp.Or(rc.Config.JobStartedHook, rc.Config.Env[jobStartedHookEnv]), "job started")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rc *RunContext) runJobCompletedHook(ctx context.Context) error {
|
||||||
|
return rc.runJobHook(ctx, cmp.Or(rc.Config.JobCompletedHook, rc.Config.Env[jobCompletedHookEnv]), "job completed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// runJobHook runs one hook in the job environment. Either hook failing fails the job, as
|
||||||
|
// on GitHub, where the operator is responsible for the hook's own resilience.
|
||||||
|
func (rc *RunContext) runJobHook(ctx context.Context, hookPath, name string) error {
|
||||||
|
if hookPath == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd, shell := hookCommand(hookPath)
|
||||||
|
rawLogger := common.Logger(ctx).WithField(rawOutputField, true)
|
||||||
|
defer rawLogger.Infof("::endgroup::")
|
||||||
|
rawLogger.Infof("::group::Run '%s'", escapeCommandData(hookPath))
|
||||||
|
rawLogger.Infof("A %s hook has been configured by the runner administrator", name)
|
||||||
|
if shell != "" {
|
||||||
|
rawLogger.Infof("shell: %s", shell)
|
||||||
|
}
|
||||||
|
|
||||||
|
env := maps.Clone(rc.GetEnv())
|
||||||
|
if jobContainer := rc.Run.Job().Container(); jobContainer != nil {
|
||||||
|
maps.Copy(env, jobContainer.Env)
|
||||||
|
}
|
||||||
|
rc.withGithubEnv(ctx, rc.getGithubContext(ctx), env)
|
||||||
|
rc.ApplyExtraPath(ctx, &env)
|
||||||
|
|
||||||
|
err := rc.setupHookFileCommands(ctx, env)
|
||||||
|
if err == nil {
|
||||||
|
err = rc.JobContainer.Exec(cmd, env, "", "")(ctx)
|
||||||
|
}
|
||||||
|
// Processed even on failure, so a hook that exports what it managed to set up before
|
||||||
|
// failing still hands it to the job.
|
||||||
|
err = cmp.Or(err, rc.processHookFileCommands(ctx))
|
||||||
|
if err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
err = fmt.Errorf("the %s hook %q failed: %w", name, hookPath, err)
|
||||||
|
// Flip the job status the way a failing pre step does, so success()-default main steps
|
||||||
|
// skip and the task is reported failed.
|
||||||
|
reportStepError(ctx, rc, err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// setupHookFileCommands points the hook at its GITHUB_ENV and GITHUB_PATH files, so it can
|
||||||
|
// export to the job's steps, and truncates them so the second hook does not re-read what
|
||||||
|
// the first one wrote.
|
||||||
|
func (rc *RunContext) setupHookFileCommands(ctx context.Context, env map[string]string) error {
|
||||||
|
actPath := rc.JobContainer.GetActPath()
|
||||||
|
env["GITHUB_ENV"] = path.Join(actPath, hookEnvFileCommand)
|
||||||
|
env["GITHUB_PATH"] = path.Join(actPath, hookPathFileCommand)
|
||||||
|
env["GITEA_ENV"] = env["GITHUB_ENV"]
|
||||||
|
env["GITEA_PATH"] = env["GITHUB_PATH"]
|
||||||
|
|
||||||
|
return rc.JobContainer.Copy(actPath,
|
||||||
|
&container.FileEntry{Name: hookEnvFileCommand, Mode: 0o666},
|
||||||
|
&container.FileEntry{Name: hookPathFileCommand, Mode: 0o666},
|
||||||
|
)(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rc *RunContext) processHookFileCommands(ctx context.Context) error {
|
||||||
|
if err := processRunnerEnvFileCommand(ctx, hookEnvFileCommand, rc, rc.setEnv); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return rc.UpdateExtraPath(ctx, path.Join(rc.JobContainer.GetActPath(), hookPathFileCommand))
|
||||||
|
}
|
||||||
|
|
||||||
|
// hookCommand mirrors actions/runner, which deliberately does not apply the shell flags it
|
||||||
|
// gives `run:` steps — a hook sets its own. See docs/adrs/1751-runner-job-hooks.md there.
|
||||||
|
// The second return value is how the invocation is shown in the log, empty when the file is
|
||||||
|
// executed directly.
|
||||||
|
func hookCommand(hookPath string) (cmd []string, shell string) {
|
||||||
|
switch strings.ToLower(path.Ext(hookPath)) {
|
||||||
|
case ".sh":
|
||||||
|
return []string{"bash", "-e", hookPath}, "bash -e {0}"
|
||||||
|
case ".ps1":
|
||||||
|
return []string{"pwsh", "-command", ". '" + hookPath + "'"}, `pwsh -command ". '{0}'"`
|
||||||
|
default:
|
||||||
|
return []string{hookPath}, ""
|
||||||
|
}
|
||||||
|
}
|
||||||
162
act/runner/job_hooks_test.go
Normal file
162
act/runner/job_hooks_test.go
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package runner
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"maps"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.com/gitea/runner/act/common"
|
||||||
|
"gitea.com/gitea/runner/act/model"
|
||||||
|
|
||||||
|
"github.com/sirupsen/logrus/hooks/test"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// hookContainer records the command a hook was run with and answers with what the hook
|
||||||
|
// wrote to its GITHUB_ENV and GITHUB_PATH files.
|
||||||
|
type hookContainer struct {
|
||||||
|
fakeContainer
|
||||||
|
cmd []string
|
||||||
|
env map[string]string
|
||||||
|
err error
|
||||||
|
envFile map[string]string
|
||||||
|
pathTar []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *hookContainer) ToContainerPath(path string) string { return path }
|
||||||
|
func (c *hookContainer) IsEnvironmentCaseInsensitive() bool { return false }
|
||||||
|
|
||||||
|
func (c *hookContainer) GetRunnerContext(context.Context) map[string]any {
|
||||||
|
return map[string]any{"os": "Linux"}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *hookContainer) Exec(command []string, env map[string]string, _, _ string) common.Executor {
|
||||||
|
return func(context.Context) error {
|
||||||
|
c.cmd, c.env = command, env
|
||||||
|
return c.err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *hookContainer) UpdateFromEnv(_ string, env *map[string]string) common.Executor {
|
||||||
|
return func(context.Context) error {
|
||||||
|
maps.Copy(*env, c.envFile)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *hookContainer) GetContainerArchive(context.Context, string) (io.ReadCloser, error) {
|
||||||
|
return io.NopCloser(bytes.NewReader(c.pathTar)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// newHookRunContext returns a RunContext and the context to run a hook with, whose logger is
|
||||||
|
// silenced so the hook's job-log output does not reach the test output.
|
||||||
|
func newHookRunContext(jobContainer *hookContainer, config *Config) (*RunContext, context.Context) {
|
||||||
|
// Env is left nil so that it is built from the config, as it is for a real job.
|
||||||
|
rc := &RunContext{
|
||||||
|
Config: config,
|
||||||
|
Run: &model.Run{JobID: "job", Workflow: &model.Workflow{Jobs: map[string]*model.Job{"job": {}}}},
|
||||||
|
JobContainer: jobContainer,
|
||||||
|
}
|
||||||
|
logger, _ := test.NewNullLogger()
|
||||||
|
ctx := common.WithJobErrorContainer(common.WithLogger(context.Background(), logger.WithField("test", true)))
|
||||||
|
rc.ExprEval = rc.NewExpressionEvaluator(ctx)
|
||||||
|
return rc, ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunJobHook(t *testing.T) {
|
||||||
|
t.Run("runs the hook with the job environment", func(t *testing.T) {
|
||||||
|
jobContainer := &hookContainer{}
|
||||||
|
rc, ctx := newHookRunContext(jobContainer, &Config{
|
||||||
|
JobStartedHook: "/hooks/started.sh",
|
||||||
|
Env: map[string]string{"A_VAR": "value", jobStartedHookEnv: "/from/env.sh"},
|
||||||
|
})
|
||||||
|
|
||||||
|
require.NoError(t, rc.runJobStartedHook(ctx))
|
||||||
|
|
||||||
|
// The setting wins over the environment variable.
|
||||||
|
assert.Equal(t, []string{"bash", "-e", "/hooks/started.sh"}, jobContainer.cmd)
|
||||||
|
assert.Equal(t, "value", jobContainer.env["A_VAR"])
|
||||||
|
// The github environment is there too, so a hook can tell which job it runs for.
|
||||||
|
assert.Equal(t, "job", jobContainer.env["GITHUB_JOB"])
|
||||||
|
assert.Equal(t, "/var/run/act/workflow/hook-envs.txt", jobContainer.env["GITHUB_ENV"])
|
||||||
|
assert.Equal(t, "/var/run/act/workflow/hook-path.txt", jobContainer.env["GITHUB_PATH"])
|
||||||
|
})
|
||||||
|
|
||||||
|
// Each hook reads its own variable, so a swapped constant cannot pass.
|
||||||
|
t.Run("falls back to the GitHub environment variables", func(t *testing.T) {
|
||||||
|
for name, hook := range map[string]struct {
|
||||||
|
env string
|
||||||
|
run func(*RunContext, context.Context) error
|
||||||
|
}{
|
||||||
|
"started": {jobStartedHookEnv, (*RunContext).runJobStartedHook},
|
||||||
|
"completed": {jobCompletedHookEnv, (*RunContext).runJobCompletedHook},
|
||||||
|
} {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
jobContainer := &hookContainer{}
|
||||||
|
rc, ctx := newHookRunContext(jobContainer, &Config{Env: map[string]string{hook.env: "/from/env.sh"}})
|
||||||
|
|
||||||
|
require.NoError(t, hook.run(rc, ctx))
|
||||||
|
assert.Equal(t, []string{"bash", "-e", "/from/env.sh"}, jobContainer.cmd)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("exports what the hook wrote to GITHUB_ENV and GITHUB_PATH", func(t *testing.T) {
|
||||||
|
jobContainer := &hookContainer{
|
||||||
|
envFile: map[string]string{"FROM_HOOK": "1"},
|
||||||
|
pathTar: tarArchive(t, tarEntry{name: "hook-path.txt", body: "/opt/tool/bin\n"}),
|
||||||
|
}
|
||||||
|
rc, ctx := newHookRunContext(jobContainer, &Config{JobStartedHook: "/hooks/started.sh"})
|
||||||
|
|
||||||
|
require.NoError(t, rc.runJobStartedHook(ctx))
|
||||||
|
|
||||||
|
assert.Equal(t, "1", rc.Env["FROM_HOOK"])
|
||||||
|
assert.Equal(t, []string{"/opt/tool/bin"}, rc.ExtraPath)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("a failing hook fails the job", func(t *testing.T) {
|
||||||
|
rc, ctx := newHookRunContext(&hookContainer{err: errors.New("boom")}, &Config{JobStartedHook: "/hooks/started.sh"})
|
||||||
|
|
||||||
|
err := rc.runJobStartedHook(ctx)
|
||||||
|
|
||||||
|
require.ErrorContains(t, err, `the job started hook "/hooks/started.sh" failed`)
|
||||||
|
require.ErrorContains(t, err, "boom")
|
||||||
|
// The failure has to flip the job status, or success()-default steps would still
|
||||||
|
// run and the task would be reported successful despite the missing setup.
|
||||||
|
assert.Equal(t, "failure", rc.getJobContext().Status)
|
||||||
|
require.ErrorContains(t, common.JobError(ctx), "boom")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("is a no-op without a hook", func(t *testing.T) {
|
||||||
|
jobContainer := &hookContainer{}
|
||||||
|
rc, ctx := newHookRunContext(jobContainer, &Config{})
|
||||||
|
|
||||||
|
require.NoError(t, rc.runJobStartedHook(ctx))
|
||||||
|
require.NoError(t, rc.runJobCompletedHook(ctx))
|
||||||
|
assert.Nil(t, jobContainer.cmd)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// actions/runner deliberately runs a hook without the flags it gives `run:` steps, and an
|
||||||
|
// executable without a known extension speaks for itself through its shebang.
|
||||||
|
func TestHookCommand(t *testing.T) {
|
||||||
|
for hookPath, want := range map[string]struct {
|
||||||
|
cmd []string
|
||||||
|
shell string
|
||||||
|
}{
|
||||||
|
"/hooks/started.sh": {[]string{"bash", "-e", "/hooks/started.sh"}, "bash -e {0}"},
|
||||||
|
"/hooks/started.PS1": {[]string{"pwsh", "-command", ". '/hooks/started.PS1'"}, `pwsh -command ". '{0}'"`},
|
||||||
|
"/hooks/started": {[]string{"/hooks/started"}, ""},
|
||||||
|
} {
|
||||||
|
cmd, shell := hookCommand(hookPath)
|
||||||
|
assert.Equal(t, want.cmd, cmd, hookPath)
|
||||||
|
assert.Equal(t, want.shell, shell, hookPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -30,7 +30,9 @@ import (
|
|||||||
"gitea.com/gitea/runner/act/exprparser"
|
"gitea.com/gitea/runner/act/exprparser"
|
||||||
"gitea.com/gitea/runner/act/model"
|
"gitea.com/gitea/runner/act/model"
|
||||||
|
|
||||||
|
"github.com/docker/cli/cli/compose/loader"
|
||||||
"github.com/docker/go-connections/nat"
|
"github.com/docker/go-connections/nat"
|
||||||
|
"github.com/moby/moby/api/types/mount"
|
||||||
"github.com/opencontainers/selinux/go-selinux"
|
"github.com/opencontainers/selinux/go-selinux"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -204,41 +206,81 @@ func (rc *RunContext) validVolumes() []string {
|
|||||||
getDockerDaemonSocketMountPath(rc.containerDaemonSocket()))
|
getDockerDaemonSocketMountPath(rc.containerDaemonSocket()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// toolCache returns the tool cache path the job sees, relocatable through RUNNER_TOOL_CACHE.
|
||||||
|
func (rc *RunContext) toolCache(fallback string) string {
|
||||||
|
if path := rc.GetEnv()["RUNNER_TOOL_CACHE"]; path != "" {
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
// runnerEnv returns a container's RUNNER_* variables, derived from the values runner.tool_cache
|
||||||
|
// and friends report so the two cannot drift apart.
|
||||||
|
func (rc *RunContext) runnerEnv(ctx context.Context) []string {
|
||||||
|
ext := container.LinuxContainerEnvironmentExtensions{}
|
||||||
|
runnerContext := ext.GetRunnerContext(ctx)
|
||||||
|
runnerContext["tool_cache"] = rc.toolCache(container.DefaultToolCache)
|
||||||
|
|
||||||
|
env := make([]string, 0, len(runnerContext))
|
||||||
|
for key, value := range runnerContext {
|
||||||
|
env = append(env, fmt.Sprintf("RUNNER_%s=%s", strings.ToUpper(key), value))
|
||||||
|
}
|
||||||
|
slices.Sort(env)
|
||||||
|
return env
|
||||||
|
}
|
||||||
|
|
||||||
|
// splitVolumes routes volume specs into binds and a source:target mount map, and returns the
|
||||||
|
// container paths they mount onto. Only a plain source:target volume fits the map, everything
|
||||||
|
// else (anonymous volumes, host binds, mount options) stays a bind.
|
||||||
|
func splitVolumes(specs []string) ([]string, map[string]string, map[string]bool) {
|
||||||
|
binds := []string{}
|
||||||
|
mounts := map[string]string{}
|
||||||
|
targets := map[string]bool{}
|
||||||
|
|
||||||
|
for _, spec := range specs {
|
||||||
|
parsed, err := loader.ParseVolume(spec)
|
||||||
|
if err != nil {
|
||||||
|
binds = append(binds, spec) // let Docker report the malformed spec
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
targets[parsed.Target] = true
|
||||||
|
if parsed.Type == string(mount.TypeVolume) && parsed.Source != "" && !parsed.ReadOnly {
|
||||||
|
mounts[parsed.Source] = parsed.Target
|
||||||
|
} else {
|
||||||
|
binds = append(binds, spec)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return binds, mounts, targets
|
||||||
|
}
|
||||||
|
|
||||||
// Returns the binds and mounts for the container, resolving paths as appopriate
|
// Returns the binds and mounts for the container, resolving paths as appopriate
|
||||||
func (rc *RunContext) GetBindsAndMounts() ([]string, map[string]string) {
|
func (rc *RunContext) GetBindsAndMounts() ([]string, map[string]string) {
|
||||||
name := rc.jobContainerName()
|
name := rc.jobContainerName()
|
||||||
|
|
||||||
binds := []string{}
|
|
||||||
if daemonSocket := rc.containerDaemonSocket(); daemonSocket != "-" {
|
|
||||||
daemonPath := getDockerDaemonSocketMountPath(daemonSocket)
|
|
||||||
binds = append(binds, fmt.Sprintf("%s:%s", daemonPath, "/var/run/docker.sock"))
|
|
||||||
}
|
|
||||||
|
|
||||||
ext := container.LinuxContainerEnvironmentExtensions{}
|
ext := container.LinuxContainerEnvironmentExtensions{}
|
||||||
|
|
||||||
mounts := map[string]string{
|
var volumes []string
|
||||||
"act-toolcache": "/opt/hostedtoolcache",
|
|
||||||
name + "-env": ext.GetActPath(),
|
|
||||||
}
|
|
||||||
|
|
||||||
if job := rc.Run.Job(); job != nil {
|
if job := rc.Run.Job(); job != nil {
|
||||||
if container := job.Container(); container != nil {
|
if container := job.Container(); container != nil {
|
||||||
for _, v := range container.Volumes {
|
for _, v := range container.Volumes {
|
||||||
if rc.ExprEval != nil {
|
if rc.ExprEval != nil {
|
||||||
v = rc.ExprEval.Interpolate(context.Background(), v)
|
v = rc.ExprEval.Interpolate(context.Background(), v)
|
||||||
}
|
}
|
||||||
if !strings.Contains(v, ":") || filepath.IsAbs(v) {
|
volumes = append(volumes, v)
|
||||||
// Bind anonymous volume or host file.
|
|
||||||
binds = append(binds, v)
|
|
||||||
} else {
|
|
||||||
// Mount existing volume.
|
|
||||||
paths := strings.SplitN(v, ":", 2)
|
|
||||||
mounts[paths[0]] = paths[1]
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// the runner's own mounts below yield to the targets the job claims
|
||||||
|
binds, mounts, claimed := splitVolumes(volumes)
|
||||||
|
|
||||||
|
if daemonSocket := rc.containerDaemonSocket(); daemonSocket != "-" && !claimed["/var/run/docker.sock"] {
|
||||||
|
binds = append(binds, getDockerDaemonSocketMountPath(daemonSocket)+":/var/run/docker.sock")
|
||||||
|
}
|
||||||
|
if toolCache := rc.toolCache(container.DefaultToolCache); !claimed[toolCache] {
|
||||||
|
mounts["act-toolcache"] = toolCache
|
||||||
|
}
|
||||||
|
mounts[name+"-env"] = ext.GetActPath() // runner-internal, never overridable
|
||||||
|
|
||||||
|
if workdir := ext.ToContainerPath(rc.Config.Workdir); !claimed[workdir] {
|
||||||
if rc.Config.BindWorkdir {
|
if rc.Config.BindWorkdir {
|
||||||
bindModifiers := ""
|
bindModifiers := ""
|
||||||
if runtime.GOOS == "darwin" {
|
if runtime.GOOS == "darwin" {
|
||||||
@@ -247,9 +289,10 @@ func (rc *RunContext) GetBindsAndMounts() ([]string, map[string]string) {
|
|||||||
if selinux.GetEnabled() {
|
if selinux.GetEnabled() {
|
||||||
bindModifiers = ":z"
|
bindModifiers = ":z"
|
||||||
}
|
}
|
||||||
binds = append(binds, fmt.Sprintf("%s:%s%s", rc.Config.Workdir, ext.ToContainerPath(rc.Config.Workdir), bindModifiers))
|
binds = append(binds, fmt.Sprintf("%s:%s%s", rc.Config.Workdir, workdir, bindModifiers))
|
||||||
} else {
|
} else {
|
||||||
mounts[name] = ext.ToContainerPath(rc.Config.Workdir)
|
mounts[name] = workdir
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return binds, mounts
|
return binds, mounts
|
||||||
@@ -283,7 +326,10 @@ func (rc *RunContext) startHostEnvironment() common.Executor {
|
|||||||
if err := os.MkdirAll(runnerTmp, 0o777); err != nil {
|
if err := os.MkdirAll(runnerTmp, 0o777); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
toolCache := filepath.Join(cacheDir, "tool_cache")
|
toolCache := rc.toolCache(filepath.Join(cacheDir, "tool_cache"))
|
||||||
|
if err := os.MkdirAll(toolCache, 0o777); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
rc.JobContainer = &container.HostEnvironment{
|
rc.JobContainer = &container.HostEnvironment{
|
||||||
Path: path,
|
Path: path,
|
||||||
TmpDir: runnerTmp,
|
TmpDir: runnerTmp,
|
||||||
@@ -368,10 +414,7 @@ func (rc *RunContext) startJobContainer() common.Executor {
|
|||||||
|
|
||||||
envList := make([]string, 0)
|
envList := make([]string, 0)
|
||||||
|
|
||||||
envList = append(envList, fmt.Sprintf("%s=%s", "RUNNER_TOOL_CACHE", "/opt/hostedtoolcache"))
|
envList = append(envList, rc.runnerEnv(ctx)...)
|
||||||
envList = append(envList, fmt.Sprintf("%s=%s", "RUNNER_OS", "Linux"))
|
|
||||||
envList = append(envList, fmt.Sprintf("%s=%s", "RUNNER_ARCH", container.RunnerArch(ctx)))
|
|
||||||
envList = append(envList, fmt.Sprintf("%s=%s", "RUNNER_TEMP", "/tmp"))
|
|
||||||
envList = append(envList, fmt.Sprintf("%s=%s", "LANG", "C.UTF-8")) // Use same locale as GitHub Actions
|
envList = append(envList, fmt.Sprintf("%s=%s", "LANG", "C.UTF-8")) // Use same locale as GitHub Actions
|
||||||
|
|
||||||
ext := container.LinuxContainerEnvironmentExtensions{}
|
ext := container.LinuxContainerEnvironmentExtensions{}
|
||||||
@@ -992,6 +1035,8 @@ func (rc *RunContext) getRunnerContext(ctx context.Context) map[string]any {
|
|||||||
runnerContext := map[string]any{}
|
runnerContext := map[string]any{}
|
||||||
if rc.JobContainer != nil {
|
if rc.JobContainer != nil {
|
||||||
maps0.Copy(runnerContext, rc.JobContainer.GetRunnerContext(ctx))
|
maps0.Copy(runnerContext, rc.JobContainer.GetRunnerContext(ctx))
|
||||||
|
defaultToolCache, _ := runnerContext["tool_cache"].(string)
|
||||||
|
runnerContext["tool_cache"] = rc.toolCache(defaultToolCache)
|
||||||
}
|
}
|
||||||
runnerContext["name"] = rc.Config.RunnerName
|
runnerContext["name"] = rc.Config.RunnerName
|
||||||
runnerContext["environment"] = "self-hosted"
|
runnerContext["environment"] = "self-hosted"
|
||||||
@@ -1363,24 +1408,9 @@ func (rc *RunContext) handleServiceCredentials(ctx context.Context, creds map[st
|
|||||||
|
|
||||||
// GetServiceBindsAndMounts returns the binds and mounts for the service container, resolving paths as appopriate
|
// GetServiceBindsAndMounts returns the binds and mounts for the service container, resolving paths as appopriate
|
||||||
func (rc *RunContext) GetServiceBindsAndMounts(svcVolumes []string) ([]string, map[string]string) {
|
func (rc *RunContext) GetServiceBindsAndMounts(svcVolumes []string) ([]string, map[string]string) {
|
||||||
binds := []string{}
|
binds, mounts, claimed := splitVolumes(svcVolumes)
|
||||||
if daemonSocket := rc.containerDaemonSocket(); daemonSocket != "-" {
|
if daemonSocket := rc.containerDaemonSocket(); daemonSocket != "-" && !claimed["/var/run/docker.sock"] {
|
||||||
daemonPath := getDockerDaemonSocketMountPath(daemonSocket)
|
binds = append(binds, getDockerDaemonSocketMountPath(daemonSocket)+":/var/run/docker.sock")
|
||||||
binds = append(binds, fmt.Sprintf("%s:%s", daemonPath, "/var/run/docker.sock"))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
mounts := map[string]string{}
|
|
||||||
|
|
||||||
for _, v := range svcVolumes {
|
|
||||||
if !strings.Contains(v, ":") || filepath.IsAbs(v) {
|
|
||||||
// Bind anonymous volume or host file.
|
|
||||||
binds = append(binds, v)
|
|
||||||
} else {
|
|
||||||
// Mount existing volume.
|
|
||||||
paths := strings.SplitN(v, ":", 2)
|
|
||||||
mounts[paths[0]] = paths[1]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return binds, mounts
|
return binds, mounts
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import (
|
|||||||
"gitea.com/gitea/runner/act/exprparser"
|
"gitea.com/gitea/runner/act/exprparser"
|
||||||
"gitea.com/gitea/runner/act/model"
|
"gitea.com/gitea/runner/act/model"
|
||||||
|
|
||||||
|
"github.com/docker/cli/cli/compose/loader"
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
assert "github.com/stretchr/testify/assert"
|
assert "github.com/stretchr/testify/assert"
|
||||||
require "github.com/stretchr/testify/require"
|
require "github.com/stretchr/testify/require"
|
||||||
@@ -363,6 +364,10 @@ func TestRunContext_GetBindsAndMounts(t *testing.T) {
|
|||||||
{"BindAnonymousVolume", []string{"/volume"}, "/volume", map[string]string{}},
|
{"BindAnonymousVolume", []string{"/volume"}, "/volume", map[string]string{}},
|
||||||
{"BindHostFile", []string{"/path/to/file/on/host:/volume"}, "/path/to/file/on/host:/volume", map[string]string{}},
|
{"BindHostFile", []string{"/path/to/file/on/host:/volume"}, "/path/to/file/on/host:/volume", map[string]string{}},
|
||||||
{"MountExistingVolume", []string{"volume-id:/volume"}, "", map[string]string{"volume-id": "/volume"}},
|
{"MountExistingVolume", []string{"volume-id:/volume"}, "", map[string]string{"volume-id": "/volume"}},
|
||||||
|
{"MountExistingVolumeReadOnly", []string{"volume-id:/volume:ro"}, "volume-id:/volume:ro", map[string]string{}},
|
||||||
|
{"BindRelativeHostPath", []string{"./relative:/volume"}, "./relative:/volume", map[string]string{}},
|
||||||
|
{"OverridesToolCache", []string{"/host/tools:/opt/hostedtoolcache"}, "/host/tools:/opt/hostedtoolcache", map[string]string{}},
|
||||||
|
{"OverridesDockerSocket", []string{"/host/docker.sock:/var/run/docker.sock"}, "/host/docker.sock:/var/run/docker.sock", map[string]string{}},
|
||||||
}
|
}
|
||||||
|
|
||||||
t.Run("InterpolatedContainerVolumes", func(t *testing.T) {
|
t.Run("InterpolatedContainerVolumes", func(t *testing.T) {
|
||||||
@@ -418,7 +423,14 @@ func TestRunContext_GetBindsAndMounts(t *testing.T) {
|
|||||||
rc.Run.JobID = "job1"
|
rc.Run.JobID = "job1"
|
||||||
rc.Run.Workflow.Jobs = map[string]*model.Job{"job1": job}
|
rc.Run.Workflow.Jobs = map[string]*model.Job{"job1": job}
|
||||||
|
|
||||||
gotbind, gotmount := rc.GetBindsAndMounts()
|
jobBinds, jobMounts := rc.GetBindsAndMounts()
|
||||||
|
svcBinds, svcMounts := rc.GetServiceBindsAndMounts(testcase.volumes)
|
||||||
|
// job and service containers classify volumes alike, only their own mounts differ
|
||||||
|
for _, got := range []struct {
|
||||||
|
binds []string
|
||||||
|
mounts map[string]string
|
||||||
|
}{{jobBinds, jobMounts}, {svcBinds, svcMounts}} {
|
||||||
|
gotbind, gotmount := got.binds, got.mounts
|
||||||
|
|
||||||
if len(testcase.wantbind) > 0 {
|
if len(testcase.wantbind) > 0 {
|
||||||
assert.Contains(t, gotbind, testcase.wantbind)
|
assert.Contains(t, gotbind, testcase.wantbind)
|
||||||
@@ -428,6 +440,21 @@ func TestRunContext_GetBindsAndMounts(t *testing.T) {
|
|||||||
assert.Contains(t, gotmount, k)
|
assert.Contains(t, gotmount, k)
|
||||||
assert.Equal(t, gotmount[k], v)
|
assert.Equal(t, gotmount[k], v)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Docker rejects a container with two mounts on one target, so the job's own
|
||||||
|
// volumes must displace the runner's rather than pile up next to them.
|
||||||
|
targets := map[string]bool{}
|
||||||
|
for _, bind := range gotbind {
|
||||||
|
parsed, err := loader.ParseVolume(bind)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.NotContains(t, targets, parsed.Target, "%s mounts an already mounted target", bind)
|
||||||
|
targets[parsed.Target] = true
|
||||||
|
}
|
||||||
|
for source, target := range gotmount {
|
||||||
|
assert.NotContains(t, targets, target, "%s mounts an already mounted target", source)
|
||||||
|
targets[target] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -93,6 +93,8 @@ type Config struct {
|
|||||||
MaxParallel int // max parallel jobs to run across all workflows (0 = no limit, uses CPU count)
|
MaxParallel int // max parallel jobs to run across all workflows (0 = no limit, uses CPU count)
|
||||||
AllocatePTY bool // allocate a pseudo-TTY for each step's process
|
AllocatePTY bool // allocate a pseudo-TTY for each step's process
|
||||||
RunnerName string // name this runner registered with, reported as `runner.name`, defaults to the hostname
|
RunnerName string // name this runner registered with, reported as `runner.name`, defaults to the hostname
|
||||||
|
JobStartedHook string // script run inside the job environment before the job's first step; ACTIONS_RUNNER_HOOK_JOB_STARTED is read from Env when empty
|
||||||
|
JobCompletedHook string // script run inside the job environment after the job's last step; ACTIONS_RUNNER_HOOK_JOB_COMPLETED is read from Env when empty
|
||||||
}
|
}
|
||||||
|
|
||||||
// RunnerDebug reports whether debug logging is on, exposed as `runner.debug` and
|
// RunnerDebug reports whether debug logging is on, exposed as `runner.debug` and
|
||||||
|
|||||||
@@ -1,109 +0,0 @@
|
|||||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
|
||||||
// SPDX-License-Identifier: MIT
|
|
||||||
|
|
||||||
package runner
|
|
||||||
|
|
||||||
import (
|
|
||||||
"sync"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TestMaxParallelConfig tests that MaxParallel config is properly set
|
|
||||||
func TestMaxParallelConfig(t *testing.T) {
|
|
||||||
t.Run("MaxParallel set to 2", func(t *testing.T) {
|
|
||||||
config := &Config{
|
|
||||||
Workdir: "testdata",
|
|
||||||
MaxParallel: 2,
|
|
||||||
}
|
|
||||||
|
|
||||||
runner, err := New(config)
|
|
||||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
|
||||||
assert.NotNil(t, runner)
|
|
||||||
|
|
||||||
// Verify config is properly stored
|
|
||||||
runnerImpl, ok := runner.(*runnerImpl)
|
|
||||||
assert.True(t, ok)
|
|
||||||
assert.Equal(t, 2, runnerImpl.config.MaxParallel)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("MaxParallel set to 0 (no limit)", func(t *testing.T) {
|
|
||||||
config := &Config{
|
|
||||||
Workdir: "testdata",
|
|
||||||
MaxParallel: 0,
|
|
||||||
}
|
|
||||||
|
|
||||||
runner, err := New(config)
|
|
||||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
|
||||||
assert.NotNil(t, runner)
|
|
||||||
|
|
||||||
runnerImpl, ok := runner.(*runnerImpl)
|
|
||||||
assert.True(t, ok)
|
|
||||||
assert.Equal(t, 0, runnerImpl.config.MaxParallel)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("MaxParallel not set (defaults to 0)", func(t *testing.T) {
|
|
||||||
config := &Config{
|
|
||||||
Workdir: "testdata",
|
|
||||||
}
|
|
||||||
|
|
||||||
runner, err := New(config)
|
|
||||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
|
||||||
assert.NotNil(t, runner)
|
|
||||||
|
|
||||||
runnerImpl, ok := runner.(*runnerImpl)
|
|
||||||
assert.True(t, ok)
|
|
||||||
assert.Equal(t, 0, runnerImpl.config.MaxParallel)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestMaxParallelConcurrencyTracking tests that max-parallel actually limits concurrent execution
|
|
||||||
func TestMaxParallelConcurrencyTracking(t *testing.T) {
|
|
||||||
// This is a unit test for the parallel executor logic
|
|
||||||
// We test that when MaxParallel is set, it limits the number of workers
|
|
||||||
|
|
||||||
var mu sync.Mutex
|
|
||||||
var maxConcurrent int
|
|
||||||
var currentConcurrent int
|
|
||||||
|
|
||||||
// Create a function that tracks concurrent execution
|
|
||||||
trackingFunc := func() {
|
|
||||||
mu.Lock()
|
|
||||||
currentConcurrent++
|
|
||||||
if currentConcurrent > maxConcurrent {
|
|
||||||
maxConcurrent = currentConcurrent
|
|
||||||
}
|
|
||||||
mu.Unlock()
|
|
||||||
|
|
||||||
// Simulate work
|
|
||||||
time.Sleep(50 * time.Millisecond)
|
|
||||||
|
|
||||||
mu.Lock()
|
|
||||||
currentConcurrent--
|
|
||||||
mu.Unlock()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Run multiple tasks with limited parallelism
|
|
||||||
maxConcurrent = 0
|
|
||||||
currentConcurrent = 0
|
|
||||||
|
|
||||||
// This simulates what NewParallelExecutor does with a semaphore
|
|
||||||
var wg sync.WaitGroup
|
|
||||||
semaphore := make(chan struct{}, 2) // Limit to 2 concurrent
|
|
||||||
|
|
||||||
for range 6 {
|
|
||||||
wg.Go(func() {
|
|
||||||
semaphore <- struct{}{} // Acquire
|
|
||||||
defer func() { <-semaphore }() // Release
|
|
||||||
trackingFunc()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
wg.Wait()
|
|
||||||
|
|
||||||
// With a semaphore of 2, max concurrent should be <= 2
|
|
||||||
assert.LessOrEqual(t, maxConcurrent, 2, "Maximum concurrent executions should not exceed limit")
|
|
||||||
assert.GreaterOrEqual(t, maxConcurrent, 1, "Should have at least 1 concurrent execution")
|
|
||||||
}
|
|
||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
"path"
|
"path"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -163,6 +164,12 @@ func TestGraphEvent(t *testing.T) {
|
|||||||
assert.Empty(t, plan.Stages)
|
assert.Empty(t, plan.Stages)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// these two build the same action Dockerfiles into one image tag, so they cannot overlap
|
||||||
|
var sharedImageWorkflows = []string{"local-action-dockerfile", "local-action-via-composite-dockerfile"}
|
||||||
|
|
||||||
|
// bounds concurrent plans: each job holds a network, and the daemon's address pool is finite
|
||||||
|
var planSlots = make(chan struct{}, 4)
|
||||||
|
|
||||||
type TestJobFileInfo struct {
|
type TestJobFileInfo struct {
|
||||||
workdir string
|
workdir string
|
||||||
workflowPath string
|
workflowPath string
|
||||||
@@ -187,7 +194,14 @@ func (j *TestJobFileInfo) runTest(ctx context.Context, t *testing.T, cfg *Config
|
|||||||
EventName: j.eventName,
|
EventName: j.eventName,
|
||||||
EventPath: cfg.EventPath,
|
EventPath: cfg.EventPath,
|
||||||
Platforms: j.platforms,
|
Platforms: j.platforms,
|
||||||
|
// fixtures reuse workflow and job names, so parallel tests would collide without this
|
||||||
|
ContainerNamePrefix: strings.ReplaceAll(t.Name(), "/", "-"),
|
||||||
ReuseContainers: false,
|
ReuseContainers: false,
|
||||||
|
// as the shipped runner does, else a fixture asserting a job failure keeps its
|
||||||
|
// container, and its network, on the daemon forever
|
||||||
|
AutoRemove: true,
|
||||||
|
// 0 would run jobs runtime.NumCPU()-wide, making the network peak machine-dependent
|
||||||
|
MaxParallel: 2,
|
||||||
ForceRebuild: true,
|
ForceRebuild: true,
|
||||||
Env: cfg.Env,
|
Env: cfg.Env,
|
||||||
Secrets: cfg.Secrets,
|
Secrets: cfg.Secrets,
|
||||||
@@ -210,7 +224,11 @@ func (j *TestJobFileInfo) runTest(ctx context.Context, t *testing.T, cfg *Config
|
|||||||
plan, err := planner.PlanEvent(j.eventName)
|
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
|
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 {
|
if err == nil && plan != nil {
|
||||||
err = runner.NewPlanExecutor(plan)(ctx)
|
err = func() error {
|
||||||
|
planSlots <- struct{}{}
|
||||||
|
defer func() { <-planSlots }()
|
||||||
|
return runner.NewPlanExecutor(plan)(ctx)
|
||||||
|
}()
|
||||||
if j.errorMessage == "" {
|
if j.errorMessage == "" {
|
||||||
assert.NoError(t, err, fullWorkflowPath) //nolint:testifylint // pre-existing issue from nektos/act
|
assert.NoError(t, err, fullWorkflowPath) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
} else {
|
} else {
|
||||||
@@ -227,6 +245,7 @@ type TestConfig struct {
|
|||||||
|
|
||||||
func TestRunEvent(t *testing.T) {
|
func TestRunEvent(t *testing.T) {
|
||||||
requireDocker(t)
|
requireDocker(t)
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
@@ -315,6 +334,9 @@ func TestRunEvent(t *testing.T) {
|
|||||||
// host /proc bind mounts are Linux-Docker-only
|
// host /proc bind mounts are Linux-Docker-only
|
||||||
requireLinuxDocker(t)
|
requireLinuxDocker(t)
|
||||||
}
|
}
|
||||||
|
if !slices.Contains(sharedImageWorkflows, table.workflowPath) {
|
||||||
|
t.Parallel()
|
||||||
|
}
|
||||||
|
|
||||||
config := &Config{
|
config := &Config{
|
||||||
Secrets: table.secrets,
|
Secrets: table.secrets,
|
||||||
@@ -445,6 +467,7 @@ func TestRunEventHostEnvironment(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestDryrunEvent(t *testing.T) {
|
func TestDryrunEvent(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
// Dryrun plans without containers or network (shells and local actions only).
|
// Dryrun plans without containers or network (shells and local actions only).
|
||||||
ctx := common.WithDryrun(context.Background(), true)
|
ctx := common.WithDryrun(context.Background(), true)
|
||||||
|
|
||||||
@@ -464,6 +487,7 @@ func TestDryrunEvent(t *testing.T) {
|
|||||||
|
|
||||||
for _, table := range tables {
|
for _, table := range tables {
|
||||||
t.Run(table.workflowPath, func(t *testing.T) {
|
t.Run(table.workflowPath, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
table.runTest(ctx, t, &Config{})
|
table.runTest(ctx, t, &Config{})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -474,33 +498,11 @@ func TestDryrunEvent(t *testing.T) {
|
|||||||
// workflow's outputs via `needs`).
|
// workflow's outputs via `needs`).
|
||||||
func TestReusableWorkflowCaller(t *testing.T) {
|
func TestReusableWorkflowCaller(t *testing.T) {
|
||||||
requireDocker(t)
|
requireDocker(t)
|
||||||
|
t.Parallel()
|
||||||
table := TestJobFileInfo{workdir, "uses-workflow", "push", "", platforms, map[string]string{"secret": "keep_it_private"}}
|
table := TestJobFileInfo{workdir, "uses-workflow", "push", "", platforms, map[string]string{"secret": "keep_it_private"}}
|
||||||
table.runTest(context.Background(), t, &Config{Secrets: table.secrets})
|
table.runTest(context.Background(), t, &Config{Secrets: table.secrets})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDockerActionForcePullForceRebuild(t *testing.T) {
|
|
||||||
requireDocker(t)
|
|
||||||
requireNetwork(t) // force-pulls a docker action image
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
config := &Config{
|
|
||||||
ForcePull: true,
|
|
||||||
ForceRebuild: true,
|
|
||||||
}
|
|
||||||
|
|
||||||
tables := []TestJobFileInfo{
|
|
||||||
{workdir, "local-action-dockerfile", "push", "", platforms, secrets},
|
|
||||||
{workdir, "local-action-via-composite-dockerfile", "push", "", platforms, secrets},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, table := range tables {
|
|
||||||
t.Run(table.workflowPath, func(t *testing.T) {
|
|
||||||
table.runTest(ctx, t, config)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type maskJobLoggerFactory struct {
|
type maskJobLoggerFactory struct {
|
||||||
Output bytes.Buffer
|
Output bytes.Buffer
|
||||||
}
|
}
|
||||||
@@ -513,6 +515,7 @@ func (f *maskJobLoggerFactory) WithJobLogger() *log.Logger {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestMaskValues(t *testing.T) {
|
func TestMaskValues(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
assertNoSecret := func(text, secret string) { //nolint:unparam // pre-existing issue from nektos/act
|
assertNoSecret := func(text, secret string) { //nolint:unparam // pre-existing issue from nektos/act
|
||||||
found := strings.Contains(text, "composite secret")
|
found := strings.Contains(text, "composite secret")
|
||||||
if found {
|
if found {
|
||||||
@@ -543,6 +546,7 @@ func TestMaskValues(t *testing.T) {
|
|||||||
|
|
||||||
func TestRunEventSecrets(t *testing.T) {
|
func TestRunEventSecrets(t *testing.T) {
|
||||||
requireDocker(t)
|
requireDocker(t)
|
||||||
|
t.Parallel()
|
||||||
workflowPath := "secrets"
|
workflowPath := "secrets"
|
||||||
|
|
||||||
tjfi := TestJobFileInfo{
|
tjfi := TestJobFileInfo{
|
||||||
@@ -598,6 +602,7 @@ func TestRunWithService(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestRunActionInputs(t *testing.T) {
|
func TestRunActionInputs(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
requireDocker(t)
|
requireDocker(t)
|
||||||
workflowPath := "input-from-cli"
|
workflowPath := "input-from-cli"
|
||||||
|
|
||||||
@@ -617,6 +622,7 @@ func TestRunActionInputs(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestRunEventPullRequest(t *testing.T) {
|
func TestRunEventPullRequest(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
requireDocker(t)
|
requireDocker(t)
|
||||||
|
|
||||||
workflowPath := "pull-request"
|
workflowPath := "pull-request"
|
||||||
@@ -633,6 +639,7 @@ func TestRunEventPullRequest(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestRunMatrixWithUserDefinedInclusions(t *testing.T) {
|
func TestRunMatrixWithUserDefinedInclusions(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
requireDocker(t)
|
requireDocker(t)
|
||||||
workflowPath := "matrix-with-user-inclusions"
|
workflowPath := "matrix-with-user-inclusions"
|
||||||
|
|
||||||
|
|||||||
@@ -110,10 +110,7 @@ func (sd *stepDocker) newStepContainer(ctx context.Context, image string, cmd, e
|
|||||||
envList = append(envList, fmt.Sprintf("%s=%s", k, v))
|
envList = append(envList, fmt.Sprintf("%s=%s", k, v))
|
||||||
}
|
}
|
||||||
|
|
||||||
envList = append(envList, fmt.Sprintf("%s=%s", "RUNNER_TOOL_CACHE", "/opt/hostedtoolcache"))
|
envList = append(envList, rc.runnerEnv(ctx)...)
|
||||||
envList = append(envList, fmt.Sprintf("%s=%s", "RUNNER_OS", "Linux"))
|
|
||||||
envList = append(envList, fmt.Sprintf("%s=%s", "RUNNER_ARCH", container.RunnerArch(ctx)))
|
|
||||||
envList = append(envList, fmt.Sprintf("%s=%s", "RUNNER_TEMP", "/tmp"))
|
|
||||||
|
|
||||||
binds, mounts := rc.GetBindsAndMounts()
|
binds, mounts := rc.GetBindsAndMounts()
|
||||||
networkMode := "container:" + rc.jobContainerName()
|
networkMode := "container:" + rc.jobContainerName()
|
||||||
|
|||||||
70
docs/job-hooks.md
Normal file
70
docs/job-hooks.md
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
# Job hooks
|
||||||
|
|
||||||
|
Job hooks are operator-provided scripts that run **inside the job environment**, before the job's first step and after its last one. They are the equivalent of GitHub's [job hooks](https://docs.github.com/en/actions/how-tos/manage-runners/self-hosted-runners/run-scripts) and are configured under `runner.hooks` in the runner YAML config (see [config.example.yaml](../internal/pkg/config/config.example.yaml)):
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
runner:
|
||||||
|
hooks:
|
||||||
|
job_started: /hooks/started.sh
|
||||||
|
job_completed: /hooks/completed.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
| Setting | Runs |
|
||||||
|
| --- | --- |
|
||||||
|
| `runner.hooks.job_started` | Before the job's first step, before any action is downloaded |
|
||||||
|
| `runner.hooks.job_completed` | After the job's last post step, while the job environment is still up |
|
||||||
|
|
||||||
|
`ACTIONS_RUNNER_HOOK_JOB_STARTED` and `ACTIONS_RUNNER_HOOK_JOB_COMPLETED` are read from the runner's environment (`runner.envs`, `runner.env_file`) when the settings are unset, so a configuration carried over from actions/runner keeps working. The settings take precedence. A workflow cannot point the runner at a different hook: the variables are only read from the runner's own environment, never from the job's.
|
||||||
|
|
||||||
|
Both hooks are **synchronous** and block the job while they run, and a non-zero exit from either one fails the job. There is no `continue-on-error` and no per-hook timeout — the job's own `runner.timeout` is the only bound. The operator is responsible for the hook's resilience; run anything long in the background from within the hook.
|
||||||
|
|
||||||
|
## Where they run
|
||||||
|
|
||||||
|
The hooks run in the same place as the job's steps: inside the job container, or on the host in host mode. The paths are resolved *there*, so the script has to exist in the job image or on the host — a path that only exists on the runner host is not visible to a containerized job. For host-wide cleanup that runs after the job environment is gone, use the [post-task script](post-task-script.md) instead.
|
||||||
|
|
||||||
|
> This is a deliberate difference from actions/runner, which runs its job hooks on the host, outside any container the job declares. Running them where the steps run is what lets a hook prepare the environment the steps actually see.
|
||||||
|
|
||||||
|
The script is run according to its extension:
|
||||||
|
|
||||||
|
| Extension | Command |
|
||||||
|
| --- | --- |
|
||||||
|
| `.sh` | `bash -e <path>` |
|
||||||
|
| `.ps1` | `pwsh -command . '<path>'` |
|
||||||
|
| anything else | the file itself, which needs its own shebang and executable bit |
|
||||||
|
|
||||||
|
As on GitHub, the shell flags applied to `run:` steps are **not** applied to a hook — set `pipefail` or anything else you want inside the script.
|
||||||
|
|
||||||
|
### Docker-in-Docker and Docker-out-of-Docker
|
||||||
|
|
||||||
|
The hook is executed and its files are exchanged over the Docker API, addressed by container ID, so no path is translated between the runner and the daemon. Both setups work unchanged, but they differ in where the hook file has to be:
|
||||||
|
|
||||||
|
- **DinD** — the daemon has its own filesystem. Bake the hook into the job image; a path from the runner's filesystem is not visible to it.
|
||||||
|
- **DooD** — the job container is created by the host's daemon, so a bind mount in `container.options` is resolved against the **host**, not against the runner container. Either bake the hook into the job image, or mount a host directory and add it to `container.valid_volumes`.
|
||||||
|
|
||||||
|
A hook path that does not exist inside the job environment fails the job with `No such file or directory`, naming the path.
|
||||||
|
|
||||||
|
## Environment
|
||||||
|
|
||||||
|
A hook sees the job's environment: the workflow, job and `container:` `env:`, the runner's `envs`, and the `GITHUB_*` context variables, with the same masking applied to its output as to a step's. The step-specific ones (`GITHUB_ACTION`, `GITHUB_OUTPUT`, `GITHUB_STATE`) are not set — a hook is not a step, so `::save-state::` and `::set-output::` have nowhere to go.
|
||||||
|
|
||||||
|
Its stdout is part of the job log, inside a collapsible group, and is scanned for workflow commands. `::add-mask::` registers a value to be masked for the rest of the job, `::set-env::` and `::add-path::` apply to the steps that follow.
|
||||||
|
|
||||||
|
`$GITHUB_ENV` and `$GITHUB_PATH` point at files that are read back after the hook exits, so the file-command form works too:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
#!/bin/bash
|
||||||
|
echo "REGISTRY_TOKEN=$(fetch-token)" >> "$GITHUB_ENV"
|
||||||
|
echo "/opt/tooling/bin" >> "$GITHUB_PATH"
|
||||||
|
```
|
||||||
|
|
||||||
|
Both files are the hook's own, separate from the per-step ones, so nothing a hook writes is truncated by the first step.
|
||||||
|
|
||||||
|
## Recommendations
|
||||||
|
|
||||||
|
- Keep hooks **fast** and return the right exit code: they are on the critical path of every job, and nothing bounds them.
|
||||||
|
- Use **idempotent** operations, and expect `job_completed` to run after success, failure, and cancellation alike.
|
||||||
|
- Mask anything secret the hook prints or exports with `::add-mask::`.
|
||||||
|
|
||||||
|
## See also
|
||||||
|
|
||||||
|
- [Post-task script](post-task-script.md) — host-side cleanup after the job environment is torn down.
|
||||||
@@ -150,6 +150,7 @@ powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "%~dp0po
|
|||||||
|
|
||||||
## See also
|
## See also
|
||||||
|
|
||||||
|
- [Job hooks](job-hooks.md) — scripts running inside the job environment, around its steps
|
||||||
- [Configuration](../README.md#configuration) — generating and loading `config.yaml`
|
- [Configuration](../README.md#configuration) — generating and loading `config.yaml`
|
||||||
- [config.example.yaml](../internal/pkg/config/config.example.yaml) — all runner options
|
- [config.example.yaml](../internal/pkg/config/config.example.yaml) — all runner options
|
||||||
- Bind-workdir idle cleanup (`runner.workdir_cleanup_age`) — separate from this hook; runs only when the runner is idle
|
- Bind-workdir idle cleanup (`runner.workdir_cleanup_age`) — separate from this hook; runs only when the runner is idle
|
||||||
|
|||||||
@@ -471,6 +471,8 @@ func (r *Runner) run(ctx context.Context, task *runnerv1.Task, reporter *report.
|
|||||||
DefaultActionInstance: r.getDefaultActionsURL(task),
|
DefaultActionInstance: r.getDefaultActionsURL(task),
|
||||||
DefaultActionInstanceIsSelfHosted: r.isSelfHostedActionsURL(task),
|
DefaultActionInstanceIsSelfHosted: r.isSelfHostedActionsURL(task),
|
||||||
PlatformPicker: r.labels.PickPlatform,
|
PlatformPicker: r.labels.PickPlatform,
|
||||||
|
JobStartedHook: r.cfg.Runner.Hooks.JobStarted,
|
||||||
|
JobCompletedHook: r.cfg.Runner.Hooks.JobCompleted,
|
||||||
Vars: task.Vars,
|
Vars: task.Vars,
|
||||||
ValidVolumes: r.cfg.Container.ValidVolumes,
|
ValidVolumes: r.cfg.Container.ValidVolumes,
|
||||||
InsecureSkipTLS: r.cfg.Runner.Insecure,
|
InsecureSkipTLS: r.cfg.Runner.Insecure,
|
||||||
|
|||||||
@@ -104,6 +104,14 @@ runner:
|
|||||||
post_task_script: ''
|
post_task_script: ''
|
||||||
# Hard limit on post_task_script runtime. Default if omitted: 5m.
|
# Hard limit on post_task_script runtime. Default if omitted: 5m.
|
||||||
post_task_script_timeout: 5m
|
post_task_script_timeout: 5m
|
||||||
|
# Scripts run inside the job environment before the job's first step and after its last
|
||||||
|
# one, the equivalent of GitHub's ACTIONS_RUNNER_HOOK_JOB_STARTED and
|
||||||
|
# ACTIONS_RUNNER_HOOK_JOB_COMPLETED, which are read when these are unset. The paths are
|
||||||
|
# resolved inside the job environment. Either one failing fails the job.
|
||||||
|
# Full guide: docs/job-hooks.md
|
||||||
|
hooks:
|
||||||
|
job_started: ''
|
||||||
|
job_completed: ''
|
||||||
|
|
||||||
cache:
|
cache:
|
||||||
# Enable the built-in cache server (used by actions/cache and similar actions).
|
# Enable the built-in cache server (used by actions/cache and similar actions).
|
||||||
@@ -159,7 +167,11 @@ container:
|
|||||||
enable_ipv6: false # Omit to use Docker's default (IPv6 disabled). Enabling it requires dockerd started with --ipv6.
|
enable_ipv6: false # Omit to use Docker's default (IPv6 disabled). Enabling it requires dockerd started with --ipv6.
|
||||||
# Whether to use privileged mode or not when launching task containers (privileged mode is required for Docker-in-Docker).
|
# Whether to use privileged mode or not when launching task containers (privileged mode is required for Docker-in-Docker).
|
||||||
privileged: false
|
privileged: false
|
||||||
# Any other options to be used when the container is started (e.g., --add-host=my.gitea.url:host-gateway).
|
# Any other options to be used when the container is started, for example:
|
||||||
|
# options: --add-host=my.gitea.url:host-gateway
|
||||||
|
# A volume declared here replaces the one the runner mounts on the same container path, so the
|
||||||
|
# tool cache can be kept on the host. Its source must also be allowed by valid_volumes below:
|
||||||
|
# options: --volume /host/toolcache:/opt/hostedtoolcache
|
||||||
options:
|
options:
|
||||||
# The parent directory of a job's working directory.
|
# The parent directory of a job's working directory.
|
||||||
# NOTE: There is no need to add the first '/' of the path as runner will add it automatically.
|
# NOTE: There is no need to add the first '/' of the path as runner will add it automatically.
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"maps"
|
"maps"
|
||||||
@@ -54,6 +55,13 @@ type Runner struct {
|
|||||||
AllocatePTY bool `yaml:"allocate_pty"` // AllocatePTY allocates a pseudo-TTY for each step's process. Default is false, matching GitHub's actions/runner. Enable only for jobs that need an interactive terminal; tools like docker build emit redrawing progress frames into the captured log when a TTY is present. Applies to both host and docker backends.
|
AllocatePTY bool `yaml:"allocate_pty"` // AllocatePTY allocates a pseudo-TTY for each step's process. Default is false, matching GitHub's actions/runner. Enable only for jobs that need an interactive terminal; tools like docker build emit redrawing progress frames into the captured log when a TTY is present. Applies to both host and docker backends.
|
||||||
PostTaskScript string `yaml:"post_task_script"` // PostTaskScript is the path to an executable script run on the host after each task's cleanup completes. Empty disables the hook. On Windows use .exe/.bat/.cmd; PowerShell (.ps1) is not supported yet as the configured path.
|
PostTaskScript string `yaml:"post_task_script"` // PostTaskScript is the path to an executable script run on the host after each task's cleanup completes. Empty disables the hook. On Windows use .exe/.bat/.cmd; PowerShell (.ps1) is not supported yet as the configured path.
|
||||||
PostTaskScriptTimeout time.Duration `yaml:"post_task_script_timeout"` // PostTaskScriptTimeout caps how long the post-task script may run. Default is 5m when post_task_script is set.
|
PostTaskScriptTimeout time.Duration `yaml:"post_task_script_timeout"` // PostTaskScriptTimeout caps how long the post-task script may run. Default is 5m when post_task_script is set.
|
||||||
|
Hooks RunnerHooks `yaml:"hooks"` // Hooks are scripts run inside the job environment around the job's steps.
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunnerHooks represents the scripts run inside the job environment around the job's steps.
|
||||||
|
type RunnerHooks struct {
|
||||||
|
JobStarted string `yaml:"job_started"` // JobStarted is the path of a script run before the job's first step. Falls back to ACTIONS_RUNNER_HOOK_JOB_STARTED; a failure fails the job.
|
||||||
|
JobCompleted string `yaml:"job_completed"` // JobCompleted is the path of a script run after the job's last step, while the job environment is still up. Falls back to ACTIONS_RUNNER_HOOK_JOB_COMPLETED; a failure fails the job.
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cache represents the configuration for caching.
|
// Cache represents the configuration for caching.
|
||||||
@@ -136,6 +144,7 @@ func LoadDefault(file string) (*Config, error) {
|
|||||||
if err := yaml.Unmarshal(content, cfg); err != nil {
|
if err := yaml.Unmarshal(content, cfg); err != nil {
|
||||||
return nil, fmt.Errorf("parse config file %q: %w", file, err)
|
return nil, fmt.Errorf("parse config file %q: %w", file, err)
|
||||||
}
|
}
|
||||||
|
warnUnknownKeys(file, content)
|
||||||
definedRunnerKeys, err = definedRunnerConfigKeys(content)
|
definedRunnerKeys, err = definedRunnerConfigKeys(content)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("parse config file %q for defaults metadata: %w", file, err)
|
return nil, fmt.Errorf("parse config file %q for defaults metadata: %w", file, err)
|
||||||
@@ -281,6 +290,21 @@ func LoadDefault(file string) (*Config, error) {
|
|||||||
return cfg, nil
|
return cfg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// warnUnknownKeys reports keys the config does not define, which are otherwise ignored
|
||||||
|
// without a trace. It only warns, so a config carrying keys from another runner version
|
||||||
|
// still loads.
|
||||||
|
func warnUnknownKeys(file string, content []byte) {
|
||||||
|
decoder := yaml.NewDecoder(bytes.NewReader(content))
|
||||||
|
decoder.KnownFields(true)
|
||||||
|
|
||||||
|
var typeErr *yaml.TypeError
|
||||||
|
if err := decoder.Decode(&Config{}); errors.As(err, &typeErr) {
|
||||||
|
for _, message := range typeErr.Errors {
|
||||||
|
log.Warnf("config file %q: %s, it will be ignored", file, message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func definedRunnerConfigKeys(content []byte) (map[string]bool, error) {
|
func definedRunnerConfigKeys(content []byte) (map[string]bool, error) {
|
||||||
var root yaml.Node
|
var root yaml.Node
|
||||||
if err := yaml.Unmarshal(content, &root); err != nil {
|
if err := yaml.Unmarshal(content, &root); err != nil {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/sirupsen/logrus/hooks/test"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
@@ -139,9 +140,6 @@ runner:
|
|||||||
assert.Equal(t, -1*time.Second, cfg.Runner.IdleCleanupInterval)
|
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_LoadsPostTaskScript(t *testing.T) {
|
func TestLoadDefault_LoadsPostTaskScript(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
path := filepath.Join(dir, "config.yaml")
|
path := filepath.Join(dir, "config.yaml")
|
||||||
@@ -170,6 +168,25 @@ runner:
|
|||||||
assert.Equal(t, 5*time.Minute, cfg.Runner.PostTaskScriptTimeout)
|
assert.Equal(t, 5*time.Minute, cfg.Runner.PostTaskScriptTimeout)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLoadDefault_LoadsJobHooks(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "config.yaml")
|
||||||
|
require.NoError(t, os.WriteFile(path, []byte(`
|
||||||
|
runner:
|
||||||
|
hooks:
|
||||||
|
job_started: /hooks/started.sh
|
||||||
|
job_completed: /hooks/completed.sh
|
||||||
|
`), 0o600))
|
||||||
|
|
||||||
|
cfg, err := LoadDefault(path)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "/hooks/started.sh", cfg.Runner.Hooks.JobStarted)
|
||||||
|
assert.Equal(t, "/hooks/completed.sh", cfg.Runner.Hooks.JobCompleted)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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) {
|
func TestLoadDefault_MalformedYAMLReturnsParseError(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
path := filepath.Join(dir, "config.yaml")
|
path := filepath.Join(dir, "config.yaml")
|
||||||
@@ -181,6 +198,21 @@ func TestLoadDefault_MalformedYAMLReturnsParseError(t *testing.T) {
|
|||||||
assert.NotContains(t, err.Error(), "defaults metadata")
|
assert.NotContains(t, err.Error(), "defaults metadata")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLoadDefault_WarnsOnUnknownKeysButStillLoads(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "config.yaml")
|
||||||
|
require.NoError(t, os.WriteFile(path, []byte("container:\n volumes:\n - /host:/ctr\n privileged: true\n"), 0o600))
|
||||||
|
|
||||||
|
hook := test.NewGlobal()
|
||||||
|
defer hook.Reset()
|
||||||
|
|
||||||
|
cfg, err := LoadDefault(path)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, cfg.Container.Privileged)
|
||||||
|
require.Len(t, hook.Entries, 1)
|
||||||
|
assert.Contains(t, hook.LastEntry().Message, "field volumes not found")
|
||||||
|
}
|
||||||
|
|
||||||
func TestContainerNetworkCreateOptions(t *testing.T) {
|
func TestContainerNetworkCreateOptions(t *testing.T) {
|
||||||
// Verify that the enable_ipv4/enable_ipv6 YAML keys unmarshal into the *bool fields,
|
// Verify that the enable_ipv4/enable_ipv6 YAML keys unmarshal into the *bool fields,
|
||||||
// distinguishing an explicit true/false from an omitted key (nil). A nil here is
|
// distinguishing an explicit true/false from an omitted key (nil). A nil here is
|
||||||
|
|||||||
Reference in New Issue
Block a user