diff --git a/.gitea/workflows/pull-pr-title.yml b/.gitea/workflows/pull-pr-title.yml new file mode 100644 index 00000000..315f11d8 --- /dev/null +++ b/.gitea/workflows/pull-pr-title.yml @@ -0,0 +1,27 @@ +name: pr-title + +on: + pull_request: + types: + - opened + - edited + - reopened + - synchronize + - ready_for_review + +permissions: + contents: read + +jobs: + lint-pr-title: + if: github.event.pull_request.draft == false + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 + with: + node-version: 24 + - run: make lint-pr-title + env: + PR_TITLE: ${{ github.event.pull_request.title }} diff --git a/.gitea/workflows/release-nightly.yml b/.gitea/workflows/release-nightly.yml index 9b8879b2..75e28589 100644 --- a/.gitea/workflows/release-nightly.yml +++ b/.gitea/workflows/release-nightly.yml @@ -71,6 +71,11 @@ jobs: - name: Echo the tag run: echo "${{ env.DOCKER_ORG }}/runner:nightly${{ matrix.variant.tag_suffix }}" + - name: Get Meta + id: meta + run: | + echo REPO_VERSION=$(git describe --tags --always | sed 's/-/+/' | sed 's/^v//') >> $GITHUB_OUTPUT + - name: Build and push uses: docker/build-push-action@v7 with: @@ -83,3 +88,5 @@ jobs: push: true tags: | ${{ env.DOCKER_ORG }}/runner:nightly${{ matrix.variant.tag_suffix }} + build-args: | + VERSION=${{ steps.meta.outputs.REPO_VERSION }} diff --git a/.gitea/workflows/release-tag.yml b/.gitea/workflows/release-tag.yml index aef461d8..0d054ee9 100644 --- a/.gitea/workflows/release-tag.yml +++ b/.gitea/workflows/release-tag.yml @@ -96,3 +96,5 @@ jobs: linux/arm64 push: true tags: ${{ steps.docker_meta.outputs.tags }} + build-args: | + VERSION=${{ steps.docker_meta.outputs.version }} diff --git a/.gitignore b/.gitignore index b7de5a70..14621da8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ /gitea-runner .env +!/act/runner/testdata/secrets/.env .runner coverage.txt /config.yaml @@ -10,4 +11,4 @@ coverage.txt .vscode __debug_bin # gorelease binary folder -dist +/dist diff --git a/Dockerfile b/Dockerfile index 707e3be8..543f2d29 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,7 @@ ### BUILDER STAGE # # -FROM golang:1.26-alpine AS builder +FROM golang:1.26-alpine3.23 AS builder # Do not remove `git` here, it is required for getting runner version when executing `make build` RUN apk add --no-cache make git @@ -17,7 +17,12 @@ RUN make clean && make build ### DIND VARIANT # # -FROM docker:29-dind AS dind +FROM docker:29.5.2-dind AS dind + +ARG VERSION=dev + +LABEL org.opencontainers.image.source="https://gitea.com/gitea/runner" +LABEL org.opencontainers.image.version="${VERSION}" RUN apk add --no-cache s6 bash git tzdata @@ -32,7 +37,12 @@ ENTRYPOINT ["s6-svscan","/etc/s6"] ### DIND-ROOTLESS VARIANT # # -FROM docker:29-dind-rootless AS dind-rootless +FROM docker:29.5.2-dind-rootless AS dind-rootless + +ARG VERSION=dev + +LABEL org.opencontainers.image.source="https://gitea.com/gitea/runner" +LABEL org.opencontainers.image.version="${VERSION}" USER root RUN apk add --no-cache s6 bash git tzdata @@ -53,7 +63,13 @@ ENTRYPOINT ["s6-svscan","/etc/s6"] ### BASIC VARIANT # # -FROM alpine AS basic +FROM alpine:3.23 AS basic + +ARG VERSION=dev + +LABEL org.opencontainers.image.source="https://gitea.com/gitea/runner" +LABEL org.opencontainers.image.version="${VERSION}" + RUN apk add --no-cache tini bash git tzdata COPY --from=builder /opt/src/runner/gitea-runner /usr/local/bin/gitea-runner diff --git a/Makefile b/Makefile index caaeb70c..693685e1 100644 --- a/Makefile +++ b/Makefile @@ -118,6 +118,10 @@ lint-go: ## lint go files lint-go-fix: ## lint go files and fix issues $(GO) run $(GOLANGCI_LINT_PACKAGE) run --fix +.PHONY: lint-pr-title +lint-pr-title: ## lint PR title against Conventional Commits (set PR_TITLE=...) + @node ./tools/lint-pr-title.ts + .PHONY: security-check security-check: deps-tools GOEXPERIMENT= $(GO) run $(GOVULNCHECK_PACKAGE) -show color ./... || true diff --git a/act/artifactcache/handler.go b/act/artifactcache/handler.go index c5bb0376..6efff636 100644 --- a/act/artifactcache/handler.go +++ b/act/artifactcache/handler.go @@ -431,6 +431,7 @@ func (h *Handler) upload(w http.ResponseWriter, r *http.Request, params httprout } if err := h.storage.Write(cache.ID, start, r.Body); err != nil { h.responseJSON(w, r, 500, err) + return } h.useCache(id) h.responseJSON(w, r, 200) diff --git a/act/artifactcache/handler_test.go b/act/artifactcache/handler_test.go index 7cca691c..71e84e64 100644 --- a/act/artifactcache/handler_test.go +++ b/act/artifactcache/handler_test.go @@ -11,6 +11,7 @@ import ( "fmt" "io" "net/http" + "os" "path/filepath" "strings" "testing" @@ -338,6 +339,54 @@ func TestHandler(t *testing.T) { } }) + t.Run("upload write failure returns only error", func(t *testing.T) { + key := strings.ToLower(t.Name()) + version := "c19da02a2bd7e77277f1ac29ab45c09b7d46a4ee758284e26bb3045ad11d9d20" + var id uint64 + { + body, err := json.Marshal(&Request{ + Key: key, + Version: version, + Size: 100, + }) + require.NoError(t, err) + resp, err := testClient.Post(base+"/caches", "application/json", bytes.NewReader(body)) + require.NoError(t, err) + defer resp.Body.Close() + require.Equal(t, 200, resp.StatusCode) + + got := struct { + CacheID uint64 `json:"cacheId"` + }{} + require.NoError(t, json.NewDecoder(resp.Body).Decode(&got)) + id = got.CacheID + } + + storageFile := filepath.Join(dir, "not-a-directory") + require.NoError(t, os.WriteFile(storageFile, []byte("blocked"), 0o600)) + originalStorage := handler.storage + handler.storage = &Storage{rootDir: storageFile} + defer func() { + handler.storage = originalStorage + }() + + req, err := http.NewRequest(http.MethodPatch, + fmt.Sprintf("%s/caches/%d", base, id), bytes.NewReader(make([]byte, 100))) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/octet-stream") + req.Header.Set("Content-Range", "bytes 0-99/*") + resp, err := testClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + require.Equal(t, 500, resp.StatusCode) + + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + var got map[string]string + require.NoError(t, json.Unmarshal(body, &got)) + assert.NotEmpty(t, got["error"]) + }) + t.Run("commit early", func(t *testing.T) { key := strings.ToLower(t.Name()) version := "c19da02a2bd7e77277f1ac29ab45c09b7d46a4ee758284e26bb3045ad11d9d20" diff --git a/act/common/draw.go b/act/common/draw.go deleted file mode 100644 index 2705c3a5..00000000 --- a/act/common/draw.go +++ /dev/null @@ -1,146 +0,0 @@ -// Copyright 2026 The Gitea Authors. All rights reserved. -// Copyright 2020 The nektos/act Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package common - -import ( - "fmt" - "io" - "os" - "strings" -) - -// Style is a specific style -type Style int - -// Styles -const ( - StyleDoubleLine = iota - StyleSingleLine - StyleDashedLine - StyleNoLine -) - -// NewPen creates a new pen -func NewPen(style Style, color int) *Pen { - bgcolor := 49 - if os.Getenv("CLICOLOR") == "0" { - color = 0 - bgcolor = 0 - } - return &Pen{ - style: style, - color: color, - bgcolor: bgcolor, - } -} - -type styleDef struct { - cornerTL string - cornerTR string - cornerBL string - cornerBR string - lineH string - lineV string -} - -var styleDefs = []styleDef{ - {"\u2554", "\u2557", "\u255a", "\u255d", "\u2550", "\u2551"}, - {"\u256d", "\u256e", "\u2570", "\u256f", "\u2500", "\u2502"}, - {"\u250c", "\u2510", "\u2514", "\u2518", "\u254c", "\u254e"}, - {" ", " ", " ", " ", " ", " "}, -} - -// Pen struct -type Pen struct { - style Style - color int - bgcolor int -} - -// Drawing struct -type Drawing struct { - buf *strings.Builder - width int -} - -func (p *Pen) drawTopBars(buf io.Writer, labels ...string) { - style := styleDefs[p.style] - for _, label := range labels { - bar := strings.Repeat(style.lineH, len(label)+2) - fmt.Fprintf(buf, " ") - fmt.Fprintf(buf, "\x1b[%d;%dm", p.color, p.bgcolor) - fmt.Fprintf(buf, "%s%s%s", style.cornerTL, bar, style.cornerTR) - fmt.Fprintf(buf, "\x1b[%dm", 0) - } - fmt.Fprintf(buf, "\n") -} - -func (p *Pen) drawBottomBars(buf io.Writer, labels ...string) { - style := styleDefs[p.style] - for _, label := range labels { - bar := strings.Repeat(style.lineH, len(label)+2) - fmt.Fprintf(buf, " ") - fmt.Fprintf(buf, "\x1b[%d;%dm", p.color, p.bgcolor) - fmt.Fprintf(buf, "%s%s%s", style.cornerBL, bar, style.cornerBR) - fmt.Fprintf(buf, "\x1b[%dm", 0) - } - fmt.Fprintf(buf, "\n") -} - -func (p *Pen) drawLabels(buf io.Writer, labels ...string) { - style := styleDefs[p.style] - for _, label := range labels { - fmt.Fprintf(buf, " ") - fmt.Fprintf(buf, "\x1b[%d;%dm", p.color, p.bgcolor) - fmt.Fprintf(buf, "%s %s %s", style.lineV, label, style.lineV) - fmt.Fprintf(buf, "\x1b[%dm", 0) - } - fmt.Fprintf(buf, "\n") -} - -// DrawArrow between boxes -func (p *Pen) DrawArrow() *Drawing { - drawing := &Drawing{ - buf: new(strings.Builder), - width: 1, - } - fmt.Fprintf(drawing.buf, "\x1b[%dm", p.color) - fmt.Fprintf(drawing.buf, "\u2b07") - fmt.Fprintf(drawing.buf, "\x1b[%dm", 0) - return drawing -} - -// DrawBoxes to draw boxes -func (p *Pen) DrawBoxes(labels ...string) *Drawing { - width := 0 - for _, l := range labels { - width += len(l) + 2 + 2 + 1 - } - drawing := &Drawing{ - buf: new(strings.Builder), - width: width, - } - p.drawTopBars(drawing.buf, labels...) - p.drawLabels(drawing.buf, labels...) - p.drawBottomBars(drawing.buf, labels...) - - return drawing -} - -// Draw to writer -func (d *Drawing) Draw(writer io.Writer, centerOnWidth int) { - padSize := max((centerOnWidth-d.GetWidth())/2, 0) - for l := range strings.SplitSeq(d.buf.String(), "\n") { - if len(l) > 0 { - padding := strings.Repeat(" ", padSize) - fmt.Fprintf(writer, "%s%s\n", padding, l) - } - } -} - -// GetWidth of drawing -func (d *Drawing) GetWidth() int { - return d.width -} diff --git a/act/common/executor.go b/act/common/executor.go index 90fed4d1..cc44cde7 100644 --- a/act/common/executor.go +++ b/act/common/executor.go @@ -12,24 +12,6 @@ import ( log "github.com/sirupsen/logrus" ) -// Warning that implements `error` but safe to ignore -type Warning struct { - Message string -} - -// Error the contract for error -func (w Warning) Error() string { - return w.Message -} - -// Warningf create a warning -func Warningf(format string, args ...any) Warning { - w := Warning{ - Message: fmt.Sprintf(format, args...), - } - return w -} - // Executor define contract for the steps of a workflow type Executor func(ctx context.Context) error @@ -162,14 +144,8 @@ func NewParallelExecutor(parallel int, executors ...Executor) Executor { // Then runs another executor if this executor succeeds func (e Executor) Then(then Executor) Executor { return func(ctx context.Context) error { - err := e(ctx) - if err != nil { - switch err.(type) { - case Warning: - Logger(ctx).Warning(err.Error()) - default: - return err - } + if err := e(ctx); err != nil { + return err } if ctx.Err() != nil { return ctx.Err() diff --git a/act/common/file.go b/act/common/file.go deleted file mode 100644 index d3a492d0..00000000 --- a/act/common/file.go +++ /dev/null @@ -1,77 +0,0 @@ -// Copyright 2022 The Gitea Authors. All rights reserved. -// Copyright 2020 The nektos/act Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package common - -import ( - "fmt" - "io" - "os" -) - -// CopyFile copy file -func CopyFile(source, dest string) (err error) { - sourcefile, err := os.Open(source) - if err != nil { - return err - } - - defer sourcefile.Close() - - destfile, err := os.Create(dest) - if err != nil { - return err - } - - defer destfile.Close() - - _, err = io.Copy(destfile, sourcefile) - if err == nil { - sourceinfo, err := os.Stat(source) - if err != nil { - _ = os.Chmod(dest, sourceinfo.Mode()) - } - } - - return err -} - -// CopyDir recursive copy of directory -func CopyDir(source, dest string) (err error) { - // get properties of source dir - sourceinfo, err := os.Stat(source) - if err != nil { - return err - } - - // create dest dir - - err = os.MkdirAll(dest, sourceinfo.Mode()) - if err != nil { - return err - } - - objects, err := os.ReadDir(source) - - for _, obj := range objects { - sourcefilepointer := source + "/" + obj.Name() - - destinationfilepointer := dest + "/" + obj.Name() - - if obj.IsDir() { - // create sub-directories - recursively - err = CopyDir(sourcefilepointer, destinationfilepointer) - if err != nil { - fmt.Println(err) //nolint:forbidigo // pre-existing issue from nektos/act - } - } else { - // perform copy - err = CopyFile(sourcefilepointer, destinationfilepointer) - if err != nil { - fmt.Println(err) //nolint:forbidigo // pre-existing issue from nektos/act - } - } - } - return err -} diff --git a/act/common/git/git.go b/act/common/git/git.go index 3ebe9724..f8d22c3d 100644 --- a/act/common/git/git.go +++ b/act/common/git/git.go @@ -243,47 +243,50 @@ type NewGitCloneExecutorInput struct { InsecureSkipTLS bool } -// CloneIfRequired ... -func CloneIfRequired(ctx context.Context, refName plumbing.ReferenceName, input NewGitCloneExecutorInput, logger log.FieldLogger) (*git.Repository, error) { +// CloneIfRequired returns the repository and a boolean indicating whether an existing local clone was reused. +func CloneIfRequired(ctx context.Context, refName plumbing.ReferenceName, input NewGitCloneExecutorInput, logger log.FieldLogger) (*git.Repository, bool, error) { r, err := git.PlainOpen(input.Dir) - if err != nil { - var progressWriter io.Writer - if isatty.IsTerminal(os.Stdout.Fd()) || isatty.IsCygwinTerminal(os.Stdout.Fd()) { - if entry, ok := logger.(*log.Entry); ok { - progressWriter = entry.WriterLevel(log.DebugLevel) - } else if lgr, ok := logger.(*log.Logger); ok { - progressWriter = lgr.WriterLevel(log.DebugLevel) - } else { - log.Errorf("Unable to get writer from logger (type=%T)", logger) - progressWriter = os.Stdout - } - } + if err == nil { + // Reuse existing clone + return r, true, nil + } - cloneOptions := git.CloneOptions{ - URL: input.URL, - Progress: progressWriter, - - InsecureSkipTLS: input.InsecureSkipTLS, // For Gitea - } - if input.Token != "" { - cloneOptions.Auth = &http.BasicAuth{ - Username: "token", - Password: input.Token, - } - } - - r, err = git.PlainCloneContext(ctx, input.Dir, false, &cloneOptions) - if err != nil { - logger.Errorf("Unable to clone %v %s: %v", input.URL, refName, err) - return nil, err - } - - if err = os.Chmod(input.Dir, 0o755); err != nil { - return nil, err + var progressWriter io.Writer + if isatty.IsTerminal(os.Stdout.Fd()) || isatty.IsCygwinTerminal(os.Stdout.Fd()) { + if entry, ok := logger.(*log.Entry); ok { + progressWriter = entry.WriterLevel(log.DebugLevel) + } else if lgr, ok := logger.(*log.Logger); ok { + progressWriter = lgr.WriterLevel(log.DebugLevel) + } else { + log.Errorf("Unable to get writer from logger (type=%T)", logger) + progressWriter = os.Stdout } } - return r, nil + cloneOptions := git.CloneOptions{ + URL: input.URL, + Progress: progressWriter, + + InsecureSkipTLS: input.InsecureSkipTLS, // For Gitea + } + if input.Token != "" { + cloneOptions.Auth = &http.BasicAuth{ + Username: "token", + Password: input.Token, + } + } + + r, err = git.PlainCloneContext(ctx, input.Dir, false, &cloneOptions) + if err != nil { + logger.Errorf("Unable to clone %v %s: %v", input.URL, refName, err) + return nil, false, err + } + + if err = os.Chmod(input.Dir, 0o755); err != nil { + return nil, false, err + } + + return r, false, nil } func gitOptions(token string) (fetchOptions git.FetchOptions, pullOptions git.PullOptions) { @@ -313,7 +316,7 @@ func NewGitCloneExecutor(input NewGitCloneExecutorInput) common.Executor { defer AcquireCloneLock(input.Dir)() refName := plumbing.ReferenceName("refs/heads/" + input.Ref) - r, err := CloneIfRequired(ctx, refName, input, logger) + r, reused, err := CloneIfRequired(ctx, refName, input, logger) if err != nil { return err } @@ -338,10 +341,10 @@ func NewGitCloneExecutor(input NewGitCloneExecutorInput) common.Executor { var hash *plumbing.Hash rev := plumbing.Revision(input.Ref) if hash, err = r.ResolveRevision(rev); err != nil { + // ResolveRevision returns a nil hash on error, and a branch ref legitimately fails + // here (no local refs/heads/); the duck-typing below resolves it. logger.Errorf("Unable to resolve %s: %v", input.Ref, err) - } - - if hash.String() != input.Ref && strings.HasPrefix(hash.String(), input.Ref) { + } else if hash.String() != input.Ref && strings.HasPrefix(hash.String(), input.Ref) { return &Error{ err: ErrShortRef, commit: hash.String(), @@ -392,12 +395,18 @@ func NewGitCloneExecutor(input NewGitCloneExecutorInput) common.Executor { return err } } + + reusedMsg := "" + if !isOfflineMode { if err = w.Pull(&pullOptions); err != nil && err != git.NoErrAlreadyUpToDate { logger.Debugf("Unable to pull %s: %v", refName, err) } + } else if reused { + reusedMsg = " (reused in offline mode)" } - logger.Debugf("Cloned %s to %s", input.URL, input.Dir) + + logger.Debugf("Cloned %s to %s%s", input.URL, input.Dir, reusedMsg) if hash.String() != input.Ref && refType == "branch" { logger.Debugf("Provided ref is not a sha. Updating branch ref after pull") diff --git a/act/common/git/git_test.go b/act/common/git/git_test.go index 710674ca..86ed1af3 100644 --- a/act/common/git/git_test.go +++ b/act/common/git/git_test.go @@ -279,6 +279,54 @@ func TestGitCloneExecutorNonFastForwardRef(t *testing.T) { assert.Equal(t, "second", strings.TrimSpace(string(out)), "working tree should be at the latest commit") } +func TestGitCloneExecutorOfflineMode(t *testing.T) { + gitConfig() + + // Build a local "remote" with a single commit on main. + 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")) + require.NoError(t, gitCmd("-C", workDir, "commit", "--allow-empty", "-m", "initial")) + require.NoError(t, gitCmd("-C", workDir, "push", "-u", "origin", "main")) + + // Prime the cache with an online clone of main. + cacheDir := t.TempDir() + require.NoError(t, NewGitCloneExecutor(NewGitCloneExecutorInput{ + URL: remoteDir, + Ref: "main", + Dir: cacheDir, + })(context.Background())) + + t.Run("cached branch resolves without fetching", func(t *testing.T) { + // Offline reuse of a cached branch must succeed even though ResolveRevision(input.Ref) + // finds no local refs/heads/. + err := NewGitCloneExecutor(NewGitCloneExecutorInput{ + URL: remoteDir, + Ref: "main", + Dir: cacheDir, + OfflineMode: true, + })(context.Background()) + require.NoError(t, err) + + out, err := exec.Command("git", "-C", cacheDir, "log", "--oneline", "-1", "--format=%s").Output() + require.NoError(t, err) + assert.Equal(t, "initial", strings.TrimSpace(string(out))) + }) + + t.Run("unresolvable cached ref returns error", func(t *testing.T) { + // The ref was never cached; offline mode cannot resolve it and must return an error. + err := NewGitCloneExecutor(NewGitCloneExecutorInput{ + URL: remoteDir, + Ref: "never-fetched", + Dir: cacheDir, + OfflineMode: true, + })(context.Background()) + require.Error(t, err) + }) +} + func gitConfig() { if os.Getenv("GITHUB_ACTIONS") == "true" { var err error diff --git a/act/container/docker_run.go b/act/container/docker_run.go index 15251721..b141bd62 100644 --- a/act/container/docker_run.go +++ b/act/container/docker_run.go @@ -17,6 +17,7 @@ import ( "path/filepath" "regexp" "runtime" + "slices" "strconv" "strings" @@ -968,22 +969,7 @@ func (cr *containerReference) sanitizeConfig(ctx context.Context, config *contai logger := common.Logger(ctx) if len(cr.input.ValidVolumes) > 0 { - globs := make([]glob.Glob, 0, len(cr.input.ValidVolumes)) - for _, v := range cr.input.ValidVolumes { - if g, err := glob.Compile(v); err != nil { - logger.Errorf("create glob from %s error: %v", v, err) - } else { - globs = append(globs, g) - } - } - isValid := func(v string) bool { - for _, g := range globs { - if g.Match(v) { - return true - } - } - return false - } + matcher := newValidVolumeMatcher(ctx, cr.input.ValidVolumes) // sanitize binds sanitizedBinds := make([]string, 0, len(hostConfig.Binds)) for _, bind := range hostConfig.Binds { @@ -997,7 +983,7 @@ func (cr *containerReference) sanitizeConfig(ctx context.Context, config *contai sanitizedBinds = append(sanitizedBinds, bind) continue } - if isValid(parsed.Source) { + if matcher.isValid(parsed.Source, mount.Type(parsed.Type)) { sanitizedBinds = append(sanitizedBinds, bind) } else { logger.Warnf("[%s] is not a valid volume, will be ignored", parsed.Source) @@ -1007,7 +993,7 @@ func (cr *containerReference) sanitizeConfig(ctx context.Context, config *contai // sanitize mounts sanitizedMounts := make([]mount.Mount, 0, len(hostConfig.Mounts)) for _, mt := range hostConfig.Mounts { - if isValid(mt.Source) { + if matcher.isValid(mt.Source, mt.Type) { sanitizedMounts = append(sanitizedMounts, mt) } else { logger.Warnf("[%s] is not a valid volume, will be ignored", mt.Source) @@ -1021,3 +1007,129 @@ func (cr *containerReference) sanitizeConfig(ctx context.Context, config *contai return config, hostConfig } + +type validVolumeMatcher struct { + allowAll bool + named []glob.Glob + host []glob.Glob +} + +func newValidVolumeMatcher(ctx context.Context, validVolumes []string) validVolumeMatcher { + logger := common.Logger(ctx) + ret := validVolumeMatcher{ + named: make([]glob.Glob, 0, len(validVolumes)), + host: make([]glob.Glob, 0, len(validVolumes)), + } + + for _, v := range validVolumes { + if v == "**" { + ret.allowAll = true + continue + } + if !isHostVolumePattern(v) { + if g, err := glob.Compile(v); err != nil { + logger.Errorf("create glob from %s error: %v", v, err) + } else { + ret.named = append(ret.named, g) + } + continue + } + normalized, err := normalizeHostVolumePath(v) + if err != nil { + logger.Errorf("normalize volume pattern %s error: %v", v, err) + continue + } + if g, err := glob.Compile(normalized); err != nil { + logger.Errorf("create glob from %s error: %v", normalized, err) + } else { + ret.host = append(ret.host, g) + } + } + + return ret +} + +func (m validVolumeMatcher) isValid(source string, sourceType mount.Type) bool { + if m.allowAll { + return true + } + if isHostVolumeSource(source, sourceType) { + normalized, err := normalizeHostVolumePath(source) + if err != nil { + return false + } + for _, g := range m.host { + if g.Match(normalized) { + return true + } + } + return false + } + for _, g := range m.named { + if g.Match(source) { + return true + } + } + return false +} + +func isHostVolumePattern(pattern string) bool { + return filepath.IsAbs(pattern) || + strings.HasPrefix(pattern, "."+string(filepath.Separator)) || + strings.HasPrefix(pattern, ".."+string(filepath.Separator)) || + strings.Contains(pattern, "/") || + strings.Contains(pattern, `\`) +} + +func isHostVolumeSource(source string, sourceType mount.Type) bool { + if sourceType == mount.TypeBind { + return true + } + if sourceType == mount.TypeVolume { + return false + } + return isHostVolumePattern(source) +} + +func normalizeHostVolumePath(path string) (string, error) { + abs, err := filepath.Abs(path) + if err != nil { + return "", err + } + return evalSymlinksExistingPrefix(abs) +} + +func evalSymlinksExistingPrefix(path string) (string, error) { + resolved, err := filepath.EvalSymlinks(path) + if err == nil { + return filepath.Clean(resolved), nil + } + if !errors.Is(err, os.ErrNotExist) { + return "", err + } + + current := path + var missing []string + for { + _, err := os.Lstat(current) + if err == nil { + resolved, err := filepath.EvalSymlinks(current) + if err != nil { + return "", err + } + for _, name := range slices.Backward(missing) { + resolved = filepath.Join(resolved, name) + } + return filepath.Clean(resolved), nil + } + if !errors.Is(err, os.ErrNotExist) { + return "", err + } + parent := filepath.Dir(current) + if parent == current { + return filepath.Clean(path), nil + } + missing = append(missing, filepath.Base(current)) + current = parent + } +} diff --git a/act/container/docker_run_test.go b/act/container/docker_run_test.go index d8a23fc5..903ad67b 100644 --- a/act/container/docker_run_test.go +++ b/act/container/docker_run_test.go @@ -11,6 +11,8 @@ import ( "errors" "io" "net" + "os" + "path/filepath" "strings" "testing" "time" @@ -375,3 +377,40 @@ func TestCheckVolumes(t *testing.T) { }) } } + +func TestCheckVolumesRejectsEscapingHostPaths(t *testing.T) { + logger, _ := test.NewNullLogger() + ctx := common.WithLogger(context.Background(), logger) + + base := t.TempDir() + allowed := filepath.Join(base, "allowed") + denied := filepath.Join(base, "denied") + require.NoError(t, os.MkdirAll(allowed, 0o700)) + require.NoError(t, os.MkdirAll(denied, 0o700)) + + cr := &containerReference{ + input: &NewContainerInput{ + ValidVolumes: []string{filepath.Join(allowed, "**")}, + }, + } + + escapingPath := allowed + string(filepath.Separator) + ".." + string(filepath.Separator) + "denied" + _, hostConf := cr.sanitizeConfig(ctx, &container.Config{}, &container.HostConfig{ + Binds: []string{escapingPath + ":/mnt"}, + }) + assert.Empty(t, hostConf.Binds) + + linkPath := filepath.Join(allowed, "link") + if err := os.Symlink(denied, linkPath); err != nil { + t.Skipf("cannot create symlink: %v", err) + } + _, hostConf = cr.sanitizeConfig(ctx, &container.Config{}, &container.HostConfig{ + Binds: []string{linkPath + ":/mnt"}, + }) + assert.Empty(t, hostConf.Binds) + + _, hostConf = cr.sanitizeConfig(ctx, &container.Config{}, &container.HostConfig{ + Binds: []string{filepath.Join(linkPath, "missing") + ":/mnt"}, + }) + assert.Empty(t, hostConf.Binds) +} diff --git a/act/container/host_environment.go b/act/container/host_environment.go index 497039bf..ee456a24 100644 --- a/act/container/host_environment.go +++ b/act/container/host_environment.go @@ -37,13 +37,13 @@ type HostEnvironment struct { TmpDir string ToolCache string Workdir string - // BindWorkdir is true when the app runner mounts the workspace on the host and - // deletes the task directory after the job; host teardown must not remove Workdir. - BindWorkdir bool - ActPath string - CleanUp func() - StdOut io.Writer - AllocatePTY bool // allocate a pseudo-TTY for each step's process + // CleanWorkdir means teardown owns Workdir and may delete it. Leave false + // when Workdir points at a caller-owned checkout (e.g. `act` local mode). + CleanWorkdir bool + ActPath string + CleanUp func() + StdOut io.Writer + AllocatePTY bool // allocate a pseudo-TTY for each step's process mu sync.Mutex runningPIDs map[int]struct{} @@ -483,7 +483,7 @@ func (e *HostEnvironment) Remove() common.Executor { logger.Warnf("failed to remove host misc state %s: %v", e.Path, err) errs = append(errs, err) } - if !e.BindWorkdir && e.Workdir != "" { + if e.CleanWorkdir { if err := removePathWithRetry(ctx, e.Workdir); err != nil { logger.Warnf("failed to remove host workspace %s: %v", e.Workdir, err) errs = append(errs, err) diff --git a/act/container/host_environment_test.go b/act/container/host_environment_test.go index a9911d19..945685c9 100644 --- a/act/container/host_environment_test.go +++ b/act/container/host_environment_test.go @@ -141,7 +141,7 @@ func TestHostEnvironmentAllocatePTY(t *testing.T) { } } -func TestHostEnvironmentRemoveCleansWorkdir(t *testing.T) { +func TestHostEnvironmentRemovePreservesWorkdirByDefault(t *testing.T) { logger := logrus.New() ctx := common.WithLogger(context.Background(), logrus.NewEntry(logger)) base := t.TempDir() @@ -152,9 +152,8 @@ func TestHostEnvironmentRemoveCleansWorkdir(t *testing.T) { require.NoError(t, os.MkdirAll(workdir, 0o700)) e := &HostEnvironment{ - Path: path, - Workdir: workdir, - BindWorkdir: false, + Path: path, + Workdir: workdir, CleanUp: func() { _ = os.RemoveAll(miscRoot) }, @@ -162,10 +161,10 @@ func TestHostEnvironmentRemoveCleansWorkdir(t *testing.T) { } require.NoError(t, e.Remove()(ctx)) _, err := os.Stat(workdir) - assert.ErrorIs(t, err, os.ErrNotExist) + require.NoError(t, err) } -func TestHostEnvironmentRemoveSkipsWorkdirWhenBindWorkdir(t *testing.T) { +func TestHostEnvironmentRemoveCleansWorkdirWhenOwned(t *testing.T) { logger := logrus.New() ctx := common.WithLogger(context.Background(), logrus.NewEntry(logger)) base := t.TempDir() @@ -176,9 +175,9 @@ func TestHostEnvironmentRemoveSkipsWorkdirWhenBindWorkdir(t *testing.T) { require.NoError(t, os.MkdirAll(workdir, 0o700)) e := &HostEnvironment{ - Path: path, - Workdir: workdir, - BindWorkdir: true, + Path: path, + Workdir: workdir, + CleanWorkdir: true, CleanUp: func() { _ = os.RemoveAll(miscRoot) }, @@ -186,5 +185,5 @@ func TestHostEnvironmentRemoveSkipsWorkdirWhenBindWorkdir(t *testing.T) { } require.NoError(t, e.Remove()(ctx)) _, err := os.Stat(workdir) - require.NoError(t, err) + assert.ErrorIs(t, err, os.ErrNotExist) } diff --git a/act/container/parse_env_file.go b/act/container/parse_env_file.go index ec27807f..bfa261ca 100644 --- a/act/container/parse_env_file.go +++ b/act/container/parse_env_file.go @@ -29,6 +29,8 @@ func parseEnvFile(e Container, srcPath string, env *map[string]string) common.Ex return err } s := bufio.NewScanner(reader) + // Default 64 KiB max token size is too small for realistic env-file lines; allow up to 16 MiB. + s.Buffer(make([]byte, 0, 64*1024), 16*1024*1024) for s.Scan() { line := s.Text() singleLineEnv := strings.Index(line, "=") @@ -50,6 +52,9 @@ func parseEnvFile(e Container, srcPath string, env *map[string]string) common.Ex } multiLineEnvContent += content } + if err := s.Err(); err != nil { + return fmt.Errorf("reading env file: %w", err) + } if !delimiterFound { return fmt.Errorf("invalid format delimiter '%v' not found before end of file", multiLineEnvDelimiter) } @@ -58,6 +63,9 @@ func parseEnvFile(e Container, srcPath string, env *map[string]string) common.Ex return fmt.Errorf("invalid format '%v', expected a line with '=' or '<<'", line) } } + if err := s.Err(); err != nil { + return fmt.Errorf("reading env file: %w", err) + } env = &localEnv return nil } diff --git a/act/container/parse_env_file_test.go b/act/container/parse_env_file_test.go new file mode 100644 index 00000000..6a8525a6 --- /dev/null +++ b/act/container/parse_env_file_test.go @@ -0,0 +1,75 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package container + +import ( + "bufio" + "context" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func newTestHostEnv(t *testing.T) (*HostEnvironment, string) { + t.Helper() + e := &HostEnvironment{Path: t.TempDir()} + return e, filepath.Join(e.Path, "envfile") +} + +func TestParseEnvFileSingleLine(t *testing.T) { + e, envPath := newTestHostEnv(t) + require.NoError(t, os.WriteFile(envPath, []byte("FOO=bar\nBAZ=qux\n"), 0o600)) + + env := map[string]string{} + require.NoError(t, parseEnvFile(e, envPath, &env)(context.Background())) + assert.Equal(t, "bar", env["FOO"]) + assert.Equal(t, "qux", env["BAZ"]) +} + +func TestParseEnvFileMultiLine(t *testing.T) { + e, envPath := newTestHostEnv(t) + content := "FOO<=24" - } + "private": true, + "type": "module" } diff --git a/act/runner/testdata/secrets/.env b/act/runner/testdata/secrets/.env new file mode 100644 index 00000000..3b66cf2a --- /dev/null +++ b/act/runner/testdata/secrets/.env @@ -0,0 +1,2 @@ +HELLO=WORLD +MULTILINE_ENV="foo\nbar\nbaz" diff --git a/act/workflowpattern/trace_writer.go b/act/workflowpattern/trace_writer.go deleted file mode 100644 index 37a4c850..00000000 --- a/act/workflowpattern/trace_writer.go +++ /dev/null @@ -1,22 +0,0 @@ -// Copyright 2023 The Gitea Authors. All rights reserved. -// Copyright 2023 The nektos/act Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package workflowpattern - -import "fmt" - -type TraceWriter interface { - Info(string, ...any) -} - -type EmptyTraceWriter struct{} - -func (*EmptyTraceWriter) Info(string, ...any) { -} - -type StdOutTraceWriter struct{} - -func (*StdOutTraceWriter) Info(format string, args ...any) { - fmt.Printf(format+"\n", args...) //nolint:forbidigo // pre-existing issue from nektos/act -} diff --git a/act/workflowpattern/workflow_pattern.go b/act/workflowpattern/workflow_pattern.go deleted file mode 100644 index 9f598618..00000000 --- a/act/workflowpattern/workflow_pattern.go +++ /dev/null @@ -1,199 +0,0 @@ -// Copyright 2023 The Gitea Authors. All rights reserved. -// Copyright 2023 The nektos/act Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package workflowpattern - -import ( - "fmt" - "regexp" - "strings" -) - -type WorkflowPattern struct { - Pattern string - Negative bool - Regex *regexp.Regexp -} - -func CompilePattern(rawpattern string) (*WorkflowPattern, error) { - negative := false - pattern := rawpattern - if strings.HasPrefix(rawpattern, "!") { - negative = true - pattern = rawpattern[1:] - } - rpattern, err := PatternToRegex(pattern) - if err != nil { - return nil, err - } - regex, err := regexp.Compile(rpattern) - if err != nil { - return nil, err - } - return &WorkflowPattern{ - Pattern: pattern, - Negative: negative, - Regex: regex, - }, nil -} - -func PatternToRegex(pattern string) (string, error) { - var rpattern strings.Builder - rpattern.WriteString("^") - pos := 0 - errors := map[int]string{} - for pos < len(pattern) { - switch pattern[pos] { - case '*': - if pos+1 < len(pattern) && pattern[pos+1] == '*' { - if pos+2 < len(pattern) && pattern[pos+2] == '/' { - rpattern.WriteString("(.+/)?") - pos += 3 - } else { - rpattern.WriteString(".*") - pos += 2 - } - } else { - rpattern.WriteString("[^/]*") - pos++ - } - case '+', '?': - if pos > 0 { - rpattern.WriteByte(pattern[pos]) - } else { - rpattern.WriteString(regexp.QuoteMeta(string([]byte{pattern[pos]}))) - } - pos++ - case '[': - rpattern.WriteByte(pattern[pos]) - pos++ - if pos < len(pattern) && pattern[pos] == ']' { - errors[pos] = "Unexpected empty brackets '[]'" - pos++ - break - } - validChar := func(a, b, test byte) bool { - return test >= a && test <= b - } - startPos := pos - for pos < len(pattern) && pattern[pos] != ']' { - switch pattern[pos] { - case '-': - if pos <= startPos || pos+1 >= len(pattern) { - errors[pos] = "Invalid range" - pos++ - break - } - validRange := func(a, b byte) bool { - return validChar(a, b, pattern[pos-1]) && validChar(a, b, pattern[pos+1]) && pattern[pos-1] <= pattern[pos+1] - } - if !validRange('A', 'z') && !validRange('0', '9') { - errors[pos] = "Ranges can only include a-z, A-Z, A-z, and 0-9" - pos++ - break - } - rpattern.WriteString(pattern[pos : pos+2]) - pos += 2 - default: - if !validChar('A', 'z', pattern[pos]) && !validChar('0', '9', pattern[pos]) { - errors[pos] = "Ranges can only include a-z, A-Z and 0-9" - pos++ - break - } - rpattern.WriteString(regexp.QuoteMeta(string([]byte{pattern[pos]}))) - pos++ - } - } - if pos >= len(pattern) || pattern[pos] != ']' { - errors[pos] = "Missing closing bracket ']' after '['" - pos++ - } - rpattern.WriteString("]") - pos++ - case '\\': - if pos+1 >= len(pattern) { - errors[pos] = "Missing symbol after \\" - pos++ - break - } - rpattern.WriteString(regexp.QuoteMeta(string([]byte{pattern[pos+1]}))) - pos += 2 - default: - rpattern.WriteString(regexp.QuoteMeta(string([]byte{pattern[pos]}))) - pos++ - } - } - if len(errors) > 0 { - var errorMessage strings.Builder - for position, err := range errors { - if errorMessage.Len() > 0 { - errorMessage.WriteString(", ") - } - fmt.Fprintf(&errorMessage, "Position: %d Error: %s", position, err) - } - return "", fmt.Errorf("invalid Pattern '%s': %s", pattern, errorMessage.String()) - } - rpattern.WriteString("$") - return rpattern.String(), nil -} - -func CompilePatterns(patterns ...string) ([]*WorkflowPattern, error) { - ret := []*WorkflowPattern{} - for _, pattern := range patterns { - cp, err := CompilePattern(pattern) - if err != nil { - return nil, err - } - ret = append(ret, cp) - } - return ret, nil -} - -// returns true if the workflow should be skipped paths/branches -func Skip(sequence []*WorkflowPattern, input []string, traceWriter TraceWriter) bool { - if len(sequence) == 0 { - return false - } - for _, file := range input { - matched := false - for _, item := range sequence { - if item.Regex.MatchString(file) { - pattern := item.Pattern - if item.Negative { - matched = false - traceWriter.Info("%s excluded by pattern %s", file, pattern) - } else { - matched = true - traceWriter.Info("%s included by pattern %s", file, pattern) - } - } - } - if matched { - return false - } - } - return true -} - -// returns true if the workflow should be skipped paths-ignore/branches-ignore -func Filter(sequence []*WorkflowPattern, input []string, traceWriter TraceWriter) bool { - if len(sequence) == 0 { - return false - } - for _, file := range input { - matched := false - for _, item := range sequence { - if item.Regex.MatchString(file) == !item.Negative { - pattern := item.Pattern - traceWriter.Info("%s ignored by pattern %s", file, pattern) - matched = true - break - } - } - if !matched { - return false - } - } - return true -} diff --git a/act/workflowpattern/workflow_pattern_test.go b/act/workflowpattern/workflow_pattern_test.go deleted file mode 100644 index 980b0838..00000000 --- a/act/workflowpattern/workflow_pattern_test.go +++ /dev/null @@ -1,418 +0,0 @@ -// Copyright 2023 The Gitea Authors. All rights reserved. -// Copyright 2023 The nektos/act Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package workflowpattern - -import ( - "strings" - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestMatchPattern(t *testing.T) { - kases := []struct { - inputs []string - patterns []string - skipResult bool - filterResult bool - }{ - { - patterns: []string{"*"}, - inputs: []string{"path/with/slash"}, - skipResult: true, - filterResult: false, - }, - { - patterns: []string{"path/a", "path/b", "path/c"}, - inputs: []string{"meta", "path/b", "otherfile"}, - skipResult: false, - filterResult: false, - }, - { - patterns: []string{"path/a", "path/b", "path/c"}, - inputs: []string{"path/b"}, - skipResult: false, - filterResult: true, - }, - { - patterns: []string{"path/a", "path/b", "path/c"}, - inputs: []string{"path/c", "path/b"}, - skipResult: false, - filterResult: true, - }, - { - patterns: []string{"path/a", "path/b", "path/c"}, - inputs: []string{"path/c", "path/b", "path/a"}, - skipResult: false, - filterResult: true, - }, - { - patterns: []string{"path/a", "path/b", "path/c"}, - inputs: []string{"path/c", "path/b", "path/d", "path/a"}, - skipResult: false, - filterResult: false, - }, - { - patterns: []string{}, - inputs: []string{}, - skipResult: false, - filterResult: false, - }, - { - patterns: []string{"\\!file"}, - inputs: []string{"!file"}, - skipResult: false, - filterResult: true, - }, - { - patterns: []string{"escape\\\\backslash"}, - inputs: []string{"escape\\backslash"}, - skipResult: false, - filterResult: true, - }, - { - patterns: []string{".yml"}, - inputs: []string{"fyml"}, - skipResult: true, - filterResult: false, - }, - // https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#patterns-to-match-branches-and-tags - { - patterns: []string{"feature/*"}, - inputs: []string{"feature/my-branch"}, - skipResult: false, - filterResult: true, - }, - { - patterns: []string{"feature/*"}, - inputs: []string{"feature/your-branch"}, - skipResult: false, - filterResult: true, - }, - { - patterns: []string{"feature/**"}, - inputs: []string{"feature/beta-a/my-branch"}, - skipResult: false, - filterResult: true, - }, - { - patterns: []string{"feature/**"}, - inputs: []string{"feature/beta-a/my-branch"}, - skipResult: false, - filterResult: true, - }, - { - patterns: []string{"feature/**"}, - inputs: []string{"feature/mona/the/octocat"}, - skipResult: false, - filterResult: true, - }, - { - patterns: []string{"main", "releases/mona-the-octocat"}, - inputs: []string{"main"}, - skipResult: false, - filterResult: true, - }, - { - patterns: []string{"main", "releases/mona-the-octocat"}, - inputs: []string{"releases/mona-the-octocat"}, - skipResult: false, - filterResult: true, - }, - { - patterns: []string{"*"}, - inputs: []string{"main"}, - skipResult: false, - filterResult: true, - }, - { - patterns: []string{"*"}, - inputs: []string{"releases"}, - skipResult: false, - filterResult: true, - }, - { - patterns: []string{"**"}, - inputs: []string{"all/the/branches"}, - skipResult: false, - filterResult: true, - }, - { - patterns: []string{"**"}, - inputs: []string{"every/tag"}, - skipResult: false, - filterResult: true, - }, - { - patterns: []string{"*feature"}, - inputs: []string{"mona-feature"}, - skipResult: false, - filterResult: true, - }, - { - patterns: []string{"*feature"}, - inputs: []string{"feature"}, - skipResult: false, - filterResult: true, - }, - { - patterns: []string{"*feature"}, - inputs: []string{"ver-10-feature"}, - skipResult: false, - filterResult: true, - }, - { - patterns: []string{"v2*"}, - inputs: []string{"v2"}, - skipResult: false, - filterResult: true, - }, - { - patterns: []string{"v2*"}, - inputs: []string{"v2.0"}, - skipResult: false, - filterResult: true, - }, - { - patterns: []string{"v2*"}, - inputs: []string{"v2.9"}, - skipResult: false, - filterResult: true, - }, - { - patterns: []string{"v[12].[0-9]+.[0-9]+"}, - inputs: []string{"v1.10.1"}, - skipResult: false, - filterResult: true, - }, - { - patterns: []string{"v[12].[0-9]+.[0-9]+"}, - inputs: []string{"v2.0.0"}, - skipResult: false, - filterResult: true, - }, - // https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#patterns-to-match-file-paths - { - patterns: []string{"*"}, - inputs: []string{"README.md"}, - skipResult: false, - filterResult: true, - }, - { - patterns: []string{"*"}, - inputs: []string{"server.rb"}, - skipResult: false, - filterResult: true, - }, - { - patterns: []string{"*.jsx?"}, - inputs: []string{"page.js"}, - skipResult: false, - filterResult: true, - }, - { - patterns: []string{"*.jsx?"}, - inputs: []string{"page.jsx"}, - skipResult: false, - filterResult: true, - }, - { - patterns: []string{"**"}, - inputs: []string{"all/the/files.md"}, - skipResult: false, - filterResult: true, - }, - { - patterns: []string{"*.js"}, - inputs: []string{"app.js"}, - skipResult: false, - filterResult: true, - }, - { - patterns: []string{"*.js"}, - inputs: []string{"index.js"}, - skipResult: false, - filterResult: true, - }, - { - patterns: []string{"**.js"}, - inputs: []string{"index.js"}, - skipResult: false, - filterResult: true, - }, - { - patterns: []string{"**.js"}, - inputs: []string{"js/index.js"}, - skipResult: false, - filterResult: true, - }, - { - patterns: []string{"**.js"}, - inputs: []string{"src/js/app.js"}, - skipResult: false, - filterResult: true, - }, - { - patterns: []string{"docs/*"}, - inputs: []string{"docs/README.md"}, - skipResult: false, - filterResult: true, - }, - { - patterns: []string{"docs/*"}, - inputs: []string{"docs/file.txt"}, - skipResult: false, - filterResult: true, - }, - { - patterns: []string{"docs/**"}, - inputs: []string{"docs/README.md"}, - skipResult: false, - filterResult: true, - }, - { - patterns: []string{"docs/**"}, - inputs: []string{"docs/mona/octocat.txt"}, - skipResult: false, - filterResult: true, - }, - { - patterns: []string{"docs/**/*.md"}, - inputs: []string{"docs/README.md"}, - skipResult: false, - filterResult: true, - }, - { - patterns: []string{"docs/**/*.md"}, - inputs: []string{"docs/mona/hello-world.md"}, - skipResult: false, - filterResult: true, - }, - { - patterns: []string{"docs/**/*.md"}, - inputs: []string{"docs/a/markdown/file.md"}, - skipResult: false, - filterResult: true, - }, - { - patterns: []string{"**/docs/**"}, - inputs: []string{"docs/hello.md"}, - skipResult: false, - filterResult: true, - }, - { - patterns: []string{"**/docs/**"}, - inputs: []string{"dir/docs/my-file.txt"}, - skipResult: false, - filterResult: true, - }, - { - patterns: []string{"**/docs/**"}, - inputs: []string{"space/docs/plan/space.doc"}, - skipResult: false, - filterResult: true, - }, - { - patterns: []string{"**/README.md"}, - inputs: []string{"README.md"}, - skipResult: false, - filterResult: true, - }, - { - patterns: []string{"**/README.md"}, - inputs: []string{"js/README.md"}, - skipResult: false, - filterResult: true, - }, - { - patterns: []string{"**/*src/**"}, - inputs: []string{"a/src/app.js"}, - skipResult: false, - filterResult: true, - }, - { - patterns: []string{"**/*src/**"}, - inputs: []string{"my-src/code/js/app.js"}, - skipResult: false, - filterResult: true, - }, - { - patterns: []string{"**/*-post.md"}, - inputs: []string{"my-post.md"}, - skipResult: false, - filterResult: true, - }, - { - patterns: []string{"**/*-post.md"}, - inputs: []string{"path/their-post.md"}, - skipResult: false, - filterResult: true, - }, - { - patterns: []string{"**/migrate-*.sql"}, - inputs: []string{"migrate-10909.sql"}, - skipResult: false, - filterResult: true, - }, - { - patterns: []string{"**/migrate-*.sql"}, - inputs: []string{"db/migrate-v1.0.sql"}, - skipResult: false, - filterResult: true, - }, - { - patterns: []string{"**/migrate-*.sql"}, - inputs: []string{"db/sept/migrate-v1.sql"}, - skipResult: false, - filterResult: true, - }, - { - patterns: []string{"*.md", "!README.md"}, - inputs: []string{"hello.md"}, - skipResult: false, - filterResult: true, - }, - { - patterns: []string{"*.md", "!README.md"}, - inputs: []string{"README.md"}, - skipResult: true, - filterResult: true, - }, - { - patterns: []string{"*.md", "!README.md"}, - inputs: []string{"docs/hello.md"}, - skipResult: true, - filterResult: true, - }, - { - patterns: []string{"*.md", "!README.md", "README*"}, - inputs: []string{"hello.md"}, - skipResult: false, - filterResult: true, - }, - { - patterns: []string{"*.md", "!README.md", "README*"}, - inputs: []string{"README.md"}, - skipResult: false, - filterResult: true, - }, - { - patterns: []string{"*.md", "!README.md", "README*"}, - inputs: []string{"README.doc"}, - skipResult: false, - filterResult: true, - }, - } - - for _, kase := range kases { - t.Run(strings.Join(kase.patterns, ","), func(t *testing.T) { - patterns, err := CompilePatterns(kase.patterns...) - assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act - - assert.EqualValues(t, kase.skipResult, Skip(patterns, kase.inputs, &StdOutTraceWriter{}), "skipResult") //nolint:testifylint // pre-existing issue from nektos/act - assert.EqualValues(t, kase.filterResult, Filter(patterns, kase.inputs, &StdOutTraceWriter{}), "filterResult") //nolint:testifylint // pre-existing issue from nektos/act - }) - } -} diff --git a/go.mod b/go.mod index 478b65b3..3cb863a1 100644 --- a/go.mod +++ b/go.mod @@ -4,17 +4,17 @@ go 1.26.0 require ( code.gitea.io/actions-proto-go v0.4.1 - connectrpc.com/connect v1.19.2 + connectrpc.com/connect v1.20.0 dario.cat/mergo v1.0.2 github.com/Masterminds/semver v1.5.0 github.com/avast/retry-go/v5 v5.0.0 github.com/containerd/errdefs v1.0.0 github.com/creack/pty v1.1.24 github.com/distribution/reference v0.6.0 - github.com/docker/cli v29.5.0+incompatible + github.com/docker/cli v29.5.2+incompatible github.com/docker/go-connections v0.7.0 github.com/go-git/go-billy/v5 v5.9.0 - github.com/go-git/go-git/v5 v5.19.0 + github.com/go-git/go-git/v5 v5.19.1 github.com/gobwas/glob v0.2.3 github.com/google/go-cmp v0.7.0 github.com/joho/godotenv v1.5.1 @@ -26,7 +26,7 @@ require ( github.com/moby/moby/client v0.4.1 github.com/moby/patternmatcher v0.6.1 github.com/opencontainers/image-spec v1.1.1 - github.com/opencontainers/selinux v1.14.1 + github.com/opencontainers/selinux v1.15.0 github.com/pkg/errors v0.9.1 github.com/prometheus/client_golang v1.23.2 github.com/rhysd/actionlint v1.7.12 diff --git a/go.sum b/go.sum index 4e5e50f5..6c013146 100644 --- a/go.sum +++ b/go.sum @@ -2,6 +2,8 @@ code.gitea.io/actions-proto-go v0.4.1 h1:l0EYhjsgpUe/1VABo2eK7zcoNX2W44WOnb0MSLr code.gitea.io/actions-proto-go v0.4.1/go.mod h1:mn7Wkqz6JbnTOHQpot3yDeHx+O5C9EGhMEE+htvHBas= connectrpc.com/connect v1.19.2 h1:McQ83FGdzL+t60peksi0gXC7MQ/iLKgLduAnThbM0mo= connectrpc.com/connect v1.19.2/go.mod h1:tN20fjdGlewnSFeZxLKb0xwIZ6ozc3OQs2hTXy4du9w= +connectrpc.com/connect v1.20.0 h1:6TNDAB+WeNd2uolWNlYczB5E0KNNaVMNUEx8JEUsPmQ= +connectrpc.com/connect v1.20.0/go.mod h1:A2ygJrukXwWy32vkCAAHNVguZrqZ+jeZ9rGRnGR4dN4= cyphar.com/go-pathrs v0.2.3 h1:0pH8gep37wB0BgaXrEaN1OtZhUMeS7VvaejSr6i822o= cyphar.com/go-pathrs v0.2.3/go.mod h1:y8f1EMG7r+hCuFf/rXsKqMJrJAUoADZGNh5/vZPKcGc= dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= @@ -47,10 +49,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= -github.com/docker/cli v29.4.3+incompatible h1:u+UliYm2J/rYrIh2FqHQg32neRG8GjbvNuwQRTzGspU= -github.com/docker/cli v29.4.3+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= -github.com/docker/cli v29.5.0+incompatible h1:FPUvKJoKpeP4Njz8NrQdeUN8o247P7ndTiILtaP5/l4= -github.com/docker/cli v29.5.0+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= +github.com/docker/cli v29.5.2+incompatible h1:ubykJ1Y8LmNRGJ2BuMQ0kHOt/RO1YzGNswqWMJgivuQ= +github.com/docker/cli v29.5.2+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/go.mod h1:v1S+hepowrQXITkEfw6o4+BMbGot02wiKpzWhGUZK6c= github.com/docker/go-connections v0.7.0 h1:6SsRfJddP22WMrCkj19x9WKjEDTB+ahsdiGYf0mN39c= @@ -73,8 +73,8 @@ github.com/go-git/go-billy/v5 v5.9.0 h1:jItGXszUDRtR/AlferWPTMN4j38BQ88XnXKbilmm github.com/go-git/go-billy/v5 v5.9.0/go.mod h1:jCnQMLj9eUgGU7+ludSTYoZL/GGmii14RxKFj7ROgHw= github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4= github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII= -github.com/go-git/go-git/v5 v5.19.0 h1:+WkVUQZSy/F1Gb13udrMKjIM2PrzsNfDKFSfo5tkMtc= -github.com/go-git/go-git/v5 v5.19.0/go.mod h1:Pb1v0c7/g8aGQJwx9Us09W85yGoyvSwuhEGMH7zjDKQ= +github.com/go-git/go-git/v5 v5.19.1 h1:nX27AnaU43/K5bKktKwgBmR9lawoYVe1Ckg0rgzzN00= +github.com/go-git/go-git/v5 v5.19.1/go.mod h1:Pb1v0c7/g8aGQJwx9Us09W85yGoyvSwuhEGMH7zjDKQ= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= @@ -151,6 +151,8 @@ github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJw github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= github.com/opencontainers/selinux v1.14.1 h1:a7XlXV/nN/l5zFP1FWZYoExpClu1QOPMfWUV2CZ8kEQ= github.com/opencontainers/selinux v1.14.1/go.mod h1:LenyElirjUHszfxrjuFqC85HIeXZKumHcKMQtnaDlQQ= +github.com/opencontainers/selinux v1.15.0 h1:4Gs40e/R2FvM8PC1HPaPncLLaDor8Y2WDfk5gjU9o5M= +github.com/opencontainers/selinux v1.15.0/go.mod h1:LenyElirjUHszfxrjuFqC85HIeXZKumHcKMQtnaDlQQ= github.com/pjbgf/sha1cd v0.6.0 h1:3WJ8Wz8gvDz29quX1OcEmkAlUg9diU4GxJHqs0/XiwU= github.com/pjbgf/sha1cd v0.6.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= diff --git a/internal/app/cmd/daemon.go b/internal/app/cmd/daemon.go index aaac6401..247d77f0 100644 --- a/internal/app/cmd/daemon.go +++ b/internal/app/cmd/daemon.go @@ -132,7 +132,6 @@ func runDaemon(ctx context.Context, daemArgs *daemonArgs, configFile *string) fu cfg.Runner.Insecure, reg.UUID, reg.Token, - ver.Version(), ) runner := run.NewRunner(cfg, reg, cli) diff --git a/internal/app/cmd/register.go b/internal/app/cmd/register.go index ea4c0ee9..c0a1950e 100644 --- a/internal/app/cmd/register.go +++ b/internal/app/cmd/register.go @@ -325,7 +325,6 @@ func doRegister(ctx context.Context, cfg *config.Config, inputs *registerInputs) cfg.Runner.Insecure, "", "", - ver.Version(), ) for { @@ -366,12 +365,11 @@ func doRegister(ctx context.Context, cfg *config.Config, inputs *registerInputs) } // register new runner. resp, err := cli.Register(ctx, connect.NewRequest(&runnerv1.RegisterRequest{ - Name: reg.Name, - Token: reg.Token, - Version: ver.Version(), - AgentLabels: ls, // Could be removed after Gitea 1.20 - Labels: ls, - Ephemeral: reg.Ephemeral, + Name: reg.Name, + Token: reg.Token, + Version: ver.Version(), + Labels: ls, + Ephemeral: reg.Ephemeral, })) if err != nil { log.WithError(err).Error("poller: cannot register new runner") diff --git a/internal/app/run/runner.go b/internal/app/run/runner.go index 6f83e560..23e648cf 100644 --- a/internal/app/run/runner.go +++ b/internal/app/run/runner.go @@ -344,10 +344,11 @@ func (r *Runner) run(ctx context.Context, task *runnerv1.Task, reporter *report. runnerConfig := &runner.Config{ // On Linux, Workdir will be like "///" // On Windows, Workdir will be like "\\\" - Workdir: workdir, - BindWorkdir: r.cfg.Container.BindWorkdir, - ActionCacheDir: filepath.FromSlash(r.cfg.Host.WorkdirParent), - AllocatePTY: r.cfg.Runner.AllocatePTY, + Workdir: workdir, + BindWorkdir: r.cfg.Container.BindWorkdir, + ActionCacheDir: filepath.FromSlash(r.cfg.Host.WorkdirParent), + AllocatePTY: r.cfg.Runner.AllocatePTY, + ActionOfflineMode: r.cfg.Cache.OfflineMode, ReuseContainers: false, ForcePull: r.cfg.Container.ForcePull, @@ -363,6 +364,7 @@ func (r *Runner) run(ctx context.Context, task *runnerv1.Task, reporter *report. EventJSON: string(eventJSON), ContainerNamePrefix: fmt.Sprintf("GITEA-ACTIONS-TASK-%d", task.Id), ContainerMaxLifetime: maxLifetime, + CleanWorkdir: true, ContainerNetworkMode: container.NetworkMode(r.cfg.Container.Network), ContainerOptions: r.cfg.Container.Options, ContainerDaemonSocket: r.cfg.Container.DockerHost, diff --git a/internal/pkg/client/header.go b/internal/pkg/client/header.go index 24844fa2..ab29a558 100644 --- a/internal/pkg/client/header.go +++ b/internal/pkg/client/header.go @@ -6,6 +6,4 @@ package client const ( UUIDHeader = "x-runner-uuid" TokenHeader = "x-runner-token" - // Deprecated: could be removed after Gitea 1.20 released - VersionHeader = "x-runner-version" ) diff --git a/internal/pkg/client/http.go b/internal/pkg/client/http.go index d976d12c..0b9a0955 100644 --- a/internal/pkg/client/http.go +++ b/internal/pkg/client/http.go @@ -31,7 +31,7 @@ func getHTTPClient(endpoint string, insecure bool) *http.Client { } // New returns a new runner client. -func New(endpoint string, insecure bool, uuid, token, version string, opts ...connect.ClientOption) *HTTPClient { +func New(endpoint string, insecure bool, uuid, token string, opts ...connect.ClientOption) *HTTPClient { baseURL := strings.TrimRight(endpoint, "/") + "/api/actions" opts = append(opts, connect.WithInterceptors(connect.UnaryInterceptorFunc(func(next connect.UnaryFunc) connect.UnaryFunc { @@ -42,10 +42,6 @@ func New(endpoint string, insecure bool, uuid, token, version string, opts ...co if token != "" { req.Header().Set(TokenHeader, token) } - // TODO: version will be removed from request header after Gitea 1.20 released. - if version != "" { - req.Header().Set(VersionHeader, version) - } return next(ctx, req) } }))) diff --git a/internal/pkg/config/config.example.yaml b/internal/pkg/config/config.example.yaml index 24fc478d..53d136a0 100644 --- a/internal/pkg/config/config.example.yaml +++ b/internal/pkg/config/config.example.yaml @@ -102,6 +102,9 @@ cache: # (or `gitea-runner cache-server`) is in use: the runner pre-registers each job's ACTIONS_RUNTIME_TOKEN with the # cache-server, and the cache-server enforces bearer auth + per-repo cache isolation. external_secret: "" + # When true, reuse a cached action instead of fetching from the remote on every job. Note: a moved tag + # (e.g. a re-tagged "v6") or an updated branch stays at the cached commit until its cache entry is removed. + offline_mode: false container: # Specifies the network to which the container will connect. diff --git a/internal/pkg/config/config.go b/internal/pkg/config/config.go index 4f5b11d5..5f573ff7 100644 --- a/internal/pkg/config/config.go +++ b/internal/pkg/config/config.go @@ -52,6 +52,7 @@ type Cache struct { Port uint16 `yaml:"port"` // Port specifies the caching port. ExternalServer string `yaml:"external_server"` // ExternalServer specifies the URL of external cache server ExternalSecret string `yaml:"external_secret"` // ExternalSecret is a shared secret between this runner and an external gitea-runner cache-server, enabling per-job ACTIONS_RUNTIME_TOKEN authentication and repo scoping over the network. Leave empty to keep the legacy unauthenticated behavior. + OfflineMode bool `yaml:"offline_mode"` // OfflineMode reuses a cached action without fetching from the remote; a moved tag or branch stays at the cached commit until the cache entry is removed. } // Container represents the configuration for the container. @@ -109,7 +110,6 @@ func LoadDefault(file string) (*Config, error) { return nil, fmt.Errorf("parse config file %q for defaults metadata: %w", file, err) } } - compatibleWithOldEnvs(file != "", cfg) if cfg.Runner.EnvFile != "" { if stat, err := os.Stat(cfg.Runner.EnvFile); err == nil && !stat.IsDir() { diff --git a/internal/pkg/config/deprecated.go b/internal/pkg/config/deprecated.go deleted file mode 100644 index b5051aa0..00000000 --- a/internal/pkg/config/deprecated.go +++ /dev/null @@ -1,62 +0,0 @@ -// Copyright 2023 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package config - -import ( - "os" - "strconv" - "strings" - - log "github.com/sirupsen/logrus" -) - -// Deprecated: could be removed in the future. TODO: remove it when Gitea 1.20.0 is released. -// Be compatible with old envs. -func compatibleWithOldEnvs(fileUsed bool, cfg *Config) { - handleEnv := func(key string) (string, bool) { - if v, ok := os.LookupEnv(key); ok { - if fileUsed { - log.Warnf("env %s has been ignored because config file is used", key) - return "", false - } - log.Warnf("env %s will be deprecated, please use config file instead", key) - return v, true - } - return "", false - } - - if v, ok := handleEnv("GITEA_DEBUG"); ok { - if b, _ := strconv.ParseBool(v); b { - cfg.Log.Level = "debug" - } - } - if v, ok := handleEnv("GITEA_TRACE"); ok { - if b, _ := strconv.ParseBool(v); b { - cfg.Log.Level = "trace" - } - } - if v, ok := handleEnv("GITEA_RUNNER_CAPACITY"); ok { - if i, _ := strconv.Atoi(v); i > 0 { - cfg.Runner.Capacity = i - } - } - if v, ok := handleEnv("GITEA_RUNNER_FILE"); ok { - cfg.Runner.File = v - } - if v, ok := handleEnv("GITEA_RUNNER_ENVIRON"); ok { - splits := strings.Split(v, ",") - if cfg.Runner.Envs == nil { - cfg.Runner.Envs = map[string]string{} - } - for _, split := range splits { - kv := strings.SplitN(split, ":", 2) - if len(kv) == 2 && kv[0] != "" { - cfg.Runner.Envs[kv[0]] = kv[1] - } - } - } - if v, ok := handleEnv("GITEA_RUNNER_ENV_FILE"); ok { - cfg.Runner.EnvFile = v - } -} diff --git a/internal/pkg/report/reporter.go b/internal/pkg/report/reporter.go index 49e839f2..1a2767b4 100644 --- a/internal/pkg/report/reporter.go +++ b/internal/pkg/report/reporter.go @@ -205,7 +205,7 @@ func (r *Reporter) Fire(entry *log.Entry) error { urgentState = true } } - if !r.duringSteps() { + if r.shouldAppendLogRow(entry) { r.logRows = appendIfNotNil(r.logRows, r.parseLogRow(entry)) } r.unlockAndNotify(urgentState) @@ -219,7 +219,7 @@ func (r *Reporter) Fire(entry *log.Entry) error { } } if step == nil { - if !r.duringSteps() { + if r.shouldAppendLogRow(entry) { r.logRows = appendIfNotNil(r.logRows, r.parseLogRow(entry)) } r.unlockAndNotify(false) @@ -246,7 +246,7 @@ func (r *Reporter) Fire(entry *log.Entry) error { r.logRows = append(r.logRows, row) } } - } else if !r.duringSteps() { + } else if r.shouldAppendLogRow(entry) { r.logRows = appendIfNotNil(r.logRows, r.parseLogRow(entry)) } if v, ok := entry.Data["stepResult"]; ok && isJobStepEntry(entry) { @@ -576,6 +576,13 @@ func (r *Reporter) duringSteps() bool { return true } +// shouldAppendLogRow reports whether a non-raw_output entry should be written +// to the job log: only when we are between steps and the entry's level is +// within the globally configured log level. +func (r *Reporter) shouldAppendLogRow(entry *log.Entry) bool { + return !r.duringSteps() && entry.Level <= log.GetLevel() +} + var stringToResult = map[string]runnerv1.Result{ "success": runnerv1.Result_RESULT_SUCCESS, "failure": runnerv1.Result_RESULT_FAILURE, @@ -639,7 +646,7 @@ func (r *Reporter) handleCommand(originalContent, command, value string) *string } func (r *Reporter) parseLogRow(entry *log.Entry) *runnerv1.LogRow { - content := strings.TrimRightFunc(entry.Message, func(r rune) bool { return r == '\r' || r == '\n' }) + content := strings.TrimRight(entry.Message, "\r\n") matches := cmdRegex.FindStringSubmatch(content) if matches != nil { diff --git a/internal/pkg/report/reporter_test.go b/internal/pkg/report/reporter_test.go index f12ee1b4..b0c0280d 100644 --- a/internal/pkg/report/reporter_test.go +++ b/internal/pkg/report/reporter_test.go @@ -219,6 +219,59 @@ func TestReporter_Fire(t *testing.T) { }) } +func TestReporter_LogLevelFiltering(t *testing.T) { + // Set global level to Info so Debug entries should be filtered. + origLevel := log.GetLevel() + log.SetLevel(log.InfoLevel) + defer log.SetLevel(origLevel) + + client := mocks.NewClient(t) + client.On("UpdateLog", mock.Anything, mock.Anything).Return(func(_ context.Context, req *connect_go.Request[runnerv1.UpdateLogRequest]) (*connect_go.Response[runnerv1.UpdateLogResponse], error) { + return connect_go.NewResponse(&runnerv1.UpdateLogResponse{ + AckIndex: req.Msg.Index + int64(len(req.Msg.Rows)), + }), nil + }) + client.On("UpdateTask", mock.Anything, mock.Anything).Return(func(_ context.Context, req *connect_go.Request[runnerv1.UpdateTaskRequest]) (*connect_go.Response[runnerv1.UpdateTaskResponse], error) { + return connect_go.NewResponse(&runnerv1.UpdateTaskResponse{}), nil + }) + + ctx, cancel := context.WithCancel(context.Background()) + taskCtx, err := structpb.NewStruct(map[string]any{}) + require.NoError(t, err) + cfg, _ := config.LoadDefault("") + reporter := NewReporter(ctx, cancel, client, &runnerv1.Task{Context: taskCtx}, cfg) + reporter.RunDaemon() + defer func() { + require.NoError(t, reporter.Close("")) + }() + reporter.ResetSteps(2) + + dataStep0 := log.Fields{"stage": "Main", "stepNumber": 0, "raw_output": true} + dataStep0Internal := log.Fields{"stage": "Main", "stepNumber": 0} + + // raw_output entries always appear in job log regardless of level. + require.NoError(t, reporter.Fire(&log.Entry{Message: "step output", Data: dataStep0, Level: log.InfoLevel})) + require.NoError(t, reporter.Fire(&log.Entry{Message: "step debug output", Data: dataStep0, Level: log.DebugLevel})) + assert.Equal(t, int64(2), reporter.state.Steps[0].LogLength, "raw_output entries must always be forwarded") + + // Non-raw_output entries during steps are not added to logRows regardless of level. + require.NoError(t, reporter.Fire(&log.Entry{Message: "internal info", Data: dataStep0Internal, Level: log.InfoLevel})) + require.NoError(t, reporter.Fire(&log.Entry{Message: "internal debug", Data: dataStep0Internal, Level: log.DebugLevel})) + + // stepResult at DebugLevel (skipped step) must still update state even when filtered from log. + require.NoError(t, reporter.Fire(&log.Entry{ + Message: "Skipping step", + Data: log.Fields{ + "stage": "Main", + "stepNumber": 1, + "stepResult": "skipped", + }, + Level: log.DebugLevel, + })) + assert.Equal(t, runnerv1.Result_RESULT_SKIPPED, reporter.state.Steps[1].Result, + "stepResult at DebugLevel must update step state even when log entry is filtered from job log output") +} + // TestReporter_EphemeralRunnerDeletion reproduces the exact scenario from // https://gitea.com/gitea/runner/issues/793: // diff --git a/tools/lint-pr-title.ts b/tools/lint-pr-title.ts new file mode 100644 index 00000000..a63defe9 --- /dev/null +++ b/tools/lint-pr-title.ts @@ -0,0 +1,19 @@ +#!/usr/bin/env node +import {env, exit} from 'node:process'; + +const allowedTypes = 'build, chore, ci, docs, enhance, feat, fix, perf, refactor, revert, style, test'; +const title = env.PR_TITLE; + +if (!title) { + console.error('Missing PR_TITLE'); + exit(1); +} + +const validTitlePattern = new RegExp(`^(${allowedTypes.replaceAll(', ', '|')})(\\([\\w.-]+\\))?(!)?: .+$`); + +if (!validTitlePattern.test(title)) { + console.error(`Invalid PR title: ${title}`); + console.error('Expected format: type(scope): subject'); + console.error(`Allowed types: ${allowedTypes}`); + exit(1); +}