mirror of
https://gitea.com/gitea/act_runner.git
synced 2026-08-06 17:04:22 +02:00
Compare commits
14 Commits
8f72c60afa
...
v2.0.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1d74ae636a | ||
|
|
b12d02c25f | ||
|
|
f2e0cf9131 | ||
|
|
0ee4643d4a | ||
|
|
e774003c18 | ||
|
|
eeb479ea89 | ||
|
|
eba33e178d | ||
|
|
3396021e0f | ||
|
|
745b0ab6e4 | ||
|
|
b7f6b6d90a | ||
|
|
cdcea87a45 | ||
|
|
3c4bcf3ebf | ||
|
|
e22d3fa263 | ||
|
|
99bc50d538 |
@@ -42,3 +42,7 @@ jobs:
|
|||||||
# after `make test` so the images it needs are already present on the host daemon.
|
# after `make test` so the images it needs are already present on the host daemon.
|
||||||
- name: test against dind image
|
- name: test against dind image
|
||||||
run: make test-dind
|
run: make test-dind
|
||||||
|
- name: coverage report
|
||||||
|
run: |
|
||||||
|
make coverage-report
|
||||||
|
cat .tmp/coverage.md >> "$GITHUB_STEP_SUMMARY"
|
||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -3,6 +3,7 @@
|
|||||||
!/act/runner/testdata/secrets/.env
|
!/act/runner/testdata/secrets/.env
|
||||||
.runner
|
.runner
|
||||||
coverage.txt
|
coverage.txt
|
||||||
|
.tmp/
|
||||||
/config.yaml
|
/config.yaml
|
||||||
|
|
||||||
# Jetbrains
|
# Jetbrains
|
||||||
@@ -12,3 +13,4 @@ coverage.txt
|
|||||||
__debug_bin
|
__debug_bin
|
||||||
# gorelease binary folder
|
# gorelease binary folder
|
||||||
/dist
|
/dist
|
||||||
|
.DS_Store
|
||||||
@@ -17,14 +17,14 @@ RUN make clean && make build
|
|||||||
### DIND VARIANT
|
### DIND VARIANT
|
||||||
#
|
#
|
||||||
#
|
#
|
||||||
FROM docker:29.6.0-dind AS dind
|
FROM docker:29.6.1-dind AS dind
|
||||||
|
|
||||||
ARG VERSION=dev
|
ARG VERSION=dev
|
||||||
|
|
||||||
LABEL org.opencontainers.image.source="https://gitea.com/gitea/runner"
|
LABEL org.opencontainers.image.source="https://gitea.com/gitea/runner"
|
||||||
LABEL org.opencontainers.image.version="${VERSION}"
|
LABEL org.opencontainers.image.version="${VERSION}"
|
||||||
|
|
||||||
RUN apk add --no-cache s6 bash git tzdata
|
RUN apk add --no-cache s6 bash git tzdata nftables
|
||||||
|
|
||||||
COPY --from=builder /opt/src/runner/gitea-runner /usr/local/bin/gitea-runner
|
COPY --from=builder /opt/src/runner/gitea-runner /usr/local/bin/gitea-runner
|
||||||
COPY scripts/run.sh /usr/local/bin/run.sh
|
COPY scripts/run.sh /usr/local/bin/run.sh
|
||||||
@@ -37,7 +37,7 @@ ENTRYPOINT ["s6-svscan","/etc/s6"]
|
|||||||
### DIND-ROOTLESS VARIANT
|
### DIND-ROOTLESS VARIANT
|
||||||
#
|
#
|
||||||
#
|
#
|
||||||
FROM docker:29.6.0-dind-rootless AS dind-rootless
|
FROM docker:29.6.1-dind-rootless AS dind-rootless
|
||||||
|
|
||||||
ARG VERSION=dev
|
ARG VERSION=dev
|
||||||
|
|
||||||
@@ -45,7 +45,7 @@ LABEL org.opencontainers.image.source="https://gitea.com/gitea/runner"
|
|||||||
LABEL org.opencontainers.image.version="${VERSION}"
|
LABEL org.opencontainers.image.version="${VERSION}"
|
||||||
|
|
||||||
USER root
|
USER root
|
||||||
RUN apk add --no-cache s6 bash git tzdata
|
RUN apk add --no-cache s6 bash git tzdata nftables
|
||||||
|
|
||||||
COPY --from=builder /opt/src/runner/gitea-runner /usr/local/bin/gitea-runner
|
COPY --from=builder /opt/src/runner/gitea-runner /usr/local/bin/gitea-runner
|
||||||
COPY scripts/run.sh /usr/local/bin/run.sh
|
COPY scripts/run.sh /usr/local/bin/run.sh
|
||||||
|
|||||||
18
Makefile
18
Makefile
@@ -38,12 +38,15 @@ endif
|
|||||||
ifeq ($(OS), Windows_NT)
|
ifeq ($(OS), Windows_NT)
|
||||||
GOFLAGS := -v -buildmode=exe
|
GOFLAGS := -v -buildmode=exe
|
||||||
EXECUTABLE ?= $(EXECUTABLE).exe
|
EXECUTABLE ?= $(EXECUTABLE).exe
|
||||||
|
GO_ENV_WINDOWS := set GOOS=windows&&
|
||||||
else ifeq ($(OS), Windows)
|
else ifeq ($(OS), Windows)
|
||||||
GOFLAGS := -v -buildmode=exe
|
GOFLAGS := -v -buildmode=exe
|
||||||
EXECUTABLE ?= $(EXECUTABLE).exe
|
EXECUTABLE ?= $(EXECUTABLE).exe
|
||||||
|
GO_ENV_WINDOWS := set GOOS=windows&&
|
||||||
else
|
else
|
||||||
GOFLAGS := -v
|
GOFLAGS := -v
|
||||||
EXECUTABLE ?= $(EXECUTABLE)
|
EXECUTABLE ?= $(EXECUTABLE)
|
||||||
|
GO_ENV_WINDOWS := GOOS=windows
|
||||||
endif
|
endif
|
||||||
|
|
||||||
STORED_VERSION_FILE := VERSION
|
STORED_VERSION_FILE := VERSION
|
||||||
@@ -108,12 +111,17 @@ deps-tools: ## install tool dependencies
|
|||||||
wait
|
wait
|
||||||
|
|
||||||
.PHONY: lint
|
.PHONY: lint
|
||||||
lint: lint-go ## lint everything
|
lint: lint-go lint-go-windows ## lint everything
|
||||||
|
|
||||||
.PHONY: lint-go
|
.PHONY: lint-go
|
||||||
lint-go: ## lint go files
|
lint-go: ## lint go files
|
||||||
$(GO) run $(GOLANGCI_LINT_PACKAGE) run
|
$(GO) run $(GOLANGCI_LINT_PACKAGE) run
|
||||||
|
|
||||||
|
.PHONY: lint-go-windows
|
||||||
|
lint-go-windows: ## lint Windows go files
|
||||||
|
$(GO) install $(GOLANGCI_LINT_PACKAGE)
|
||||||
|
$(GO_ENV_WINDOWS) golangci-lint run
|
||||||
|
|
||||||
.PHONY: lint-go-fix
|
.PHONY: lint-go-fix
|
||||||
lint-go-fix: ## lint go files and fix issues
|
lint-go-fix: ## lint go files and fix issues
|
||||||
$(GO) run $(GOLANGCI_LINT_PACKAGE) run --fix
|
$(GO) run $(GOLANGCI_LINT_PACKAGE) run --fix
|
||||||
@@ -143,6 +151,12 @@ tidy-check: tidy
|
|||||||
test: fmt-check security-check ## test everything (integration tests self-skip without docker/network)
|
test: fmt-check security-check ## 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 -race -timeout 20m -v -cover -coverprofile coverage.txt ./... && echo "\n==>\033[32m Ok\033[m\n" || exit 1
|
||||||
|
|
||||||
|
.PHONY: coverage-report
|
||||||
|
coverage-report: ## turn coverage.txt from `make test` into .tmp/coverage.md
|
||||||
|
@mkdir -p .tmp
|
||||||
|
@node ./tools/coverage-report.ts -i coverage.txt -o .tmp/coverage.md
|
||||||
|
@echo "Wrote .tmp/coverage.md"
|
||||||
|
|
||||||
.PHONY: test-dind
|
.PHONY: test-dind
|
||||||
test-dind: ## run the daemon-facing tests against the built dind image (TARGET=dind|dind-rootless)
|
test-dind: ## run the daemon-facing tests against the built dind image (TARGET=dind|dind-rootless)
|
||||||
@./scripts/test-dind.sh $(TARGET)
|
@./scripts/test-dind.sh $(TARGET)
|
||||||
@@ -210,7 +224,7 @@ docker: ## build the docker image
|
|||||||
.PHONY: clean
|
.PHONY: clean
|
||||||
clean: ## delete binary and coverage files
|
clean: ## delete binary and coverage files
|
||||||
$(GO) clean -x -i ./...
|
$(GO) clean -x -i ./...
|
||||||
rm -rf coverage.txt $(EXECUTABLE) $(DIST)
|
rm -rf coverage.txt .tmp $(EXECUTABLE) $(DIST)
|
||||||
|
|
||||||
.PHONY: version
|
.PHONY: version
|
||||||
version: ## print the version
|
version: ## print the version
|
||||||
|
|||||||
@@ -390,6 +390,43 @@ func TestMkdirFsImplSafeResolve(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestReadWriteFSWritableAndAppendable(t *testing.T) {
|
||||||
|
fsys := readWriteFSImpl{}
|
||||||
|
name := filepath.Join(t.TempDir(), "nested", "artifact.txt")
|
||||||
|
|
||||||
|
w, err := fsys.OpenWritable(name)
|
||||||
|
require.NoError(t, err)
|
||||||
|
_, err = w.Write([]byte("first"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, w.Close())
|
||||||
|
|
||||||
|
w, err = fsys.OpenAppendable(name)
|
||||||
|
require.NoError(t, err)
|
||||||
|
_, err = w.Write([]byte("-second"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, w.Close())
|
||||||
|
|
||||||
|
got, err := os.ReadFile(name)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "first-second", string(got))
|
||||||
|
|
||||||
|
w, err = fsys.OpenWritable(name)
|
||||||
|
require.NoError(t, err)
|
||||||
|
_, err = w.Write([]byte("replaced"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, w.Close())
|
||||||
|
|
||||||
|
got, err = os.ReadFile(name)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "replaced", string(got))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServeEmptyArtifactPathReturnsCancelableNoop(t *testing.T) {
|
||||||
|
cancel := Serve(t.Context(), "", "127.0.0.1", "0")
|
||||||
|
require.NotNil(t, cancel)
|
||||||
|
cancel()
|
||||||
|
}
|
||||||
|
|
||||||
func TestDownloadArtifactFileUnsafePath(t *testing.T) {
|
func TestDownloadArtifactFileUnsafePath(t *testing.T) {
|
||||||
assert := assert.New(t)
|
assert := assert.New(t)
|
||||||
|
|
||||||
|
|||||||
73
act/common/context_helpers_test.go
Normal file
73
act/common/context_helpers_test.go
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package common
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/sirupsen/logrus"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDryrunContext(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
if Dryrun(ctx) {
|
||||||
|
t.Fatal("plain context should not be dryrun")
|
||||||
|
}
|
||||||
|
if !Dryrun(WithDryrun(ctx, true)) {
|
||||||
|
t.Fatal("WithDryrun(true) should set dryrun")
|
||||||
|
}
|
||||||
|
if Dryrun(WithDryrun(ctx, false)) {
|
||||||
|
t.Fatal("WithDryrun(false) should clear dryrun")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestJobErrorContainer(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
err := errors.New("job failed")
|
||||||
|
|
||||||
|
SetJobError(ctx, err)
|
||||||
|
if got := JobError(ctx); got != nil {
|
||||||
|
t.Fatalf("JobError without container = %v, want nil", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx = WithJobErrorContainer(ctx)
|
||||||
|
SetJobError(ctx, err)
|
||||||
|
if got := JobError(ctx); !errors.Is(got, err) {
|
||||||
|
t.Fatalf("JobError = %v, want %v", got, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoggerAndHookContext(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
if Logger(ctx) != logrus.StandardLogger() {
|
||||||
|
t.Fatal("plain context should use standard logger")
|
||||||
|
}
|
||||||
|
if LoggerHook(ctx) != nil {
|
||||||
|
t.Fatal("plain context should not have a logger hook")
|
||||||
|
}
|
||||||
|
|
||||||
|
logger := logrus.New()
|
||||||
|
ctx = WithLogger(ctx, logger)
|
||||||
|
if Logger(ctx) != logger {
|
||||||
|
t.Fatal("WithLogger should set logger")
|
||||||
|
}
|
||||||
|
|
||||||
|
hook := testHook{}
|
||||||
|
ctx = WithLoggerHook(ctx, hook)
|
||||||
|
if LoggerHook(ctx) != hook {
|
||||||
|
t.Fatal("WithLoggerHook should set hook")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type testHook struct{}
|
||||||
|
|
||||||
|
func (testHook) Levels() []logrus.Level {
|
||||||
|
return logrus.AllLevels
|
||||||
|
}
|
||||||
|
|
||||||
|
func (testHook) Fire(*logrus.Entry) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -7,6 +7,8 @@ package common
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -170,3 +172,43 @@ func TestNewParallelExecutorCanceled(t *testing.T) {
|
|||||||
assert.Equal(int32(3), count.Load())
|
assert.Equal(int32(3), count.Load())
|
||||||
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 TestExecutorConditionalsAndFinally(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
var calls []string
|
||||||
|
record := func(name string) Executor {
|
||||||
|
return func(ctx context.Context) error {
|
||||||
|
calls = append(calls, name)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
require.NoError(t, record("if-true").If(func(context.Context) bool { return true })(ctx))
|
||||||
|
require.NoError(t, record("if-false").If(func(context.Context) bool { return false })(ctx))
|
||||||
|
require.NoError(t, record("if-not").IfNot(func(context.Context) bool { return false })(ctx))
|
||||||
|
require.NoError(t, record("if-bool").IfBool(true)(ctx))
|
||||||
|
require.NoError(t, record("main").Finally(record("finally"))(ctx))
|
||||||
|
|
||||||
|
want := []string{"if-true", "if-not", "if-bool", "main", "finally"}
|
||||||
|
if !reflect.DeepEqual(calls, want) {
|
||||||
|
t.Fatalf("calls = %v, want %v", calls, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecutorFinallyReturnsFinallyErrorWithOriginal(t *testing.T) {
|
||||||
|
mainErr := errors.New("main failed")
|
||||||
|
finalErr := errors.New("cleanup failed")
|
||||||
|
|
||||||
|
err := NewErrorExecutor(mainErr).Finally(NewErrorExecutor(finalErr))(context.Background())
|
||||||
|
require.Error(t, err)
|
||||||
|
if !strings.Contains(err.Error(), "cleanup failed") || !strings.Contains(err.Error(), "main failed") {
|
||||||
|
t.Fatalf("finally error = %q, want both cleanup and original error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConditionalNot(t *testing.T) {
|
||||||
|
cond := Conditional(func(context.Context) bool { return false })
|
||||||
|
if !cond.Not()(context.Background()) {
|
||||||
|
t.Fatal("inverted conditional should be true")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -257,6 +257,10 @@ type NewGitCloneExecutorInput struct {
|
|||||||
Token string
|
Token string
|
||||||
OfflineMode bool
|
OfflineMode bool
|
||||||
|
|
||||||
|
// Depth limits the clone/fetch to the given number of commits from the tip of the requested ref.
|
||||||
|
// 0 for full clone.
|
||||||
|
Depth int
|
||||||
|
|
||||||
// For Gitea
|
// For Gitea
|
||||||
InsecureSkipTLS bool
|
InsecureSkipTLS bool
|
||||||
}
|
}
|
||||||
@@ -309,7 +313,7 @@ func CloneIfRequired(ctx context.Context, refName plumbing.ReferenceName, input
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
r, err = git.PlainCloneContext(ctx, input.Dir, false, &cloneOptions)
|
r, err = cloneAtDepth(ctx, input, cloneOptions, logger)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Errorf("Unable to clone %v %s: %v", input.URL, refName, err)
|
logger.Errorf("Unable to clone %v %s: %v", input.URL, refName, err)
|
||||||
return nil, false, err
|
return nil, false, err
|
||||||
@@ -364,6 +368,16 @@ func NewGitCloneExecutor(input NewGitCloneExecutorInput) common.Executor {
|
|||||||
pullOptions.InsecureSkipTLS = true
|
pullOptions.InsecureSkipTLS = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Action clones only ever need the tip commit, so keep a shallow cache cheap on update at depth 1 regardless of its original depth
|
||||||
|
// Turning action_shallow_clone off does not convert an existing shallow cache; evict it for a full clone.
|
||||||
|
shallow := isShallow(r)
|
||||||
|
if shallow {
|
||||||
|
fetchOptions.Depth = 1
|
||||||
|
if spec, ok := shallowFetchRefSpec(r, input.Ref); ok {
|
||||||
|
fetchOptions.RefSpecs = []config.RefSpec{spec}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if !isOfflineMode {
|
if !isOfflineMode {
|
||||||
err = r.Fetch(&fetchOptions)
|
err = r.Fetch(&fetchOptions)
|
||||||
if err != nil && !errors.Is(err, git.NoErrAlreadyUpToDate) {
|
if err != nil && !errors.Is(err, git.NoErrAlreadyUpToDate) {
|
||||||
@@ -431,11 +445,13 @@ func NewGitCloneExecutor(input NewGitCloneExecutorInput) common.Executor {
|
|||||||
|
|
||||||
reusedMsg := ""
|
reusedMsg := ""
|
||||||
|
|
||||||
if !isOfflineMode {
|
switch {
|
||||||
|
case !isOfflineMode && !shallow:
|
||||||
|
// In shallow mode the depth-limited fetch above already advanced the ref.
|
||||||
if err = w.Pull(&pullOptions); err != nil && err != git.NoErrAlreadyUpToDate {
|
if err = w.Pull(&pullOptions); err != nil && err != git.NoErrAlreadyUpToDate {
|
||||||
logger.Debugf("Unable to pull %s: %v", refName, err)
|
logger.Debugf("Unable to pull %s: %v", refName, err)
|
||||||
}
|
}
|
||||||
} else if reused {
|
case isOfflineMode && reused:
|
||||||
reusedMsg = " (reused in offline mode)"
|
reusedMsg = " (reused in offline mode)"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -468,3 +484,53 @@ func NewGitCloneExecutor(input NewGitCloneExecutorInput) common.Executor {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// cloneAtDepth clones input.URL into input.Dir using opts.
|
||||||
|
// With input.Depth > 0 it first tries a shallow, single-branch clone of input.Ref, falling back when error.
|
||||||
|
func cloneAtDepth(ctx context.Context, input NewGitCloneExecutorInput, opts git.CloneOptions, logger log.FieldLogger) (*git.Repository, error) {
|
||||||
|
if input.Depth > 0 {
|
||||||
|
for _, refName := range []plumbing.ReferenceName{
|
||||||
|
plumbing.NewBranchReferenceName(input.Ref),
|
||||||
|
plumbing.NewTagReferenceName(input.Ref),
|
||||||
|
} {
|
||||||
|
shallowOpts := opts
|
||||||
|
shallowOpts.Depth = input.Depth
|
||||||
|
shallowOpts.SingleBranch = true
|
||||||
|
shallowOpts.ReferenceName = refName
|
||||||
|
shallowOpts.Tags = git.NoTags
|
||||||
|
|
||||||
|
r, err := git.PlainCloneContext(ctx, input.Dir, false, &shallowOpts)
|
||||||
|
if err == nil {
|
||||||
|
return r, nil
|
||||||
|
}
|
||||||
|
logger.Debugf("Shallow clone of %s as %s failed: %v", input.URL, refName, err)
|
||||||
|
if rmErr := os.RemoveAll(input.Dir); rmErr != nil {
|
||||||
|
return nil, fmt.Errorf("remove partial clone %s: %w", input.Dir, rmErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
logger.Debugf("Falling back to a full clone of %s for ref %q", input.URL, input.Ref)
|
||||||
|
}
|
||||||
|
|
||||||
|
return git.PlainCloneContext(ctx, input.Dir, false, &opts)
|
||||||
|
}
|
||||||
|
|
||||||
|
// isShallow reports whether the local repository was cloned with a limited depth.
|
||||||
|
func isShallow(r *git.Repository) bool {
|
||||||
|
shallows, err := r.Storer.Shallow()
|
||||||
|
return err == nil && len(shallows) > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// shallowFetchRefSpec returns the single refspec that updates only input.Ref, keeping a shallow clone from re-downloading every branch's history.
|
||||||
|
// ok is false when the ref is not present locally as a tag or remote-tracking branch, in which case the broad default refspec is used.
|
||||||
|
func shallowFetchRefSpec(r *git.Repository, ref string) (config.RefSpec, bool) {
|
||||||
|
tagRef := plumbing.NewTagReferenceName(ref)
|
||||||
|
if _, err := r.Reference(tagRef, false); err == nil {
|
||||||
|
return config.RefSpec(fmt.Sprintf("+%s:%s", tagRef, tagRef)), true
|
||||||
|
}
|
||||||
|
remoteRef := plumbing.NewRemoteReferenceName("origin", ref)
|
||||||
|
if _, err := r.Reference(remoteRef, false); err == nil {
|
||||||
|
branchRef := plumbing.NewBranchReferenceName(ref)
|
||||||
|
return config.RefSpec(fmt.Sprintf("+%s:%s", branchRef, remoteRef)), true
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"syscall"
|
"syscall"
|
||||||
@@ -49,6 +50,13 @@ func TestFindGitSlug(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestErrorWrapsCommitAndCause(t *testing.T) {
|
||||||
|
err := &Error{err: ErrShortRef, commit: "abc123"}
|
||||||
|
require.Equal(t, ErrShortRef.Error(), err.Error())
|
||||||
|
require.ErrorIs(t, err, ErrShortRef)
|
||||||
|
require.Equal(t, "abc123", err.Commit())
|
||||||
|
}
|
||||||
|
|
||||||
func cleanGitHooks(dir string) error {
|
func cleanGitHooks(dir string) error {
|
||||||
hooksDir := filepath.Join(dir, ".git", "hooks")
|
hooksDir := filepath.Join(dir, ".git", "hooks")
|
||||||
files, err := os.ReadDir(hooksDir)
|
files, err := os.ReadDir(hooksDir)
|
||||||
@@ -95,6 +103,22 @@ func TestFindGitRemoteURL(t *testing.T) {
|
|||||||
assert.Equal(remoteURL, u)
|
assert.Equal(remoteURL, u)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestFindGithubRepoUsesOriginAndCustomRemote(t *testing.T) {
|
||||||
|
basedir := t.TempDir()
|
||||||
|
require.NoError(t, gitCmd("init", basedir))
|
||||||
|
require.NoError(t, cleanGitHooks(basedir))
|
||||||
|
require.NoError(t, gitCmd("-C", basedir, "remote", "add", "origin", "https://github.com/owner/repo.git"))
|
||||||
|
require.NoError(t, gitCmd("-C", basedir, "remote", "add", "ghe", "git@git.example.com:team/project.git"))
|
||||||
|
|
||||||
|
slug, err := FindGithubRepo(context.Background(), basedir, "github.com", "")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "owner/repo", slug)
|
||||||
|
|
||||||
|
slug, err = FindGithubRepo(context.Background(), basedir, "git.example.com", "ghe")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "team/project", slug)
|
||||||
|
}
|
||||||
|
|
||||||
func TestGitFindRef(t *testing.T) {
|
func TestGitFindRef(t *testing.T) {
|
||||||
basedir := t.TempDir()
|
basedir := t.TempDir()
|
||||||
|
|
||||||
@@ -380,6 +404,96 @@ func TestGitCloneExecutorOfflineMode(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestGitCloneExecutorShallow(t *testing.T) {
|
||||||
|
// Build a local "remote" with several commits on main plus a tag, so a full clone would pull noticeably more history than a shallow one.
|
||||||
|
remoteDir := t.TempDir()
|
||||||
|
require.NoError(t, gitCmd("init", "--bare", "--initial-branch=main", remoteDir))
|
||||||
|
workDir := t.TempDir()
|
||||||
|
require.NoError(t, gitCmd("clone", remoteDir, workDir))
|
||||||
|
require.NoError(t, gitCmd("-C", workDir, "checkout", "-b", "main"))
|
||||||
|
for _, m := range []string{"c1", "c2", "c3"} {
|
||||||
|
require.NoError(t, gitCmd("-C", workDir, "commit", "--allow-empty", "-m", m))
|
||||||
|
}
|
||||||
|
require.NoError(t, gitCmd("-C", workDir, "tag", "v1"))
|
||||||
|
sha := gitRevParse(t, workDir, "HEAD~1") // c2, a SHA that go-git cannot shallow-clone
|
||||||
|
require.NoError(t, gitCmd("-C", workDir, "push", "-u", "origin", "main"))
|
||||||
|
require.NoError(t, gitCmd("-C", workDir, "push", "origin", "v1"))
|
||||||
|
|
||||||
|
shallowMarker := func(dir string) string { return filepath.Join(dir, ".git", "shallow") }
|
||||||
|
|
||||||
|
t.Run("branch is cloned shallowly", func(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
require.NoError(t, NewGitCloneExecutor(NewGitCloneExecutorInput{
|
||||||
|
URL: remoteDir, Ref: "main", Dir: dir, Depth: 1,
|
||||||
|
})(t.Context()))
|
||||||
|
assert.FileExists(t, shallowMarker(dir), "clone should be shallow")
|
||||||
|
assert.Equal(t, 1, gitRevCount(t, dir), "only the tip commit should be present")
|
||||||
|
assert.Equal(t, "c3", gitHeadSubject(t, dir))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("tag is cloned shallowly", func(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
require.NoError(t, NewGitCloneExecutor(NewGitCloneExecutorInput{
|
||||||
|
URL: remoteDir, Ref: "v1", Dir: dir, Depth: 1,
|
||||||
|
})(t.Context()))
|
||||||
|
assert.FileExists(t, shallowMarker(dir), "clone should be shallow")
|
||||||
|
assert.Equal(t, 1, gitRevCount(t, dir))
|
||||||
|
assert.Equal(t, "c3", gitHeadSubject(t, dir))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("SHA falls back to a full clone", func(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
require.NoError(t, NewGitCloneExecutor(NewGitCloneExecutorInput{
|
||||||
|
URL: remoteDir, Ref: sha, Dir: dir, Depth: 1,
|
||||||
|
})(t.Context()))
|
||||||
|
// go-git cannot shallow-clone a raw SHA, so it falls back to a full clone; the absence of a shallow marker proves the fallback happened.
|
||||||
|
assert.NoFileExists(t, shallowMarker(dir), "a SHA ref must not produce a shallow clone")
|
||||||
|
assert.Equal(t, sha, gitRevParse(t, dir, "HEAD"))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("moving branch updates while staying shallow", func(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
require.NoError(t, NewGitCloneExecutor(NewGitCloneExecutorInput{
|
||||||
|
URL: remoteDir, Ref: "main", Dir: dir, Depth: 1,
|
||||||
|
})(t.Context()))
|
||||||
|
require.Equal(t, "c3", gitHeadSubject(t, dir))
|
||||||
|
|
||||||
|
// Advance main on the remote, then reuse the existing shallow clone.
|
||||||
|
require.NoError(t, gitCmd("-C", workDir, "commit", "--allow-empty", "-m", "c4"))
|
||||||
|
require.NoError(t, gitCmd("-C", workDir, "push", "origin", "main"))
|
||||||
|
|
||||||
|
require.NoError(t, NewGitCloneExecutor(NewGitCloneExecutorInput{
|
||||||
|
URL: remoteDir, Ref: "main", Dir: dir, Depth: 1,
|
||||||
|
})(t.Context()))
|
||||||
|
assert.Equal(t, "c4", gitHeadSubject(t, dir), "reused shallow clone should update to the new tip")
|
||||||
|
assert.FileExists(t, shallowMarker(dir), "repo should remain shallow after update")
|
||||||
|
assert.Equal(t, 1, gitRevCount(t, dir))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func gitRevParse(t *testing.T, dir, rev string) string {
|
||||||
|
t.Helper()
|
||||||
|
out, err := exec.Command("git", "-C", dir, "rev-parse", rev).Output()
|
||||||
|
require.NoError(t, err)
|
||||||
|
return strings.TrimSpace(string(out))
|
||||||
|
}
|
||||||
|
|
||||||
|
func gitRevCount(t *testing.T, dir string) int {
|
||||||
|
t.Helper()
|
||||||
|
out, err := exec.Command("git", "-C", dir, "rev-list", "--count", "HEAD").Output()
|
||||||
|
require.NoError(t, err)
|
||||||
|
n, err := strconv.Atoi(strings.TrimSpace(string(out)))
|
||||||
|
require.NoError(t, err)
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
func gitHeadSubject(t *testing.T, dir string) string {
|
||||||
|
t.Helper()
|
||||||
|
out, err := exec.Command("git", "-C", dir, "log", "-1", "--format=%s").Output()
|
||||||
|
require.NoError(t, err)
|
||||||
|
return strings.TrimSpace(string(out))
|
||||||
|
}
|
||||||
|
|
||||||
func gitCmd(args ...string) error {
|
func gitCmd(args ...string) error {
|
||||||
cmd := exec.Command("git", args...)
|
cmd := exec.Command("git", args...)
|
||||||
cmd.Stdout = os.Stdout
|
cmd.Stdout = os.Stdout
|
||||||
|
|||||||
@@ -498,6 +498,79 @@ func TestParseDevice(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestParseDeviceByServerOS(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
device string
|
||||||
|
serverOS string
|
||||||
|
want container.DeviceMapping
|
||||||
|
wantErr string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "linux source only",
|
||||||
|
device: "/dev/snd",
|
||||||
|
serverOS: "linux",
|
||||||
|
want: container.DeviceMapping{
|
||||||
|
PathOnHost: "/dev/snd",
|
||||||
|
PathInContainer: "/dev/snd",
|
||||||
|
CgroupPermissions: "rwm",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "linux source and mode",
|
||||||
|
device: "/dev/snd:rw",
|
||||||
|
serverOS: "linux",
|
||||||
|
want: container.DeviceMapping{
|
||||||
|
PathOnHost: "/dev/snd",
|
||||||
|
PathInContainer: "/dev/snd",
|
||||||
|
CgroupPermissions: "rw",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "linux source target and mode",
|
||||||
|
device: "/dev/snd:/container/snd:m",
|
||||||
|
serverOS: "linux",
|
||||||
|
want: container.DeviceMapping{
|
||||||
|
PathOnHost: "/dev/snd",
|
||||||
|
PathInContainer: "/container/snd",
|
||||||
|
CgroupPermissions: "m",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "windows passes value through",
|
||||||
|
device: `class/GUID`,
|
||||||
|
serverOS: "windows",
|
||||||
|
want: container.DeviceMapping{
|
||||||
|
PathOnHost: `class/GUID`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid server OS",
|
||||||
|
device: "/dev/snd",
|
||||||
|
serverOS: "plan9",
|
||||||
|
wantErr: "unknown server OS: plan9",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "too many linux fields",
|
||||||
|
device: "/dev/snd:/container/snd:rw:extra",
|
||||||
|
serverOS: "linux",
|
||||||
|
wantErr: "invalid device specification: /dev/snd:/container/snd:rw:extra",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
got, err := parseDevice(tc.device, tc.serverOS)
|
||||||
|
if tc.wantErr != "" {
|
||||||
|
assert.Error(t, err, tc.wantErr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
assert.NilError(t, err)
|
||||||
|
assert.Equal(t, got, tc.want)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestParseNetworkConfig(t *testing.T) {
|
func TestParseNetworkConfig(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
@@ -930,6 +1003,82 @@ func TestValidateDevice(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestValidateDeviceByServerOS(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
value string
|
||||||
|
serverOS string
|
||||||
|
want string
|
||||||
|
wantError string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "linux preserves three-field container path",
|
||||||
|
value: "/host:/container/../device:rw",
|
||||||
|
serverOS: "linux",
|
||||||
|
want: "/host:/container/../device:rw",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "linux source path can be relative when target is absolute",
|
||||||
|
value: "relative-host:/container/device",
|
||||||
|
serverOS: "linux",
|
||||||
|
want: "relative-host:/container/device",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "windows defers validation",
|
||||||
|
value: `class/GUID`,
|
||||||
|
serverOS: "windows",
|
||||||
|
want: `class/GUID`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "linux rejects bad mode",
|
||||||
|
value: "/host:/container:ro",
|
||||||
|
serverOS: "linux",
|
||||||
|
wantError: "bad mode specified: ro",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "linux target must be absolute",
|
||||||
|
value: "/host:relative",
|
||||||
|
serverOS: "linux",
|
||||||
|
wantError: "relative is not an absolute path",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unknown server OS",
|
||||||
|
value: "/dev/snd",
|
||||||
|
serverOS: "plan9",
|
||||||
|
wantError: "unknown server OS: plan9",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
got, err := validateDevice(tc.value, tc.serverOS)
|
||||||
|
if tc.wantError != "" {
|
||||||
|
assert.Error(t, err, tc.wantError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
assert.NilError(t, err)
|
||||||
|
assert.Equal(t, got, tc.want)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeviceCgroupRulesAndInvalidParameter(t *testing.T) {
|
||||||
|
got, err := validateDeviceCgroupRule("c 1:3 rwm")
|
||||||
|
assert.NilError(t, err)
|
||||||
|
assert.Equal(t, got, "c 1:3 rwm")
|
||||||
|
|
||||||
|
_, err = validateDeviceCgroupRule("invalid")
|
||||||
|
assert.Error(t, err, "invalid device cgroup format 'invalid'")
|
||||||
|
|
||||||
|
if invalidParameter(nil) != nil {
|
||||||
|
t.Fatal("invalidParameter(nil) should be nil")
|
||||||
|
}
|
||||||
|
err = invalidParameter(errors.New("bad input"))
|
||||||
|
assert.Assert(t, err != nil)
|
||||||
|
var invalid interface{ InvalidParameter() }
|
||||||
|
assert.Assert(t, errors.As(err, &invalid))
|
||||||
|
}
|
||||||
|
|
||||||
func TestParseSystemPaths(t *testing.T) {
|
func TestParseSystemPaths(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
doc string
|
doc string
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
"runtime"
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/go-git/go-billy/v5"
|
"github.com/go-git/go-billy/v5"
|
||||||
"github.com/go-git/go-billy/v5/memfs"
|
"github.com/go-git/go-billy/v5/memfs"
|
||||||
@@ -221,3 +222,63 @@ func TestCopyCollectorWriteFileOverwritesFileWithSymlink(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, "target", resolved)
|
assert.Equal(t, "target", resolved)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDefaultFsOpenReadlinkAndWalk(t *testing.T) {
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
t.Skip("creating symlinks requires elevated privileges on Windows")
|
||||||
|
}
|
||||||
|
|
||||||
|
root := t.TempDir()
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(root, "file.txt"), []byte("content"), 0o644))
|
||||||
|
require.NoError(t, os.Symlink("file.txt", filepath.Join(root, "link.txt")))
|
||||||
|
|
||||||
|
fsys := &DefaultFs{}
|
||||||
|
var walked []string
|
||||||
|
require.NoError(t, fsys.Walk(root, func(path string, info os.FileInfo, err error) error {
|
||||||
|
require.NoError(t, err)
|
||||||
|
walked = append(walked, info.Name())
|
||||||
|
return nil
|
||||||
|
}))
|
||||||
|
require.Contains(t, walked, "file.txt")
|
||||||
|
require.Contains(t, walked, "link.txt")
|
||||||
|
|
||||||
|
file, err := fsys.Open(filepath.Join(root, "file.txt"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
data, err := io.ReadAll(file)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, file.Close())
|
||||||
|
require.Equal(t, "content", string(data))
|
||||||
|
|
||||||
|
link, err := fsys.Readlink(filepath.Join(root, "link.txt"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "file.txt", link)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFileCollectorCancellationAndWalkError(t *testing.T) {
|
||||||
|
fc := &FileCollector{Fs: &memoryFs{Filesystem: memfs.New()}}
|
||||||
|
walk := fc.CollectFiles(cancelledContext(t), nil)
|
||||||
|
|
||||||
|
err := walk("file", fakeFileInfo{name: "file"}, nil)
|
||||||
|
require.EqualError(t, err, "copy cancelled")
|
||||||
|
|
||||||
|
err = walk("file", fakeFileInfo{name: "file"}, os.ErrPermission)
|
||||||
|
require.ErrorIs(t, err, os.ErrPermission)
|
||||||
|
}
|
||||||
|
|
||||||
|
func cancelledContext(t *testing.T) context.Context {
|
||||||
|
t.Helper()
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel()
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
type fakeFileInfo struct {
|
||||||
|
name string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f fakeFileInfo) Name() string { return f.name }
|
||||||
|
func (f fakeFileInfo) Size() int64 { return 0 }
|
||||||
|
func (f fakeFileInfo) Mode() os.FileMode { return 0o644 }
|
||||||
|
func (f fakeFileInfo) ModTime() time.Time { return time.Time{} }
|
||||||
|
func (f fakeFileInfo) IsDir() bool { return false }
|
||||||
|
func (f fakeFileInfo) Sys() any { return nil }
|
||||||
|
|||||||
74
act/lookpath/lp_unix_test.go
Normal file
74
act/lookpath/lp_unix_test.go
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
//go:build aix || darwin || dragonfly || freebsd || linux || netbsd || openbsd || solaris
|
||||||
|
|
||||||
|
package lookpath
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"io/fs"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
type testEnv map[string]string
|
||||||
|
|
||||||
|
func (e testEnv) Getenv(name string) string {
|
||||||
|
return e[name]
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLookPath2SearchesPathAndEmptyElement(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
exe := filepath.Join(dir, "tool")
|
||||||
|
if err := os.WriteFile(exe, []byte("#!/bin/sh\n"), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := LookPath2("tool", testEnv{"PATH": string(filepath.ListSeparator) + dir})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got != exe {
|
||||||
|
t.Fatalf("LookPath2() = %q, want %q", got, exe)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLookPath2DirectPathDoesNotSearchPath(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
exe := filepath.Join(dir, "tool")
|
||||||
|
if err := os.WriteFile(exe, []byte("#!/bin/sh\n"), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := LookPath2(exe, testEnv{"PATH": ""})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got != exe {
|
||||||
|
t.Fatalf("LookPath2() = %q, want %q", got, exe)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLookPath2ReportsPermissionAndNotFound(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
file := filepath.Join(dir, "not-executable")
|
||||||
|
if err := os.WriteFile(file, []byte("plain text"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := LookPath2(file, testEnv{"PATH": dir})
|
||||||
|
var pathErr *Error
|
||||||
|
if !errors.As(err, &pathErr) || !errors.Is(pathErr.Err, fs.ErrPermission) {
|
||||||
|
t.Fatalf("LookPath2(non-executable) error = %v, want fs.ErrPermission wrapped in *Error", err)
|
||||||
|
}
|
||||||
|
if pathErr.Error() != fs.ErrPermission.Error() {
|
||||||
|
t.Fatalf("Error() = %q, want %q", pathErr.Error(), fs.ErrPermission.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = LookPath2("missing", testEnv{"PATH": dir})
|
||||||
|
if !errors.As(err, &pathErr) || !errors.Is(pathErr.Err, ErrNotFound) {
|
||||||
|
t.Fatalf("LookPath2(missing) error = %v, want ErrNotFound wrapped in *Error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -62,7 +62,7 @@ func LookPath2(file string, lenv Env) (string, error) {
|
|||||||
var exts []string
|
var exts []string
|
||||||
x := lenv.Getenv(`PATHEXT`)
|
x := lenv.Getenv(`PATHEXT`)
|
||||||
if x != "" {
|
if x != "" {
|
||||||
for _, e := range strings.Split(strings.ToLower(x), `;`) {
|
for e := range strings.SplitSeq(strings.ToLower(x), `;`) {
|
||||||
if e == "" {
|
if e == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|||||||
63
act/model/action_test.go
Normal file
63
act/model/action_test.go
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestReadActionDefaultsAndCaseInsensitiveUsing(t *testing.T) {
|
||||||
|
action, err := ReadAction(strings.NewReader(`
|
||||||
|
name: example
|
||||||
|
runs:
|
||||||
|
using: NoDe24
|
||||||
|
main: dist/index.js
|
||||||
|
`))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if action.Runs.Using != ActionRunsUsingNode24 {
|
||||||
|
t.Fatalf("using = %q, want %q", action.Runs.Using, ActionRunsUsingNode24)
|
||||||
|
}
|
||||||
|
if action.Runs.PreIf != "always()" {
|
||||||
|
t.Fatalf("pre-if = %q, want always()", action.Runs.PreIf)
|
||||||
|
}
|
||||||
|
if action.Runs.PostIf != "always()" {
|
||||||
|
t.Fatalf("post-if = %q, want always()", action.Runs.PostIf)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReadActionPreservesExplicitConditions(t *testing.T) {
|
||||||
|
action, err := ReadAction(strings.NewReader(`
|
||||||
|
runs:
|
||||||
|
using: composite
|
||||||
|
pre-if: success()
|
||||||
|
post-if: failure()
|
||||||
|
steps:
|
||||||
|
- run: echo hello
|
||||||
|
`))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if action.Runs.PreIf != "success()" || action.Runs.PostIf != "failure()" {
|
||||||
|
t.Fatalf("conditions = %q/%q, want explicit values", action.Runs.PreIf, action.Runs.PostIf)
|
||||||
|
}
|
||||||
|
if !action.Runs.Using.IsComposite() || action.Runs.Using.IsDocker() || action.Runs.Using.IsNode() {
|
||||||
|
t.Fatalf("unexpected using predicates for %q", action.Runs.Using)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReadActionRejectsUnknownUsing(t *testing.T) {
|
||||||
|
_, err := ReadAction(strings.NewReader(`
|
||||||
|
runs:
|
||||||
|
using: node99
|
||||||
|
`))
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected unknown runs.using to fail")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "node99") {
|
||||||
|
t.Fatalf("error = %q, want invalid value", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,10 +6,12 @@ package model
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
type WorkflowPlanTest struct {
|
type WorkflowPlanTest struct {
|
||||||
@@ -65,3 +67,133 @@ func TestWorkflow(t *testing.T) {
|
|||||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
assert.NotNil(t, result)
|
assert.NotNil(t, result)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestNewSingleWorkflowPlannerAndPlanMethods(t *testing.T) {
|
||||||
|
planner, err := NewSingleWorkflowPlanner("ci.yml", strings.NewReader(`
|
||||||
|
name: CI
|
||||||
|
on: [push, pull_request]
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
name: Build project
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- run: make build
|
||||||
|
test:
|
||||||
|
needs: build
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- run: make test
|
||||||
|
`))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, []string{"pull_request", "push"}, planner.GetEvents())
|
||||||
|
|
||||||
|
eventPlan, err := planner.PlanEvent("push")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, eventPlan.Stages, 2)
|
||||||
|
assert.Equal(t, []string{"build"}, eventPlan.Stages[0].GetJobIDs())
|
||||||
|
assert.Equal(t, []string{"test"}, eventPlan.Stages[1].GetJobIDs())
|
||||||
|
assert.Equal(t, len("Build project"), eventPlan.MaxRunNameLen())
|
||||||
|
assert.Equal(t, "Build project", eventPlan.Stages[0].Runs[0].String())
|
||||||
|
assert.Equal(t, "build", eventPlan.Stages[0].Runs[0].JobID)
|
||||||
|
assert.NotNil(t, eventPlan.Stages[0].Runs[0].Job())
|
||||||
|
|
||||||
|
jobPlan, err := planner.PlanJob("test")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, jobPlan.Stages, 2)
|
||||||
|
assert.Equal(t, []string{"build"}, jobPlan.Stages[0].GetJobIDs())
|
||||||
|
assert.Equal(t, []string{"test"}, jobPlan.Stages[1].GetJobIDs())
|
||||||
|
|
||||||
|
allPlan, err := planner.PlanAll()
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, allPlan.Stages, 2)
|
||||||
|
assert.Equal(t, []string{"build"}, allPlan.Stages[0].GetJobIDs())
|
||||||
|
assert.Equal(t, []string{"test"}, allPlan.Stages[1].GetJobIDs())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCombineWorkflowPlannerMergesWorkflowStages(t *testing.T) {
|
||||||
|
first := mustReadWorkflow(t, `
|
||||||
|
name: First
|
||||||
|
on: push
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- run: make build
|
||||||
|
`)
|
||||||
|
second := mustReadWorkflow(t, `
|
||||||
|
name: Second
|
||||||
|
on: push
|
||||||
|
jobs:
|
||||||
|
lint:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- run: make lint
|
||||||
|
test:
|
||||||
|
needs: lint
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- run: make test
|
||||||
|
`)
|
||||||
|
|
||||||
|
planner := CombineWorkflowPlanner(first, second)
|
||||||
|
plan, err := planner.PlanEvent("push")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, plan.Stages, 2)
|
||||||
|
assert.ElementsMatch(t, []string{"build", "lint"}, plan.Stages[0].GetJobIDs())
|
||||||
|
assert.Equal(t, []string{"test"}, plan.Stages[1].GetJobIDs())
|
||||||
|
|
||||||
|
empty, err := planner.PlanEvent("schedule")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Empty(t, empty.Stages)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPlannerErrorsForMissingAndCyclicJobs(t *testing.T) {
|
||||||
|
workflow := mustReadWorkflow(t, `
|
||||||
|
name: Cyclic
|
||||||
|
on: push
|
||||||
|
jobs:
|
||||||
|
a:
|
||||||
|
needs: b
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- run: echo a
|
||||||
|
b:
|
||||||
|
needs: a
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- run: echo b
|
||||||
|
`)
|
||||||
|
planner := CombineWorkflowPlanner(workflow)
|
||||||
|
|
||||||
|
plan, err := planner.PlanJob("missing")
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Empty(t, plan.Stages)
|
||||||
|
assert.Contains(t, err.Error(), "Could not find any stages")
|
||||||
|
|
||||||
|
plan, err = planner.PlanEvent("push")
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Empty(t, plan.Stages)
|
||||||
|
assert.Contains(t, err.Error(), "unable to build dependency graph")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewSingleWorkflowPlannerErrors(t *testing.T) {
|
||||||
|
_, err := NewSingleWorkflowPlanner("empty.yml", strings.NewReader(""))
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "file is empty")
|
||||||
|
|
||||||
|
_, err = NewSingleWorkflowPlanner("invalid.yml", strings.NewReader("jobs: ["))
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "workflow is not valid")
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustReadWorkflow(t *testing.T, content string) *Workflow {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
workflow, err := ReadWorkflow(strings.NewReader(content))
|
||||||
|
require.NoError(t, err)
|
||||||
|
if workflow.Name == "" {
|
||||||
|
workflow.Name = "workflow"
|
||||||
|
}
|
||||||
|
return workflow
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
package model
|
package model
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -58,6 +59,190 @@ func TestJobNeedsResult(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestJobSetContinueOnErrorFirmFailureWins(t *testing.T) {
|
||||||
|
job := &Job{}
|
||||||
|
job.SetContinueOnError(true)
|
||||||
|
assert.True(t, job.ContinueOnError)
|
||||||
|
|
||||||
|
job.SetContinueOnError(false)
|
||||||
|
assert.False(t, job.ContinueOnError)
|
||||||
|
|
||||||
|
job.SetContinueOnError(true)
|
||||||
|
assert.False(t, job.ContinueOnError, "a later tolerated failure must not hide an earlier firm failure")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStepStatusText(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
status stepStatus
|
||||||
|
text string
|
||||||
|
}{
|
||||||
|
{StepStatusSuccess, "success"},
|
||||||
|
{StepStatusFailure, "failure"},
|
||||||
|
{StepStatusSkipped, "skipped"},
|
||||||
|
} {
|
||||||
|
t.Run(tc.text, func(t *testing.T) {
|
||||||
|
got, err := tc.status.MarshalText()
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, tc.text, string(got))
|
||||||
|
|
||||||
|
var parsed stepStatus
|
||||||
|
require.NoError(t, parsed.UnmarshalText(got))
|
||||||
|
assert.Equal(t, tc.status, parsed)
|
||||||
|
assert.Equal(t, tc.text, parsed.String())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
var parsed stepStatus
|
||||||
|
require.Error(t, parsed.UnmarshalText([]byte("cancelled")))
|
||||||
|
assert.Empty(t, stepStatus(99).String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWorkflowCallConfig(t *testing.T) {
|
||||||
|
workflow, err := ReadWorkflow(strings.NewReader(`
|
||||||
|
on:
|
||||||
|
workflow_call:
|
||||||
|
inputs:
|
||||||
|
name:
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
outputs:
|
||||||
|
digest:
|
||||||
|
value: ${{ jobs.build.outputs.digest }}
|
||||||
|
jobs: {}
|
||||||
|
`))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
config := workflow.WorkflowCallConfig()
|
||||||
|
require.NotNil(t, config)
|
||||||
|
require.Contains(t, config.Inputs, "name")
|
||||||
|
assert.True(t, config.Inputs["name"].Required)
|
||||||
|
assert.Equal(t, "string", config.Inputs["name"].Type)
|
||||||
|
assert.Equal(t, "${{ jobs.build.outputs.digest }}", config.Outputs["digest"].Value)
|
||||||
|
|
||||||
|
listWorkflow, err := ReadWorkflow(strings.NewReader("on: [workflow_call]\njobs: {}\n"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.NotNil(t, listWorkflow.WorkflowCallConfig())
|
||||||
|
assert.Empty(t, listWorkflow.WorkflowCallConfig().Inputs)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestJobSecretsAndEnvironment(t *testing.T) {
|
||||||
|
inheritJob := readJob(t, `
|
||||||
|
secrets: inherit
|
||||||
|
env:
|
||||||
|
A: one
|
||||||
|
B: two
|
||||||
|
`)
|
||||||
|
assert.True(t, inheritJob.InheritSecrets())
|
||||||
|
assert.Nil(t, inheritJob.Secrets())
|
||||||
|
assert.Equal(t, map[string]string{"A": "one", "B": "two"}, inheritJob.Environment())
|
||||||
|
|
||||||
|
mappingJob := readJob(t, `
|
||||||
|
secrets:
|
||||||
|
TOKEN: ${{ secrets.TOKEN }}
|
||||||
|
`)
|
||||||
|
assert.False(t, mappingJob.InheritSecrets())
|
||||||
|
assert.Equal(t, map[string]string{"TOKEN": "${{ secrets.TOKEN }}"}, mappingJob.Secrets())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestJobTypeAndString(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
job Job
|
||||||
|
want JobType
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{job: Job{}, want: JobTypeDefault},
|
||||||
|
{job: Job{Uses: "./.github/workflows/reuse.yml"}, want: JobTypeReusableWorkflowLocal},
|
||||||
|
{job: Job{Uses: "owner/repo/.github/workflows/reuse.yaml@v1"}, want: JobTypeReusableWorkflowRemote},
|
||||||
|
{job: Job{Uses: "owner/repo/.github/workflows/reuse.yaml"}, want: JobTypeInvalid, wantErr: true},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(fmt.Sprintf("%s/%s", tc.job.Uses, tc.want), func(t *testing.T) {
|
||||||
|
got, err := tc.job.Type()
|
||||||
|
if tc.wantErr {
|
||||||
|
require.Error(t, err)
|
||||||
|
} else {
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
assert.Equal(t, tc.want, got)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Equal(t, "default", JobTypeDefault.String())
|
||||||
|
assert.Equal(t, "local-reusable-workflow", JobTypeReusableWorkflowLocal.String())
|
||||||
|
assert.Equal(t, "remote-reusable-workflow", JobTypeReusableWorkflowRemote.String())
|
||||||
|
assert.Equal(t, "unknown", JobType(99).String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStepStringEnvironmentEnvAndType(t *testing.T) {
|
||||||
|
step := readStep(t, `
|
||||||
|
id: example
|
||||||
|
env:
|
||||||
|
DIRECT: value
|
||||||
|
with:
|
||||||
|
mixed-key: input
|
||||||
|
`)
|
||||||
|
assert.Equal(t, "example", step.String())
|
||||||
|
assert.Equal(t, map[string]string{"DIRECT": "value"}, step.Environment())
|
||||||
|
assert.Equal(t, map[string]string{"DIRECT": "value", "INPUT_MIXED-KEY": "input"}, step.GetEnv())
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
step Step
|
||||||
|
want StepType
|
||||||
|
}{
|
||||||
|
{step: Step{}, want: StepTypeInvalid},
|
||||||
|
{step: Step{Run: "echo hi"}, want: StepTypeRun},
|
||||||
|
{step: Step{Run: "echo hi", Uses: "actions/checkout@v4"}, want: StepTypeInvalid},
|
||||||
|
{step: Step{Uses: "docker://alpine:latest"}, want: StepTypeUsesDockerURL},
|
||||||
|
{step: Step{Uses: "./.github/workflows/reuse.yml"}, want: StepTypeReusableWorkflowLocal},
|
||||||
|
{step: Step{Uses: "owner/repo/.github/workflows/reuse.yml@v1"}, want: StepTypeReusableWorkflowRemote},
|
||||||
|
{step: Step{Uses: "./actions/local"}, want: StepTypeUsesActionLocal},
|
||||||
|
{step: Step{Uses: "actions/checkout@v4"}, want: StepTypeUsesActionRemote},
|
||||||
|
} {
|
||||||
|
t.Run(tc.want.String(), func(t *testing.T) {
|
||||||
|
assert.Equal(t, tc.want, tc.step.Type())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Equal(t, "invalid", StepTypeInvalid.String())
|
||||||
|
assert.Equal(t, "run", StepTypeRun.String())
|
||||||
|
assert.Equal(t, "local-action", StepTypeUsesActionLocal.String())
|
||||||
|
assert.Equal(t, "remote-action", StepTypeUsesActionRemote.String())
|
||||||
|
assert.Equal(t, "docker", StepTypeUsesDockerURL.String())
|
||||||
|
assert.Equal(t, "local-reusable-workflow", StepTypeReusableWorkflowLocal.String())
|
||||||
|
assert.Equal(t, "remote-reusable-workflow", StepTypeReusableWorkflowRemote.String())
|
||||||
|
assert.Equal(t, "unknown", StepType(99).String())
|
||||||
|
assert.NotEmpty(t, (&Step{Uses: "actions/checkout@v4"}).UsesHash())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWorkflowGetJobAndIDs(t *testing.T) {
|
||||||
|
workflow := &Workflow{Jobs: map[string]*Job{"build": {}}}
|
||||||
|
assert.Equal(t, []string{"build"}, workflow.GetJobIDs())
|
||||||
|
|
||||||
|
job := workflow.GetJob("build")
|
||||||
|
require.NotNil(t, job)
|
||||||
|
assert.Equal(t, "build", job.Name)
|
||||||
|
assert.Equal(t, "success()", job.If.Value)
|
||||||
|
assert.Nil(t, workflow.GetJob("missing"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRawConcurrencyYaml(t *testing.T) {
|
||||||
|
var expr RawConcurrency
|
||||||
|
require.NoError(t, yaml.Unmarshal([]byte("group-${{ github.ref }}"), &expr))
|
||||||
|
assert.Equal(t, "group-${{ github.ref }}", expr.RawExpression)
|
||||||
|
marshaled, err := expr.MarshalYAML()
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "group-${{ github.ref }}", marshaled)
|
||||||
|
|
||||||
|
var object RawConcurrency
|
||||||
|
require.NoError(t, yaml.Unmarshal([]byte("group: ci\ncancel-in-progress: true\n"), &object))
|
||||||
|
assert.Equal(t, "ci", object.Group)
|
||||||
|
assert.Equal(t, "true", object.CancelInProgress)
|
||||||
|
marshaled, err = object.MarshalYAML()
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, (*objectConcurrency)(&object), marshaled)
|
||||||
|
}
|
||||||
|
|
||||||
func TestReadWorkflow_ScheduleEvent(t *testing.T) {
|
func TestReadWorkflow_ScheduleEvent(t *testing.T) {
|
||||||
yaml := `
|
yaml := `
|
||||||
name: local-action-docker-url
|
name: local-action-docker-url
|
||||||
@@ -952,3 +1137,19 @@ func TestJobMatrixValidation(t *testing.T) {
|
|||||||
assert.Nil(t, matrix, "matrix with nested map should return nil")
|
assert.Nil(t, matrix, "matrix with nested map should return nil")
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func readJob(t *testing.T, content string) *Job {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
var job Job
|
||||||
|
require.NoError(t, yaml.Unmarshal([]byte(content), &job))
|
||||||
|
return &job
|
||||||
|
}
|
||||||
|
|
||||||
|
func readStep(t *testing.T, content string) *Step {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
var step Step
|
||||||
|
require.NoError(t, yaml.Unmarshal([]byte(content), &step))
|
||||||
|
return &step
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ package runner
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
"embed"
|
"embed"
|
||||||
|
"encoding/hex"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
@@ -272,6 +274,36 @@ func removeGitIgnore(ctx context.Context, directory string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// dockerActionImageTag derives the local docker image tag used when an action
|
||||||
|
// is built from a Dockerfile.
|
||||||
|
//
|
||||||
|
// For Gitea: a local action (`uses: ./` or `uses: ./path`) has an actionName
|
||||||
|
// that is the workspace-relative path of the action. That path is identical
|
||||||
|
// across repositories (e.g. "./" for a self-referencing action), so without
|
||||||
|
// namespacing, every repository's local docker action would build and reuse the
|
||||||
|
// same `act-dockeraction:latest` image on a shared docker daemon. A subsequent
|
||||||
|
// repository would then silently run the image built for an earlier one.
|
||||||
|
// Including the repository keeps the tag stable for caching within a repository
|
||||||
|
// while preventing cross-repository collisions.
|
||||||
|
// See https://gitea.com/gitea/runner/issues/1039.
|
||||||
|
func dockerActionImageTag(repository, actionName string, localAction bool) string {
|
||||||
|
name := actionName
|
||||||
|
if localAction {
|
||||||
|
name = path.Join(repository, actionName)
|
||||||
|
}
|
||||||
|
// The human-readable name is sanitized by collapsing every non-alphanumeric character to "-".
|
||||||
|
sanitized := regexp.MustCompile("[^a-zA-Z0-9]").ReplaceAllString(name, "-")
|
||||||
|
if localAction {
|
||||||
|
// For local actions a short hash of the raw repository and action path is appended so the tag stays unique per repository.
|
||||||
|
sum := sha256.Sum256([]byte(repository + "\x00" + actionName))
|
||||||
|
sanitized += "-" + hex.EncodeToString(sum[:])[:12]
|
||||||
|
}
|
||||||
|
// "-dockeraction" ensures that "./", "./test " won't get converted to "act-:latest", "act-test-:latest" which are invalid docker image names
|
||||||
|
image := fmt.Sprintf("%s-dockeraction:%s", sanitized, "latest")
|
||||||
|
image = "act-" + strings.TrimLeft(image, "-")
|
||||||
|
return strings.ToLower(image)
|
||||||
|
}
|
||||||
|
|
||||||
// TODO: break out parts of function to reduce complexicity
|
// TODO: break out parts of function to reduce complexicity
|
||||||
func execAsDocker(ctx context.Context, step actionStep, actionName, actionDir, basedir string, localAction bool) error {
|
func execAsDocker(ctx context.Context, step actionStep, actionName, actionDir, basedir string, localAction bool) error {
|
||||||
logger := common.Logger(ctx)
|
logger := common.Logger(ctx)
|
||||||
@@ -286,10 +318,7 @@ func execAsDocker(ctx context.Context, step actionStep, actionName, actionDir, b
|
|||||||
// Apply forcePull only for prebuild docker images
|
// Apply forcePull only for prebuild docker images
|
||||||
forcePull = rc.Config.ForcePull
|
forcePull = rc.Config.ForcePull
|
||||||
} else {
|
} else {
|
||||||
// "-dockeraction" enshures that "./", "./test " won't get converted to "act-:latest", "act-test-:latest" which are invalid docker image names
|
image = dockerActionImageTag(step.getGithubContext(ctx).Repository, actionName, localAction)
|
||||||
image = fmt.Sprintf("%s-dockeraction:%s", regexp.MustCompile("[^a-zA-Z0-9]").ReplaceAllString(actionName, "-"), "latest")
|
|
||||||
image = "act-" + strings.TrimLeft(image, "-")
|
|
||||||
image = strings.ToLower(image)
|
|
||||||
contextDir, fileName := filepath.Split(filepath.Join(basedir, action.Runs.Image))
|
contextDir, fileName := filepath.Split(filepath.Join(basedir, action.Runs.Image))
|
||||||
|
|
||||||
anyArchExists, err := ContainerImageExistsLocally(ctx, image, "any")
|
anyArchExists, err := ContainerImageExistsLocally(ctx, image, "any")
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"slices"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -85,6 +86,19 @@ func newCompositeRunContext(ctx context.Context, parent *RunContext, step action
|
|||||||
return compositerc
|
return compositerc
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// appendUniqueMasks appends the masks from src to dst, skipping any mask that
|
||||||
|
// is already present in dst. This prevents the parent RunContext's Masks slice
|
||||||
|
// from growing exponentially when composite actions are nested or repeated,
|
||||||
|
// since each composite RunContext is seeded with its parent's masks.
|
||||||
|
func appendUniqueMasks(dst, src []string) []string {
|
||||||
|
for _, m := range src {
|
||||||
|
if !slices.Contains(dst, m) {
|
||||||
|
dst = append(dst, m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return dst
|
||||||
|
}
|
||||||
|
|
||||||
func execAsComposite(step actionStep) common.Executor {
|
func execAsComposite(step actionStep) common.Executor {
|
||||||
rc := step.getRunContext()
|
rc := step.getRunContext()
|
||||||
action := step.getActionModel()
|
action := step.getActionModel()
|
||||||
@@ -110,7 +124,11 @@ func execAsComposite(step actionStep) common.Executor {
|
|||||||
}, eval.Interpolate(ctx, output.Value))
|
}, eval.Interpolate(ctx, output.Value))
|
||||||
}
|
}
|
||||||
|
|
||||||
rc.Masks = append(rc.Masks, compositeRC.Masks...)
|
// compositeRC.Masks is seeded with rc.Masks (see newCompositeRunContext)
|
||||||
|
// and may have additional masks appended while the composite action runs.
|
||||||
|
// Only append masks that are not already present, otherwise nested or
|
||||||
|
// repeated composite actions grow rc.Masks exponentially.
|
||||||
|
rc.Masks = appendUniqueMasks(rc.Masks, compositeRC.Masks)
|
||||||
rc.ExtraPath = compositeRC.ExtraPath
|
rc.ExtraPath = compositeRC.ExtraPath
|
||||||
// compositeRC.Env is dirty, contains INPUT_ and merged step env, only rely on compositeRC.GlobalEnv
|
// compositeRC.Env is dirty, contains INPUT_ and merged step env, only rely on compositeRC.GlobalEnv
|
||||||
mergeIntoMap := mergeIntoMapCaseSensitive
|
mergeIntoMap := mergeIntoMapCaseSensitive
|
||||||
|
|||||||
70
act/runner/action_composite_test.go
Normal file
70
act/runner/action_composite_test.go
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package runner
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAppendUniqueMasks(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
dst []string
|
||||||
|
src []string
|
||||||
|
want []string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "appends new masks",
|
||||||
|
dst: []string{"a"},
|
||||||
|
src: []string{"b", "c"},
|
||||||
|
want: []string{"a", "b", "c"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "skips masks already present",
|
||||||
|
dst: []string{"a", "b"},
|
||||||
|
src: []string{"a", "b"},
|
||||||
|
want: []string{"a", "b"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "deduplicates within src",
|
||||||
|
dst: []string{"a"},
|
||||||
|
src: []string{"b", "b", "a"},
|
||||||
|
want: []string{"a", "b"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty src leaves dst unchanged",
|
||||||
|
dst: []string{"a"},
|
||||||
|
src: nil,
|
||||||
|
want: []string{"a"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
assert.Equal(t, tt.want, appendUniqueMasks(tt.dst, tt.src))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAppendUniqueMasksNoExponentialGrowth reproduces the exponential growth of
|
||||||
|
// the parent's Masks slice observed with nested/repeated composite actions. A
|
||||||
|
// composite RunContext is seeded with its parent's masks and the whole seeded
|
||||||
|
// slice was previously appended back into the parent, doubling its length on
|
||||||
|
// every composite action.
|
||||||
|
func TestAppendUniqueMasksNoExponentialGrowth(t *testing.T) {
|
||||||
|
parentMasks := []string{"secret"}
|
||||||
|
|
||||||
|
for range 20 {
|
||||||
|
// compositeRC.Masks starts as a copy of the parent's masks (it is
|
||||||
|
// seeded with parent.Masks in newCompositeRunContext).
|
||||||
|
compositeMasks := make([]string, len(parentMasks))
|
||||||
|
copy(compositeMasks, parentMasks)
|
||||||
|
|
||||||
|
parentMasks = appendUniqueMasks(parentMasks, compositeMasks)
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Equal(t, []string{"secret"}, parentMasks)
|
||||||
|
}
|
||||||
@@ -455,3 +455,50 @@ func TestExecAsDockerHoldsCloneLockForRemoteUncached(t *testing.T) {
|
|||||||
t.Fatal("execAsDocker did not return after inner was released and ctx was canceled")
|
t.Fatal("execAsDocker did not return after inner was released and ctx was canceled")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDockerActionImageTag(t *testing.T) {
|
||||||
|
// Remote actions already carry a unique, ref-scoped actionName (the uses
|
||||||
|
// hash), so the tag must be left untouched for backwards compatibility.
|
||||||
|
assert.Equal(t,
|
||||||
|
"act-abc123-dockeraction:latest",
|
||||||
|
dockerActionImageTag("owner/repo", "abc123", false),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Local actions keep a human-readable, repository-namespaced prefix and gain a short hash suffix that makes the tag unique per (repository, actionName).
|
||||||
|
// See https://gitea.com/gitea/runner/issues/1039.
|
||||||
|
assert.Equal(t,
|
||||||
|
"act-owner-repo-baca2daaa2fe-dockeraction:latest",
|
||||||
|
dockerActionImageTag("owner/repo", "./", true),
|
||||||
|
)
|
||||||
|
assert.Equal(t,
|
||||||
|
"act-owner-repo-sub-e847b61255a8-dockeraction:latest",
|
||||||
|
dockerActionImageTag("owner/repo", "./sub", true),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Sanitizing every non-alphanumeric character to "-" is lossy, so distinct inputs can collapse to the same readable prefix.
|
||||||
|
// The hash suffix must keep such cases apart, otherwise an image built for one repository is reused for another.
|
||||||
|
collisions := [][2]struct {
|
||||||
|
repoName string
|
||||||
|
actionName string
|
||||||
|
}{
|
||||||
|
// Two different repositories, both `uses: ./`: "a/b-c" and "a-b/c" both sanitize to "a-b-c".
|
||||||
|
{{"a/b-c", "./"}, {"a-b/c", "./"}},
|
||||||
|
// A repository's root action vs another repository's sub-path action:
|
||||||
|
// "owner/repo-a" + "./" and "owner/repo" + "./a" both sanitize to "owner-repo-a".
|
||||||
|
{{"owner/repo-a", "./"}, {"owner/repo", "./a"}},
|
||||||
|
}
|
||||||
|
for _, c := range collisions {
|
||||||
|
assert.NotEqual(t,
|
||||||
|
dockerActionImageTag(c[0].repoName, c[0].actionName, true),
|
||||||
|
dockerActionImageTag(c[1].repoName, c[1].actionName, true),
|
||||||
|
"local docker action tags must differ for %q/%q vs %q/%q",
|
||||||
|
c[0].repoName, c[0].actionName, c[1].repoName, c[1].actionName,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Distinct local actions within the same repository keep distinct tags.
|
||||||
|
assert.NotEqual(t,
|
||||||
|
dockerActionImageTag("owner/repo", "./", true),
|
||||||
|
dockerActionImageTag("owner/repo", "./sub", true),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
285
act/runner/cancellation_test.go
Normal file
285
act/runner/cancellation_test.go
Normal file
@@ -0,0 +1,285 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package runner
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.com/gitea/runner/act/common"
|
||||||
|
"gitea.com/gitea/runner/act/exprparser"
|
||||||
|
"gitea.com/gitea/runner/act/model"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"go.yaml.in/yaml/v4"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestCancelledJobStatusEnablesAlwaysAndCancelledSteps verifies that once a job is
|
||||||
|
// cancelled, getJobContext reports the "cancelled" status so the step `if` functions
|
||||||
|
// evaluate the way GitHub Actions does: cancelled()/always() are true, success()/failure()
|
||||||
|
// are false. A step that defaults to success() is therefore skipped while an always() step
|
||||||
|
// still runs. Before the fix the status could only ever be success/failure, so cancelled()
|
||||||
|
// was structurally impossible and cancel-only cleanup steps never ran.
|
||||||
|
func TestCancelledJobStatusEnablesAlwaysAndCancelledSteps(t *testing.T) {
|
||||||
|
rc := createIfTestRunContext(map[string]*model.Job{
|
||||||
|
"job1": createJob(t, `runs-on: ubuntu-latest`, ""),
|
||||||
|
})
|
||||||
|
rc.markCancelled()
|
||||||
|
|
||||||
|
// The core fix: the job status context now reports "cancelled" instead of being
|
||||||
|
// pinned to success/failure.
|
||||||
|
jobCtx := rc.getJobContext()
|
||||||
|
require.Equal(t, "cancelled", jobCtx.Status)
|
||||||
|
|
||||||
|
// Feed that status through the step-context expression functions, which is what a
|
||||||
|
// step `if` evaluates. On a cancelled job only always()/cancelled() are true.
|
||||||
|
interp := exprparser.NewInterpeter(
|
||||||
|
&exprparser.EvaluationEnvironment{Job: jobCtx},
|
||||||
|
exprparser.Config{Context: "step"},
|
||||||
|
)
|
||||||
|
for expr, want := range map[string]bool{
|
||||||
|
"cancelled()": true,
|
||||||
|
"always()": true,
|
||||||
|
"success()": false,
|
||||||
|
"failure()": false,
|
||||||
|
"!cancelled()": false,
|
||||||
|
} {
|
||||||
|
got, err := interp.Evaluate(expr, exprparser.DefaultStatusCheckNone)
|
||||||
|
require.NoErrorf(t, err, "Evaluate(%q)", expr)
|
||||||
|
assert.Equalf(t, want, got, "Evaluate(%q) on a cancelled job", expr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A step without an `if` defaults to success() and must be skipped on cancel,
|
||||||
|
// while an `if: always()` step must still run.
|
||||||
|
disabled, err := interp.Evaluate("", exprparser.DefaultStatusCheckSuccess)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, false, disabled, "default-success step must be skipped on a cancelled job")
|
||||||
|
|
||||||
|
enabled, err := interp.Evaluate("always()", exprparser.DefaultStatusCheckSuccess)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, true, enabled, "`if: always()` step must run on a cancelled job")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMainStepsExecutorRunsAlwaysStepsAfterCancel verifies that newMainStepsExecutor does
|
||||||
|
// not abandon the remaining steps when the run is cancelled mid-pipeline. The later step
|
||||||
|
// still runs (so a main-stage always() step is reached), it runs under a fresh,
|
||||||
|
// non-cancelled context, and the job is marked cancelled. The interrupt error is still
|
||||||
|
// propagated so callers up the chain see the cancellation.
|
||||||
|
func TestMainStepsExecutorRunsAlwaysStepsAfterCancel(t *testing.T) {
|
||||||
|
rc := createIfTestRunContext(map[string]*model.Job{
|
||||||
|
"job1": createJob(t, `runs-on: ubuntu-latest`, ""),
|
||||||
|
})
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
var ran []string
|
||||||
|
var laterStepCtxErr error
|
||||||
|
steps := []common.Executor{
|
||||||
|
func(_ context.Context) error {
|
||||||
|
ran = append(ran, "step1")
|
||||||
|
cancel() // server cancellation lands while step1 runs
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
func(c context.Context) error {
|
||||||
|
ran = append(ran, "always-step")
|
||||||
|
laterStepCtxErr = c.Err()
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
err := newMainStepsExecutor(rc, steps)(ctx)
|
||||||
|
|
||||||
|
require.ErrorIs(t, err, context.Canceled, "interrupt error is propagated")
|
||||||
|
assert.Equal(t, []string{"step1", "always-step"}, ran, "the always() step still runs after cancel")
|
||||||
|
require.NoError(t, laterStepCtxErr, "remaining steps run under a fresh, non-cancelled context")
|
||||||
|
assert.True(t, rc.jobCancelled, "the job is marked cancelled")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMainStepsExecutorMarksFailedOnTimeoutBetweenSteps guards the timeout path's symmetry with the cancel path.
|
||||||
|
// When the job deadline (timeout-minutes) lands in the gap between two steps, the job must be marked as failed (not cancelled),
|
||||||
|
// so always()/failure() cleanup steps run while default success() steps skip, and so the timed-out job is not reported as success.
|
||||||
|
func TestMainStepsExecutorMarksFailedOnTimeoutBetweenSteps(t *testing.T) {
|
||||||
|
rc := createIfTestRunContext(map[string]*model.Job{
|
||||||
|
"job1": createJob(t, `runs-on: ubuntu-latest`, ""),
|
||||||
|
})
|
||||||
|
|
||||||
|
// A short deadline that we let elapse between steps, so no step records the error itself.
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
var ran []string
|
||||||
|
var laterStepCtxErr error
|
||||||
|
steps := []common.Executor{
|
||||||
|
func(c context.Context) error {
|
||||||
|
ran = append(ran, "step1")
|
||||||
|
// Block until the job deadline elapses, then return cleanly: the interrupt lands in the loop's between-steps check, not inside a step.
|
||||||
|
<-c.Done()
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
func(c context.Context) error {
|
||||||
|
ran = append(ran, "always-step")
|
||||||
|
laterStepCtxErr = c.Err()
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
err := newMainStepsExecutor(rc, steps)(ctx)
|
||||||
|
|
||||||
|
require.ErrorIs(t, err, context.DeadlineExceeded, "the timeout error is propagated")
|
||||||
|
assert.Equal(t, []string{"step1", "always-step"}, ran, "the always() step still runs after a timeout")
|
||||||
|
require.NoError(t, laterStepCtxErr, "remaining steps run under a fresh, non-expired context")
|
||||||
|
assert.True(t, rc.jobFailed, "a job timeout marks the job failed")
|
||||||
|
assert.False(t, rc.jobCancelled, "a timeout is not a cancellation")
|
||||||
|
|
||||||
|
// The status the real main-step `if` evaluation sees: "failure", so default success() steps skip while always()/failure() steps run.
|
||||||
|
assert.Equal(t, "failure", rc.getJobContext().Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestStepsExecutorRunsMainStepsAfterPreCancel verifies that a cancellation landing during the
|
||||||
|
// pre phase does not abandon the main steps: newStepsExecutor still runs the main-steps executor,
|
||||||
|
// so a main-stage always()/cancelled() step is reached (under a fresh, non-cancelled context),
|
||||||
|
// the job is marked cancelled, and the cancellation is propagated. Before the fix the `.Then(...)`
|
||||||
|
// short-circuit skipped the main steps entirely when a pre step was cancelled.
|
||||||
|
func TestStepsExecutorRunsMainStepsAfterPreCancel(t *testing.T) {
|
||||||
|
rc := createIfTestRunContext(map[string]*model.Job{
|
||||||
|
"job1": createJob(t, `runs-on: ubuntu-latest`, ""),
|
||||||
|
})
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
var ran []string
|
||||||
|
var mainStepCtxErr error
|
||||||
|
preSteps := []common.Executor{
|
||||||
|
func(_ context.Context) error {
|
||||||
|
ran = append(ran, "pre1")
|
||||||
|
cancel() // server cancellation lands during the pre phase
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
steps := []common.Executor{
|
||||||
|
func(c context.Context) error {
|
||||||
|
ran = append(ran, "always-step")
|
||||||
|
mainStepCtxErr = c.Err()
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
err := newStepsExecutor(rc, preSteps, steps)(ctx)
|
||||||
|
|
||||||
|
require.ErrorIs(t, err, context.Canceled, "the cancellation is propagated")
|
||||||
|
assert.Equal(t, []string{"pre1", "always-step"}, ran, "the main always() step runs after a pre-phase cancel")
|
||||||
|
require.NoError(t, mainStepCtxErr, "the main step runs under a fresh, non-cancelled context")
|
||||||
|
assert.True(t, rc.jobCancelled, "the job is marked cancelled")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestStepsExecutorRunsMainStepsAfterPreFailure verifies that a failing pre step does not abandon
|
||||||
|
// the main steps: they still run (so a main-stage always()/failure() step is reached), and the
|
||||||
|
// pre-step error is propagated so the job is reported as failed. The main steps' own `if`
|
||||||
|
// evaluation is what skips success()-default steps, so running them here is safe.
|
||||||
|
func TestStepsExecutorRunsMainStepsAfterPreFailure(t *testing.T) {
|
||||||
|
rc := createIfTestRunContext(map[string]*model.Job{
|
||||||
|
"job1": createJob(t, `runs-on: ubuntu-latest`, ""),
|
||||||
|
})
|
||||||
|
|
||||||
|
var ran []string
|
||||||
|
preSteps := []common.Executor{
|
||||||
|
func(_ context.Context) error {
|
||||||
|
ran = append(ran, "pre1")
|
||||||
|
return assert.AnError
|
||||||
|
},
|
||||||
|
}
|
||||||
|
steps := []common.Executor{
|
||||||
|
func(_ context.Context) error {
|
||||||
|
ran = append(ran, "always-step")
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
err := newStepsExecutor(rc, preSteps, steps)(context.Background())
|
||||||
|
|
||||||
|
require.ErrorIs(t, err, assert.AnError, "the pre-step error is propagated")
|
||||||
|
assert.Equal(t, []string{"pre1", "always-step"}, ran, "the main always() step runs after a pre-step failure")
|
||||||
|
assert.False(t, rc.jobCancelled, "a pre-step failure is not a cancellation")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPreStepFailureAffectsMainStepIfStatus verifies the status path used by real
|
||||||
|
// main-step `if` evaluation. A pre-step failure is not present in StepResults, so
|
||||||
|
// recording only the context job error is not enough: getJobContext must also report
|
||||||
|
// failure so success()-default main steps skip and failure() steps run.
|
||||||
|
func TestPreStepFailureAffectsMainStepIfStatus(t *testing.T) {
|
||||||
|
rc := createIfTestRunContext(map[string]*model.Job{
|
||||||
|
"job1": createJob(t, `runs-on: ubuntu-latest`, ""),
|
||||||
|
})
|
||||||
|
ctx := common.WithJobErrorContainer(context.Background())
|
||||||
|
|
||||||
|
reportStepError(ctx, rc, assert.AnError)
|
||||||
|
|
||||||
|
assert.Equal(t, "failure", rc.getJobContext().Status)
|
||||||
|
require.ErrorIs(t, common.JobError(ctx), assert.AnError)
|
||||||
|
|
||||||
|
defaultStep := &stepRun{
|
||||||
|
RunContext: rc,
|
||||||
|
Step: &model.Step{ID: "default-step"},
|
||||||
|
env: map[string]string{},
|
||||||
|
}
|
||||||
|
defaultEnabled, err := isStepEnabled(ctx, defaultStep.getIfExpression(ctx, stepStageMain), defaultStep, stepStageMain)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.False(t, defaultEnabled, "default success() main step must skip after a pre-step failure")
|
||||||
|
|
||||||
|
failureStep := &stepRun{
|
||||||
|
RunContext: rc,
|
||||||
|
Step: &model.Step{
|
||||||
|
ID: "failure-step",
|
||||||
|
If: yaml.Node{Value: "failure()"},
|
||||||
|
},
|
||||||
|
env: map[string]string{},
|
||||||
|
}
|
||||||
|
failureEnabled, err := isStepEnabled(ctx, failureStep.getIfExpression(ctx, stepStageMain), failureStep, stepStageMain)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, failureEnabled, "failure() main step must run after a pre-step failure")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPostStepsContextCancelledIsUsableForFailingStep guards against a panic: post/cleanup
|
||||||
|
// steps run on a context derived from the cancelled job context, and a failing post step
|
||||||
|
// records its error via common.SetJobError. If that derived context lacks a job-error container,
|
||||||
|
// SetJobError dereferences a nil map and panics. The post context must therefore be detached
|
||||||
|
// from cancellation (so the steps run) yet still carry a usable error container.
|
||||||
|
func TestPostStepsContextCancelledIsUsableForFailingStep(t *testing.T) {
|
||||||
|
cancelled, cancel := context.WithCancel(common.WithJobErrorContainer(context.Background()))
|
||||||
|
cancel()
|
||||||
|
require.ErrorIs(t, cancelled.Err(), context.Canceled)
|
||||||
|
|
||||||
|
postCtx, done := postStepsContext(cancelled)
|
||||||
|
defer done()
|
||||||
|
|
||||||
|
// Detached from cancellation, so the post steps actually run.
|
||||||
|
require.NoError(t, postCtx.Err(), "post context must not be cancelled")
|
||||||
|
|
||||||
|
// A failing post step records its error instead of panicking.
|
||||||
|
require.NotPanics(t, func() {
|
||||||
|
common.SetJobError(postCtx, assert.AnError)
|
||||||
|
}, "a failing post step must not panic on the cancel path")
|
||||||
|
assert.ErrorIs(t, common.JobError(postCtx), assert.AnError)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPostStepsContextDeadlinePreservesJobError verifies the job-timeout path keeps the original
|
||||||
|
// job-error container (via context.WithoutCancel), so the timeout failure and any post-step error
|
||||||
|
// survive into the post phase and the job is still reported as failed.
|
||||||
|
func TestPostStepsContextDeadlinePreservesJobError(t *testing.T) {
|
||||||
|
base := common.WithJobErrorContainer(context.Background())
|
||||||
|
common.SetJobError(base, assert.AnError)
|
||||||
|
expired, cancel := context.WithDeadline(base, time.Now().Add(-time.Hour))
|
||||||
|
defer cancel()
|
||||||
|
require.ErrorIs(t, expired.Err(), context.DeadlineExceeded)
|
||||||
|
|
||||||
|
postCtx, done := postStepsContext(expired)
|
||||||
|
defer done()
|
||||||
|
|
||||||
|
require.NoError(t, postCtx.Err(), "post context must not carry the expired deadline")
|
||||||
|
assert.ErrorIs(t, common.JobError(postCtx), assert.AnError, "the timeout job error must be preserved")
|
||||||
|
}
|
||||||
@@ -58,9 +58,10 @@ type jobInfo interface {
|
|||||||
|
|
||||||
// reportStepError emits the GitHub Actions ##[error] annotation and records
|
// reportStepError emits the GitHub Actions ##[error] annotation and records
|
||||||
// the error against the job so the job is reported as failed.
|
// the error against the job so the job is reported as failed.
|
||||||
func reportStepError(ctx context.Context, err error) {
|
func reportStepError(ctx context.Context, rc *RunContext, err error) {
|
||||||
common.Logger(ctx).Errorf("##[error]%v", err)
|
common.Logger(ctx).Errorf("##[error]%v", err)
|
||||||
common.SetJobError(ctx, err)
|
common.SetJobError(ctx, err)
|
||||||
|
rc.markFailed()
|
||||||
}
|
}
|
||||||
|
|
||||||
func newJobExecutor(info jobInfo, sf stepFactory, rc *RunContext) common.Executor {
|
func newJobExecutor(info jobInfo, sf stepFactory, rc *RunContext) common.Executor {
|
||||||
@@ -118,9 +119,9 @@ func newJobExecutor(info jobInfo, sf stepFactory, rc *RunContext) common.Executo
|
|||||||
rc.CurrentStepIndex = stepIdx
|
rc.CurrentStepIndex = stepIdx
|
||||||
preErr := preExec(ctx)
|
preErr := preExec(ctx)
|
||||||
if preErr != nil {
|
if preErr != nil {
|
||||||
reportStepError(ctx, preErr)
|
reportStepError(ctx, rc, preErr)
|
||||||
} else if ctx.Err() != nil {
|
} else if ctx.Err() != nil {
|
||||||
reportStepError(ctx, ctx.Err())
|
reportStepError(ctx, rc, ctx.Err())
|
||||||
}
|
}
|
||||||
return preErr
|
return preErr
|
||||||
}))
|
}))
|
||||||
@@ -130,9 +131,9 @@ func newJobExecutor(info jobInfo, sf stepFactory, rc *RunContext) common.Executo
|
|||||||
rc.CurrentStepIndex = stepIdx
|
rc.CurrentStepIndex = stepIdx
|
||||||
err := stepExec(ctx)
|
err := stepExec(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
reportStepError(ctx, err)
|
reportStepError(ctx, rc, err)
|
||||||
} else if ctx.Err() != nil {
|
} else if ctx.Err() != nil {
|
||||||
reportStepError(ctx, ctx.Err())
|
reportStepError(ctx, rc, ctx.Err())
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}))
|
}))
|
||||||
@@ -142,9 +143,9 @@ func newJobExecutor(info jobInfo, sf stepFactory, rc *RunContext) common.Executo
|
|||||||
rc.CurrentStepIndex = stepIdx
|
rc.CurrentStepIndex = stepIdx
|
||||||
err := postFn(ctx)
|
err := postFn(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
reportStepError(ctx, err)
|
reportStepError(ctx, rc, err)
|
||||||
} else if ctx.Err() != nil {
|
} else if ctx.Err() != nil {
|
||||||
reportStepError(ctx, ctx.Err())
|
reportStepError(ctx, rc, ctx.Err())
|
||||||
}
|
}
|
||||||
return err
|
return err
|
||||||
})
|
})
|
||||||
@@ -159,7 +160,12 @@ func newJobExecutor(info jobInfo, sf stepFactory, rc *RunContext) common.Executo
|
|||||||
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
|
||||||
if rc.Config.AutoRemove || jobError == nil {
|
// jobError == nil keeps a failed job's container alive for post-mortem debugging when
|
||||||
|
// AutoRemove is off (the act-CLI --rm behavior; the shipped runner always sets
|
||||||
|
// AutoRemove). A cancelled run is not a failure to inspect, and the cancel-path post
|
||||||
|
// context now carries its own error container so a failing post step makes jobError
|
||||||
|
// non-nil — OR in rc.jobCancelled so cancellation still always tears the container down.
|
||||||
|
if rc.Config.AutoRemove || jobError == nil || rc.jobCancelled {
|
||||||
// always allow 1 min for stopping and removing the runner, even if we were cancelled
|
// always allow 1 min for stopping and removing the runner, even if we were cancelled
|
||||||
ctx, cancel := context.WithTimeout(common.WithLogger(context.Background(), common.Logger(ctx)), time.Minute)
|
ctx, cancel := context.WithTimeout(common.WithLogger(context.Background(), common.Logger(ctx)), time.Minute)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
@@ -198,35 +204,107 @@ func newJobExecutor(info jobInfo, sf stepFactory, rc *RunContext) common.Executo
|
|||||||
return err
|
return err
|
||||||
})
|
})
|
||||||
|
|
||||||
pipeline := make([]common.Executor, 0)
|
stepsExecutor := newStepsExecutor(rc, preSteps, steps)
|
||||||
pipeline = append(pipeline, preSteps...)
|
|
||||||
pipeline = append(pipeline, steps...)
|
|
||||||
|
|
||||||
return common.NewPipelineExecutor(info.startContainer(), common.NewPipelineExecutor(pipeline...).
|
return common.NewPipelineExecutor(info.startContainer(), stepsExecutor.
|
||||||
Finally(func(ctx context.Context) error {
|
Finally(func(ctx context.Context) error {
|
||||||
var cancel context.CancelFunc
|
// Record an interrupt (backstop for interrupts that land outside the main
|
||||||
switch ctx.Err() {
|
// step loop) so the post steps observe the cancelled/failed job status.
|
||||||
case context.Canceled:
|
rc.markInterrupted(ctx.Err())
|
||||||
// in case of an aborted run, we still should execute the
|
postCtx, cancel := postStepsContext(ctx)
|
||||||
// post steps to allow cleanup.
|
defer cancel()
|
||||||
ctx, cancel = context.WithTimeout(common.WithLogger(context.Background(), common.Logger(ctx)), 5*time.Minute)
|
return postExecutor(postCtx)
|
||||||
defer cancel()
|
|
||||||
case context.DeadlineExceeded:
|
|
||||||
// The job hit its timeout-minutes. Without a fresh context the post
|
|
||||||
// steps would run against the already-expired context and be skipped,
|
|
||||||
// so cleanup post-hooks (e.g. actions/checkout post, cache save) would
|
|
||||||
// not run. Derive the context with WithoutCancel so the new deadline
|
|
||||||
// applies but the job error state is preserved: the job is still
|
|
||||||
// reported as failed and container teardown matches a normal failure.
|
|
||||||
ctx, cancel = context.WithTimeout(context.WithoutCancel(ctx), 5*time.Minute)
|
|
||||||
defer cancel()
|
|
||||||
}
|
|
||||||
return postExecutor(ctx)
|
|
||||||
}).
|
}).
|
||||||
Finally(info.interpolateOutputs()).
|
Finally(info.interpolateOutputs()).
|
||||||
Finally(info.closeContainer()))
|
Finally(info.closeContainer()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// postStepsContext derives the context used to run the job's post/cleanup steps from the
|
||||||
|
// finished main-pipeline context. Cleanup has to run even when the run was interrupted, so the
|
||||||
|
// returned context always carries a fresh bounded deadline and is never itself cancelled.
|
||||||
|
//
|
||||||
|
// - context.Canceled (server cancel): detach from the cancelled context via a fresh root so
|
||||||
|
// the post steps can run.
|
||||||
|
// - context.DeadlineExceeded (job timeout): detach the deadline with WithoutCancel, which
|
||||||
|
// keeps the original values — including the job-error container — so the timeout failure and
|
||||||
|
// any post-step error are preserved and the job is still reported as failed.
|
||||||
|
// - otherwise: run on the live context unchanged.
|
||||||
|
func postStepsContext(ctx context.Context) (context.Context, context.CancelFunc) {
|
||||||
|
switch ctx.Err() {
|
||||||
|
case context.Canceled:
|
||||||
|
// The cancelled context is abandoned for a fresh root, which drops the job-error
|
||||||
|
// container installed at the job root. Re-attach a fresh one so a failing post step
|
||||||
|
// records its error via SetJobError instead of panicking on a nil container.
|
||||||
|
return context.WithTimeout(common.WithJobErrorContainer(common.WithLogger(context.Background(), common.Logger(ctx))), 5*time.Minute)
|
||||||
|
case context.DeadlineExceeded:
|
||||||
|
return context.WithTimeout(context.WithoutCancel(ctx), 5*time.Minute)
|
||||||
|
default:
|
||||||
|
return ctx, func() {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// newStepsExecutor sequences the job's pre steps and main steps.
|
||||||
|
//
|
||||||
|
// The pre steps run as a normal pipeline that short-circuits on the first failure or
|
||||||
|
// cancellation. The main-steps executor then runs unconditionally — even if a pre step failed
|
||||||
|
// or the job was interrupted — so always()/cancelled()/failure() main steps still run, mirroring
|
||||||
|
// GitHub Actions. This is safe because each main step re-evaluates its own `if` (a pre-step
|
||||||
|
// failure flips the expression job status to failure, so success()-default steps skip) and
|
||||||
|
// newMainStepsExecutor detaches from an interrupted context before running the remaining steps.
|
||||||
|
//
|
||||||
|
// A pre-step failure or interrupt is still propagated so the job is reported with the correct
|
||||||
|
// conclusion; the pre error takes precedence since it happened first.
|
||||||
|
func newStepsExecutor(rc *RunContext, preSteps, steps []common.Executor) common.Executor {
|
||||||
|
preExecutor := common.NewPipelineExecutor(preSteps...)
|
||||||
|
mainExecutor := newMainStepsExecutor(rc, steps)
|
||||||
|
return func(ctx context.Context) error {
|
||||||
|
preErr := preExecutor(ctx)
|
||||||
|
mainErr := mainExecutor(ctx)
|
||||||
|
if preErr != nil {
|
||||||
|
return preErr
|
||||||
|
}
|
||||||
|
return mainErr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// newMainStepsExecutor runs the job's main-stage step executors in order. Unlike a plain
|
||||||
|
// pipeline, an interruption (context.Canceled from a server cancel, or context.DeadlineExceeded
|
||||||
|
// from the job timeout) does not abandon the remaining steps: it marks the job cancelled when
|
||||||
|
// appropriate and keeps iterating under a fresh, bounded context so steps whose `if` still
|
||||||
|
// evaluates true — always() and cancelled() — run for cleanup, mirroring GitHub Actions. Steps
|
||||||
|
// that default to success() skip themselves because success() is false once the job is no longer
|
||||||
|
// successful. The main-step wrappers report their own errors and return nil, so the loop drives
|
||||||
|
// step ordering off the context, not return values.
|
||||||
|
func newMainStepsExecutor(rc *RunContext, steps []common.Executor) common.Executor {
|
||||||
|
return func(ctx context.Context) error {
|
||||||
|
for i, step := range steps {
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return runMainStepsAfterInterrupt(ctx, rc, steps[i:])
|
||||||
|
}
|
||||||
|
_ = step(ctx)
|
||||||
|
}
|
||||||
|
// An interrupt can land during the final step, after the loop's last context
|
||||||
|
// check; record it so the post steps still observe the cancelled/failed status.
|
||||||
|
rc.markInterrupted(ctx.Err())
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// runMainStepsAfterInterrupt runs the remaining main steps after the job context was cancelled or
|
||||||
|
// timed out. It detaches from the interrupted context (keeping its values: logger and job error)
|
||||||
|
// and applies a fresh deadline so always()/cancelled() steps run to completion. The original
|
||||||
|
// interrupt error is returned so callers up the chain still see the job as cancelled/timed out.
|
||||||
|
func runMainStepsAfterInterrupt(ctx context.Context, rc *RunContext, steps []common.Executor) error {
|
||||||
|
interruptErr := ctx.Err()
|
||||||
|
rc.markInterrupted(interruptErr)
|
||||||
|
freshCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
for _, step := range steps {
|
||||||
|
_ = step(freshCtx)
|
||||||
|
}
|
||||||
|
return interruptErr
|
||||||
|
}
|
||||||
|
|
||||||
func setJobResult(ctx context.Context, info jobInfo, rc *RunContext, success bool) {
|
func setJobResult(ctx context.Context, info jobInfo, rc *RunContext, success bool) {
|
||||||
logger := common.Logger(ctx)
|
logger := common.Logger(ctx)
|
||||||
|
|
||||||
|
|||||||
@@ -141,6 +141,7 @@ func cloneRemoteReusableWorkflow(rc *RunContext, cloneURL, ref, targetDirectory,
|
|||||||
Dir: targetDirectory,
|
Dir: targetDirectory,
|
||||||
Token: token,
|
Token: token,
|
||||||
OfflineMode: rc.Config.ActionOfflineMode,
|
OfflineMode: rc.Config.ActionOfflineMode,
|
||||||
|
Depth: rc.Config.ActionCloneDepth,
|
||||||
})(ctx)
|
})(ctx)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -304,30 +305,45 @@ func setReusedWorkflowCallerResult(rc *RunContext, runner Runner) common.Executo
|
|||||||
// getGitCloneToken returns GITEA_TOKEN when shouldCloneURLUseToken returns true,
|
// getGitCloneToken returns GITEA_TOKEN when shouldCloneURLUseToken returns true,
|
||||||
// otherwise returns an empty string
|
// otherwise returns an empty string
|
||||||
func getGitCloneToken(conf *Config, cloneURL string) string {
|
func getGitCloneToken(conf *Config, cloneURL string) string {
|
||||||
if !shouldCloneURLUseToken(conf.GitHubInstance, cloneURL) {
|
if !shouldCloneURLUseToken(conf.GitHubInstance, conf.trustedActionInstance(), cloneURL) {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
return conf.GetToken()
|
return conf.GetToken()
|
||||||
}
|
}
|
||||||
|
|
||||||
// For Gitea
|
// For Gitea
|
||||||
// shouldCloneURLUseToken returns true when the following conditions are met:
|
// trustedActionInstance returns the self-hosted DEFAULT_ACTIONS_URL host that may carry the
|
||||||
// 1. cloneURL is from the same Gitea instance that the runner is registered to
|
// task token, or "" when actions resolve to github.com / a GithubMirror (never trusted).
|
||||||
// 2. the cloneURL does not have basic auth embedded
|
func (c Config) trustedActionInstance() string {
|
||||||
func shouldCloneURLUseToken(instanceURL, cloneURL string) bool {
|
if c.DefaultActionInstanceIsSelfHosted {
|
||||||
if !strings.HasPrefix(instanceURL, "http://") &&
|
return c.DefaultActionInstance
|
||||||
!strings.HasPrefix(instanceURL, "https://") {
|
|
||||||
instanceURL = "https://" + instanceURL
|
|
||||||
}
|
}
|
||||||
|
return ""
|
||||||
u1, err1 := url.Parse(instanceURL)
|
}
|
||||||
u2, err2 := url.Parse(cloneURL)
|
|
||||||
if err1 != nil || err2 != nil {
|
// For Gitea
|
||||||
return false
|
// shouldCloneURLUseToken returns true when the following conditions are met:
|
||||||
}
|
// 1. cloneURL's host matches this Gitea instance: either the registered instance
|
||||||
if u2.User != nil {
|
// (instanceURL) or, for DEFAULT_ACTIONS_URL=self on a different hostname, the
|
||||||
return false
|
// self-hosted action instance (trustedActionInstance, "" when not trusted)
|
||||||
}
|
// 2. the cloneURL does not have basic auth embedded
|
||||||
|
func shouldCloneURLUseToken(instanceURL, trustedActionInstance, cloneURL string) bool {
|
||||||
return u1.Host == u2.Host
|
u2, err := url.Parse(cloneURL)
|
||||||
|
if err != nil || u2.User != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, candidate := range []string{instanceURL, trustedActionInstance} {
|
||||||
|
if candidate == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(candidate, "http://") &&
|
||||||
|
!strings.HasPrefix(candidate, "https://") {
|
||||||
|
candidate = "https://" + candidate
|
||||||
|
}
|
||||||
|
if u1, err := url.Parse(candidate); err == nil && u1.Host == u2.Host {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -136,12 +136,30 @@ func TestGetGitCloneTokenWithSchemalessGiteaInstance(t *testing.T) {
|
|||||||
require.Equal(t, "token-value", token)
|
require.Equal(t, "token-value", token)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestGetGitCloneTokenSelfHostedActionsDifferentHost(t *testing.T) {
|
||||||
|
// The runner registered with one hostname while DEFAULT_ACTIONS_URL=self resolves
|
||||||
|
// actions against AppURL on a different hostname for the same instance.
|
||||||
|
conf := &Config{
|
||||||
|
GitHubInstance: "gitea.local",
|
||||||
|
DefaultActionInstance: "https://gitea.my-nas.lan",
|
||||||
|
DefaultActionInstanceIsSelfHosted: true,
|
||||||
|
Secrets: map[string]string{
|
||||||
|
"GITEA_TOKEN": "token-value",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
token := getGitCloneToken(conf, "https://gitea.my-nas.lan/owner/action")
|
||||||
|
|
||||||
|
require.Equal(t, "token-value", token)
|
||||||
|
}
|
||||||
|
|
||||||
func TestShouldCloneURLUseToken(t *testing.T) {
|
func TestShouldCloneURLUseToken(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
instanceURL string
|
instanceURL string
|
||||||
cloneURL string
|
trustedActionInstance string
|
||||||
want bool
|
cloneURL string
|
||||||
|
want bool
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "same host with schemaless instance",
|
name: "same host with schemaless instance",
|
||||||
@@ -173,11 +191,37 @@ func TestShouldCloneURLUseToken(t *testing.T) {
|
|||||||
cloneURL: "://gitea.example.net/actions/tools",
|
cloneURL: "://gitea.example.net/actions/tools",
|
||||||
want: false,
|
want: false,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
// self-hosted DEFAULT_ACTIONS_URL on a different hostname than the
|
||||||
|
// registered instance: the token must still be attached.
|
||||||
|
name: "self-hosted action instance on different host",
|
||||||
|
instanceURL: "gitea.local",
|
||||||
|
trustedActionInstance: "https://gitea.my-nas.lan",
|
||||||
|
cloneURL: "https://gitea.my-nas.lan/owner/action",
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// embedded basic auth must still be rejected even when the host matches
|
||||||
|
// the trusted action instance.
|
||||||
|
name: "self-hosted action instance with embedded basic auth",
|
||||||
|
instanceURL: "gitea.local",
|
||||||
|
trustedActionInstance: "https://gitea.my-nas.lan",
|
||||||
|
cloneURL: "https://user:pass@gitea.my-nas.lan/owner/action",
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// github.com / mirror hosts are never trusted: trustedActionInstance is
|
||||||
|
// empty in github mode, so an off-instance clone URL gets no token.
|
||||||
|
name: "github mode does not trust mirror host",
|
||||||
|
instanceURL: "gitea.local",
|
||||||
|
cloneURL: "https://mirror.example.com/owner/action",
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
require.Equal(t, tt.want, shouldCloneURLUseToken(tt.instanceURL, tt.cloneURL))
|
require.Equal(t, tt.want, shouldCloneURLUseToken(tt.instanceURL, tt.trustedActionInstance, tt.cloneURL))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,6 +73,39 @@ type RunContext struct {
|
|||||||
// captured before execution so each matrix combo interpolates from the originals rather
|
// captured before execution so each matrix combo interpolates from the originals rather
|
||||||
// than from a sibling's already-resolved values written into the shared Job.Outputs.
|
// than from a sibling's already-resolved values written into the shared Job.Outputs.
|
||||||
outputTemplate map[string]string
|
outputTemplate map[string]string
|
||||||
|
// jobCancelled records that this job's run was cancelled (context.Canceled). It makes
|
||||||
|
// getJobContext report the "cancelled" status so cancelled()/always() evaluate the way
|
||||||
|
// GitHub Actions does, letting cleanup and always() steps run while normal steps skip.
|
||||||
|
jobCancelled bool
|
||||||
|
// jobFailed records failures outside normal main-step results, such as action pre-step
|
||||||
|
// failures. Those failures must still make success() false and failure() true for later
|
||||||
|
// main-step if evaluation.
|
||||||
|
jobFailed bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// markCancelled flags the job as cancelled so subsequent step `if` evaluations and the
|
||||||
|
// job status context observe the "cancelled" state.
|
||||||
|
func (rc *RunContext) markCancelled() {
|
||||||
|
rc.jobCancelled = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// markFailed flags the job as failed so subsequent step `if` evaluations observe
|
||||||
|
// failure even when the error happened outside a main step result.
|
||||||
|
func (rc *RunContext) markFailed() {
|
||||||
|
rc.jobFailed = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// markInterrupted records the job's interruption status from a context error so later step `if` evaluations and the job result observe it,
|
||||||
|
// keeping the timeout path symmetric with the cancel path:
|
||||||
|
// - context.Canceled (server cancel) marks the job cancelled, matching GitHub's "only always()/cancelled() run on cancel".
|
||||||
|
// - context.DeadlineExceeded (job timeout-minutes) marks the job failed, matching the "Timeout -> FAILURE" reporting semantics.
|
||||||
|
func (rc *RunContext) markInterrupted(err error) {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, context.Canceled):
|
||||||
|
rc.markCancelled()
|
||||||
|
case errors.Is(err, context.DeadlineExceeded):
|
||||||
|
rc.markFailed()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (rc *RunContext) AddMask(mask string) {
|
func (rc *RunContext) AddMask(mask string) {
|
||||||
@@ -346,6 +379,13 @@ func (rc *RunContext) startJobContainer() common.Executor {
|
|||||||
|
|
||||||
// add service containers
|
// add service containers
|
||||||
for serviceID, spec := range rc.Run.Job().Services {
|
for serviceID, spec := range rc.Run.Job().Services {
|
||||||
|
// GitHub compatibility: skip services whose image evaluates to an
|
||||||
|
// empty string, enabling conditional services via expressions
|
||||||
|
serviceImage := rc.ExprEval.Interpolate(ctx, spec.Image)
|
||||||
|
if serviceImage == "" {
|
||||||
|
logger.Infof("The service '%s' will not be started because the container definition has an empty image.", serviceID)
|
||||||
|
continue
|
||||||
|
}
|
||||||
// interpolate env
|
// interpolate env
|
||||||
interpolatedEnvs := make(map[string]string, len(spec.Env))
|
interpolatedEnvs := make(map[string]string, len(spec.Env))
|
||||||
for k, v := range spec.Env {
|
for k, v := range spec.Env {
|
||||||
@@ -384,7 +424,7 @@ func (rc *RunContext) startJobContainer() common.Executor {
|
|||||||
c := container.NewContainer(&container.NewContainerInput{
|
c := container.NewContainer(&container.NewContainerInput{
|
||||||
Name: serviceContainerName,
|
Name: serviceContainerName,
|
||||||
WorkingDir: ext.ToContainerPath(rc.Config.Workdir),
|
WorkingDir: ext.ToContainerPath(rc.Config.Workdir),
|
||||||
Image: rc.ExprEval.Interpolate(ctx, spec.Image),
|
Image: serviceImage,
|
||||||
Username: username,
|
Username: username,
|
||||||
Password: password,
|
Password: password,
|
||||||
Cmd: interpolatedCmd,
|
Cmd: interpolatedCmd,
|
||||||
@@ -904,12 +944,21 @@ func trimToLen(s string, l int) string {
|
|||||||
|
|
||||||
func (rc *RunContext) getJobContext() *model.JobContext {
|
func (rc *RunContext) getJobContext() *model.JobContext {
|
||||||
jobStatus := "success"
|
jobStatus := "success"
|
||||||
|
if rc.jobFailed {
|
||||||
|
jobStatus = "failure"
|
||||||
|
}
|
||||||
for _, stepStatus := range rc.StepResults {
|
for _, stepStatus := range rc.StepResults {
|
||||||
if stepStatus.Conclusion == model.StepStatusFailure {
|
if stepStatus.Conclusion == model.StepStatusFailure {
|
||||||
jobStatus = "failure"
|
jobStatus = "failure"
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// A cancelled run takes precedence over success/failure so cancelled() is true and
|
||||||
|
// success()/failure() are false, matching GitHub Actions: on cancellation only
|
||||||
|
// always() and cancelled() steps run.
|
||||||
|
if rc.jobCancelled {
|
||||||
|
jobStatus = "cancelled"
|
||||||
|
}
|
||||||
return &model.JobContext{
|
return &model.JobContext{
|
||||||
Status: jobStatus,
|
Status: jobStatus,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ type Config struct {
|
|||||||
Workdir string // path to working directory
|
Workdir string // path to working directory
|
||||||
ActionCacheDir string // path used for caching action contents
|
ActionCacheDir string // path used for caching action contents
|
||||||
ActionOfflineMode bool // when offline, use cached action contents
|
ActionOfflineMode bool // when offline, use cached action contents
|
||||||
|
ActionCloneDepth int // limit history when cloning an action repo; 0 clones every branch in full
|
||||||
BindWorkdir bool // bind the workdir to the job container
|
BindWorkdir bool // bind the workdir to the job container
|
||||||
EventName string // name of event to run
|
EventName string // name of event to run
|
||||||
EventPath string // path to JSON file to use for event.json in containers
|
EventPath string // path to JSON file to use for event.json in containers
|
||||||
@@ -72,18 +73,24 @@ type Config struct {
|
|||||||
ContainerNetworkCreateOptions container.NewDockerNetworkCreateExecutorInput // the default network create options
|
ContainerNetworkCreateOptions container.NewDockerNetworkCreateExecutorInput // the default network create options
|
||||||
ActionCache ActionCache // Use a custom ActionCache Implementation
|
ActionCache ActionCache // Use a custom ActionCache Implementation
|
||||||
|
|
||||||
PresetGitHubContext *model.GithubContext // the preset github context, overrides some fields like DefaultBranch, Env, Secrets etc.
|
PresetGitHubContext *model.GithubContext // the preset github context, overrides some fields like DefaultBranch, Env, Secrets etc.
|
||||||
EventJSON string // the content of JSON file to use for event.json in containers, overrides EventPath
|
EventJSON string // the content of JSON file to use for event.json in containers, overrides EventPath
|
||||||
ContainerNamePrefix string // the prefix of container name
|
ContainerNamePrefix string // the prefix of container name
|
||||||
ContainerMaxLifetime time.Duration // the max lifetime of job containers
|
ContainerMaxLifetime time.Duration // the max lifetime of job containers
|
||||||
CleanWorkdir bool // remove host executor workdir on teardown
|
CleanWorkdir bool // remove host executor workdir on teardown
|
||||||
DefaultActionInstance string // the default actions web site
|
DefaultActionInstance string // the default actions web site
|
||||||
PlatformPicker func(labels []string) string // platform picker, it will take precedence over Platforms if isn't nil
|
// DefaultActionInstanceIsSelfHosted reports whether DefaultActionInstance is this
|
||||||
JobLoggerLevel *log.Level // the level of job logger
|
// self-hosted Gitea (DEFAULT_ACTIONS_URL=self). It gates token trust: only then may the
|
||||||
ValidVolumes []string // only volumes (and bind mounts) in this slice can be mounted on the job container or service containers
|
// task token be attached to action clone URLs on DefaultActionInstance's host, which can
|
||||||
InsecureSkipTLS bool // whether to skip verifying TLS certificate of the Gitea instance
|
// differ from GitHubInstance when the runner registered with a different hostname than
|
||||||
MaxParallel int // max parallel jobs to run across all workflows (0 = no limit, uses CPU count)
|
// AppURL. It is never set for github.com or a GithubMirror, so the token stays on-instance.
|
||||||
AllocatePTY bool // allocate a pseudo-TTY for each step's process
|
DefaultActionInstanceIsSelfHosted bool
|
||||||
|
PlatformPicker func(labels []string) string // platform picker, it will take precedence over Platforms if isn't nil
|
||||||
|
JobLoggerLevel *log.Level // the level of job logger
|
||||||
|
ValidVolumes []string // only volumes (and bind mounts) in this slice can be mounted on the job container or service containers
|
||||||
|
InsecureSkipTLS bool // whether to skip verifying TLS certificate of the Gitea instance
|
||||||
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetToken: Adapt to Gitea
|
// GetToken: Adapt to Gitea
|
||||||
|
|||||||
@@ -303,6 +303,7 @@ func TestRunEvent(t *testing.T) {
|
|||||||
// services
|
// services
|
||||||
{workdir, "services", "push", "", platforms, secrets},
|
{workdir, "services", "push", "", platforms, secrets},
|
||||||
{workdir, "services-with-container", "push", "", platforms, secrets},
|
{workdir, "services-with-container", "push", "", platforms, secrets},
|
||||||
|
{workdir, "services-empty-image", "push", "", platforms, secrets},
|
||||||
|
|
||||||
// local remote action overrides
|
// local remote action overrides
|
||||||
{workdir, "local-remote-action-overrides", "push", "", platforms, secrets},
|
{workdir, "local-remote-action-overrides", "push", "", platforms, secrets},
|
||||||
|
|||||||
@@ -114,13 +114,23 @@ func (sar *stepActionRemote) prepareActionExecutor() common.Executor {
|
|||||||
|
|
||||||
actionDir := fmt.Sprintf("%s/%s", sar.RunContext.ActionCacheDir(), sar.Step.UsesHash())
|
actionDir := fmt.Sprintf("%s/%s", sar.RunContext.ActionCacheDir(), sar.Step.UsesHash())
|
||||||
defaultActionURL := sar.RunContext.Config.DefaultActionURL()
|
defaultActionURL := sar.RunContext.Config.DefaultActionURL()
|
||||||
token := getGitCloneToken(sar.getRunContext().Config, sar.remoteAction.CloneURL(defaultActionURL))
|
// For Gitea
|
||||||
|
// A composite RunContext nils Config.Secrets, so getGitCloneToken would yield an
|
||||||
|
// empty token and clone the action anonymously (401 against the authenticated
|
||||||
|
// instance). github.Token survives the composite config copy and matches the
|
||||||
|
// top-level token; keep the shouldCloneURLUseToken host gate to avoid leaking it.
|
||||||
|
cloneURL := sar.remoteAction.CloneURL(defaultActionURL)
|
||||||
|
token := ""
|
||||||
|
if shouldCloneURLUseToken(sar.RunContext.Config.GitHubInstance, sar.RunContext.Config.trustedActionInstance(), cloneURL) {
|
||||||
|
token = github.Token
|
||||||
|
}
|
||||||
gitClone := stepActionRemoteNewCloneExecutor(git.NewGitCloneExecutorInput{
|
gitClone := stepActionRemoteNewCloneExecutor(git.NewGitCloneExecutorInput{
|
||||||
URL: sar.remoteAction.CloneURL(defaultActionURL),
|
URL: cloneURL,
|
||||||
Ref: sar.remoteAction.Ref,
|
Ref: sar.remoteAction.Ref,
|
||||||
Dir: actionDir,
|
Dir: actionDir,
|
||||||
Token: token,
|
Token: token,
|
||||||
OfflineMode: sar.RunContext.Config.ActionOfflineMode,
|
OfflineMode: sar.RunContext.Config.ActionOfflineMode,
|
||||||
|
Depth: sar.RunContext.Config.ActionCloneDepth,
|
||||||
|
|
||||||
InsecureSkipTLS: sar.cloneSkipTLS(), // For Gitea
|
InsecureSkipTLS: sar.cloneSkipTLS(), // For Gitea
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -838,3 +838,83 @@ func Test_safeFilename(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Regression: a nested action in a composite cloned anonymously (401) because the
|
||||||
|
// composite RunContext nils Config.Secrets. The token must come from github.Token,
|
||||||
|
// which survives the config copy; the host gate must still withhold it cross-host.
|
||||||
|
func TestStepActionRemoteCloneTokenSurvivesNilSecrets(t *testing.T) {
|
||||||
|
const wantToken = "job-token"
|
||||||
|
|
||||||
|
table := []struct {
|
||||||
|
name string
|
||||||
|
gitHubInstance string
|
||||||
|
defaultActionInstance string
|
||||||
|
wantCloneToken string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "same host forwards token despite nil secrets",
|
||||||
|
gitHubInstance: "gitea.example.com",
|
||||||
|
wantCloneToken: wantToken,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "foreign host is not given the token",
|
||||||
|
gitHubInstance: "gitea.example.com",
|
||||||
|
defaultActionInstance: "github.com",
|
||||||
|
wantCloneToken: "",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range table {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
var capturedToken string
|
||||||
|
origStepAtionRemoteNewCloneExecutor := stepActionRemoteNewCloneExecutor
|
||||||
|
stepActionRemoteNewCloneExecutor = func(input git.NewGitCloneExecutorInput) common.Executor {
|
||||||
|
capturedToken = input.Token
|
||||||
|
return func(ctx context.Context) error { return nil }
|
||||||
|
}
|
||||||
|
defer (func() {
|
||||||
|
stepActionRemoteNewCloneExecutor = origStepAtionRemoteNewCloneExecutor
|
||||||
|
})()
|
||||||
|
|
||||||
|
sarm := &stepActionRemoteMocks{}
|
||||||
|
sar := &stepActionRemote{
|
||||||
|
Step: &model.Step{Uses: "org/repo@v1"},
|
||||||
|
RunContext: &RunContext{
|
||||||
|
Config: &Config{
|
||||||
|
GitHubInstance: tt.gitHubInstance,
|
||||||
|
DefaultActionInstance: tt.defaultActionInstance,
|
||||||
|
ActionCacheDir: "/tmp/test-cache",
|
||||||
|
// Mirrors the state of a composite RunContext: job secrets are
|
||||||
|
// stripped, but the job token is still reachable via Config.Token.
|
||||||
|
Secrets: nil,
|
||||||
|
Token: wantToken,
|
||||||
|
},
|
||||||
|
Run: &model.Run{
|
||||||
|
JobID: "1",
|
||||||
|
Workflow: &model.Workflow{
|
||||||
|
Jobs: map[string]*model.Job{"1": {}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
StepResults: map[string]*model.StepResult{},
|
||||||
|
},
|
||||||
|
readAction: sarm.readAction,
|
||||||
|
}
|
||||||
|
sar.RunContext.ExprEval = sar.RunContext.NewExpressionEvaluator(ctx)
|
||||||
|
|
||||||
|
suffixMatcher := func(suffix string) any {
|
||||||
|
return mock.MatchedBy(func(actionDir string) bool {
|
||||||
|
return strings.HasSuffix(actionDir, suffix)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
sarm.On("readAction", sar.Step, suffixMatcher(sar.Step.UsesHash()), "", mock.Anything, mock.Anything).Return(&model.Action{}, nil)
|
||||||
|
|
||||||
|
err := sar.prepareActionExecutor()(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, tt.wantCloneToken, capturedToken)
|
||||||
|
|
||||||
|
sarm.AssertExpectations(t)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
10
act/runner/testdata/services-empty-image/push.yml
vendored
Normal file
10
act/runner/testdata/services-empty-image/push.yml
vendored
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
name: services-empty-image
|
||||||
|
on: push
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
services:
|
||||||
|
db:
|
||||||
|
image: ${{ false && 'postgres:16' || '' }}
|
||||||
|
steps:
|
||||||
|
- run: echo "empty-image service was skipped"
|
||||||
6
go.mod
6
go.mod
@@ -11,7 +11,7 @@ require (
|
|||||||
github.com/containerd/errdefs v1.0.0
|
github.com/containerd/errdefs v1.0.0
|
||||||
github.com/creack/pty v1.1.24
|
github.com/creack/pty v1.1.24
|
||||||
github.com/distribution/reference v0.6.0
|
github.com/distribution/reference v0.6.0
|
||||||
github.com/docker/cli v29.6.0+incompatible
|
github.com/docker/cli v29.6.1+incompatible
|
||||||
github.com/docker/go-connections v0.7.0
|
github.com/docker/go-connections v0.7.0
|
||||||
github.com/go-git/go-billy/v5 v5.9.0
|
github.com/go-git/go-billy/v5 v5.9.0
|
||||||
github.com/go-git/go-git/v5 v5.19.1
|
github.com/go-git/go-git/v5 v5.19.1
|
||||||
@@ -29,6 +29,7 @@ require (
|
|||||||
github.com/opencontainers/selinux v1.15.1
|
github.com/opencontainers/selinux v1.15.1
|
||||||
github.com/pkg/errors v0.9.1
|
github.com/pkg/errors v0.9.1
|
||||||
github.com/prometheus/client_golang v1.23.2
|
github.com/prometheus/client_golang v1.23.2
|
||||||
|
github.com/prometheus/client_model v0.6.2
|
||||||
github.com/rhysd/actionlint v1.7.12
|
github.com/rhysd/actionlint v1.7.12
|
||||||
github.com/sirupsen/logrus v1.9.4
|
github.com/sirupsen/logrus v1.9.4
|
||||||
github.com/spf13/cobra v1.10.2
|
github.com/spf13/cobra v1.10.2
|
||||||
@@ -37,7 +38,7 @@ require (
|
|||||||
github.com/timshannon/bolthold v0.0.0-20240314194003-30aac6950928
|
github.com/timshannon/bolthold v0.0.0-20240314194003-30aac6950928
|
||||||
go.etcd.io/bbolt v1.5.0
|
go.etcd.io/bbolt v1.5.0
|
||||||
go.yaml.in/yaml/v4 v4.0.0-rc.3
|
go.yaml.in/yaml/v4 v4.0.0-rc.3
|
||||||
golang.org/x/sys v0.46.0
|
golang.org/x/sys v0.47.0
|
||||||
golang.org/x/term v0.44.0
|
golang.org/x/term v0.44.0
|
||||||
google.golang.org/protobuf v1.36.11
|
google.golang.org/protobuf v1.36.11
|
||||||
gotest.tools/v3 v3.5.2
|
gotest.tools/v3 v3.5.2
|
||||||
@@ -84,7 +85,6 @@ require (
|
|||||||
github.com/opencontainers/go-digest v1.0.0 // indirect
|
github.com/opencontainers/go-digest v1.0.0 // indirect
|
||||||
github.com/pjbgf/sha1cd v0.6.0 // indirect
|
github.com/pjbgf/sha1cd v0.6.0 // indirect
|
||||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||||
github.com/prometheus/client_model v0.6.2 // indirect
|
|
||||||
github.com/prometheus/common v0.66.1 // indirect
|
github.com/prometheus/common v0.66.1 // indirect
|
||||||
github.com/prometheus/procfs v0.17.0 // indirect
|
github.com/prometheus/procfs v0.17.0 // indirect
|
||||||
github.com/robfig/cron/v3 v3.0.1 // indirect
|
github.com/robfig/cron/v3 v3.0.1 // indirect
|
||||||
|
|||||||
4
go.sum
4
go.sum
@@ -51,6 +51,8 @@ github.com/docker/cli v29.5.3+incompatible h1:nbEFfz774vBwQ5KRYv7c/AghjReqnGISvr
|
|||||||
github.com/docker/cli v29.5.3+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
|
github.com/docker/cli v29.5.3+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
|
||||||
github.com/docker/cli v29.6.0+incompatible h1:nw9himxMMZ7eIeherJNlKQq+acnlzGgHd+4uf10QRSc=
|
github.com/docker/cli v29.6.0+incompatible h1:nw9himxMMZ7eIeherJNlKQq+acnlzGgHd+4uf10QRSc=
|
||||||
github.com/docker/cli v29.6.0+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
|
github.com/docker/cli v29.6.0+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
|
||||||
|
github.com/docker/cli v29.6.1+incompatible h1:oO7F4nn3Ovr/5TlfTUWFbMwBSS/B7Xs6Epv26gBrUP8=
|
||||||
|
github.com/docker/cli v29.6.1+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
|
||||||
github.com/docker/docker-credential-helpers v0.9.6 h1:cT2PbRPSlnMmNTfT2TDMXRyQ1KMWHG7xoTLBcn1ZNv0=
|
github.com/docker/docker-credential-helpers v0.9.6 h1:cT2PbRPSlnMmNTfT2TDMXRyQ1KMWHG7xoTLBcn1ZNv0=
|
||||||
github.com/docker/docker-credential-helpers v0.9.6/go.mod h1:v1S+hepowrQXITkEfw6o4+BMbGot02wiKpzWhGUZK6c=
|
github.com/docker/docker-credential-helpers v0.9.6/go.mod h1:v1S+hepowrQXITkEfw6o4+BMbGot02wiKpzWhGUZK6c=
|
||||||
github.com/docker/go-connections v0.7.0 h1:6SsRfJddP22WMrCkj19x9WKjEDTB+ahsdiGYf0mN39c=
|
github.com/docker/go-connections v0.7.0 h1:6SsRfJddP22WMrCkj19x9WKjEDTB+ahsdiGYf0mN39c=
|
||||||
@@ -260,6 +262,8 @@ golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|||||||
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
|
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
|
||||||
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
|
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||||
|
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc=
|
golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc=
|
||||||
golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y=
|
golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y=
|
||||||
|
|||||||
40
internal/app/cmd/daemon_test.go
Normal file
40
internal/app/cmd/daemon_test.go
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.com/gitea/runner/internal/pkg/config"
|
||||||
|
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGetDockerSocketPathUsesConfigAndEnvironment(t *testing.T) {
|
||||||
|
got, err := getDockerSocketPath("tcp://docker.example:2376")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "tcp://docker.example:2376", got)
|
||||||
|
|
||||||
|
t.Setenv("DOCKER_HOST", "unix:///tmp/docker.sock")
|
||||||
|
got, err = getDockerSocketPath("-")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "unix:///tmp/docker.sock", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInitLoggingSetsLevelAndCaller(t *testing.T) {
|
||||||
|
oldLevel := log.GetLevel()
|
||||||
|
oldReportCaller := log.StandardLogger().ReportCaller
|
||||||
|
t.Cleanup(func() {
|
||||||
|
log.SetLevel(oldLevel)
|
||||||
|
log.SetReportCaller(oldReportCaller)
|
||||||
|
})
|
||||||
|
|
||||||
|
cfg := &config.Config{}
|
||||||
|
cfg.Log.Level = "debug"
|
||||||
|
initLogging(cfg)
|
||||||
|
|
||||||
|
require.Equal(t, log.DebugLevel, log.GetLevel())
|
||||||
|
require.True(t, log.StandardLogger().ReportCaller)
|
||||||
|
}
|
||||||
@@ -443,10 +443,11 @@ func runExec(ctx context.Context, execArgs *executeArgs) func(cmd *cobra.Command
|
|||||||
NoSkipCheckout: execArgs.noSkipCheckout,
|
NoSkipCheckout: execArgs.noSkipCheckout,
|
||||||
// PresetGitHubContext: preset,
|
// PresetGitHubContext: preset,
|
||||||
// EventJSON: string(eventJSON),
|
// EventJSON: string(eventJSON),
|
||||||
ContainerNamePrefix: "GITEA-ACTIONS-TASK-" + eventName,
|
ContainerNamePrefix: "GITEA-ACTIONS-TASK-" + eventName,
|
||||||
ContainerMaxLifetime: maxLifetime,
|
ContainerMaxLifetime: maxLifetime,
|
||||||
ContainerNetworkMode: container.NetworkMode(execArgs.network),
|
ContainerNetworkMode: container.NetworkMode(execArgs.network),
|
||||||
DefaultActionInstance: execArgs.defaultActionsURL,
|
DefaultActionInstance: execArgs.defaultActionsURL,
|
||||||
|
DefaultActionInstanceIsSelfHosted: execArgs.defaultActionsURL != "" && execArgs.defaultActionsURL != "https://github.com",
|
||||||
PlatformPicker: func(_ []string) string {
|
PlatformPicker: func(_ []string) string {
|
||||||
return execArgs.image
|
return execArgs.image
|
||||||
},
|
},
|
||||||
|
|||||||
220
internal/app/cmd/exec_test.go
Normal file
220
internal/app/cmd/exec_test.go
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.com/gitea/runner/act/model"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"go.yaml.in/yaml/v4"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestExecuteArgsResolve(t *testing.T) {
|
||||||
|
workdir := t.TempDir()
|
||||||
|
args := &executeArgs{workdir: workdir}
|
||||||
|
|
||||||
|
require.Empty(t, args.resolve(""))
|
||||||
|
require.Equal(t, filepath.Join(workdir, "sub", "file"), args.resolve("sub/file"))
|
||||||
|
|
||||||
|
abs := filepath.Join(workdir, "abs")
|
||||||
|
require.Equal(t, abs, args.resolve(abs))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecuteArgsPaths(t *testing.T) {
|
||||||
|
workdir := t.TempDir()
|
||||||
|
args := &executeArgs{
|
||||||
|
workdir: workdir,
|
||||||
|
workflowsPath: ".gitea/workflows",
|
||||||
|
envfile: ".env",
|
||||||
|
}
|
||||||
|
|
||||||
|
require.Equal(t, filepath.Join(workdir, ".gitea/workflows"), args.WorkflowsPath())
|
||||||
|
require.Equal(t, filepath.Join(workdir, ".env"), args.Envfile())
|
||||||
|
require.Equal(t, workdir, args.Workdir())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecuteArgsLoadVars(t *testing.T) {
|
||||||
|
require.Empty(t, (&executeArgs{}).LoadVars())
|
||||||
|
|
||||||
|
args := &executeArgs{vars: []string{"FOO=bar", "EMPTY", "WITH=eq=sign"}}
|
||||||
|
require.Equal(t, map[string]string{
|
||||||
|
"FOO": "bar",
|
||||||
|
"EMPTY": "",
|
||||||
|
"WITH": "eq=sign",
|
||||||
|
}, args.LoadVars())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecuteArgsLoadSecrets(t *testing.T) {
|
||||||
|
t.Setenv("FROMENV", "from-env-value")
|
||||||
|
|
||||||
|
args := &executeArgs{secrets: []string{"token=abc", "fromenv"}}
|
||||||
|
require.Equal(t, map[string]string{
|
||||||
|
"TOKEN": "abc",
|
||||||
|
"FROMENV": "from-env-value",
|
||||||
|
}, args.LoadSecrets())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReadEnvs(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
envFile := filepath.Join(dir, ".env")
|
||||||
|
require.NoError(t, os.WriteFile(envFile, []byte("FOO=bar\nBAZ=qux\n"), 0o600))
|
||||||
|
|
||||||
|
envs := map[string]string{"EXISTING": "keep"}
|
||||||
|
require.True(t, readEnvs(envFile, envs))
|
||||||
|
require.Equal(t, map[string]string{
|
||||||
|
"EXISTING": "keep",
|
||||||
|
"FOO": "bar",
|
||||||
|
"BAZ": "qux",
|
||||||
|
}, envs)
|
||||||
|
|
||||||
|
missing := map[string]string{}
|
||||||
|
require.False(t, readEnvs(filepath.Join(dir, "does-not-exist"), missing))
|
||||||
|
require.Empty(t, missing)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunExecListUsesJobEventAndAllPlans(t *testing.T) {
|
||||||
|
planner := &fakeWorkflowPlanner{
|
||||||
|
events: []string{"push", "pull_request"},
|
||||||
|
plans: map[string]*model.Plan{
|
||||||
|
"job:build": listPlan("build", "Build", "push"),
|
||||||
|
"event:push": listPlan("test", "Test", "push"),
|
||||||
|
"all": listPlan("lint", "Lint", "push"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
out := captureStdout(t, func() {
|
||||||
|
require.NoError(t, runExecList(planner, &executeArgs{job: "build"}))
|
||||||
|
require.NoError(t, runExecList(planner, &executeArgs{event: "push"}))
|
||||||
|
require.NoError(t, runExecList(planner, &executeArgs{autodetectEvent: true}))
|
||||||
|
require.NoError(t, runExecList(planner, &executeArgs{}))
|
||||||
|
})
|
||||||
|
|
||||||
|
require.Contains(t, out, "Build")
|
||||||
|
require.Contains(t, out, "Test")
|
||||||
|
require.Contains(t, out, "Lint")
|
||||||
|
require.Equal(t, []string{"job:build", "event:push", "event:push", "all"}, planner.calls)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPrintListReportsDuplicateJobIDs(t *testing.T) {
|
||||||
|
workflowA := workflowForList("A", "a.yml", "push", "build", "Build A")
|
||||||
|
workflowB := workflowForList("B", "b.yml", "pull_request", "build", "Build B")
|
||||||
|
plan := &model.Plan{Stages: []*model.Stage{{
|
||||||
|
Runs: []*model.Run{
|
||||||
|
{Workflow: workflowA, JobID: "build"},
|
||||||
|
{Workflow: workflowB, JobID: "build"},
|
||||||
|
},
|
||||||
|
}}}
|
||||||
|
|
||||||
|
out := captureStdout(t, func() {
|
||||||
|
printList(plan)
|
||||||
|
})
|
||||||
|
|
||||||
|
require.Contains(t, out, "Workflow file")
|
||||||
|
require.Contains(t, out, "Build A")
|
||||||
|
require.Contains(t, out, "Build B")
|
||||||
|
require.Contains(t, out, "Detected multiple jobs with the same job name")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadExecCmdDefinesExpectedFlags(t *testing.T) {
|
||||||
|
cmd := loadExecCmd(context.Background())
|
||||||
|
|
||||||
|
for _, name := range []string{
|
||||||
|
"list",
|
||||||
|
"job",
|
||||||
|
"event",
|
||||||
|
"workflows",
|
||||||
|
"directory",
|
||||||
|
"env",
|
||||||
|
"secret",
|
||||||
|
"var",
|
||||||
|
"dryrun",
|
||||||
|
"image",
|
||||||
|
"gitea-instance",
|
||||||
|
} {
|
||||||
|
if cmd.Flags().Lookup(name) == nil && cmd.PersistentFlags().Lookup(name) == nil {
|
||||||
|
t.Fatalf("expected flag %q to be registered", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
require.Equal(t, "exec", cmd.Use)
|
||||||
|
require.NoError(t, cmd.Args(cmd, strings.Split("a b c", " ")))
|
||||||
|
require.Error(t, cmd.Args(cmd, strings.Fields(strings.Repeat("arg ", 21))))
|
||||||
|
}
|
||||||
|
|
||||||
|
type fakeWorkflowPlanner struct {
|
||||||
|
events []string
|
||||||
|
plans map[string]*model.Plan
|
||||||
|
calls []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *fakeWorkflowPlanner) PlanEvent(eventName string) (*model.Plan, error) {
|
||||||
|
p.calls = append(p.calls, "event:"+eventName)
|
||||||
|
return p.plans["event:"+eventName], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *fakeWorkflowPlanner) PlanJob(jobName string) (*model.Plan, error) {
|
||||||
|
p.calls = append(p.calls, "job:"+jobName)
|
||||||
|
return p.plans["job:"+jobName], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *fakeWorkflowPlanner) PlanAll() (*model.Plan, error) {
|
||||||
|
p.calls = append(p.calls, "all")
|
||||||
|
return p.plans["all"], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *fakeWorkflowPlanner) GetEvents() []string {
|
||||||
|
return p.events
|
||||||
|
}
|
||||||
|
|
||||||
|
func listPlan(jobID, jobName, event string) *model.Plan {
|
||||||
|
workflow := workflowForList("Workflow "+jobID, jobID+".yml", event, jobID, jobName)
|
||||||
|
return &model.Plan{Stages: []*model.Stage{{Runs: []*model.Run{{Workflow: workflow, JobID: jobID}}}}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func workflowForList(name, file, event, jobID, jobName string) *model.Workflow {
|
||||||
|
return &model.Workflow{
|
||||||
|
Name: name,
|
||||||
|
File: file,
|
||||||
|
RawOn: rawOnNode(event),
|
||||||
|
Jobs: map[string]*model.Job{
|
||||||
|
jobID: {Name: jobName},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func rawOnNode(event string) yaml.Node {
|
||||||
|
var node yaml.Node
|
||||||
|
if err := yaml.Unmarshal([]byte(event), &node); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return *node.Content[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
func captureStdout(t *testing.T, fn func()) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
old := os.Stdout
|
||||||
|
r, w, err := os.Pipe()
|
||||||
|
require.NoError(t, err)
|
||||||
|
os.Stdout = w
|
||||||
|
|
||||||
|
fn()
|
||||||
|
|
||||||
|
require.NoError(t, w.Close())
|
||||||
|
os.Stdout = old
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
_, err = io.Copy(&buf, r)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, r.Close())
|
||||||
|
return buf.String()
|
||||||
|
}
|
||||||
@@ -4,8 +4,12 @@
|
|||||||
package cmd
|
package cmd
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"os"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"gitea.com/gitea/runner/internal/pkg/config"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
"gotest.tools/v3/assert"
|
"gotest.tools/v3/assert"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -17,3 +21,136 @@ func TestRegisterNonInteractiveReturnsLabelValidationError(t *testing.T) {
|
|||||||
})
|
})
|
||||||
assert.Error(t, err, "unsupported schema: invalid")
|
assert.Error(t, err, "unsupported schema: invalid")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRegisterInputsValidate(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
inputs registerInputs
|
||||||
|
wantErr string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "empty instance address",
|
||||||
|
inputs: registerInputs{Token: "token"},
|
||||||
|
wantErr: "instance address is empty",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty token",
|
||||||
|
inputs: registerInputs{InstanceAddr: "http://localhost:3000"},
|
||||||
|
wantErr: "token is empty",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid label",
|
||||||
|
inputs: registerInputs{InstanceAddr: "http://localhost:3000", Token: "token", Labels: []string{"ubuntu:vm:bad"}},
|
||||||
|
wantErr: "unsupported schema: vm",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "valid",
|
||||||
|
inputs: registerInputs{InstanceAddr: "http://localhost:3000", Token: "token", Labels: []string{"ubuntu:host"}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
err := tt.inputs.validate()
|
||||||
|
if tt.wantErr != "" {
|
||||||
|
require.EqualError(t, err, tt.wantErr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
require.NoError(t, err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateLabels(t *testing.T) {
|
||||||
|
require.NoError(t, validateLabels([]string{"ubuntu:host", "ubuntu:docker://node:18"}))
|
||||||
|
require.Error(t, validateLabels([]string{"ubuntu:host", "ubuntu:vm:bad"}))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegisterInputsStageValue(t *testing.T) {
|
||||||
|
inputs := ®isterInputs{
|
||||||
|
InstanceAddr: "http://localhost:3000",
|
||||||
|
Token: "token",
|
||||||
|
RunnerName: "runner",
|
||||||
|
Labels: []string{"ubuntu:host", "ubuntu:docker://node:18"},
|
||||||
|
}
|
||||||
|
require.Equal(t, "http://localhost:3000", inputs.stageValue(StageInputInstance))
|
||||||
|
require.Equal(t, "token", inputs.stageValue(StageInputToken))
|
||||||
|
require.Equal(t, "runner", inputs.stageValue(StageInputRunnerName))
|
||||||
|
require.Equal(t, "ubuntu:host,ubuntu:docker://node:18", inputs.stageValue(StageInputLabels))
|
||||||
|
require.Empty(t, (®isterInputs{}).stageValue(StageInputLabels))
|
||||||
|
require.Empty(t, inputs.stageValue(StageWaitingForRegistration))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegisterInputsAssignToNext(t *testing.T) {
|
||||||
|
emptyCfg := &config.Config{}
|
||||||
|
|
||||||
|
t.Run("instance and token stay on empty value", func(t *testing.T) {
|
||||||
|
inputs := ®isterInputs{}
|
||||||
|
require.Equal(t, StageInputInstance, inputs.assignToNext(StageInputInstance, "", emptyCfg))
|
||||||
|
require.Equal(t, StageInputToken, inputs.assignToNext(StageInputToken, "", emptyCfg))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("instance then token then runner name", func(t *testing.T) {
|
||||||
|
inputs := ®isterInputs{}
|
||||||
|
require.Equal(t, StageInputToken, inputs.assignToNext(StageInputInstance, "http://localhost:3000", emptyCfg))
|
||||||
|
require.Equal(t, "http://localhost:3000", inputs.InstanceAddr)
|
||||||
|
require.Equal(t, StageInputRunnerName, inputs.assignToNext(StageInputToken, "token", emptyCfg))
|
||||||
|
require.Equal(t, "token", inputs.Token)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("empty runner name falls back to hostname", func(t *testing.T) {
|
||||||
|
inputs := ®isterInputs{}
|
||||||
|
require.Equal(t, StageInputLabels, inputs.assignToNext(StageInputRunnerName, "", emptyCfg))
|
||||||
|
hostname, _ := os.Hostname()
|
||||||
|
require.Equal(t, hostname, inputs.RunnerName)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("labels from config skip the labels stage", func(t *testing.T) {
|
||||||
|
cfg := &config.Config{}
|
||||||
|
cfg.Runner.Labels = []string{"ubuntu:host", "ubuntu:vm:bad"}
|
||||||
|
inputs := ®isterInputs{}
|
||||||
|
require.Equal(t, StageWaitingForRegistration, inputs.assignToNext(StageInputRunnerName, "runner", cfg))
|
||||||
|
// only the valid label survives
|
||||||
|
require.Equal(t, []string{"ubuntu:host"}, inputs.Labels)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("blank labels input uses defaults", func(t *testing.T) {
|
||||||
|
inputs := ®isterInputs{}
|
||||||
|
require.Equal(t, StageWaitingForRegistration, inputs.assignToNext(StageInputLabels, "", emptyCfg))
|
||||||
|
require.Equal(t, defaultLabels, inputs.Labels)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("invalid labels input loops back", func(t *testing.T) {
|
||||||
|
inputs := ®isterInputs{}
|
||||||
|
require.Equal(t, StageInputLabels, inputs.assignToNext(StageInputLabels, "ubuntu:vm:bad", emptyCfg))
|
||||||
|
require.Nil(t, inputs.Labels)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("overwrite local config", func(t *testing.T) {
|
||||||
|
inputs := ®isterInputs{}
|
||||||
|
require.Equal(t, StageInputInstance, inputs.assignToNext(StageOverwriteLocalConfig, "Y", emptyCfg))
|
||||||
|
require.Equal(t, StageInputInstance, inputs.assignToNext(StageOverwriteLocalConfig, "y", emptyCfg))
|
||||||
|
require.Equal(t, StageExit, inputs.assignToNext(StageOverwriteLocalConfig, "n", emptyCfg))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("unknown stage", func(t *testing.T) {
|
||||||
|
inputs := ®isterInputs{}
|
||||||
|
require.Equal(t, StageUnknown, inputs.assignToNext(StageWaitingForRegistration, "x", emptyCfg))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInitInputs(t *testing.T) {
|
||||||
|
inputs := initInputs(®isterArgs{
|
||||||
|
InstanceAddr: "http://localhost:3000",
|
||||||
|
Token: "token",
|
||||||
|
RunnerName: "runner",
|
||||||
|
Ephemeral: true,
|
||||||
|
Labels: " ubuntu:host , ubuntu:docker://node:18 ",
|
||||||
|
})
|
||||||
|
require.Equal(t, "http://localhost:3000", inputs.InstanceAddr)
|
||||||
|
require.Equal(t, "token", inputs.Token)
|
||||||
|
require.Equal(t, "runner", inputs.RunnerName)
|
||||||
|
require.True(t, inputs.Ephemeral)
|
||||||
|
require.Equal(t, []string{"ubuntu:host ", " ubuntu:docker://node:18"}, inputs.Labels)
|
||||||
|
|
||||||
|
require.Nil(t, initInputs(®isterArgs{Labels: " "}).Labels)
|
||||||
|
}
|
||||||
|
|||||||
@@ -299,6 +299,15 @@ func (r *Runner) getDefaultActionsURL(task *runnerv1.Task) string {
|
|||||||
return giteaDefaultActionsURL
|
return giteaDefaultActionsURL
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// isSelfHostedActionsURL reports whether actions resolve to this self-hosted Gitea
|
||||||
|
// (DEFAULT_ACTIONS_URL=self), i.e. gitea_default_actions_url is AppURL rather than
|
||||||
|
// github.com (which may be mirror-substituted by getDefaultActionsURL). Only then may the
|
||||||
|
// task token be attached to action clone URLs on the actions instance host.
|
||||||
|
func (r *Runner) isSelfHostedActionsURL(task *runnerv1.Task) bool {
|
||||||
|
giteaDefaultActionsURL := task.Context.Fields["gitea_default_actions_url"].GetStringValue()
|
||||||
|
return giteaDefaultActionsURL != "" && giteaDefaultActionsURL != "https://github.com"
|
||||||
|
}
|
||||||
|
|
||||||
func (r *Runner) run(ctx context.Context, task *runnerv1.Task, reporter *report.Reporter) (err error) {
|
func (r *Runner) run(ctx context.Context, task *runnerv1.Task, reporter *report.Reporter) (err error) {
|
||||||
defer func() {
|
defer func() {
|
||||||
if r := recover(); r != nil {
|
if r := recover(); r != nil {
|
||||||
@@ -396,6 +405,12 @@ func (r *Runner) run(ctx context.Context, task *runnerv1.Task, reporter *report.
|
|||||||
maxLifetime = time.Until(deadline)
|
maxLifetime = time.Until(deadline)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// shallow clones the requested ref at depth 1, otherwise 0 means a full clone
|
||||||
|
actionCloneDepth := 1
|
||||||
|
if r.cfg.Runner.ActionShallowClone != nil && !*r.cfg.Runner.ActionShallowClone {
|
||||||
|
actionCloneDepth = 0
|
||||||
|
}
|
||||||
|
|
||||||
workdirParent := strings.TrimLeft(r.cfg.Container.WorkdirParent, "/")
|
workdirParent := strings.TrimLeft(r.cfg.Container.WorkdirParent, "/")
|
||||||
if r.cfg.Container.BindWorkdir {
|
if r.cfg.Container.BindWorkdir {
|
||||||
// Append the task ID to isolate concurrent jobs from the same repo.
|
// Append the task ID to isolate concurrent jobs from the same repo.
|
||||||
@@ -418,6 +433,7 @@ func (r *Runner) run(ctx context.Context, task *runnerv1.Task, reporter *report.
|
|||||||
ActionCacheDir: filepath.FromSlash(r.cfg.Host.WorkdirParent),
|
ActionCacheDir: filepath.FromSlash(r.cfg.Host.WorkdirParent),
|
||||||
AllocatePTY: r.cfg.Runner.AllocatePTY,
|
AllocatePTY: r.cfg.Runner.AllocatePTY,
|
||||||
ActionOfflineMode: r.cfg.Cache.OfflineMode,
|
ActionOfflineMode: r.cfg.Cache.OfflineMode,
|
||||||
|
ActionCloneDepth: actionCloneDepth,
|
||||||
|
|
||||||
ReuseContainers: false,
|
ReuseContainers: false,
|
||||||
ForcePull: r.cfg.Container.ForcePull,
|
ForcePull: r.cfg.Container.ForcePull,
|
||||||
@@ -439,14 +455,15 @@ func (r *Runner) run(ctx context.Context, task *runnerv1.Task, reporter *report.
|
|||||||
EnableIPv4: r.cfg.Container.NetworkCreateOptions.EnableIPv4,
|
EnableIPv4: r.cfg.Container.NetworkCreateOptions.EnableIPv4,
|
||||||
EnableIPv6: r.cfg.Container.NetworkCreateOptions.EnableIPv6,
|
EnableIPv6: r.cfg.Container.NetworkCreateOptions.EnableIPv6,
|
||||||
},
|
},
|
||||||
ContainerOptions: r.cfg.Container.Options,
|
ContainerOptions: r.cfg.Container.Options,
|
||||||
ContainerDaemonSocket: r.cfg.Container.DockerHost,
|
ContainerDaemonSocket: r.cfg.Container.DockerHost,
|
||||||
Privileged: r.cfg.Container.Privileged,
|
Privileged: r.cfg.Container.Privileged,
|
||||||
DefaultActionInstance: r.getDefaultActionsURL(task),
|
DefaultActionInstance: r.getDefaultActionsURL(task),
|
||||||
PlatformPicker: r.labels.PickPlatform,
|
DefaultActionInstanceIsSelfHosted: r.isSelfHostedActionsURL(task),
|
||||||
Vars: task.Vars,
|
PlatformPicker: r.labels.PickPlatform,
|
||||||
ValidVolumes: r.cfg.Container.ValidVolumes,
|
Vars: task.Vars,
|
||||||
InsecureSkipTLS: r.cfg.Runner.Insecure,
|
ValidVolumes: r.cfg.Container.ValidVolumes,
|
||||||
|
InsecureSkipTLS: r.cfg.Runner.Insecure,
|
||||||
}
|
}
|
||||||
|
|
||||||
rr, err := runner.New(runnerConfig)
|
rr, err := runner.New(runnerConfig)
|
||||||
|
|||||||
103
internal/app/run/runner_test.go
Normal file
103
internal/app/run/runner_test.go
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package run
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
clientmocks "gitea.com/gitea/runner/internal/pkg/client/mocks"
|
||||||
|
"gitea.com/gitea/runner/internal/pkg/config"
|
||||||
|
"gitea.com/gitea/runner/internal/pkg/ver"
|
||||||
|
|
||||||
|
"connectrpc.com/connect"
|
||||||
|
runnerv1 "gitea.dev/actions-proto-go/runner/v1"
|
||||||
|
"github.com/stretchr/testify/mock"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"google.golang.org/protobuf/types/known/structpb"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRunnerCapabilitiesAndDeclare(t *testing.T) {
|
||||||
|
require.Equal(t, []string{CapabilityCancelling}, RunnerCapabilities())
|
||||||
|
|
||||||
|
cli := clientmocks.NewClient(t)
|
||||||
|
cli.On("Declare", mock.Anything, mock.MatchedBy(func(req *connect.Request[runnerv1.DeclareRequest]) bool {
|
||||||
|
return req.Msg.Version == ver.Version() &&
|
||||||
|
len(req.Msg.Labels) == 1 &&
|
||||||
|
req.Msg.Labels[0] == "ubuntu" &&
|
||||||
|
len(req.Msg.Capabilities) == 1 &&
|
||||||
|
req.Msg.Capabilities[0] == CapabilityCancelling
|
||||||
|
})).Return(connect.NewResponse(&runnerv1.DeclareResponse{}), nil)
|
||||||
|
|
||||||
|
r := &Runner{client: cli}
|
||||||
|
_, err := r.Declare(context.Background(), []string{"ubuntu"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunnerSetCapabilitiesFromDeclare(t *testing.T) {
|
||||||
|
r := &Runner{}
|
||||||
|
r.SetCapabilitiesFromDeclare(nil)
|
||||||
|
require.Empty(t, r.capabilities)
|
||||||
|
|
||||||
|
resp := connect.NewResponse(&runnerv1.DeclareResponse{})
|
||||||
|
resp.Header().Set("X-Gitea-Actions-Capabilities", " cancelling,cache-v2 ")
|
||||||
|
r.SetCapabilitiesFromDeclare(resp)
|
||||||
|
require.Equal(t, "cancelling,cache-v2", r.capabilities)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunnerDefaultActionsURLUsesMirrorOnlyForGithub(t *testing.T) {
|
||||||
|
r := &Runner{cfg: &config.Config{}}
|
||||||
|
r.cfg.Runner.GithubMirror = "https://mirror.example"
|
||||||
|
|
||||||
|
task := taskWithDefaultActionsURL("https://github.com")
|
||||||
|
require.Equal(t, "https://mirror.example", r.getDefaultActionsURL(task))
|
||||||
|
|
||||||
|
task = taskWithDefaultActionsURL("https://gitea.example")
|
||||||
|
require.Equal(t, "https://gitea.example", r.getDefaultActionsURL(task))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunnerRunningCountAndNullLogger(t *testing.T) {
|
||||||
|
r := &Runner{}
|
||||||
|
require.Equal(t, int64(0), r.RunningCount())
|
||||||
|
r.runningCount.Add(2)
|
||||||
|
require.Equal(t, int64(2), r.RunningCount())
|
||||||
|
|
||||||
|
logger := NullLogger{}.WithJobLogger()
|
||||||
|
require.NotNil(t, logger)
|
||||||
|
require.NotNil(t, logger.Out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewRunnerInitializesLabelsAndEnvironment(t *testing.T) {
|
||||||
|
cacheEnabled := false
|
||||||
|
cfg := &config.Config{}
|
||||||
|
cfg.Cache.Enabled = &cacheEnabled
|
||||||
|
cfg.Runner.Envs = map[string]string{"EXISTING": "value"}
|
||||||
|
reg := &config.Registration{
|
||||||
|
Name: "runner",
|
||||||
|
Labels: []string{"ubuntu:host", "bad:vm:label"},
|
||||||
|
}
|
||||||
|
cli := clientmocks.NewClient(t)
|
||||||
|
cli.On("Address").Return("https://gitea.example/").Maybe()
|
||||||
|
|
||||||
|
r := NewRunner(cfg, reg, cli)
|
||||||
|
|
||||||
|
require.Equal(t, "runner", r.name)
|
||||||
|
require.Len(t, r.labels, 1)
|
||||||
|
require.Equal(t, "value", r.envs["EXISTING"])
|
||||||
|
require.Equal(t, "https://gitea.example/api/actions_pipeline/", r.envs["ACTIONS_RUNTIME_URL"])
|
||||||
|
require.Equal(t, "https://gitea.example", r.envs["ACTIONS_RESULTS_URL"])
|
||||||
|
require.Equal(t, "true", r.envs["GITEA_ACTIONS"])
|
||||||
|
require.NotEmpty(t, r.envs["GITEA_ACTIONS_RUNNER_VERSION"])
|
||||||
|
require.Nil(t, r.cacheHandler)
|
||||||
|
}
|
||||||
|
|
||||||
|
func taskWithDefaultActionsURL(url string) *runnerv1.Task {
|
||||||
|
return &runnerv1.Task{
|
||||||
|
Context: &structpb.Struct{
|
||||||
|
Fields: map[string]*structpb.Value{
|
||||||
|
"gitea_default_actions_url": structpb.NewStringValue(url),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,8 +5,12 @@ package client
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"connectrpc.com/connect"
|
||||||
|
pingv1 "gitea.dev/actions-proto-go/ping/v1"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -25,3 +29,67 @@ func TestGetHTTPClientUsesProxyFromEnvironment(t *testing.T) {
|
|||||||
require.NotNil(t, proxyURL)
|
require.NotNil(t, proxyURL)
|
||||||
require.Equal(t, "http://proxy.example.com:8080", proxyURL.String())
|
require.Equal(t, "http://proxy.example.com:8080", proxyURL.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestGetHTTPClientInsecureTLS(t *testing.T) {
|
||||||
|
// insecure only takes effect for https endpoints
|
||||||
|
httpsInsecure := getHTTPClient("https://gitea.example.com", true)
|
||||||
|
transport, ok := httpsInsecure.Transport.(*http.Transport)
|
||||||
|
require.True(t, ok)
|
||||||
|
require.NotNil(t, transport.TLSClientConfig)
|
||||||
|
require.True(t, transport.TLSClientConfig.InsecureSkipVerify)
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
endpoint string
|
||||||
|
insecure bool
|
||||||
|
}{
|
||||||
|
{"https secure", "https://gitea.example.com", false},
|
||||||
|
{"http insecure ignored", "http://gitea.example.com", true},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
c := getHTTPClient(tc.endpoint, tc.insecure)
|
||||||
|
tr, ok := c.Transport.(*http.Transport)
|
||||||
|
require.True(t, ok)
|
||||||
|
require.Nil(t, tr.TLSClientConfig)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewSetsBaseURLAndHeaders(t *testing.T) {
|
||||||
|
var gotPath string
|
||||||
|
gotHeaders := make(http.Header)
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
gotPath = r.URL.Path
|
||||||
|
gotHeaders = r.Header.Clone()
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
// trailing slash must be trimmed before "/api/actions" is appended
|
||||||
|
c := New(server.URL+"/", false, "the-uuid", "the-token")
|
||||||
|
// Address returns the endpoint as supplied (untrimmed)
|
||||||
|
require.Equal(t, server.URL+"/", c.Address())
|
||||||
|
require.False(t, c.Insecure())
|
||||||
|
|
||||||
|
// the call is expected to fail (server returns 500), we only assert what was sent
|
||||||
|
_, _ = c.Ping(t.Context(), connect.NewRequest(&pingv1.PingRequest{Data: "hi"}))
|
||||||
|
|
||||||
|
require.True(t, strings.HasPrefix(gotPath, "/api/actions/"), "unexpected path %q", gotPath)
|
||||||
|
require.Equal(t, "the-uuid", gotHeaders.Get(UUIDHeader))
|
||||||
|
require.Equal(t, "the-token", gotHeaders.Get(TokenHeader))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewOmitsEmptyHeaders(t *testing.T) {
|
||||||
|
gotHeaders := make(http.Header)
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
gotHeaders = r.Header.Clone()
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
c := New(server.URL, false, "", "")
|
||||||
|
_, _ = c.Ping(t.Context(), connect.NewRequest(&pingv1.PingRequest{Data: "hi"}))
|
||||||
|
|
||||||
|
require.Empty(t, gotHeaders.Get(UUIDHeader))
|
||||||
|
require.Empty(t, gotHeaders.Get(TokenHeader))
|
||||||
|
}
|
||||||
|
|||||||
@@ -69,6 +69,9 @@ runner:
|
|||||||
# and github_mirror is not empty. In this case,
|
# and github_mirror is not empty. In this case,
|
||||||
# it replaces https://github.com with the value here, which is useful for some special network environments.
|
# it replaces https://github.com with the value here, which is useful for some special network environments.
|
||||||
github_mirror: ''
|
github_mirror: ''
|
||||||
|
# When true (the default), fetch only the requested ref of an action repository (e.g. actions/checkout@v4) at depth 1 instead of cloning every branch's full history.
|
||||||
|
# Set to false to clone the full history.
|
||||||
|
action_shallow_clone: true
|
||||||
# The labels of a runner are used to determine which jobs the runner can run, and how to run them.
|
# The labels of a runner are used to determine which jobs the runner can run, and how to run them.
|
||||||
# Like: "macos-arm64:host" or "ubuntu-latest:docker://docker.gitea.com/runner-images:ubuntu-latest"
|
# Like: "macos-arm64:host" or "ubuntu-latest:docker://docker.gitea.com/runner-images:ubuntu-latest"
|
||||||
# Find more images provided by Gitea at https://gitea.com/gitea/runner-images .
|
# Find more images provided by Gitea at https://gitea.com/gitea/runner-images .
|
||||||
@@ -107,6 +110,11 @@ cache:
|
|||||||
dir: ""
|
dir: ""
|
||||||
# Outbound IP or hostname that job containers use to reach this runner's cache server.
|
# Outbound IP or hostname that job containers use to reach this runner's cache server.
|
||||||
# Leave empty to detect automatically. 0.0.0.0 is not valid here.
|
# Leave empty to detect automatically. 0.0.0.0 is not valid here.
|
||||||
|
# If the runner itself runs in Docker, automatic detection can choose an
|
||||||
|
# address on the runner container's network that job containers cannot reach
|
||||||
|
# when the runner creates a separate per-job network. In that case, set this
|
||||||
|
# to a hostname/IP reachable from job containers, and set port to a fixed
|
||||||
|
# published port or put the job containers on a shared Docker network.
|
||||||
# Ignored when external_server is set.
|
# Ignored when external_server is set.
|
||||||
host: ""
|
host: ""
|
||||||
# Port for the built-in cache server. 0 picks a random free port.
|
# Port for the built-in cache server. 0 picks a random free port.
|
||||||
@@ -130,6 +138,8 @@ container:
|
|||||||
# Specifies the network to which the container will connect.
|
# Specifies the network to which the container will connect.
|
||||||
# Could be host, bridge or the name of a custom network.
|
# Could be host, bridge or the name of a custom network.
|
||||||
# If it's empty, runner will create a network automatically.
|
# If it's empty, runner will create a network automatically.
|
||||||
|
# For dockerized runners using the built-in cache server, a custom shared
|
||||||
|
# network can be required so job containers can reach cache.host/cache.port.
|
||||||
# Deprecated: `network_mode` is still accepted for old configs; use `network` instead.
|
# Deprecated: `network_mode` is still accepted for old configs; use `network` instead.
|
||||||
network: ""
|
network: ""
|
||||||
# network_create_options only apply when `network` is left empty and the runner
|
# network_create_options only apply when `network` is left empty and the runner
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ type Runner struct {
|
|||||||
ReportCloseTimeout time.Duration `yaml:"report_close_timeout"` // ReportCloseTimeout caps each RPC attempt when flushing the final logs and task state at job completion, on a detached context so a server cancel can't block the acknowledgement.
|
ReportCloseTimeout time.Duration `yaml:"report_close_timeout"` // ReportCloseTimeout caps each RPC attempt when flushing the final logs and task state at job completion, on a detached context so a server cancel can't block the acknowledgement.
|
||||||
Labels []string `yaml:"labels"` // Labels specify the labels of the runner. Labels are declared on each startup
|
Labels []string `yaml:"labels"` // Labels specify the labels of the runner. Labels are declared on each startup
|
||||||
GithubMirror string `yaml:"github_mirror"` // GithubMirror defines what mirrors should be used when using github
|
GithubMirror string `yaml:"github_mirror"` // GithubMirror defines what mirrors should be used when using github
|
||||||
|
ActionShallowClone *bool `yaml:"action_shallow_clone"` // ActionShallowClone fetches only the requested ref of an action repository at depth 1 instead of cloning every branch's full history. It is a pointer to distinguish between false and not set; if not set, it defaults to true.
|
||||||
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.
|
||||||
@@ -151,6 +152,10 @@ func LoadDefault(file string) (*Config, error) {
|
|||||||
if cfg.Runner.Timeout <= 0 {
|
if cfg.Runner.Timeout <= 0 {
|
||||||
cfg.Runner.Timeout = 3 * time.Hour
|
cfg.Runner.Timeout = 3 * time.Hour
|
||||||
}
|
}
|
||||||
|
if cfg.Runner.ActionShallowClone == nil {
|
||||||
|
b := true
|
||||||
|
cfg.Runner.ActionShallowClone = &b
|
||||||
|
}
|
||||||
if cfg.Cache.Enabled == nil {
|
if cfg.Cache.Enabled == nil {
|
||||||
b := true
|
b := true
|
||||||
cfg.Cache.Enabled = &b
|
cfg.Cache.Enabled = &b
|
||||||
|
|||||||
51
internal/pkg/config/registration_test.go
Normal file
51
internal/pkg/config/registration_test.go
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSaveAndLoadRegistration(t *testing.T) {
|
||||||
|
file := filepath.Join(t.TempDir(), ".runner")
|
||||||
|
|
||||||
|
reg := &Registration{
|
||||||
|
ID: 42,
|
||||||
|
UUID: "the-uuid",
|
||||||
|
Name: "runner",
|
||||||
|
Token: "the-token",
|
||||||
|
Address: "http://localhost:3000",
|
||||||
|
Labels: []string{"ubuntu:host", "ubuntu:docker://node:18"},
|
||||||
|
Ephemeral: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
require.NoError(t, SaveRegistration(file, reg))
|
||||||
|
// SaveRegistration stamps the warning onto the in-memory struct
|
||||||
|
require.Equal(t, registrationWarning, reg.Warning)
|
||||||
|
|
||||||
|
loaded, err := LoadRegistration(file)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// the warning is intentionally cleared on load
|
||||||
|
require.Empty(t, loaded.Warning)
|
||||||
|
loaded.Warning = reg.Warning
|
||||||
|
require.Equal(t, reg, loaded)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadRegistrationMissingFile(t *testing.T) {
|
||||||
|
_, err := LoadRegistration(filepath.Join(t.TempDir(), "does-not-exist"))
|
||||||
|
require.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadRegistrationInvalidJSON(t *testing.T) {
|
||||||
|
file := filepath.Join(t.TempDir(), ".runner")
|
||||||
|
require.NoError(t, os.WriteFile(file, []byte("not json"), 0o600))
|
||||||
|
|
||||||
|
_, err := LoadRegistration(file)
|
||||||
|
require.Error(t, err)
|
||||||
|
}
|
||||||
20
internal/pkg/envcheck/docker_test.go
Normal file
20
internal/pkg/envcheck/docker_test.go
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package envcheck
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCheckIfDockerRunningReturnsPingError(t *testing.T) {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
err := CheckIfDockerRunning(ctx, "unix:///definitely/missing/docker.sock")
|
||||||
|
require.Error(t, err)
|
||||||
|
require.Contains(t, err.Error(), "cannot ping the docker daemon")
|
||||||
|
}
|
||||||
@@ -61,3 +61,75 @@ func TestParse(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// mustParse parses the given label strings, failing the test on any error.
|
||||||
|
func mustParse(t *testing.T, strs ...string) Labels {
|
||||||
|
t.Helper()
|
||||||
|
ls := make(Labels, 0, len(strs))
|
||||||
|
for _, s := range strs {
|
||||||
|
l, err := Parse(s)
|
||||||
|
require.NoError(t, err)
|
||||||
|
ls = append(ls, l)
|
||||||
|
}
|
||||||
|
return ls
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRequireDocker(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
strs []string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"empty", nil, false},
|
||||||
|
{"only host", []string{"ubuntu:host", "self-hosted"}, false},
|
||||||
|
{"has docker", []string{"ubuntu:host", "ubuntu:docker://node:18"}, true},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
require.Equal(t, tt.want, mustParse(t, tt.strs...).RequireDocker())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPickPlatform(t *testing.T) {
|
||||||
|
ls := mustParse(t,
|
||||||
|
"ubuntu:docker://node:18",
|
||||||
|
"self-hosted:host",
|
||||||
|
)
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
runsOn []string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"docker strips leading slashes", []string{"ubuntu"}, "node:18"},
|
||||||
|
{"host maps to self-hosted marker", []string{"self-hosted"}, "-self-hosted"},
|
||||||
|
{"first match wins", []string{"self-hosted", "ubuntu"}, "-self-hosted"},
|
||||||
|
{"unknown falls back to default", []string{"windows"}, "docker.gitea.com/runner-images:ubuntu-latest"},
|
||||||
|
{"no runsOn falls back to default", nil, "docker.gitea.com/runner-images:ubuntu-latest"},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
require.Equal(t, tt.want, ls.PickPlatform(tt.runsOn))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNames(t *testing.T) {
|
||||||
|
ls := mustParse(t, "ubuntu:docker://node:18", "self-hosted:host")
|
||||||
|
require.Equal(t, []string{"ubuntu", "self-hosted"}, ls.Names())
|
||||||
|
require.Empty(t, Labels{}.Names())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestToStrings(t *testing.T) {
|
||||||
|
ls := mustParse(t,
|
||||||
|
"ubuntu:docker://node:18",
|
||||||
|
"self-hosted:host",
|
||||||
|
"bare",
|
||||||
|
)
|
||||||
|
require.Equal(t, []string{
|
||||||
|
"ubuntu:docker://node:18",
|
||||||
|
"self-hosted:host",
|
||||||
|
"bare:host",
|
||||||
|
}, ls.ToStrings())
|
||||||
|
}
|
||||||
|
|||||||
95
internal/pkg/metrics/metrics_test.go
Normal file
95
internal/pkg/metrics/metrics_test.go
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package metrics
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
runnerv1 "gitea.dev/actions-proto-go/runner/v1"
|
||||||
|
"github.com/prometheus/client_golang/prometheus"
|
||||||
|
dto "github.com/prometheus/client_model/go"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestResultToStatusLabel(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
result runnerv1.Result
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"success", runnerv1.Result_RESULT_SUCCESS, LabelStatusSuccess},
|
||||||
|
{"failure", runnerv1.Result_RESULT_FAILURE, LabelStatusFailure},
|
||||||
|
{"cancelled", runnerv1.Result_RESULT_CANCELLED, LabelStatusCancelled},
|
||||||
|
{"skipped", runnerv1.Result_RESULT_SKIPPED, LabelStatusSkipped},
|
||||||
|
{"unspecified", runnerv1.Result_RESULT_UNSPECIFIED, LabelStatusUnknown},
|
||||||
|
{"out of range", runnerv1.Result(999), LabelStatusUnknown},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
require.Equal(t, tt.want, ResultToStatusLabel(tt.result))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInitAndDynamicMetricRegistration(t *testing.T) {
|
||||||
|
oldRegistry := Registry
|
||||||
|
t.Cleanup(func() {
|
||||||
|
Registry = oldRegistry
|
||||||
|
})
|
||||||
|
|
||||||
|
Registry = prometheus.NewRegistry()
|
||||||
|
initOnce = sync.Once{}
|
||||||
|
|
||||||
|
Init()
|
||||||
|
Init()
|
||||||
|
RunnerInfo.WithLabelValues("test", "runner").Set(1)
|
||||||
|
RegisterUptimeFunc(time.Now().Add(-time.Second))
|
||||||
|
RegisterRunningJobsFunc(func() int64 { return 2 }, 4)
|
||||||
|
|
||||||
|
metrics, err := Registry.Gather()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
require.True(t, hasMetric(metrics, "gitea_runner_info"))
|
||||||
|
require.True(t, hasMetric(metrics, "gitea_runner_uptime_seconds"))
|
||||||
|
require.True(t, hasMetric(metrics, "gitea_runner_job_running"))
|
||||||
|
require.True(t, hasMetric(metrics, "gitea_runner_job_capacity_utilization_ratio"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegisterRunningJobsFuncZeroCapacity(t *testing.T) {
|
||||||
|
oldRegistry := Registry
|
||||||
|
t.Cleanup(func() { Registry = oldRegistry })
|
||||||
|
Registry = prometheus.NewRegistry()
|
||||||
|
|
||||||
|
RegisterRunningJobsFunc(func() int64 { return 3 }, 0)
|
||||||
|
|
||||||
|
metrics, err := Registry.Gather()
|
||||||
|
require.NoError(t, err)
|
||||||
|
for _, mf := range metrics {
|
||||||
|
if mf.GetName() == "gitea_runner_job_capacity_utilization_ratio" {
|
||||||
|
require.Len(t, mf.GetMetric(), 1)
|
||||||
|
require.InDelta(t, 0, mf.GetMetric()[0].GetGauge().GetValue(), 0)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatal("capacity utilization metric not gathered")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStartServerCanBeCancelled(t *testing.T) {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
StartServer(ctx, "127.0.0.1:0")
|
||||||
|
cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasMetric(metrics []*dto.MetricFamily, name string) bool {
|
||||||
|
for _, mf := range metrics {
|
||||||
|
if strings.EqualFold(mf.GetName(), name) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -39,13 +39,13 @@ func NewKiller(p *os.Process) (*Killer, error) {
|
|||||||
|
|
||||||
h, err := windows.OpenProcess(windows.PROCESS_SET_QUOTA|windows.PROCESS_TERMINATE, false, uint32(p.Pid))
|
h, err := windows.OpenProcess(windows.PROCESS_SET_QUOTA|windows.PROCESS_TERMINATE, false, uint32(p.Pid))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
windows.CloseHandle(job)
|
_ = windows.CloseHandle(job)
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer windows.CloseHandle(h)
|
defer func() { _ = windows.CloseHandle(h) }()
|
||||||
|
|
||||||
if err := windows.AssignProcessToJobObject(job, h); err != nil {
|
if err := windows.AssignProcessToJobObject(job, h); err != nil {
|
||||||
windows.CloseHandle(job)
|
_ = windows.CloseHandle(job)
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ func processAlive(pid int) bool {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
defer windows.CloseHandle(h)
|
defer func() { _ = windows.CloseHandle(h) }()
|
||||||
var code uint32
|
var code uint32
|
||||||
if err := windows.GetExitCodeProcess(h, &code); err != nil {
|
if err := windows.GetExitCodeProcess(h, &code); err != nil {
|
||||||
return false
|
return false
|
||||||
|
|||||||
23
internal/pkg/process/sysprocattr_unix_test.go
Normal file
23
internal/pkg/process/sysprocattr_unix_test.go
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
//go:build !windows && !plan9
|
||||||
|
|
||||||
|
package process
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSysProcAttrUnixModes(t *testing.T) {
|
||||||
|
plain := SysProcAttr("", false)
|
||||||
|
require.True(t, plain.Setpgid)
|
||||||
|
require.False(t, plain.Setsid)
|
||||||
|
|
||||||
|
tty := SysProcAttr("", true)
|
||||||
|
require.True(t, tty.Setsid)
|
||||||
|
require.True(t, tty.Setctty)
|
||||||
|
require.False(t, tty.Setpgid)
|
||||||
|
}
|
||||||
36
internal/pkg/process/treekill_test.go
Normal file
36
internal/pkg/process/treekill_test.go
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package process
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os/exec"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNewTreeKillConfiguresCommand(t *testing.T) {
|
||||||
|
cmd := exec.CommandContext(context.Background(), "sleep", "1")
|
||||||
|
tk := NewTreeKill(cmd)
|
||||||
|
|
||||||
|
require.NotNil(t, tk)
|
||||||
|
require.NotNil(t, cmd.Cancel)
|
||||||
|
require.Equal(t, treeKillWaitDelay, cmd.WaitDelay)
|
||||||
|
require.NoError(t, cmd.Cancel())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTreeKillCaptureStoresKiller(t *testing.T) {
|
||||||
|
cmd := exec.CommandContext(context.Background(), "sleep", "10")
|
||||||
|
cmd.SysProcAttr = SysProcAttr("", false)
|
||||||
|
tk := NewTreeKill(cmd)
|
||||||
|
require.NoError(t, cmd.Start())
|
||||||
|
defer func() { _ = cmd.Wait() }()
|
||||||
|
|
||||||
|
killer, err := tk.Capture(cmd.Process)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, killer)
|
||||||
|
require.NoError(t, cmd.Cancel())
|
||||||
|
require.NoError(t, killer.Close())
|
||||||
|
}
|
||||||
@@ -983,3 +983,75 @@ func TestReporter_StopHeartbeats(t *testing.T) {
|
|||||||
assert.Greater(t, updateTaskCalls.Load(), beforeStop,
|
assert.Greater(t, updateTaskCalls.Load(), beforeStop,
|
||||||
"Close() must still send a final UpdateTask after StopHeartbeats")
|
"Close() must still send a final UpdateTask after StopHeartbeats")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAppendIfNotNil(t *testing.T) {
|
||||||
|
var s []*int
|
||||||
|
s = appendIfNotNil(s, nil)
|
||||||
|
assert.Empty(t, s)
|
||||||
|
|
||||||
|
v := 7
|
||||||
|
s = appendIfNotNil(s, &v)
|
||||||
|
require.Len(t, s, 1)
|
||||||
|
assert.Equal(t, &v, s[0])
|
||||||
|
|
||||||
|
s = appendIfNotNil(s, nil)
|
||||||
|
require.Len(t, s, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReporter_Levels(t *testing.T) {
|
||||||
|
assert.Equal(t, log.AllLevels, (&Reporter{}).Levels())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReporter_Result(t *testing.T) {
|
||||||
|
r := &Reporter{state: &runnerv1.TaskState{Result: runnerv1.Result_RESULT_SUCCESS}}
|
||||||
|
assert.Equal(t, runnerv1.Result_RESULT_SUCCESS, r.Result())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReporter_SetOutputs(t *testing.T) {
|
||||||
|
r := &Reporter{state: &runnerv1.TaskState{}}
|
||||||
|
|
||||||
|
r.SetOutputs(map[string]string{"foo": "bar"})
|
||||||
|
got, ok := r.outputs.Load("foo")
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.Equal(t, "bar", got)
|
||||||
|
|
||||||
|
// first value wins: a later write to the same key is ignored
|
||||||
|
r.SetOutputs(map[string]string{"foo": "baz"})
|
||||||
|
got, _ = r.outputs.Load("foo")
|
||||||
|
assert.Equal(t, "bar", got)
|
||||||
|
|
||||||
|
// keys longer than 255 chars are dropped
|
||||||
|
longKey := strings.Repeat("k", 256)
|
||||||
|
r.SetOutputs(map[string]string{longKey: "v"})
|
||||||
|
_, ok = r.outputs.Load(longKey)
|
||||||
|
assert.False(t, ok)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReporter_EffectiveCloseTimeout(t *testing.T) {
|
||||||
|
assert.Equal(t, 10*time.Second, (&Reporter{}).effectiveCloseTimeout())
|
||||||
|
assert.Equal(t, 5*time.Second, (&Reporter{closeTimeout: 5 * time.Second}).effectiveCloseTimeout())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReporter_ParseResult(t *testing.T) {
|
||||||
|
r := &Reporter{}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input any
|
||||||
|
want runnerv1.Result
|
||||||
|
wantOk bool
|
||||||
|
}{
|
||||||
|
{"job result string", "success", runnerv1.Result_RESULT_SUCCESS, true},
|
||||||
|
{"failure string", "failure", runnerv1.Result_RESULT_FAILURE, true},
|
||||||
|
{"step result stringer", runnerv1.Result_RESULT_SKIPPED, runnerv1.Result_RESULT_UNSPECIFIED, false},
|
||||||
|
{"unknown string", "bogus", runnerv1.Result_RESULT_UNSPECIFIED, false},
|
||||||
|
{"unsupported type", 123, runnerv1.Result_RESULT_UNSPECIFIED, false},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got, ok := r.parseResult(tt.input)
|
||||||
|
assert.Equal(t, tt.wantOk, ok)
|
||||||
|
assert.Equal(t, tt.want, got)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
13
internal/pkg/ver/version_test.go
Normal file
13
internal/pkg/ver/version_test.go
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package ver
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestVersion(t *testing.T) {
|
||||||
|
// version defaults to "dev" and is overridden at build time via -ldflags
|
||||||
|
if got := Version(); got != version {
|
||||||
|
t.Errorf("Version() = %q, want %q", got, version)
|
||||||
|
}
|
||||||
|
}
|
||||||
162
tools/coverage-report.ts
Normal file
162
tools/coverage-report.ts
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
// Turns a `go test -coverprofile` file into a human-friendly Markdown report:
|
||||||
|
// an overall total, a per-package summary sorted worst-first, and collapsible
|
||||||
|
// per-file details for each package.
|
||||||
|
//
|
||||||
|
// Coverage is statement-weighted (covered statements / total statements),
|
||||||
|
// matching `go tool cover -func`'s total, instead of naively averaging
|
||||||
|
// per-function percentages.
|
||||||
|
//
|
||||||
|
// Usage: node ./tools/coverage-report.ts -i coverage.txt -o .tmp/coverage.md
|
||||||
|
|
||||||
|
import {readFileSync, writeFileSync} from 'node:fs';
|
||||||
|
import {basename, dirname} from 'node:path';
|
||||||
|
import {argv, exit, stderr} from 'node:process';
|
||||||
|
|
||||||
|
const modulePrefix = 'gitea.com/gitea/runner/';
|
||||||
|
|
||||||
|
type Counter = {
|
||||||
|
covered: number;
|
||||||
|
total: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function percent(c: Counter): number {
|
||||||
|
if (c.total === 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return (c.covered / c.total) * 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addCounter(a: Counter, b: Counter): Counter {
|
||||||
|
return {covered: a.covered + b.covered, total: a.total + b.total};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseArgs(): {input: string; output: string} {
|
||||||
|
let input = 'coverage.txt';
|
||||||
|
let output = '.tmp/coverage.md';
|
||||||
|
for (let i = 2; i < argv.length; i++) {
|
||||||
|
const arg = argv[i];
|
||||||
|
if (arg === '-i' && argv[i + 1]) {
|
||||||
|
input = argv[++i];
|
||||||
|
} else if (arg === '-o' && argv[i + 1]) {
|
||||||
|
output = argv[++i];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {input, output};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseProfile(name: string): Map<string, Counter> {
|
||||||
|
const files = new Map<string, Counter>();
|
||||||
|
const content = readFileSync(name, 'utf8');
|
||||||
|
for (const line of content.split('\n')) {
|
||||||
|
if (line === '' || line.startsWith('mode:')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Format: path:start.col,end.col numStmt count
|
||||||
|
const colon = line.lastIndexOf(':');
|
||||||
|
const fields = line.trimEnd().split(/\s+/);
|
||||||
|
if (colon < 0 || fields.length < 3) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const file = line.slice(0, colon).replace(modulePrefix, '');
|
||||||
|
const stmts = Number.parseInt(fields.at(-2)!, 10);
|
||||||
|
const count = Number.parseInt(fields.at(-1)!, 10);
|
||||||
|
if (Number.isNaN(stmts) || Number.isNaN(count)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const c = files.get(file) ?? {covered: 0, total: 0};
|
||||||
|
c.total += stmts;
|
||||||
|
if (count > 0) {
|
||||||
|
c.covered += stmts;
|
||||||
|
}
|
||||||
|
files.set(file, c);
|
||||||
|
}
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
|
||||||
|
function render(files: Map<string, Counter>): string {
|
||||||
|
const pkgCounts = new Map<string, Counter>();
|
||||||
|
const pkgFiles = new Map<string, string[]>();
|
||||||
|
let total: Counter = {covered: 0, total: 0};
|
||||||
|
|
||||||
|
for (const [file, c] of files) {
|
||||||
|
const pkg = dirname(file);
|
||||||
|
pkgCounts.set(pkg, addCounter(pkgCounts.get(pkg) ?? {covered: 0, total: 0}, c));
|
||||||
|
const names = pkgFiles.get(pkg) ?? [];
|
||||||
|
names.push(file);
|
||||||
|
pkgFiles.set(pkg, names);
|
||||||
|
total = addCounter(total, c);
|
||||||
|
}
|
||||||
|
|
||||||
|
const pkgs = [...pkgCounts.keys()];
|
||||||
|
pkgs.sort((a, b) => {
|
||||||
|
const ci = pkgCounts.get(a)!;
|
||||||
|
const cj = pkgCounts.get(b)!;
|
||||||
|
const pi = percent(ci);
|
||||||
|
const pj = percent(cj);
|
||||||
|
if (pi !== pj) {
|
||||||
|
return pi - pj;
|
||||||
|
}
|
||||||
|
if (ci.total !== cj.total) {
|
||||||
|
return cj.total - ci.total;
|
||||||
|
}
|
||||||
|
return a.localeCompare(b);
|
||||||
|
});
|
||||||
|
|
||||||
|
const lines: string[] = [];
|
||||||
|
lines.push('# Coverage\n');
|
||||||
|
lines.push(
|
||||||
|
`**Total: ${percent(total).toFixed(1)}%** · ${total.covered} / ${total.total} statements covered · ${pkgs.length} packages\n`,
|
||||||
|
);
|
||||||
|
|
||||||
|
lines.push('## Packages\n');
|
||||||
|
lines.push('| Package | Coverage | Statements |');
|
||||||
|
lines.push('|---------|---------:|-----------:|');
|
||||||
|
for (const pkg of pkgs) {
|
||||||
|
const c = pkgCounts.get(pkg)!;
|
||||||
|
lines.push(`| ${pkg} | ${percent(c).toFixed(1)}% | ${c.covered} / ${c.total} |`);
|
||||||
|
}
|
||||||
|
lines.push('');
|
||||||
|
|
||||||
|
lines.push('## Files\n');
|
||||||
|
for (const pkg of pkgs) {
|
||||||
|
const c = pkgCounts.get(pkg)!;
|
||||||
|
const names = [...(pkgFiles.get(pkg) ?? [])];
|
||||||
|
names.sort((a, b) => {
|
||||||
|
const ci = files.get(a)!;
|
||||||
|
const cj = files.get(b)!;
|
||||||
|
const pi = percent(ci);
|
||||||
|
const pj = percent(cj);
|
||||||
|
if (pi !== pj) {
|
||||||
|
return pi - pj;
|
||||||
|
}
|
||||||
|
return a.localeCompare(b);
|
||||||
|
});
|
||||||
|
lines.push(
|
||||||
|
`<details><summary><strong>${pkg}</strong> — ${percent(c).toFixed(1)}% (${c.covered}/${c.total})</summary>\n`,
|
||||||
|
);
|
||||||
|
lines.push('| File | Coverage | Statements |');
|
||||||
|
lines.push('|------|---------:|-----------:|');
|
||||||
|
for (const file of names) {
|
||||||
|
const fc = files.get(file)!;
|
||||||
|
lines.push(`| ${basename(file)} | ${percent(fc).toFixed(1)}% | ${fc.covered} / ${fc.total} |`);
|
||||||
|
}
|
||||||
|
lines.push('\n</details>\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function main(): void {
|
||||||
|
const {input, output} = parseArgs();
|
||||||
|
try {
|
||||||
|
const files = parseProfile(input);
|
||||||
|
writeFileSync(output, render(files), {mode: 0o644});
|
||||||
|
} catch (err) {
|
||||||
|
stderr.write(`coverage-report: ${err}\n`);
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
Reference in New Issue
Block a user