9 Commits

Author SHA1 Message Date
Nicolas
6023928876 Fix token use with schemaless Gitea instance (#977)
Fixes #973

## Summary
- Normalize schemaless `--gitea-instance` values before comparing clone URL hosts
- Add regression tests for `GITEA_TOKEN` use with private action/reusable workflow clones on the same instance

---------

Co-authored-by: silverwind <2021+silverwind@noreply.gitea.com>
Co-authored-by: silverwind <me@silverwind.io>
Reviewed-on: https://gitea.com/gitea/runner/pulls/977
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
Reviewed-by: silverwind <2021+silverwind@noreply.gitea.com>
Co-authored-by: Nicolas <bircni@icloud.com>
Co-committed-by: Nicolas <bircni@icloud.com>
2026-05-18 02:22:04 +00:00
silverwind
014ce438c1 Add OCI source and version labels to images (#975)
Adds `org.opencontainers.image.source` and `org.opencontainers.image.version` labels to all three image variants (`basic`, `dind`, `dind-rootless`).

- `source` lets tools like renovate retrieve release notes from the source repo.
- `version` exposes the build version on the image itself.

Both `release-tag` and `release-nightly` workflows pass `VERSION` as a build arg so the label reflects the actual git tag (or `git describe` output for nightly).

---
This PR was written with the help of Claude Opus 4.7

---------

Reviewed-on: https://gitea.com/gitea/runner/pulls/975
Reviewed-by: Nicolas <bircni@icloud.com>
Co-authored-by: silverwind <me@silverwind.io>
Co-committed-by: silverwind <me@silverwind.io>
2026-05-17 18:15:19 +00:00
Jacob Alberty
cf7e29c10d fix(parse_env_file): support env-file lines larger than 64 KiB (#974)
My builds kept flaking out with errors like `invalid format delimiter 'ghadelimiter_...' not found before end of file` or just strange failures in the complete job. After some digging I found an issue in `parseEnvFile` and have tested this fix against the test case presented.

  - `parseEnvFile` reads `$GITHUB_ENV` / `$GITHUB_OUTPUT` with a `bufio.Scanner` using the default 64 KiB token size, and never checks `s.Err()`.
  - Any action that writes a multi-line value with a single line >64 KiB silently aborts the scan with `bufio.ErrTooLong`, which surfaces as the misleading `"invalid format delimiter
  'ghadelimiter_…' not found before end of file"`.
  - Real-world trigger: `docker/build-push-action`'s `metadata` output embeds the full `GITHUB_EVENT_PATH` payload via buildx provenance; a long PR description (e.g. a Renovate dependency
  table) puts the body field on one JSON-escaped line well past 64 KiB.
  - Raise the scanner buffer to 1 MiB so realistic outputs parse.

### Reproduction
Test this in an action. This removes the `docker/build-push-action` aspect and reproduces it directly.
```yaml
  jobs:
    repro:
      runs-on: ubuntu-latest
      steps:
        - id: big
          run: |
            {
              echo 'value<<EOF'
              head -c 70000 /dev/urandom | base64 -w0
              echo
              echo 'EOF'
            } >> "$GITHUB_OUTPUT"
```

---------

Co-authored-by: Nicolas <bircni@icloud.com>
Co-authored-by: silverwind <me@silverwind.io>
Reviewed-on: https://gitea.com/gitea/runner/pulls/974
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
Reviewed-by: silverwind <2021+silverwind@noreply.gitea.com>
Co-authored-by: Jacob Alberty <jacob.alberty@gmail.com>
Co-committed-by: Jacob Alberty <jacob.alberty@gmail.com>
2026-05-17 13:00:17 +00:00
Nicolas
8a99506fed Fix host cleanup, volume allowlist, cache upload, and action host edge cases (#970)
## Summary
- prevent host-mode execution from deleting caller-owned workdirs
- harden `valid_volumes` checks against `..` and symlink escapes
- return immediately after artifact cache upload write failures
- default implicit remote action clone hosts to `GitHubInstance`/`github.com`

Authored with assistance from OpenAI Codex GPT-5.

---------

Co-authored-by: silverwind <me@silverwind.io>
Reviewed-on: https://gitea.com/gitea/runner/pulls/970
Reviewed-by: silverwind <2021+silverwind@noreply.gitea.com>
2026-05-17 12:53:04 +00:00
silverwind
5873b8b054 Remove dead code from act/ (#971)
Removes code that whole-program reachability analysis (`deadcode` from `golang.org/x/tools`) confirmed unreachable, plus the `act/workflowpattern` package which no file outside its own directory imports.

- `act/common/draw.go` — CLI box-drawing helpers left over from nektos/act's dropped CLI
- `act/common/file.go` — `CopyFile`/`CopyDir` package-level helpers (container types have their own `CopyDir` methods, kept)
- `act/common/executor.go` — `Warning` type and `Warningf`. The `case Warning:` arm in `(Executor).Then`'s type switch was dead too (no code ever constructed a `Warning`); the switch is replaced with `if err != nil { return err }`
- `act/lookpath/env.go` — `LookPath` no-arg wrapper and `defaultEnv` struct. Only `LookPath2(file, env)` was used externally; the `Env` interface is kept
- `act/runner/action_cache_offline_mode.go` — `GoGitActionCacheOfflineMode` wrapper, never instantiated
- `act/workflowpattern/` — entire package, never imported

Net `-943` lines.

---
This PR was written with the help of Claude Opus 4.7

---------

Co-authored-by: Nicolas <bircni@icloud.com>
Reviewed-on: https://gitea.com/gitea/runner/pulls/971
Reviewed-by: Nicolas <bircni@icloud.com>
Co-authored-by: silverwind <me@silverwind.io>
Co-committed-by: silverwind <me@silverwind.io>
2026-05-17 03:53:28 +00:00
Lunny Xiao
5464d33eef fix: Return if executors length is zero in ParallelExecutor (#960)
It displayed an unused log and start an unused go routine. We should check the executors number before continue.

```
INFO[2026-05-12T21:01:04-07:00] Running job with maxParallel=1 for 1 matrix combinations
INFO[2026-05-12T21:01:04-07:00] NewParallelExecutor: Creating 1 workers for 1 executors
INFO[2026-05-12T21:01:04-07:00] NewParallelExecutor: Creating 1 workers for 0 executors
INFO[2026-05-12T21:01:04-07:00] NewParallelExecutor: Creating 1 workers for 0 executors
```

Reviewed-on: https://gitea.com/gitea/runner/pulls/960
Reviewed-by: silverwind <2021+silverwind@noreply.gitea.com>
Reviewed-by: Nicolas <bircni@icloud.com>
Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-committed-by: Lunny Xiao <xiaolunwen@gmail.com>
2026-05-15 22:14:13 +00:00
silverwind
3c5f03ff8f feat: make pseudo-TTY allocation opt-in (#961)
Fixes #956.

Pseudo-TTY allocation is now an explicit, runner-wide opt-in via `runner.allocate_pty`, applied to both host and docker backends. Default is off, matching GitHub `actions/runner`.

```yaml
runner:
  allocate_pty: false  # default
```

**Before:** the host backend hardcoded `if true /* allocate Terminal */` and the docker backend used `term.IsTerminal(os.Stdout.Fd())`. As a result, `docker build` (and other TTY-aware tools) saw a TTY and emitted cursor-control redraw frames that flooded captured logs with thousands of duplicate-looking progress lines — only on host-mode runners in production, and on docker-mode runners when the daemon happened to be launched from a shell rather than a service.

**After:** both backends consult `Config.AllocatePTY`. The `term.IsTerminal` heuristic is gone, so behavior no longer depends on whether the daemon has a controlling terminal.

**Reproduction:** running `docker build` through `HostEnvironment.Exec` with output captured to a buffer:

| | Before (`if true`) | After (`AllocatePTY=false`) |
|---|---:|---:|
| bytes captured | 18,167 | 1,048 |
| ANSI CSI sequences | 556 | 0 |
| cursor-up `\e[1A` | 181 | 0 |

**Side fix:** `ptyWriter.AutoStop` is now `atomic.Bool`. The field is written from the exec goroutine after `cmd.Wait()` and read from the `copyPtyOutput` goroutine via `ptyWriter.Write`; existing tests never tripped the race detector because their commands produced no output before exit. The new host-mode test does.

---
This PR was written with the help of Claude Opus 4.7

---------

Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: Nicolas <bircni@icloud.com>
Reviewed-on: https://gitea.com/gitea/runner/pulls/961
Reviewed-by: Nicolas <bircni@icloud.com>
Co-authored-by: silverwind <2021+silverwind@noreply.gitea.com>
Co-committed-by: silverwind <2021+silverwind@noreply.gitea.com>
2026-05-15 18:11:39 +00:00
Renovate Bot
880e9755d9 chore(deps): update workflow dependencies (major) (#968)
Reviewed-on: https://gitea.com/gitea/runner/pulls/968
Reviewed-by: silverwind <2021+silverwind@noreply.gitea.com>
Co-authored-by: Renovate Bot <renovate-bot@gitea.com>
Co-committed-by: Renovate Bot <renovate-bot@gitea.com>
2026-05-15 14:08:07 +00:00
Renovate Bot
8d7cf48a6f fix(deps): update module github.com/docker/cli to v29.5.0+incompatible (#969)
This PR contains the following updates:

| Package | Change | [Age](https://docs.renovatebot.com/merge-confidence/) | [Confidence](https://docs.renovatebot.com/merge-confidence/) |
|---|---|---|---|
| [github.com/docker/cli](https://github.com/docker/cli) | `v29.4.3+incompatible` → `v29.5.0+incompatible` | ![age](https://developer.mend.io/api/mc/badges/age/go/github.com%2fdocker%2fcli/v29.5.0+incompatible?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/go/github.com%2fdocker%2fcli/v29.4.3+incompatible/v29.5.0+incompatible?slim=true) |

---

### Release Notes

<details>
<summary>docker/cli (github.com/docker/cli)</summary>

### [`v29.5.0+incompatible`](https://github.com/docker/cli/compare/v29.4.3...v29.5.0)

[Compare Source](https://github.com/docker/cli/compare/v29.4.3...v29.5.0)

</details>

---

### Configuration

📅 **Schedule**: (UTC)

- Branch creation
  - At any time (no schedule defined)
- Automerge
  - At any time (no schedule defined)

🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update again.

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box

---

This PR has been generated by [Mend Renovate](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My4xNzkuNCIsInVwZGF0ZWRJblZlciI6IjQzLjE3OS40IiwidGFyZ2V0QnJhbmNoIjoibWFpbiIsImxhYmVscyI6W119-->

Reviewed-on: https://gitea.com/gitea/runner/pulls/969
Reviewed-by: silverwind <2021+silverwind@noreply.gitea.com>
Co-authored-by: Renovate Bot <renovate-bot@gitea.com>
Co-committed-by: Renovate Bot <renovate-bot@gitea.com>
2026-05-15 14:05:52 +00:00
35 changed files with 609 additions and 1012 deletions

View File

@@ -71,6 +71,11 @@ jobs:
- name: Echo the tag - name: Echo the tag
run: echo "${{ env.DOCKER_ORG }}/runner:nightly${{ matrix.variant.tag_suffix }}" 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 - name: Build and push
uses: docker/build-push-action@v7 uses: docker/build-push-action@v7
with: with:
@@ -83,3 +88,5 @@ jobs:
push: true push: true
tags: | tags: |
${{ env.DOCKER_ORG }}/runner:nightly${{ matrix.variant.tag_suffix }} ${{ env.DOCKER_ORG }}/runner:nightly${{ matrix.variant.tag_suffix }}
build-args: |
VERSION=${{ steps.meta.outputs.REPO_VERSION }}

View File

@@ -96,3 +96,5 @@ jobs:
linux/arm64 linux/arm64
push: true push: true
tags: ${{ steps.docker_meta.outputs.tags }} tags: ${{ steps.docker_meta.outputs.tags }}
build-args: |
VERSION=${{ steps.docker_meta.outputs.version }}

View File

@@ -19,6 +19,11 @@ RUN make clean && make build
# #
FROM docker:29-dind AS dind FROM docker:29-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 RUN apk add --no-cache s6 bash git tzdata
COPY --from=builder /opt/src/runner/gitea-runner /usr/local/bin/gitea-runner COPY --from=builder /opt/src/runner/gitea-runner /usr/local/bin/gitea-runner
@@ -34,6 +39,11 @@ ENTRYPOINT ["s6-svscan","/etc/s6"]
# #
FROM docker:29-dind-rootless AS dind-rootless FROM docker:29-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 USER root
RUN apk add --no-cache s6 bash git tzdata RUN apk add --no-cache s6 bash git tzdata
@@ -54,6 +64,12 @@ ENTRYPOINT ["s6-svscan","/etc/s6"]
# #
# #
FROM alpine AS basic FROM alpine 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 RUN apk add --no-cache tini bash git tzdata
COPY --from=builder /opt/src/runner/gitea-runner /usr/local/bin/gitea-runner COPY --from=builder /opt/src/runner/gitea-runner /usr/local/bin/gitea-runner

View File

@@ -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 { if err := h.storage.Write(cache.ID, start, r.Body); err != nil {
h.responseJSON(w, r, 500, err) h.responseJSON(w, r, 500, err)
return
} }
h.useCache(id) h.useCache(id)
h.responseJSON(w, r, 200) h.responseJSON(w, r, 200)

View File

@@ -11,6 +11,7 @@ import (
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"os"
"path/filepath" "path/filepath"
"strings" "strings"
"testing" "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) { t.Run("commit early", func(t *testing.T) {
key := strings.ToLower(t.Name()) key := strings.ToLower(t.Name())
version := "c19da02a2bd7e77277f1ac29ab45c09b7d46a4ee758284e26bb3045ad11d9d20" version := "c19da02a2bd7e77277f1ac29ab45c09b7d46a4ee758284e26bb3045ad11d9d20"

View File

@@ -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
}

View File

@@ -12,24 +12,6 @@ import (
log "github.com/sirupsen/logrus" 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 // Executor define contract for the steps of a workflow
type Executor func(ctx context.Context) error 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 // Then runs another executor if this executor succeeds
func (e Executor) Then(then Executor) Executor { func (e Executor) Then(then Executor) Executor {
return func(ctx context.Context) error { return func(ctx context.Context) error {
err := e(ctx) if err := e(ctx); err != nil {
if err != nil { return err
switch err.(type) {
case Warning:
Logger(ctx).Warning(err.Error())
default:
return err
}
} }
if ctx.Err() != nil { if ctx.Err() != nil {
return ctx.Err() return ctx.Err()

View File

@@ -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
}

View File

@@ -47,6 +47,7 @@ type NewContainerInput struct {
// Gitea specific // Gitea specific
AutoRemove bool AutoRemove bool
ValidVolumes []string ValidVolumes []string
AllocatePTY bool // allocate a pseudo-TTY for the container's exec processes
} }
// FileEntry is a file to copy to a container // FileEntry is a file to copy to a container

View File

@@ -17,6 +17,7 @@ import (
"path/filepath" "path/filepath"
"regexp" "regexp"
"runtime" "runtime"
"slices"
"strconv" "strconv"
"strings" "strings"
@@ -41,7 +42,6 @@ import (
"github.com/moby/moby/client" "github.com/moby/moby/client"
specs "github.com/opencontainers/image-spec/specs-go/v1" specs "github.com/opencontainers/image-spec/specs-go/v1"
"github.com/spf13/pflag" "github.com/spf13/pflag"
"golang.org/x/term"
) )
// NewContainer creates a reference to a container // NewContainer creates a reference to a container
@@ -450,7 +450,6 @@ func (cr *containerReference) create(capAdd, capDrop []string) common.Executor {
return nil return nil
} }
logger := common.Logger(ctx) logger := common.Logger(ctx)
isTerminal := term.IsTerminal(int(os.Stdout.Fd()))
input := cr.input input := cr.input
exposedPorts, err := convertPortSet(input.ExposedPorts) exposedPorts, err := convertPortSet(input.ExposedPorts)
if err != nil { if err != nil {
@@ -466,7 +465,7 @@ func (cr *containerReference) create(capAdd, capDrop []string) common.Executor {
WorkingDir: input.WorkingDir, WorkingDir: input.WorkingDir,
Env: input.Env, Env: input.Env,
ExposedPorts: exposedPorts, ExposedPorts: exposedPorts,
Tty: isTerminal, Tty: input.AllocatePTY,
} }
// For Gitea, reduce log noise // For Gitea, reduce log noise
// logger.Debugf("Common container.Config ==> %+v", config) // logger.Debugf("Common container.Config ==> %+v", config)
@@ -604,7 +603,7 @@ func (cr *containerReference) exec(cmd []string, env map[string]string, user, wo
} }
logger.Debugf("Exec command '%s'", cmd) logger.Debugf("Exec command '%s'", cmd)
isTerminal := term.IsTerminal(int(os.Stdout.Fd())) isTerminal := cr.input.AllocatePTY
envList := make([]string, 0) envList := make([]string, 0)
for k, v := range env { for k, v := range env {
envList = append(envList, fmt.Sprintf("%s=%s", k, v)) envList = append(envList, fmt.Sprintf("%s=%s", k, v))
@@ -899,7 +898,7 @@ func (cr *containerReference) attach() common.Executor {
if err != nil { if err != nil {
return fmt.Errorf("failed to attach to container: %w", err) return fmt.Errorf("failed to attach to container: %w", err)
} }
isTerminal := term.IsTerminal(int(os.Stdout.Fd())) isTerminal := cr.input.AllocatePTY
var outWriter io.Writer var outWriter io.Writer
outWriter = cr.input.Stdout outWriter = cr.input.Stdout
@@ -970,22 +969,7 @@ func (cr *containerReference) sanitizeConfig(ctx context.Context, config *contai
logger := common.Logger(ctx) logger := common.Logger(ctx)
if len(cr.input.ValidVolumes) > 0 { if len(cr.input.ValidVolumes) > 0 {
globs := make([]glob.Glob, 0, len(cr.input.ValidVolumes)) matcher := newValidVolumeMatcher(ctx, 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
}
// sanitize binds // sanitize binds
sanitizedBinds := make([]string, 0, len(hostConfig.Binds)) sanitizedBinds := make([]string, 0, len(hostConfig.Binds))
for _, bind := range hostConfig.Binds { for _, bind := range hostConfig.Binds {
@@ -999,7 +983,7 @@ func (cr *containerReference) sanitizeConfig(ctx context.Context, config *contai
sanitizedBinds = append(sanitizedBinds, bind) sanitizedBinds = append(sanitizedBinds, bind)
continue continue
} }
if isValid(parsed.Source) { if matcher.isValid(parsed.Source, mount.Type(parsed.Type)) {
sanitizedBinds = append(sanitizedBinds, bind) sanitizedBinds = append(sanitizedBinds, bind)
} else { } else {
logger.Warnf("[%s] is not a valid volume, will be ignored", parsed.Source) logger.Warnf("[%s] is not a valid volume, will be ignored", parsed.Source)
@@ -1009,7 +993,7 @@ func (cr *containerReference) sanitizeConfig(ctx context.Context, config *contai
// sanitize mounts // sanitize mounts
sanitizedMounts := make([]mount.Mount, 0, len(hostConfig.Mounts)) sanitizedMounts := make([]mount.Mount, 0, len(hostConfig.Mounts))
for _, mt := range hostConfig.Mounts { for _, mt := range hostConfig.Mounts {
if isValid(mt.Source) { if matcher.isValid(mt.Source, mt.Type) {
sanitizedMounts = append(sanitizedMounts, mt) sanitizedMounts = append(sanitizedMounts, mt)
} else { } else {
logger.Warnf("[%s] is not a valid volume, will be ignored", mt.Source) logger.Warnf("[%s] is not a valid volume, will be ignored", mt.Source)
@@ -1023,3 +1007,129 @@ func (cr *containerReference) sanitizeConfig(ctx context.Context, config *contai
return config, hostConfig 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
}
}

View File

@@ -11,6 +11,8 @@ import (
"errors" "errors"
"io" "io"
"net" "net"
"os"
"path/filepath"
"strings" "strings"
"testing" "testing"
"time" "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)
}

View File

@@ -19,6 +19,7 @@ import (
"strconv" "strconv"
"strings" "strings"
"sync" "sync"
"sync/atomic"
"time" "time"
"gitea.com/gitea/runner/act/common" "gitea.com/gitea/runner/act/common"
@@ -36,12 +37,13 @@ type HostEnvironment struct {
TmpDir string TmpDir string
ToolCache string ToolCache string
Workdir string Workdir string
// BindWorkdir is true when the app runner mounts the workspace on the host and // CleanWorkdir means teardown owns Workdir and may delete it. Leave false
// deletes the task directory after the job; host teardown must not remove Workdir. // when Workdir points at a caller-owned checkout (e.g. `act` local mode).
BindWorkdir bool CleanWorkdir bool
ActPath string ActPath string
CleanUp func() CleanUp func()
StdOut io.Writer StdOut io.Writer
AllocatePTY bool // allocate a pseudo-TTY for each step's process
mu sync.Mutex mu sync.Mutex
runningPIDs map[int]struct{} runningPIDs map[int]struct{}
@@ -200,12 +202,12 @@ func (e *HostEnvironment) Start(_ bool) common.Executor {
type ptyWriter struct { type ptyWriter struct {
Out io.Writer Out io.Writer
AutoStop bool AutoStop atomic.Bool
dirtyLine bool dirtyLine bool
} }
func (w *ptyWriter) Write(buf []byte) (int, error) { func (w *ptyWriter) Write(buf []byte) (int, error) {
if w.AutoStop && len(buf) > 0 && buf[len(buf)-1] == 4 { if w.AutoStop.Load() && len(buf) > 0 && buf[len(buf)-1] == 4 {
n, err := w.Out.Write(buf[:len(buf)-1]) n, err := w.Out.Write(buf[:len(buf)-1])
if err != nil { if err != nil {
return n, err return n, err
@@ -335,21 +337,20 @@ func (e *HostEnvironment) exec(ctx context.Context, command []string, cmdline st
tty.Close() tty.Close()
} }
}() }()
if true /* allocate Terminal */ { if e.AllocatePTY {
var err error var err error
ppty, tty, err = setupPty(cmd, cmdline) ppty, tty, err = setupPty(cmd, cmdline)
if err != nil { if err != nil {
common.Logger(ctx).Debugf("Failed to setup Pty %v\n", err.Error()) common.Logger(ctx).Debugf("Failed to setup Pty %v\n", err.Error())
} }
} }
writer := &ptyWriter{Out: e.StdOut} var writer *ptyWriter
logctx, finishLog := context.WithCancel(context.Background()) var logctx context.Context
if ppty != nil { if ppty != nil {
writer = &ptyWriter{Out: e.StdOut}
var finishLog context.CancelFunc
logctx, finishLog = context.WithCancel(context.Background())
go copyPtyOutput(writer, ppty, finishLog) go copyPtyOutput(writer, ppty, finishLog)
} else {
finishLog()
}
if ppty != nil {
go writeKeepAlive(ppty) go writeKeepAlive(ppty)
} }
// Split Start/Wait so the PID can be registered before the process can exit; // Split Start/Wait so the PID can be registered before the process can exit;
@@ -379,14 +380,11 @@ func (e *HostEnvironment) exec(ctx context.Context, command []string, cmdline st
return err return err
} }
if tty != nil { if tty != nil {
writer.AutoStop = true writer.AutoStop.Store(true)
if _, err := tty.WriteString("\x04"); err != nil { if _, err := tty.WriteString("\x04"); err != nil {
common.Logger(ctx).Debug("Failed to write EOT") common.Logger(ctx).Debug("Failed to write EOT")
} }
} <-logctx.Done()
<-logctx.Done()
if ppty != nil {
ppty.Close() ppty.Close()
ppty = nil ppty = nil
} }
@@ -485,7 +483,7 @@ func (e *HostEnvironment) Remove() common.Executor {
logger.Warnf("failed to remove host misc state %s: %v", e.Path, err) logger.Warnf("failed to remove host misc state %s: %v", e.Path, err)
errs = append(errs, err) errs = append(errs, err)
} }
if !e.BindWorkdir && e.Workdir != "" { if e.CleanWorkdir {
if err := removePathWithRetry(ctx, e.Workdir); err != nil { if err := removePathWithRetry(ctx, e.Workdir); err != nil {
logger.Warnf("failed to remove host workspace %s: %v", e.Workdir, err) logger.Warnf("failed to remove host workspace %s: %v", e.Workdir, err)
errs = append(errs, err) errs = append(errs, err)

View File

@@ -6,12 +6,14 @@ package container
import ( import (
"archive/tar" "archive/tar"
"bytes"
"context" "context"
"io" "io"
"os" "os"
"path" "path"
"path/filepath" "path/filepath"
"runtime" "runtime"
"strings"
"testing" "testing"
"gitea.com/gitea/runner/act/common" "gitea.com/gitea/runner/act/common"
@@ -100,7 +102,46 @@ func TestHostEnvironmentExecExitCode(t *testing.T) {
assert.Equal(t, "Process completed with exit code 3.", err.Error()) assert.Equal(t, "Process completed with exit code 3.", err.Error())
} }
func TestHostEnvironmentRemoveCleansWorkdir(t *testing.T) { func TestHostEnvironmentAllocatePTY(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("uses POSIX shell")
}
for _, tc := range []struct {
name string
allocPTY bool
expect string
}{
{name: "off", allocPTY: false, expect: "NOTTY"},
{name: "on", allocPTY: true, expect: "TTY"},
} {
t.Run(tc.name, func(t *testing.T) {
dir := t.TempDir()
buf := &bytes.Buffer{}
e := &HostEnvironment{
Path: filepath.Join(dir, "path"),
TmpDir: filepath.Join(dir, "tmp"),
ToolCache: filepath.Join(dir, "tool_cache"),
ActPath: filepath.Join(dir, "act_path"),
StdOut: buf,
Workdir: filepath.Join(dir, "path"),
AllocatePTY: tc.allocPTY,
}
for _, p := range []string{e.Path, e.TmpDir, e.ToolCache, e.ActPath} {
require.NoError(t, os.MkdirAll(p, 0o700))
}
err := e.Exec(
[]string{"sh", "-c", "[ -t 1 ] && printf TTY || printf NOTTY"},
map[string]string{"PATH": os.Getenv("PATH")}, "", "",
)(context.Background())
require.NoError(t, err)
got := strings.TrimSpace(strings.ReplaceAll(buf.String(), "\r", ""))
assert.Equal(t, tc.expect, got)
})
}
}
func TestHostEnvironmentRemovePreservesWorkdirByDefault(t *testing.T) {
logger := logrus.New() logger := logrus.New()
ctx := common.WithLogger(context.Background(), logrus.NewEntry(logger)) ctx := common.WithLogger(context.Background(), logrus.NewEntry(logger))
base := t.TempDir() base := t.TempDir()
@@ -111,9 +152,8 @@ func TestHostEnvironmentRemoveCleansWorkdir(t *testing.T) {
require.NoError(t, os.MkdirAll(workdir, 0o700)) require.NoError(t, os.MkdirAll(workdir, 0o700))
e := &HostEnvironment{ e := &HostEnvironment{
Path: path, Path: path,
Workdir: workdir, Workdir: workdir,
BindWorkdir: false,
CleanUp: func() { CleanUp: func() {
_ = os.RemoveAll(miscRoot) _ = os.RemoveAll(miscRoot)
}, },
@@ -121,10 +161,10 @@ func TestHostEnvironmentRemoveCleansWorkdir(t *testing.T) {
} }
require.NoError(t, e.Remove()(ctx)) require.NoError(t, e.Remove()(ctx))
_, err := os.Stat(workdir) _, 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() logger := logrus.New()
ctx := common.WithLogger(context.Background(), logrus.NewEntry(logger)) ctx := common.WithLogger(context.Background(), logrus.NewEntry(logger))
base := t.TempDir() base := t.TempDir()
@@ -135,9 +175,9 @@ func TestHostEnvironmentRemoveSkipsWorkdirWhenBindWorkdir(t *testing.T) {
require.NoError(t, os.MkdirAll(workdir, 0o700)) require.NoError(t, os.MkdirAll(workdir, 0o700))
e := &HostEnvironment{ e := &HostEnvironment{
Path: path, Path: path,
Workdir: workdir, Workdir: workdir,
BindWorkdir: true, CleanWorkdir: true,
CleanUp: func() { CleanUp: func() {
_ = os.RemoveAll(miscRoot) _ = os.RemoveAll(miscRoot)
}, },
@@ -145,5 +185,5 @@ func TestHostEnvironmentRemoveSkipsWorkdirWhenBindWorkdir(t *testing.T) {
} }
require.NoError(t, e.Remove()(ctx)) require.NoError(t, e.Remove()(ctx))
_, err := os.Stat(workdir) _, err := os.Stat(workdir)
require.NoError(t, err) assert.ErrorIs(t, err, os.ErrNotExist)
} }

View File

@@ -29,6 +29,8 @@ func parseEnvFile(e Container, srcPath string, env *map[string]string) common.Ex
return err return err
} }
s := bufio.NewScanner(reader) 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() { for s.Scan() {
line := s.Text() line := s.Text()
singleLineEnv := strings.Index(line, "=") singleLineEnv := strings.Index(line, "=")
@@ -50,6 +52,9 @@ func parseEnvFile(e Container, srcPath string, env *map[string]string) common.Ex
} }
multiLineEnvContent += content multiLineEnvContent += content
} }
if err := s.Err(); err != nil {
return fmt.Errorf("reading env file: %w", err)
}
if !delimiterFound { if !delimiterFound {
return fmt.Errorf("invalid format delimiter '%v' not found before end of file", multiLineEnvDelimiter) 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) 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 env = &localEnv
return nil return nil
} }

View File

@@ -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<<EOF\nline1\nline2\nEOF\n"
require.NoError(t, os.WriteFile(envPath, []byte(content), 0o600))
env := map[string]string{}
require.NoError(t, parseEnvFile(e, envPath, &env)(context.Background()))
assert.Equal(t, "line1\nline2", env["FOO"])
}
func TestParseEnvFileLargeValueWithinLimit(t *testing.T) {
e, envPath := newTestHostEnv(t)
big := strings.Repeat("x", 2*1024*1024)
content := "FOO<<EOF\n" + big + "\nEOF\n"
require.NoError(t, os.WriteFile(envPath, []byte(content), 0o600))
env := map[string]string{}
require.NoError(t, parseEnvFile(e, envPath, &env)(context.Background()))
assert.Equal(t, big, env["FOO"])
}
func TestParseEnvFileLineExceedsBufferReportsScannerError(t *testing.T) {
e, envPath := newTestHostEnv(t)
tooBig := strings.Repeat("x", 17*1024*1024) // over the 16 MiB cap
content := "FOO<<EOF\n" + tooBig + "\nEOF\n"
require.NoError(t, os.WriteFile(envPath, []byte(content), 0o600))
env := map[string]string{}
err := parseEnvFile(e, envPath, &env)(context.Background())
require.ErrorIs(t, err, bufio.ErrTooLong)
assert.Contains(t, err.Error(), "reading env file")
}
func TestParseEnvFileMissingDelimiter(t *testing.T) {
e, envPath := newTestHostEnv(t)
require.NoError(t, os.WriteFile(envPath, []byte("FOO<<EOF\nline1\nline2\n"), 0o600))
env := map[string]string{}
err := parseEnvFile(e, envPath, &env)(context.Background())
require.Error(t, err)
assert.Contains(t, err.Error(), "delimiter")
}

View File

@@ -4,18 +4,6 @@
package lookpath package lookpath
import "os"
type Env interface { type Env interface {
Getenv(name string) string Getenv(name string) string
} }
type defaultEnv struct{}
func (*defaultEnv) Getenv(name string) string {
return os.Getenv(name)
}
func LookPath(file string) (string, error) {
return LookPath2(file, &defaultEnv{})
}

View File

@@ -456,6 +456,7 @@ func newStepContainer(ctx context.Context, step step, image string, cmd, entrypo
Options: rc.Config.ContainerOptions, Options: rc.Config.ContainerOptions,
AutoRemove: rc.Config.AutoRemove, AutoRemove: rc.Config.AutoRemove,
ValidVolumes: rc.Config.ValidVolumes, ValidVolumes: rc.Config.ValidVolumes,
AllocatePTY: rc.Config.AllocatePTY,
}) })
return stepContainer return stepContainer
} }

View File

@@ -1,45 +0,0 @@
// Copyright 2024 The Gitea Authors. All rights reserved.
// Copyright 2024 The nektos/act Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package runner
import (
"context"
"io"
"path"
git "github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/plumbing"
)
type GoGitActionCacheOfflineMode struct {
Parent GoGitActionCache
}
func (c GoGitActionCacheOfflineMode) Fetch(ctx context.Context, cacheDir, url, ref, token string) (string, error) {
sha, fetchErr := c.Parent.Fetch(ctx, cacheDir, url, ref, token)
gitPath := path.Join(c.Parent.Path, safeFilename(cacheDir)+".git")
gogitrepo, err := git.PlainOpen(gitPath)
if err != nil {
return "", fetchErr
}
refName := plumbing.ReferenceName("refs/action-cache-offline/" + ref)
r, err := gogitrepo.Reference(refName, true)
if fetchErr == nil {
if err != nil || sha != r.Hash().String() {
if err == nil {
refName = r.Name()
}
ref := plumbing.NewHashReference(refName, plumbing.NewHash(sha))
_ = gogitrepo.Storer.SetReference(ref)
}
} else if err == nil {
return r.Hash().String(), nil
}
return sha, fetchErr
}
func (c GoGitActionCacheOfflineMode) GetTarArchive(ctx context.Context, cacheDir, sha, includePrefix string) (io.ReadCloser, error) {
return c.Parent.GetTarArchive(ctx, cacheDir, sha, includePrefix)
}

View File

@@ -308,6 +308,11 @@ func getGitCloneToken(conf *Config, cloneURL string) string {
// 1. cloneURL is from the same Gitea instance that the runner is registered to // 1. cloneURL is from the same Gitea instance that the runner is registered to
// 2. the cloneURL does not have basic auth embedded // 2. the cloneURL does not have basic auth embedded
func shouldCloneURLUseToken(instanceURL, cloneURL string) bool { func shouldCloneURLUseToken(instanceURL, cloneURL string) bool {
if !strings.HasPrefix(instanceURL, "http://") &&
!strings.HasPrefix(instanceURL, "https://") {
instanceURL = "https://" + instanceURL
}
u1, err1 := url.Parse(instanceURL) u1, err1 := url.Parse(instanceURL)
u2, err2 := url.Parse(cloneURL) u2, err2 := url.Parse(cloneURL)
if err1 != nil || err2 != nil { if err1 != nil || err2 != nil {

View File

@@ -123,6 +123,65 @@ func TestNewReusableWorkflowExecutorHoldsCloneLock(t *testing.T) {
} }
} }
func TestGetGitCloneTokenWithSchemalessGiteaInstance(t *testing.T) {
conf := &Config{
GitHubInstance: "gitea.example.net",
Secrets: map[string]string{
"GITEA_TOKEN": "token-value",
},
}
token := getGitCloneToken(conf, "https://gitea.example.net/actions/tools")
require.Equal(t, "token-value", token)
}
func TestShouldCloneURLUseToken(t *testing.T) {
tests := []struct {
name string
instanceURL string
cloneURL string
want bool
}{
{
name: "same host with schemaless instance",
instanceURL: "gitea.example.net",
cloneURL: "https://gitea.example.net/actions/tools",
want: true,
},
{
name: "same host with schemaless instance and port",
instanceURL: "gitea.example.net:3000",
cloneURL: "https://gitea.example.net:3000/actions/tools",
want: true,
},
{
name: "different host",
instanceURL: "gitea.example.net",
cloneURL: "https://github.com/actions/tools",
want: false,
},
{
name: "embedded basic auth",
instanceURL: "gitea.example.net",
cloneURL: "https://user:pass@gitea.example.net/actions/tools",
want: false,
},
{
name: "invalid clone URL",
instanceURL: "gitea.example.net",
cloneURL: "://gitea.example.net/actions/tools",
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
require.Equal(t, tt.want, shouldCloneURLUseToken(tt.instanceURL, tt.cloneURL))
})
}
}
func gitMust(t *testing.T, dir string, args ...string) { func gitMust(t *testing.T, dir string, args ...string) {
t.Helper() t.Helper()
cmd := exec.Command("git", args...) cmd := exec.Command("git", args...)

View File

@@ -220,16 +220,17 @@ func (rc *RunContext) startHostEnvironment() common.Executor {
} }
toolCache := filepath.Join(cacheDir, "tool_cache") toolCache := filepath.Join(cacheDir, "tool_cache")
rc.JobContainer = &container.HostEnvironment{ rc.JobContainer = &container.HostEnvironment{
Path: path, Path: path,
TmpDir: runnerTmp, TmpDir: runnerTmp,
ToolCache: toolCache, ToolCache: toolCache,
Workdir: rc.Config.Workdir, Workdir: rc.Config.Workdir,
BindWorkdir: rc.Config.BindWorkdir, CleanWorkdir: rc.Config.CleanWorkdir,
ActPath: actPath, ActPath: actPath,
CleanUp: func() { CleanUp: func() {
os.RemoveAll(miscpath) os.RemoveAll(miscpath)
}, },
StdOut: logWriter, StdOut: logWriter,
AllocatePTY: rc.Config.AllocatePTY,
} }
rc.cleanUpJobContainer = rc.JobContainer.Remove() rc.cleanUpJobContainer = rc.JobContainer.Remove()
for k, v := range rc.JobContainer.GetRunnerContext(ctx) { for k, v := range rc.JobContainer.GetRunnerContext(ctx) {
@@ -371,6 +372,7 @@ func (rc *RunContext) startJobContainer() common.Executor {
NetworkAliases: []string{serviceID}, NetworkAliases: []string{serviceID},
ExposedPorts: exposedPorts, ExposedPorts: exposedPorts,
PortBindings: portBindings, PortBindings: portBindings,
AllocatePTY: rc.Config.AllocatePTY,
}) })
rc.ServiceContainers = append(rc.ServiceContainers, c) rc.ServiceContainers = append(rc.ServiceContainers, c)
} }
@@ -431,6 +433,7 @@ func (rc *RunContext) startJobContainer() common.Executor {
Options: rc.options(ctx), Options: rc.options(ctx),
AutoRemove: rc.Config.AutoRemove, AutoRemove: rc.Config.AutoRemove,
ValidVolumes: rc.Config.ValidVolumes, ValidVolumes: rc.Config.ValidVolumes,
AllocatePTY: rc.Config.AllocatePTY,
}) })
if rc.JobContainer == nil { if rc.JobContainer == nil {
return errors.New("Failed to create job container") return errors.New("Failed to create job container")

View File

@@ -73,12 +73,14 @@ type Config struct {
EventJSON string // the content of JSON file to use for event.json in containers, overrides EventPath EventJSON string // the content of JSON file to use for event.json in containers, overrides EventPath
ContainerNamePrefix string // the prefix of container name ContainerNamePrefix string // the prefix of container name
ContainerMaxLifetime time.Duration // the max lifetime of job containers ContainerMaxLifetime time.Duration // the max lifetime of job containers
CleanWorkdir bool // remove host executor workdir on teardown
DefaultActionInstance string // the default actions web site DefaultActionInstance string // the default actions web site
PlatformPicker func(labels []string) string // platform picker, it will take precedence over Platforms if isn't nil PlatformPicker func(labels []string) string // platform picker, it will take precedence over Platforms if isn't nil
JobLoggerLevel *log.Level // the level of job logger JobLoggerLevel *log.Level // the level of job logger
ValidVolumes []string // only volumes (and bind mounts) in this slice can be mounted on the job container or service containers ValidVolumes []string // only volumes (and bind mounts) in this slice can be mounted on the job container or service containers
InsecureSkipTLS bool // whether to skip verifying TLS certificate of the Gitea instance InsecureSkipTLS bool // whether to skip verifying TLS certificate of the Gitea instance
MaxParallel int // max parallel jobs to run across all workflows (0 = no limit, uses CPU count) MaxParallel int // max parallel jobs to run across all workflows (0 = no limit, uses CPU count)
AllocatePTY bool // allocate a pseudo-TTY for each step's process
} }
// GetToken: Adapt to Gitea // GetToken: Adapt to Gitea
@@ -90,6 +92,17 @@ func (c Config) GetToken() string {
return token return token
} }
// DefaultActionURL returns the host used for implicit remote actions.
func (c Config) DefaultActionURL() string {
if c.DefaultActionInstance != "" {
return c.DefaultActionInstance
}
if c.GitHubInstance != "" {
return c.GitHubInstance
}
return "github.com"
}
type caller struct { type caller struct {
runContext *RunContext runContext *RunContext

View File

@@ -113,9 +113,10 @@ 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())
token := getGitCloneToken(sar.getRunContext().Config, sar.remoteAction.CloneURL(sar.RunContext.Config.DefaultActionInstance)) defaultActionURL := sar.RunContext.Config.DefaultActionURL()
token := getGitCloneToken(sar.getRunContext().Config, sar.remoteAction.CloneURL(defaultActionURL))
gitClone := stepActionRemoteNewCloneExecutor(git.NewGitCloneExecutorInput{ gitClone := stepActionRemoteNewCloneExecutor(git.NewGitCloneExecutorInput{
URL: sar.remoteAction.CloneURL(sar.RunContext.Config.DefaultActionInstance), URL: sar.remoteAction.CloneURL(defaultActionURL),
Ref: sar.remoteAction.Ref, Ref: sar.remoteAction.Ref,
Dir: actionDir, Dir: actionDir,
Token: token, Token: token,
@@ -274,7 +275,7 @@ func (sar *stepActionRemote) cloneSkipTLS() bool {
if sar.remoteAction.URL == "" { if sar.remoteAction.URL == "" {
// Empty URL means the default action instance should be used // Empty URL means the default action instance should be used
// Return true if the URL of the Gitea instance is the same as the URL of the default action instance // Return true if the URL of the Gitea instance is the same as the URL of the default action instance
return sar.RunContext.Config.DefaultActionInstance == sar.RunContext.Config.GitHubInstance return sar.RunContext.Config.DefaultActionURL() == sar.RunContext.Config.GitHubInstance
} }
// Return true if the URL of the remote action is the same as the URL of the Gitea instance // Return true if the URL of the remote action is the same as the URL of the Gitea instance
return sar.remoteAction.URL == sar.RunContext.Config.GitHubInstance return sar.remoteAction.URL == sar.RunContext.Config.GitHubInstance

View File

@@ -20,6 +20,7 @@ import (
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock" "github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
"go.yaml.in/yaml/v4" "go.yaml.in/yaml/v4"
) )
@@ -434,6 +435,57 @@ func TestStepActionRemotePreThroughActionToken(t *testing.T) {
} }
} }
func TestStepActionRemoteUsesGitHubInstanceWhenDefaultActionInstanceEmpty(t *testing.T) {
ctx := context.Background()
var actualURL string
sarm := &stepActionRemoteMocks{}
origStepAtionRemoteNewCloneExecutor := stepActionRemoteNewCloneExecutor
stepActionRemoteNewCloneExecutor = func(input git.NewGitCloneExecutorInput) common.Executor {
return func(ctx context.Context) error {
actualURL = input.URL
return nil
}
}
defer func() {
stepActionRemoteNewCloneExecutor = origStepAtionRemoteNewCloneExecutor
}()
sar := &stepActionRemote{
Step: &model.Step{
Uses: "actions/setup-go@v4",
},
RunContext: &RunContext{
Config: &Config{
GitHubInstance: "gitea.example",
DefaultActionInstance: "",
ActionCacheDir: t.TempDir(),
},
Run: &model.Run{
JobID: "1",
Workflow: &model.Workflow{
Jobs: map[string]*model.Job{
"1": {},
},
},
},
},
readAction: sarm.readAction,
}
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)
require.NoError(t, sar.prepareActionExecutor()(ctx))
assert.Equal(t, "https://gitea.example/actions/setup-go", actualURL)
sarm.AssertExpectations(t)
}
func TestStepActionRemotePost(t *testing.T) { func TestStepActionRemotePost(t *testing.T) {
table := []struct { table := []struct {
name string name string

View File

@@ -139,6 +139,7 @@ func (sd *stepDocker) newStepContainer(ctx context.Context, image string, cmd, e
Platform: rc.Config.ContainerArchitecture, Platform: rc.Config.ContainerArchitecture,
AutoRemove: rc.Config.AutoRemove, AutoRemove: rc.Config.AutoRemove,
ValidVolumes: rc.Config.ValidVolumes, ValidVolumes: rc.Config.ValidVolumes,
AllocatePTY: rc.Config.AllocatePTY,
}) })
return stepContainer return stepContainer
} }

View File

@@ -109,6 +109,55 @@ func TestStepDockerMain(t *testing.T) {
cm.AssertExpectations(t) cm.AssertExpectations(t)
} }
func TestStepDockerNewStepContainerAllocatePTY(t *testing.T) {
for _, tc := range []struct {
name string
allocPTY bool
}{
{name: "off", allocPTY: false},
{name: "on", allocPTY: true},
} {
t.Run(tc.name, func(t *testing.T) {
cm := &containerMock{}
var captured *container.NewContainerInput
origContainerNewContainer := ContainerNewContainer
ContainerNewContainer = func(input *container.NewContainerInput) container.ExecutionsEnvironment {
captured = input
return cm
}
defer func() {
ContainerNewContainer = origContainerNewContainer
}()
ctx := context.Background()
sd := &stepDocker{
RunContext: &RunContext{
StepResults: map[string]*model.StepResult{},
Config: &Config{
AllocatePTY: tc.allocPTY,
PlatformPicker: func(_ []string) string {
return "node:14"
},
},
Run: &model.Run{
JobID: "1",
Workflow: &model.Workflow{
Jobs: map[string]*model.Job{"1": {}},
},
},
JobContainer: cm,
},
Step: &model.Step{ID: "1", Uses: "docker://node:14"},
}
sd.RunContext.ExprEval = sd.RunContext.NewExpressionEvaluator(ctx)
_ = sd.newStepContainer(ctx, "node:14", []string{"echo", "hi"}, nil)
assert.Equal(t, tc.allocPTY, captured.AllocatePTY)
})
}
}
func TestStepDockerPrePost(t *testing.T) { func TestStepDockerPrePost(t *testing.T) {
ctx := context.Background() ctx := context.Background()
sd := &stepDocker{} sd := &stepDocker{}

View File

@@ -9,9 +9,9 @@ inputs:
runs: runs:
using: "composite" using: "composite"
steps: steps:
- uses: actions/setup-node@v3 - uses: actions/setup-node@v6
with: with:
node-version: '16' node-version: '24'
- run: | - run: |
console.log(process.version); console.log(process.version);
console.log("Hi from node"); console.log("Hi from node");

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
})
}
}

2
go.mod
View File

@@ -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.4.3+incompatible github.com/docker/cli v29.5.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.0 github.com/go-git/go-git/v5 v5.19.0

2
go.sum
View File

@@ -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.4.3+incompatible h1:u+UliYm2J/rYrIh2FqHQg32neRG8GjbvNuwQRTzGspU= 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.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/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=

View File

@@ -347,6 +347,7 @@ func (r *Runner) run(ctx context.Context, task *runnerv1.Task, reporter *report.
Workdir: workdir, Workdir: workdir,
BindWorkdir: r.cfg.Container.BindWorkdir, BindWorkdir: r.cfg.Container.BindWorkdir,
ActionCacheDir: filepath.FromSlash(r.cfg.Host.WorkdirParent), ActionCacheDir: filepath.FromSlash(r.cfg.Host.WorkdirParent),
AllocatePTY: r.cfg.Runner.AllocatePTY,
ReuseContainers: false, ReuseContainers: false,
ForcePull: r.cfg.Container.ForcePull, ForcePull: r.cfg.Container.ForcePull,
@@ -362,6 +363,7 @@ func (r *Runner) run(ctx context.Context, task *runnerv1.Task, reporter *report.
EventJSON: string(eventJSON), EventJSON: string(eventJSON),
ContainerNamePrefix: fmt.Sprintf("GITEA-ACTIONS-TASK-%d", task.Id), ContainerNamePrefix: fmt.Sprintf("GITEA-ACTIONS-TASK-%d", task.Id),
ContainerMaxLifetime: maxLifetime, ContainerMaxLifetime: maxLifetime,
CleanWorkdir: true,
ContainerNetworkMode: container.NetworkMode(r.cfg.Container.Network), ContainerNetworkMode: container.NetworkMode(r.cfg.Container.Network),
ContainerOptions: r.cfg.Container.Options, ContainerOptions: r.cfg.Container.Options,
ContainerDaemonSocket: r.cfg.Container.DockerHost, ContainerDaemonSocket: r.cfg.Container.DockerHost,

View File

@@ -74,6 +74,11 @@ runner:
- "ubuntu-latest:docker://docker.gitea.com/runner-images:ubuntu-latest" - "ubuntu-latest:docker://docker.gitea.com/runner-images:ubuntu-latest"
- "ubuntu-24.04:docker://docker.gitea.com/runner-images:ubuntu-24.04" - "ubuntu-24.04:docker://docker.gitea.com/runner-images:ubuntu-24.04"
- "ubuntu-22.04:docker://docker.gitea.com/runner-images:ubuntu-22.04" - "ubuntu-22.04:docker://docker.gitea.com/runner-images:ubuntu-22.04"
# Allocate a pseudo-TTY for each step's process. Applies to both host and docker backends.
# Default false matches GitHub 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.
allocate_pty: false
cache: cache:
# Enable cache server to use actions/cache. # Enable cache server to use actions/cache.

View File

@@ -41,6 +41,7 @@ type Runner struct {
StateReportInterval time.Duration `yaml:"state_report_interval"` // StateReportInterval specifies the interval for state reporting. StateReportInterval time.Duration `yaml:"state_report_interval"` // StateReportInterval specifies the interval for state reporting.
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
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.
} }
// Cache represents the configuration for caching. // Cache represents the configuration for caching.