mirror of
https://gitea.com/gitea/act_runner.git
synced 2026-08-06 17:04:22 +02:00
Compare commits
9 Commits
40e021309a
...
v2.0.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b7f6b6d90a | ||
|
|
cdcea87a45 | ||
|
|
3c4bcf3ebf | ||
|
|
e22d3fa263 | ||
|
|
99bc50d538 | ||
|
|
8f72c60afa | ||
|
|
4e7fd1c68a | ||
|
|
bd41a367fe | ||
|
|
c566013db4 |
@@ -17,7 +17,7 @@ RUN make clean && make build
|
|||||||
### DIND VARIANT
|
### DIND VARIANT
|
||||||
#
|
#
|
||||||
#
|
#
|
||||||
FROM docker:29.5.3-dind AS dind
|
FROM docker:29.6.0-dind AS dind
|
||||||
|
|
||||||
ARG VERSION=dev
|
ARG VERSION=dev
|
||||||
|
|
||||||
@@ -37,7 +37,7 @@ ENTRYPOINT ["s6-svscan","/etc/s6"]
|
|||||||
### DIND-ROOTLESS VARIANT
|
### DIND-ROOTLESS VARIANT
|
||||||
#
|
#
|
||||||
#
|
#
|
||||||
FROM docker:29.5.3-dind-rootless AS dind-rootless
|
FROM docker:29.6.0-dind-rootless AS dind-rootless
|
||||||
|
|
||||||
ARG VERSION=dev
|
ARG VERSION=dev
|
||||||
|
|
||||||
|
|||||||
10
Makefile
10
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
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -380,6 +381,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
|
||||||
|
|||||||
@@ -24,7 +24,9 @@ func JobError(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func SetJobError(ctx context.Context, err error) {
|
func SetJobError(ctx context.Context, err error) {
|
||||||
ctx.Value(jobErrorContextKeyVal).(map[string]error)["error"] = err
|
if container, ok := ctx.Value(jobErrorContextKeyVal).(map[string]error); ok {
|
||||||
|
container["error"] = err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// WithJobErrorContainer adds a value to the context as a container for an error
|
// WithJobErrorContainer adds a value to the context as a container for an error
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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.
|
|
||||||
ctx, cancel = context.WithTimeout(common.WithLogger(context.Background(), common.Logger(ctx)), 5*time.Minute)
|
|
||||||
defer cancel()
|
defer cancel()
|
||||||
case context.DeadlineExceeded:
|
return postExecutor(postCtx)
|
||||||
// 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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
@@ -904,12 +937,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
|
||||||
|
|||||||
@@ -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, 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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
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.5.3+incompatible
|
github.com/docker/cli v29.6.0+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
|
||||||
@@ -22,8 +22,8 @@ require (
|
|||||||
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51
|
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51
|
||||||
github.com/mattn/go-isatty v0.0.22
|
github.com/mattn/go-isatty v0.0.22
|
||||||
github.com/moby/go-archive v0.2.0
|
github.com/moby/go-archive v0.2.0
|
||||||
github.com/moby/moby/api v1.54.2
|
github.com/moby/moby/api v1.55.0
|
||||||
github.com/moby/moby/client v0.4.1
|
github.com/moby/moby/client v0.5.0
|
||||||
github.com/moby/patternmatcher v0.6.1
|
github.com/moby/patternmatcher v0.6.1
|
||||||
github.com/opencontainers/image-spec v1.1.1
|
github.com/opencontainers/image-spec v1.1.1
|
||||||
github.com/opencontainers/selinux v1.15.1
|
github.com/opencontainers/selinux v1.15.1
|
||||||
|
|||||||
6
go.sum
6
go.sum
@@ -49,6 +49,8 @@ github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5Qvfr
|
|||||||
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
|
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
|
||||||
github.com/docker/cli v29.5.3+incompatible h1:nbEFfz774vBwQ5KRYv7c/AghjReqnGISvrRhzjV0evs=
|
github.com/docker/cli v29.5.3+incompatible h1:nbEFfz774vBwQ5KRYv7c/AghjReqnGISvrRhzjV0evs=
|
||||||
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/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=
|
||||||
@@ -129,8 +131,12 @@ github.com/moby/go-archive v0.2.0 h1:zg5QDUM2mi0JIM9fdQZWC7U8+2ZfixfTYoHL7rWUcP8
|
|||||||
github.com/moby/go-archive v0.2.0/go.mod h1:mNeivT14o8xU+5q1YnNrkQVpK+dnNe/K6fHqnTg4qPU=
|
github.com/moby/go-archive v0.2.0/go.mod h1:mNeivT14o8xU+5q1YnNrkQVpK+dnNe/K6fHqnTg4qPU=
|
||||||
github.com/moby/moby/api v1.54.2 h1:wiat9QAhnDQjA7wk1kh/TqHz2I1uUA7M7t9SAl/JNXg=
|
github.com/moby/moby/api v1.54.2 h1:wiat9QAhnDQjA7wk1kh/TqHz2I1uUA7M7t9SAl/JNXg=
|
||||||
github.com/moby/moby/api v1.54.2/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs=
|
github.com/moby/moby/api v1.54.2/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs=
|
||||||
|
github.com/moby/moby/api v1.55.0 h1:2/sexvQyqIWS8pRSCFddBfpW2qE7vR7FCL+vN8pxwMc=
|
||||||
|
github.com/moby/moby/api v1.55.0/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs=
|
||||||
github.com/moby/moby/client v0.4.1 h1:DMQgisVoMkmMs7fp3ROSdiBnoAu8+vo3GggFl06M/wY=
|
github.com/moby/moby/client v0.4.1 h1:DMQgisVoMkmMs7fp3ROSdiBnoAu8+vo3GggFl06M/wY=
|
||||||
github.com/moby/moby/client v0.4.1/go.mod h1:z52C9O2POPOsnxZAy//WtKcQ32P+jT/NGeXu/7nfjGQ=
|
github.com/moby/moby/client v0.4.1/go.mod h1:z52C9O2POPOsnxZAy//WtKcQ32P+jT/NGeXu/7nfjGQ=
|
||||||
|
github.com/moby/moby/client v0.5.0 h1:5XhyPk2fuOWf6RlSFa3MkIIgDZkF25xToXW8Q/BH7cc=
|
||||||
|
github.com/moby/moby/client v0.5.0/go.mod h1:rcVpF8ncl9vo5gaIBdol6CnbEtSj1uxMvEV/UrykF/s=
|
||||||
github.com/moby/patternmatcher v0.6.1 h1:qlhtafmr6kgMIJjKJMDmMWq7WLkKIo23hsrpR3x084U=
|
github.com/moby/patternmatcher v0.6.1 h1:qlhtafmr6kgMIJjKJMDmMWq7WLkKIo23hsrpR3x084U=
|
||||||
github.com/moby/patternmatcher v0.6.1/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc=
|
github.com/moby/patternmatcher v0.6.1/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc=
|
||||||
github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU=
|
github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU=
|
||||||
|
|||||||
@@ -396,6 +396,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 +424,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,
|
||||||
|
|||||||
@@ -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 .
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user