1 Commits

Author SHA1 Message Date
Renovate Bot
60b0e0a735 fix(deps): update module golang.org/x/term to v0.42.0 2026-05-03 00:15:37 +00:00
1203 changed files with 262922 additions and 4941 deletions

View File

@@ -1,27 +0,0 @@
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 }}

View File

@@ -24,7 +24,7 @@ jobs:
with:
go-version-file: "go.mod"
- name: goreleaser
uses: goreleaser/goreleaser-action@v7
uses: goreleaser/goreleaser-action@v6
with:
distribution: goreleaser-pro
args: release --nightly
@@ -57,13 +57,13 @@ jobs:
fetch-depth: 0 # all history for all branches and tags
- name: Set up QEMU
uses: docker/setup-qemu-action@v4
uses: docker/setup-qemu-action@v3
- name: Set up Docker BuildX
uses: docker/setup-buildx-action@v4
uses: docker/setup-buildx-action@v3
- name: Login to DockerHub
uses: docker/login-action@v4
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
@@ -71,13 +71,8 @@ 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
uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile
@@ -88,5 +83,3 @@ jobs:
push: true
tags: |
${{ env.DOCKER_ORG }}/runner:nightly${{ matrix.variant.tag_suffix }}
build-args: |
VERSION=${{ steps.meta.outputs.REPO_VERSION }}

View File

@@ -17,13 +17,13 @@ jobs:
go-version-file: "go.mod"
- name: Import GPG key
id: import_gpg
uses: crazy-max/ghaction-import-gpg@v7
uses: crazy-max/ghaction-import-gpg@v6
with:
gpg_private_key: ${{ secrets.GPG_PRIVATE_KEY }}
passphrase: ${{ secrets.PASSPHRASE }}
fingerprint: CC64B1DB67ABBEECAB24B6455FC346329753F4B0
- name: goreleaser
uses: goreleaser/goreleaser-action@v7
uses: goreleaser/goreleaser-action@v6
with:
distribution: goreleaser-pro
args: release
@@ -60,20 +60,20 @@ jobs:
fetch-depth: 0 # all history for all branches and tags
- name: Set up QEMU
uses: docker/setup-qemu-action@v4
uses: docker/setup-qemu-action@v3
- name: Set up Docker BuildX
uses: docker/setup-buildx-action@v4
uses: docker/setup-buildx-action@v3
- name: Login to DockerHub
uses: docker/login-action@v4
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: "Docker meta"
id: docker_meta
uses: docker/metadata-action@v6
uses: https://github.com/docker/metadata-action@v5
with:
images: |
${{ env.DOCKER_ORG }}/runner
@@ -86,7 +86,7 @@ jobs:
suffix=${{ matrix.variant.tag_suffix }},onlatest=true
- name: Build and push
uses: docker/build-push-action@v7
uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile
@@ -96,5 +96,3 @@ jobs:
linux/arm64
push: true
tags: ${{ steps.docker_meta.outputs.tags }}
build-args: |
VERSION=${{ steps.docker_meta.outputs.version }}

View File

@@ -9,36 +9,14 @@ jobs:
lint:
name: check and test
runs-on: ubuntu-latest
env:
# The runner image ships a stale docker.io login; point docker at an empty config so
# image pulls go straight to anonymous instead of attempting (and failing) that auth
# first. The path must be a literal: the `runner` context is unavailable in job-level
# env, so `${{ runner.temp }}` would resolve to empty and config.Dir() would fall back
# to ~/.docker with the stale credentials.
DOCKER_CONFIG: /tmp/docker-noauth
steps:
- uses: actions/checkout@v6
- uses: actions/setup-go@v6
with:
go-version-file: 'go.mod'
- name: prepare anonymous docker config
run: mkdir -p "$DOCKER_CONFIG" && echo '{}' > "$DOCKER_CONFIG/config.json"
# Pre-pull act/runner's two largest base images so a slow pull can't dominate `make test`;
# the rest (alpine/ubuntu) pull on demand, absorbed by the make-test -timeout. The host
# daemon retains them between runs, so this is usually a fast manifest re-check.
- name: pre-pull test images
run: |
for img in node:24-bookworm-slim nginx:alpine; do
for try in 1 2 3; do docker pull "$img" && break || sleep 5; done
done
- name: lint
run: make lint
- name: build
run: make build
- name: test
run: make test
# Build the dind image and run the daemon-facing tests against the docker version it
# ships, catching daemon-level regressions (e.g. gitea/runner#981) before release. Runs
# after `make test` so the images it needs are already present on the host daemon.
- name: test against dind image
run: make test-dind
run: make test

3
.gitignore vendored
View File

@@ -1,6 +1,5 @@
/gitea-runner
.env
!/act/runner/testdata/secrets/.env
.runner
coverage.txt
/config.yaml
@@ -11,4 +10,4 @@ coverage.txt
.vscode
__debug_bin
# gorelease binary folder
/dist
dist

View File

@@ -1,7 +1,7 @@
### BUILDER STAGE
#
#
FROM golang:1.26-alpine3.23 AS builder
FROM golang:1.26-alpine 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,12 +17,7 @@ RUN make clean && make build
### DIND VARIANT
#
#
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}"
FROM docker:28-dind AS dind
RUN apk add --no-cache s6 bash git tzdata
@@ -37,12 +32,7 @@ ENTRYPOINT ["s6-svscan","/etc/s6"]
### DIND-ROOTLESS VARIANT
#
#
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}"
FROM docker:28-dind-rootless AS dind-rootless
USER root
RUN apk add --no-cache s6 bash git tzdata
@@ -63,13 +53,7 @@ ENTRYPOINT ["s6-svscan","/etc/s6"]
### BASIC VARIANT
#
#
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}"
FROM alpine AS basic
RUN apk add --no-cache tini bash git tzdata
COPY --from=builder /opt/src/runner/gitea-runner /usr/local/bin/gitea-runner

View File

@@ -18,8 +18,8 @@ DOCKER_TAG ?= nightly
DOCKER_REF := $(DOCKER_IMAGE):$(DOCKER_TAG)
DOCKER_ROOTLESS_REF := $(DOCKER_IMAGE):$(DOCKER_TAG)-dind-rootless
GOLANGCI_LINT_PACKAGE ?= github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.12.2
GOVULNCHECK_PACKAGE ?= golang.org/x/vuln/cmd/govulncheck@v1.3.0
GOLANGCI_LINT_PACKAGE ?= github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.11.4
GOVULNCHECK_PACKAGE ?= golang.org/x/vuln/cmd/govulncheck@v1
STATIC ?=
EXTLDFLAGS ?=
@@ -118,10 +118,6 @@ 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
@@ -140,12 +136,8 @@ tidy-check: tidy
fi
.PHONY: test
test: fmt-check security-check ## test everything (integration tests self-skip without docker/network)
@$(GO) test -race -timeout 20m -v -cover -coverprofile coverage.txt ./... && echo "\n==>\033[32m Ok\033[m\n" || exit 1
.PHONY: test-dind
test-dind: ## run the daemon-facing tests against the built dind image (TARGET=dind|dind-rootless)
@./scripts/test-dind.sh $(TARGET)
test: fmt-check security-check ## test everything
@$(GO) test -race -short -v -cover -coverprofile coverage.txt ./... && echo "\n==>\033[32m Ok\033[m\n" || exit 1
.PHONY: install
install: $(GOFILES) ## install the runner binary via `go install`

View File

@@ -132,12 +132,6 @@ Besides `GITEA_INSTANCE_URL` and `GITEA_RUNNER_REGISTRATION_TOKEN`, the image en
For a fuller container-oriented walkthrough, see [examples/docker](examples/docker/README.md).
When `container.bind_workdir` is enabled, stale task workspace directories can be cleaned while the runner is idle:
- directories older than `runner.workdir_cleanup_age` are removed (default: `24h`; set `0` to disable)
- cleanup runs every `runner.idle_cleanup_interval` (default: `10m`; set `0` to disable)
- only purely numeric subdirectories under `container.workdir_parent` are treated as task workspaces and may be removed
- cleanup assumes `container.workdir_parent` is not shared across multiple runners
### Example Deployments
Check out the [examples](examples) directory for sample deployment types.

View File

@@ -325,6 +325,10 @@ func (h *Handler) openDB() (*bolthold.Store, error) {
func (h *Handler) find(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
cred := credFromContext(r.Context())
keys := strings.Split(r.URL.Query().Get("keys"), ",")
// cache keys are case insensitive
for i, key := range keys {
keys[i] = strings.ToLower(key)
}
version := r.URL.Query().Get("version")
db, err := h.openDB()
@@ -367,6 +371,8 @@ func (h *Handler) reserve(w http.ResponseWriter, r *http.Request, _ httprouter.P
h.responseJSON(w, r, 400, err)
return
}
// cache keys are case insensitive
api.Key = strings.ToLower(api.Key)
cache := api.ToCache()
cache.Repo = cred.Repo
@@ -431,7 +437,6 @@ 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)

View File

@@ -11,7 +11,6 @@ import (
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"testing"
@@ -339,54 +338,6 @@ 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"
@@ -508,20 +459,17 @@ func TestHandler(t *testing.T) {
assert.Equal(t, contents[except], content)
})
t.Run("case preserved", func(t *testing.T) {
// Some actions (e.g. actions/setup-go, actions/setup-node) build cache keys that contain mixed-case fragments such as RUNNER_OS=Linux,
// then compare the cacheKey returned by the cache server to their original key with case-sensitive equality to decide whether the
// cache was a complete hit. The server must therefore preserve the original key case.
t.Run("case insensitive", func(t *testing.T) {
version := "c19da02a2bd7e77277f1ac29ab45c09b7d46a4ee758284e26bb3045ad11d9d20"
key := strings.ToLower(t.Name()) + "_ABC"
key := strings.ToLower(t.Name())
content := make([]byte, 100)
_, err := rand.Read(content)
require.NoError(t, err)
uploadCacheNormally(t, base, key, version, content)
uploadCacheNormally(t, base, key+"_ABC", version, content)
{
resp, err := testClient.Get(fmt.Sprintf("%s/cache?keys=%s&version=%s", base, key, version))
reqKey := key + "_aBc"
resp, err := testClient.Get(fmt.Sprintf("%s/cache?keys=%s&version=%s", base, reqKey, version))
require.NoError(t, err)
defer resp.Body.Close()
require.Equal(t, 200, resp.StatusCode)
@@ -532,8 +480,7 @@ func TestHandler(t *testing.T) {
}{}
require.NoError(t, json.NewDecoder(resp.Body).Decode(&got))
assert.Equal(t, "hit", got.Result)
assert.Equal(t, key, got.CacheKey)
assert.NotEqual(t, strings.ToLower(key), got.CacheKey)
assert.Equal(t, key+"_abc", got.CacheKey)
}
})
@@ -696,7 +643,7 @@ func uploadCacheNormally(t *testing.T, base, key, version string, content []byte
}{}
require.NoError(t, json.NewDecoder(resp.Body).Decode(&got))
assert.Equal(t, "hit", got.Result)
assert.Equal(t, key, got.CacheKey)
assert.Equal(t, strings.ToLower(key), got.CacheKey)
archiveLocation = got.ArchiveLocation
}
{

View File

@@ -0,0 +1,30 @@
# Copied from https://github.com/actions/cache#example-cache-workflow
name: Caching Primes
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- run: env
- uses: actions/checkout@v3
- name: Cache Primes
id: cache-primes
uses: actions/cache@v3
with:
path: prime-numbers
key: ${{ runner.os }}-primes-${{ github.run_id }}
restore-keys: |
${{ runner.os }}-primes
${{ runner.os }}
- name: Generate Prime Numbers
if: steps.cache-primes.outputs.cache-hit != 'true'
run: cat /proc/sys/kernel/random/uuid > prime-numbers
- name: Use Prime Numbers
run: cat prime-numbers

View File

@@ -5,25 +5,24 @@
package artifacts
import (
"bytes"
"compress/gzip"
"context"
"encoding/json"
"fmt"
"io"
"maps"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path"
"path/filepath"
"strings"
"testing"
"testing/fstest"
"time"
"gitea.com/gitea/runner/act/model"
"gitea.com/gitea/runner/act/runner"
"github.com/julienschmidt/httprouter"
log "github.com/sirupsen/logrus"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
type writableMapFile struct {
@@ -235,133 +234,89 @@ func TestDownloadArtifactFile(t *testing.T) {
assert.Equal("content", string(data))
}
// TestArtifactFlow drives the real Serve() artifact server over a loopback socket, exercising
// the same upload -> finalize -> list -> download protocol the upload-artifact/download-artifact
// actions speak. Running it in-process (rather than from a job container) keeps it network-free
// and reachable everywhere, including when the CI job is itself a container.
type TestJobFileInfo struct {
workdir string
workflowPath string
eventName string
errorMessage string
platforms map[string]string
containerArchitecture string
}
var (
artifactsPath = path.Join(os.TempDir(), "test-artifacts")
artifactsAddr = "127.0.0.1"
artifactsPort = "12345"
)
func TestArtifactFlow(t *testing.T) {
artifactPath := t.TempDir()
// Serve the exact routes Serve() wires up, on a real loopback socket via httptest. httptest
// picks a free port and Close() tears the server down synchronously — avoiding both the
// port-rebind race and Serve()'s detached ListenAndServe goroutine, which logger.Fatal()s
// (process exit) on a bind error and can outlive the test's temp-dir cleanup.
router := httprouter.New()
fsys := readWriteFSImpl{}
uploads(router, artifactPath, fsys)
downloads(router, artifactPath, fsys)
server := httptest.NewServer(router)
defer server.Close()
baseURL := server.URL
client := server.Client()
client.Timeout = 5 * time.Second
// request performs one HTTP call and returns the status and body. The default transport adds
// Accept-Encoding: gzip and transparently decompresses, so gzipped downloads come back plain.
request := func(t *testing.T, method, rawURL string, body io.Reader, header http.Header) (int, []byte) {
t.Helper()
req, err := http.NewRequest(method, rawURL, body)
require.NoError(t, err)
maps.Copy(req.Header, header)
resp, err := client.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
data, err := io.ReadAll(resp.Body)
require.NoError(t, err)
return resp.StatusCode, data
if testing.Short() {
t.Skip("skipping integration test")
}
t.Run("upload-and-download", func(t *testing.T) {
const runID, item, content = "1", "my-artifact/data.txt", "hello artifact\n"
ctx := context.Background()
status, data := request(t, http.MethodPost, baseURL+"/_apis/pipelines/workflows/"+runID+"/artifacts", nil, nil)
require.Equal(t, http.StatusOK, status, string(data))
var prep FileContainerResourceURL
require.NoError(t, json.Unmarshal(data, &prep))
require.Equal(t, baseURL+"/upload/"+runID, prep.FileContainerResourceURL)
cancel := Serve(ctx, artifactsPath, artifactsAddr, artifactsPort)
defer cancel()
status, data = request(t, http.MethodPut, prep.FileContainerResourceURL+"?itemPath="+url.QueryEscape(item), strings.NewReader(content), nil)
require.Equal(t, http.StatusOK, status, string(data))
var msg ResponseMessage
require.NoError(t, json.Unmarshal(data, &msg))
require.Equal(t, "success", msg.Message)
platforms := map[string]string{
"ubuntu-latest": "node:16-buster", // Don't use node:16-buster-slim because it doesn't have curl command, which is used in the tests
}
status, data = request(t, http.MethodPatch, baseURL+"/_apis/pipelines/workflows/"+runID+"/artifacts", nil, nil)
require.Equal(t, http.StatusOK, status, string(data))
tables := []TestJobFileInfo{
{"testdata", "upload-and-download", "push", "", platforms, ""},
{"testdata", "GHSL-2023-004", "push", "", platforms, ""},
}
log.SetLevel(log.DebugLevel)
status, data = request(t, http.MethodGet, baseURL+"/_apis/pipelines/workflows/"+runID+"/artifacts", nil, nil)
require.Equal(t, http.StatusOK, status, string(data))
var list NamedFileContainerResourceURLResponse
require.NoError(t, json.Unmarshal(data, &list))
require.Equal(t, 1, list.Count)
require.Equal(t, "my-artifact", list.Value[0].Name)
for _, table := range tables {
runTestJobFile(ctx, t, table)
}
}
status, data = request(t, http.MethodGet, list.Value[0].FileContainerResourceURL+"?itemPath=my-artifact", nil, nil)
require.Equal(t, http.StatusOK, status, string(data))
var items ContainerItemResponse
require.NoError(t, json.Unmarshal(data, &items))
require.Len(t, items.Value, 1)
require.Equal(t, "file", items.Value[0].ItemType)
require.Equal(t, "my-artifact/data.txt", items.Value[0].Path)
func runTestJobFile(ctx context.Context, t *testing.T, tjfi TestJobFileInfo) {
t.Run(tjfi.workflowPath, func(t *testing.T) {
fmt.Printf("::group::%s\n", tjfi.workflowPath) //nolint:forbidigo // pre-existing issue from nektos/act
status, data = request(t, http.MethodGet, items.Value[0].ContentLocation, nil, nil)
require.Equal(t, http.StatusOK, status)
require.Equal(t, content, string(data))
if err := os.RemoveAll(artifactsPath); err != nil {
panic(err)
}
stored, err := os.ReadFile(filepath.Join(artifactPath, runID, "my-artifact", "data.txt"))
require.NoError(t, err)
require.Equal(t, content, string(stored))
})
workdir, err := filepath.Abs(tjfi.workdir)
assert.NoError(t, err, workdir) //nolint:testifylint // pre-existing issue from nektos/act
fullWorkflowPath := filepath.Join(workdir, tjfi.workflowPath)
runnerConfig := &runner.Config{
Workdir: workdir,
BindWorkdir: false,
EventName: tjfi.eventName,
Platforms: tjfi.platforms,
ReuseContainers: false,
ContainerArchitecture: tjfi.containerArchitecture,
GitHubInstance: "github.com",
ArtifactServerPath: artifactsPath,
ArtifactServerAddr: artifactsAddr,
ArtifactServerPort: artifactsPort,
}
t.Run("gzip-roundtrip", func(t *testing.T) {
const runID, item, content = "2", "logs/app.log", "compressed payload\n"
runner, err := runner.New(runnerConfig)
assert.NoError(t, err, tjfi.workflowPath) //nolint:testifylint // pre-existing issue from nektos/act
var buf bytes.Buffer
gz := gzip.NewWriter(&buf)
_, err := gz.Write([]byte(content))
require.NoError(t, err)
require.NoError(t, gz.Close())
planner, err := model.NewWorkflowPlanner(fullWorkflowPath, true)
assert.NoError(t, err, fullWorkflowPath) //nolint:testifylint // pre-existing issue from nektos/act
status, data := request(t, http.MethodPut, baseURL+"/upload/"+runID+"?itemPath="+url.QueryEscape(item),
&buf, http.Header{"Content-Encoding": []string{"gzip"}})
require.Equal(t, http.StatusOK, status, string(data))
plan, err := planner.PlanEvent(tjfi.eventName)
if err == nil {
err = runner.NewPlanExecutor(plan)(ctx)
if tjfi.errorMessage == "" {
assert.NoError(t, err, fullWorkflowPath) //nolint:testifylint // pre-existing issue from nektos/act
} else {
assert.Error(t, err, tjfi.errorMessage) //nolint:testifylint // pre-existing issue from nektos/act
}
} else {
assert.Nil(t, plan)
}
// stored compressed, with the server's gzip marker suffix
_, err = os.Stat(filepath.Join(artifactPath, runID, "logs", "app.log.gz__"))
require.NoError(t, err)
status, data = request(t, http.MethodGet, baseURL+"/download/"+runID+"?itemPath=logs", nil, nil)
require.Equal(t, http.StatusOK, status, string(data))
var items ContainerItemResponse
require.NoError(t, json.Unmarshal(data, &items))
require.Len(t, items.Value, 1)
require.Equal(t, "logs/app.log", items.Value[0].Path)
status, data = request(t, http.MethodGet, items.Value[0].ContentLocation, nil, nil)
require.Equal(t, http.StatusOK, status)
require.Equal(t, content, string(data))
})
// GHSL-2023-004: an itemPath that climbs out of the run directory must be neutralised so the
// blob cannot be written outside the artifact root.
t.Run("GHSL-2023-004", func(t *testing.T) {
const runID, content = "3", "contained\n"
status, data := request(t, http.MethodPut, baseURL+"/upload/"+runID+"?itemPath="+url.QueryEscape("../../escape.txt"),
strings.NewReader(content), nil)
require.Equal(t, http.StatusOK, status, string(data))
stored, err := os.ReadFile(filepath.Join(artifactPath, runID, "escape.txt"))
require.NoError(t, err)
require.Equal(t, content, string(stored))
_, err = os.Stat(filepath.Join(filepath.Dir(artifactPath), "escape.txt"))
require.True(t, os.IsNotExist(err), "upload escaped the artifact root")
status, data = request(t, http.MethodGet, baseURL+"/artifact/"+runID+"/escape.txt", nil, nil)
require.Equal(t, http.StatusOK, status)
require.Equal(t, content, string(data))
fmt.Println("::endgroup::") //nolint:forbidigo // pre-existing issue from nektos/act
})
}

View File

@@ -0,0 +1,39 @@
name: "GHSL-2023-0004"
on: push
jobs:
test-artifacts:
runs-on: ubuntu-latest
steps:
- run: echo "hello world" > test.txt
- name: curl upload
run: curl --silent --show-error --fail ${ACTIONS_RUNTIME_URL}upload/1?itemPath=../../my-artifact/secret.txt --upload-file test.txt
- uses: actions/download-artifact@v2
with:
name: my-artifact
path: test-artifacts
- name: 'Verify Artifact #1'
run: |
file="test-artifacts/secret.txt"
if [ ! -f $file ] ; then
echo "Expected file does not exist"
exit 1
fi
if [ "$(cat $file)" != "hello world" ] ; then
echo "File contents of downloaded artifact are incorrect"
exit 1
fi
- name: Verify download should work by clean extra dots
run: curl --silent --show-error --fail --path-as-is -o out.txt ${ACTIONS_RUNTIME_URL}artifact/1/../../../1/my-artifact/secret.txt
- name: 'Verify download content'
run: |
file="out.txt"
if [ ! -f $file ] ; then
echo "Expected file does not exist"
exit 1
fi
if [ "$(cat $file)" != "hello world" ] ; then
echo "File contents of downloaded artifact are incorrect"
exit 1
fi

View File

@@ -0,0 +1,230 @@
name: "Test that artifact uploads and downloads succeed"
on: push
jobs:
test-artifacts:
runs-on: ubuntu-latest
steps:
- run: mkdir -p path/to/artifact
- run: echo hello > path/to/artifact/world.txt
- uses: actions/upload-artifact@v2
with:
name: my-artifact
path: path/to/artifact/world.txt
- run: rm -rf path
- uses: actions/download-artifact@v2
with:
name: my-artifact
- name: Display structure of downloaded files
run: ls -la
# Test end-to-end by uploading two artifacts and then downloading them
- name: Create artifact files
run: |
mkdir -p path/to/dir-1
mkdir -p path/to/dir-2
mkdir -p path/to/dir-3
mkdir -p path/to/dir-5
mkdir -p path/to/dir-6
mkdir -p path/to/dir-7
echo "Lorem ipsum dolor sit amet" > path/to/dir-1/file1.txt
echo "Hello world from file #2" > path/to/dir-2/file2.txt
echo "This is a going to be a test for a large enough file that should get compressed with GZip. The @actions/artifact package uses GZip to upload files. This text should have a compression ratio greater than 100% so it should get uploaded using GZip" > path/to/dir-3/gzip.txt
dd if=/dev/random of=path/to/dir-5/file5.rnd bs=1024 count=1024
dd if=/dev/random of=path/to/dir-6/file6.rnd bs=1024 count=$((10*1024))
dd if=/dev/random of=path/to/dir-7/file7.rnd bs=1024 count=$((10*1024))
# Upload a single file artifact
- name: 'Upload artifact #1'
uses: actions/upload-artifact@v2
with:
name: 'Artifact-A'
path: path/to/dir-1/file1.txt
# Upload using a wildcard pattern, name should default to 'artifact' if not provided
- name: 'Upload artifact #2'
uses: actions/upload-artifact@v2
with:
path: path/**/dir*/
# Upload a directory that contains a file that will be uploaded with GZip
- name: 'Upload artifact #3'
uses: actions/upload-artifact@v2
with:
name: 'GZip-Artifact'
path: path/to/dir-3/
# Upload a directory that contains a file that will be uploaded with GZip
- name: 'Upload artifact #4'
uses: actions/upload-artifact@v2
with:
name: 'Multi-Path-Artifact'
path: |
path/to/dir-1/*
path/to/dir-[23]/*
!path/to/dir-3/*.txt
# Upload a mid-size file artifact
- name: 'Upload artifact #5'
uses: actions/upload-artifact@v2
with:
name: 'Mid-Size-Artifact'
path: path/to/dir-5/file5.rnd
# Upload a big file artifact
- name: 'Upload artifact #6'
uses: actions/upload-artifact@v2
with:
name: 'Big-Artifact'
path: path/to/dir-6/file6.rnd
# Upload a big file artifact twice
- name: 'Upload artifact #7 (First)'
uses: actions/upload-artifact@v2
with:
name: 'Big-Uploaded-Twice'
path: path/to/dir-7/file7.rnd
# Upload a big file artifact twice
- name: 'Upload artifact #7 (Second)'
uses: actions/upload-artifact@v2
with:
name: 'Big-Uploaded-Twice'
path: path/to/dir-7/file7.rnd
# Verify artifacts. Switch to download-artifact@v2 once it's out of preview
# Download Artifact #1 and verify the correctness of the content
- name: 'Download artifact #1'
uses: actions/download-artifact@v2
with:
name: 'Artifact-A'
path: some/new/path
- name: 'Verify Artifact #1'
run: |
file="some/new/path/file1.txt"
if [ ! -f $file ] ; then
echo "Expected file does not exist"
exit 1
fi
if [ "$(cat $file)" != "Lorem ipsum dolor sit amet" ] ; then
echo "File contents of downloaded artifact are incorrect"
exit 1
fi
# Download Artifact #2 and verify the correctness of the content
- name: 'Download artifact #2'
uses: actions/download-artifact@v2
with:
name: 'artifact'
path: some/other/path
- name: 'Verify Artifact #2'
run: |
file1="some/other/path/to/dir-1/file1.txt"
file2="some/other/path/to/dir-2/file2.txt"
if [ ! -f $file1 -o ! -f $file2 ] ; then
echo "Expected files do not exist"
exit 1
fi
if [ "$(cat $file1)" != "Lorem ipsum dolor sit amet" -o "$(cat $file2)" != "Hello world from file #2" ] ; then
echo "File contents of downloaded artifacts are incorrect"
exit 1
fi
# Download Artifact #3 and verify the correctness of the content
- name: 'Download artifact #3'
uses: actions/download-artifact@v2
with:
name: 'GZip-Artifact'
path: gzip/artifact/path
# Because a directory was used as input during the upload the parent directories, path/to/dir-3/, should not be included in the uploaded artifact
- name: 'Verify Artifact #3'
run: |
gzipFile="gzip/artifact/path/gzip.txt"
if [ ! -f $gzipFile ] ; then
echo "Expected file do not exist"
exit 1
fi
if [ "$(cat $gzipFile)" != "This is a going to be a test for a large enough file that should get compressed with GZip. The @actions/artifact package uses GZip to upload files. This text should have a compression ratio greater than 100% so it should get uploaded using GZip" ] ; then
echo "File contents of downloaded artifact is incorrect"
exit 1
fi
- name: 'Download artifact #4'
uses: actions/download-artifact@v2
with:
name: 'Multi-Path-Artifact'
path: multi/artifact
- name: 'Verify Artifact #4'
run: |
file1="multi/artifact/dir-1/file1.txt"
file2="multi/artifact/dir-2/file2.txt"
if [ ! -f $file1 -o ! -f $file2 ] ; then
echo "Expected files do not exist"
exit 1
fi
if [ "$(cat $file1)" != "Lorem ipsum dolor sit amet" -o "$(cat $file2)" != "Hello world from file #2" ] ; then
echo "File contents of downloaded artifacts are incorrect"
exit 1
fi
- name: 'Download artifact #5'
uses: actions/download-artifact@v2
with:
name: 'Mid-Size-Artifact'
path: mid-size/artifact/path
- name: 'Verify Artifact #5'
run: |
file="mid-size/artifact/path/file5.rnd"
if [ ! -f $file ] ; then
echo "Expected file does not exist"
exit 1
fi
if ! diff $file path/to/dir-5/file5.rnd ; then
echo "File contents of downloaded artifact are incorrect"
exit 1
fi
- name: 'Download artifact #6'
uses: actions/download-artifact@v2
with:
name: 'Big-Artifact'
path: big/artifact/path
- name: 'Verify Artifact #6'
run: |
file="big/artifact/path/file6.rnd"
if [ ! -f $file ] ; then
echo "Expected file does not exist"
exit 1
fi
if ! diff $file path/to/dir-6/file6.rnd ; then
echo "File contents of downloaded artifact are incorrect"
exit 1
fi
- name: 'Download artifact #7'
uses: actions/download-artifact@v2
with:
name: 'Big-Uploaded-Twice'
path: big-uploaded-twice/artifact/path
- name: 'Verify Artifact #7'
run: |
file="big-uploaded-twice/artifact/path/file7.rnd"
if [ ! -f $file ] ; then
echo "Expected file does not exist"
exit 1
fi
if ! diff $file path/to/dir-7/file7.rnd ; then
echo "File contents of downloaded artifact are incorrect"
exit 1
fi

View File

@@ -4,8 +4,6 @@
package common
import "slices"
// CartesianProduct takes map of lists and returns list of unique tuples
func CartesianProduct(mapOfLists map[string][]any) []map[string]any {
listNames := make([]string, 0)
@@ -48,7 +46,7 @@ func cartN(a ...[]any) [][]any {
for j, n := range n {
pi[j] = a[j][n]
}
for j := range slices.Backward(n) {
for j := len(n) - 1; j >= 0; j-- {
n[j]++
if n[j] < len(a[j]) {
break

146
act/common/draw.go Normal file
View File

@@ -0,0 +1,146 @@
// 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,6 +12,24 @@ 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
@@ -79,12 +97,6 @@ func NewErrorExecutor(err error) Executor {
// NewParallelExecutor creates a new executor from a parallel of other executors
func NewParallelExecutor(parallel int, executors ...Executor) Executor {
if len(executors) == 0 {
return func(ctx context.Context) error {
return ctx.Err()
}
}
return func(ctx context.Context) error {
work := make(chan Executor, len(executors))
errs := make(chan error, len(executors))
@@ -144,8 +156,14 @@ 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 {
if err := e(ctx); err != nil {
return err
err := e(ctx)
if err != nil {
switch err.(type) {
case Warning:
Logger(ctx).Warning(err.Error())
default:
return err
}
}
if ctx.Err() != nil {
return ctx.Err()

View File

@@ -170,6 +170,68 @@ func TestMaxParallelWithErrors(t *testing.T) {
})
}
// TestMaxParallelPerformance tests performance characteristics
func TestMaxParallelPerformance(t *testing.T) {
if testing.Short() {
t.Skip("Skipping performance test in short mode")
}
t.Run("ParallelFasterThanSequential", func(t *testing.T) {
executors := make([]Executor, 10)
for i := range 10 {
executors[i] = func(ctx context.Context) error {
time.Sleep(50 * time.Millisecond)
return nil
}
}
ctx := context.Background()
// Sequential (max-parallel=1)
start := time.Now()
err := NewParallelExecutor(1, executors...)(ctx)
sequentialDuration := time.Since(start)
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
// Parallel (max-parallel=5)
start = time.Now()
err = NewParallelExecutor(5, executors...)(ctx)
parallelDuration := time.Since(start)
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
// Parallel should be significantly faster
assert.Less(t, parallelDuration, sequentialDuration/2,
"Parallel execution should be at least 2x faster")
})
t.Run("OptimalWorkerCount", func(t *testing.T) {
executors := make([]Executor, 20)
for i := range 20 {
executors[i] = func(ctx context.Context) error {
time.Sleep(10 * time.Millisecond)
return nil
}
}
ctx := context.Background()
// Test with different worker counts
workerCounts := []int{1, 2, 5, 10, 20}
durations := make(map[int]time.Duration)
for _, count := range workerCounts {
start := time.Now()
err := NewParallelExecutor(count, executors...)(ctx)
durations[count] = time.Since(start)
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
}
// More workers should generally be faster (up to a point)
assert.Less(t, durations[5], durations[1], "5 workers should be faster than 1")
assert.Less(t, durations[10], durations[2], "10 workers should be faster than 2")
})
}
// TestMaxParallelResourceSharing tests resource sharing scenarios
func TestMaxParallelResourceSharing(t *testing.T) {
t.Run("SharedResourceWithMutex", func(t *testing.T) {

View File

@@ -12,7 +12,6 @@ import (
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestNewWorkflow(t *testing.T) {
@@ -120,19 +119,6 @@ func TestNewParallelExecutor(t *testing.T) {
assert.NoError(errSingle)
}
func TestNewParallelExecutorEmpty(t *testing.T) {
assert := assert.New(t)
ctx := context.Background()
require.NoError(t, NewParallelExecutor(2)(ctx))
canceledCtx, cancel := context.WithCancel(context.Background())
cancel()
err := NewParallelExecutor(2)(canceledCtx)
assert.ErrorIs(err, context.Canceled)
}
func TestNewParallelExecutorFailed(t *testing.T) {
assert := assert.New(t)

77
act/common/file.go Normal file
View File

@@ -0,0 +1,77 @@
// 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

@@ -38,11 +38,9 @@ var (
ErrNoRepo = errors.New("unable to find git repo")
)
// AcquireCloneLock returns an unlock function after locking the per-directory mutex for dir.
// Only concurrent operations targeting the same directory are serialized; clones into different directories run in parallel.
// Callers reading files inside dir (e.g. tarring a checked-out action into a job container) must hold this lock too,
// otherwise a concurrent NewGitCloneExecutor on the same dir can mutate the worktree mid-read.
func AcquireCloneLock(dir string) func() {
// acquireCloneLock returns an unlock function after locking the per-directory mutex for dir.
// Only concurrent operations targeting the same directory are erialized; clones into different directories run in parallel.
func acquireCloneLock(dir string) func() {
v, _ := cloneLocks.LoadOrStore(dir, &sync.Mutex{})
mu := v.(*sync.Mutex)
mu.Lock()
@@ -66,21 +64,8 @@ func (e *Error) Commit() string {
return e.commit
}
// goGitMu serializes go-git repository access across the process. go-git is not safe for
// concurrent use of the same repository (even read access decodes packfiles into shared
// state), so parallel jobs inspecting the shared workdir repo race without this. The guarded
// operations are fast local reads; gitea runs one job per process, so the lock is effectively
// uncontended in production.
var goGitMu sync.Mutex
// FindGitRevision get the current git revision
func FindGitRevision(ctx context.Context, file string) (shortSha, sha string, err error) {
goGitMu.Lock()
defer goGitMu.Unlock()
return findGitRevision(ctx, file)
}
func findGitRevision(ctx context.Context, file string) (shortSha, sha string, err error) {
logger := common.Logger(ctx)
gitDir, err := git.PlainOpenWithOptions(
@@ -112,13 +97,10 @@ func findGitRevision(ctx context.Context, file string) (shortSha, sha string, er
// FindGitRef get the current git ref
func FindGitRef(ctx context.Context, file string) (string, error) {
goGitMu.Lock()
defer goGitMu.Unlock()
logger := common.Logger(ctx)
logger.Debugf("Loading revision from git directory")
_, ref, err := findGitRevision(ctx, file)
_, ref, err := FindGitRevision(ctx, file)
if err != nil {
return "", err
}
@@ -190,8 +172,6 @@ func FindGitRef(ctx context.Context, file string) (string, error) {
// FindGithubRepo get the repo
func FindGithubRepo(ctx context.Context, file, githubInstance, remoteName string) (string, error) {
goGitMu.Lock()
defer goGitMu.Unlock()
if remoteName == "" {
remoteName = "origin"
}
@@ -261,50 +241,47 @@ type NewGitCloneExecutorInput struct {
InsecureSkipTLS bool
}
// 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) {
// CloneIfRequired ...
func CloneIfRequired(ctx context.Context, refName plumbing.ReferenceName, input NewGitCloneExecutorInput, logger log.FieldLogger) (*git.Repository, error) {
r, err := git.PlainOpen(input.Dir)
if err == nil {
// Reuse existing clone
return r, true, 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
}
}
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
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
}
}
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
}
}
if err = os.Chmod(input.Dir, 0o755); err != nil {
return nil, false, err
}
return r, false, nil
return r, nil
}
func gitOptions(token string) (fetchOptions git.FetchOptions, pullOptions git.PullOptions) {
@@ -328,13 +305,13 @@ func gitOptions(token string) (fetchOptions git.FetchOptions, pullOptions git.Pu
func NewGitCloneExecutor(input NewGitCloneExecutorInput) common.Executor {
return func(ctx context.Context) error {
logger := common.Logger(ctx)
logger.Infof("git clone '%s' # ref=%s", input.URL, input.Ref)
logger.Infof(" \u2601 git clone '%s' # ref=%s", input.URL, input.Ref)
logger.Debugf(" cloning %s to %s", input.URL, input.Dir)
defer AcquireCloneLock(input.Dir)()
defer acquireCloneLock(input.Dir)()
refName := plumbing.ReferenceName("refs/heads/" + input.Ref)
r, reused, err := CloneIfRequired(ctx, refName, input, logger)
r, err := CloneIfRequired(ctx, refName, input, logger)
if err != nil {
return err
}
@@ -359,10 +336,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/<ref>); the duck-typing below resolves it.
logger.Errorf("Unable to resolve %s: %v", input.Ref, err)
} else if hash.String() != input.Ref && strings.HasPrefix(hash.String(), input.Ref) {
}
if hash.String() != input.Ref && strings.HasPrefix(hash.String(), input.Ref) {
return &Error{
err: ErrShortRef,
commit: hash.String(),
@@ -413,18 +390,12 @@ 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%s", input.URL, input.Dir, reusedMsg)
logger.Debugf("Cloned %s to %s", input.URL, input.Dir)
if hash.String() != input.Ref && refType == "branch" {
logger.Debugf("Provided ref is not a sha. Updating branch ref after pull")

View File

@@ -16,6 +16,7 @@ import (
"testing"
"time"
log "github.com/sirupsen/logrus"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -49,6 +50,10 @@ func TestFindGitSlug(t *testing.T) {
}
}
func testDir(t *testing.T) string {
return t.TempDir()
}
func cleanGitHooks(dir string) error {
hooksDir := filepath.Join(dir, ".git", "hooks")
files, err := os.ReadDir(hooksDir)
@@ -73,7 +78,8 @@ func cleanGitHooks(dir string) error {
func TestFindGitRemoteURL(t *testing.T) {
assert := assert.New(t)
basedir := t.TempDir()
basedir := testDir(t)
gitConfig()
err := gitCmd("init", basedir)
assert.NoError(err) //nolint:testifylint // pre-existing issue from nektos/act
err = cleanGitHooks(basedir)
@@ -96,7 +102,8 @@ func TestFindGitRemoteURL(t *testing.T) {
}
func TestGitFindRef(t *testing.T) {
basedir := t.TempDir()
basedir := testDir(t)
gitConfig()
for name, tt := range map[string]struct {
Prepare func(t *testing.T, dir string)
@@ -173,55 +180,36 @@ func TestGitFindRef(t *testing.T) {
}
func TestGitCloneExecutor(t *testing.T) {
// Build a local bare "remote" so this runs offline and fast. The cases below mirror
// the tag/branch/sha/short-sha ref paths the executor handles, formerly exercised by
// cloning actions/checkout and anchore/scan-action over the network.
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, "tag", "v2"))
require.NoError(t, gitCmd("-C", workDir, "push", "-u", "origin", "main"))
require.NoError(t, gitCmd("-C", workDir, "push", "origin", "v2"))
// A branch with a dash in the name (mirrors the historical scan-action@act-fails case).
require.NoError(t, gitCmd("-C", workDir, "checkout", "-b", "act-fails"))
require.NoError(t, gitCmd("-C", workDir, "commit", "--allow-empty", "-m", "branch-commit"))
require.NoError(t, gitCmd("-C", workDir, "push", "origin", "act-fails"))
out, err := exec.Command("git", "-C", workDir, "rev-parse", "main").Output()
require.NoError(t, err)
fullSha := strings.TrimSpace(string(out))
for name, tt := range map[string]struct {
Err error
Ref string
Err error
URL, Ref string
}{
"tag": {
Err: nil,
URL: "https://github.com/actions/checkout",
Ref: "v2",
},
"branch": {
Err: nil,
URL: "https://github.com/anchore/scan-action",
Ref: "act-fails",
},
"sha": {
Err: nil,
Ref: fullSha,
URL: "https://github.com/actions/checkout",
Ref: "5a4ac9002d0be2fb38bd78e4b4dbde5606d7042f", // v2
},
"short-sha": {
Err: &Error{ErrShortRef, fullSha},
Ref: fullSha[:7],
Err: &Error{ErrShortRef, "5a4ac9002d0be2fb38bd78e4b4dbde5606d7042f"},
URL: "https://github.com/actions/checkout",
Ref: "5a4ac90", // v2
},
} {
t.Run(name, func(t *testing.T) {
clone := NewGitCloneExecutor(NewGitCloneExecutorInput{
URL: remoteDir,
URL: tt.URL,
Ref: tt.Ref,
Dir: t.TempDir(),
Dir: testDir(t),
})
err := clone(context.Background())
@@ -240,6 +228,8 @@ func TestGitCloneExecutorNonFastForwardRef(t *testing.T) {
// non-fast-forward between two fetches. Before the fix, the fetch used Force=false,
// causing go-git to return ErrForceNeeded and short-circuit the checkout.
gitConfig()
// Create a bare "remote" repo with an initial commit on main and a feature branch.
remoteDir := t.TempDir()
require.NoError(t, gitCmd("init", "--bare", "--initial-branch=main", remoteDir))
@@ -289,67 +279,22 @@ 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) {
// 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/<ref>.
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
if err = gitCmd("config", "--global", "user.email", "test@test.com"); err != nil {
log.Error(err)
}
if err = gitCmd("config", "--global", "user.name", "Unit Test"); err != nil {
log.Error(err)
}
}
}
func gitCmd(args ...string) error {
cmd := exec.Command("git", args...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
// Inject a deterministic identity and ignore the host's global/system config so commits
// succeed regardless of the host having no user.name/user.email (e.g. CI, GITHUB_ACTIONS
// unset) or a global commit.gpgsign, and without mutating the developer's ~/.gitconfig.
cmd.Env = append(os.Environ(),
"GIT_AUTHOR_NAME=Unit Test",
"GIT_AUTHOR_EMAIL=test@test.com",
"GIT_COMMITTER_NAME=Unit Test",
"GIT_COMMITTER_EMAIL=test@test.com",
"GIT_CONFIG_GLOBAL=/dev/null",
"GIT_CONFIG_SYSTEM=/dev/null",
)
err := cmd.Run()
if exitError, ok := err.(*exec.ExitError); ok {
@@ -365,11 +310,11 @@ func TestAcquireCloneLock(t *testing.T) {
t.Run("same directory serializes", func(t *testing.T) {
dir := t.TempDir()
unlock1 := AcquireCloneLock(dir)
unlock1 := acquireCloneLock(dir)
secondAcquired := make(chan struct{})
go func() {
unlock := AcquireCloneLock(dir)
unlock := acquireCloneLock(dir)
close(secondAcquired)
unlock()
}()
@@ -393,12 +338,12 @@ func TestAcquireCloneLock(t *testing.T) {
dirA := t.TempDir()
dirB := t.TempDir()
unlockA := AcquireCloneLock(dirA)
unlockA := acquireCloneLock(dirA)
defer unlockA()
done := make(chan struct{})
go func() {
unlock := AcquireCloneLock(dirB)
unlock := acquireCloneLock(dirB)
unlock()
close(done)
}()

View File

@@ -6,7 +6,6 @@ package container
import (
"context"
"fmt"
"io"
"gitea.com/gitea/runner/act/common"
@@ -14,13 +13,6 @@ import (
"github.com/docker/go-connections/nat"
)
// ExitCodeError reports a non-zero process exit code from a container command.
type ExitCodeError int
func (e ExitCodeError) Error() string {
return fmt.Sprintf("Process completed with exit code %d.", int(e))
}
// NewContainerInput the input for the New function
type NewContainerInput struct {
Image string
@@ -47,7 +39,6 @@ type NewContainerInput struct {
// Gitea specific
AutoRemove bool
ValidVolumes []string
AllocatePTY bool // allocate a pseudo-TTY for the container's exec processes
}
// FileEntry is a file to copy to a container

View File

@@ -8,33 +8,34 @@ package container
import (
"context"
"strings"
"gitea.com/gitea/runner/act/common"
"github.com/distribution/reference"
"github.com/docker/cli/cli/config"
"github.com/moby/moby/api/types/registry"
"github.com/docker/cli/cli/config/credentials"
"github.com/docker/docker/api/types/registry"
)
func LoadDockerAuthConfig(ctx context.Context, image string) (registry.AuthConfig, error) {
logger := common.Logger(ctx)
// config.LoadDefaultConfigFile panics on nil io.Writer when the config
// file is malformed; use config.Load to route errors through the logger.
cfg, err := config.Load(config.Dir())
config, err := config.Load(config.Dir())
if err != nil {
logger.Warnf("Could not load docker config: %v", err)
return registry.AuthConfig{}, err
}
registryKey := registryAuthConfigKey("docker.io")
if image != "" {
if registryRef, refErr := reference.ParseNormalizedNamed(image); refErr != nil {
logger.Warnf("Could not normalize image reference: %v", refErr)
} else {
registryKey = registryAuthConfigKey(reference.Domain(registryRef))
}
if !config.ContainsAuth() {
config.CredentialsStore = credentials.DetectDefaultStore(config.CredentialsStore)
}
authConfig, err := cfg.GetAuthConfig(registryKey)
hostName := "index.docker.io"
index := strings.IndexRune(image, '/')
if index > -1 && (strings.ContainsAny(image[:index], ".:") || image[:index] == "localhost") {
hostName = image[:index]
}
authConfig, err := config.GetAuthConfig(hostName)
if err != nil {
logger.Warnf("Could not get auth config from docker config: %v", err)
return registry.AuthConfig{}, err
@@ -45,16 +46,17 @@ func LoadDockerAuthConfig(ctx context.Context, image string) (registry.AuthConfi
func LoadDockerAuthConfigs(ctx context.Context) map[string]registry.AuthConfig {
logger := common.Logger(ctx)
cfg, err := config.Load(config.Dir())
config, err := config.Load(config.Dir())
if err != nil {
logger.Warnf("Could not load docker config: %v", err)
return nil
}
creds, err := cfg.GetAllCredentials()
if err != nil {
logger.Warnf("Could not get docker auth configs: %v", err)
return nil
if !config.ContainsAuth() {
config.CredentialsStore = credentials.DetectDefaultStore(config.CredentialsStore)
}
creds, _ := config.GetAllCredentials()
authConfigs := make(map[string]registry.AuthConfig, len(creds))
for k, v := range creds {
authConfigs[k] = registry.AuthConfig(v)
@@ -62,10 +64,3 @@ func LoadDockerAuthConfigs(ctx context.Context) map[string]registry.AuthConfig {
return authConfigs
}
func registryAuthConfigKey(domainName string) string {
if domainName == "docker.io" || domainName == "index.docker.io" {
return "https://index.docker.io/v1/"
}
return domainName
}

View File

@@ -14,12 +14,10 @@ import (
"gitea.com/gitea/runner/act/common"
"github.com/moby/go-archive"
"github.com/moby/go-archive/compression"
"github.com/moby/moby/client"
"github.com/docker/docker/api/types"
"github.com/docker/docker/pkg/archive"
"github.com/moby/buildkit/frontend/dockerfile/dockerignore"
"github.com/moby/patternmatcher"
"github.com/moby/patternmatcher/ignorefile"
specs "github.com/opencontainers/image-spec/specs-go/v1"
)
// NewDockerBuildExecutor function to create a run executor for the container
@@ -27,9 +25,9 @@ func NewDockerBuildExecutor(input NewDockerBuildExecutorInput) common.Executor {
return func(ctx context.Context) error {
logger := common.Logger(ctx)
if input.Platform != "" {
logger.Infof("docker build -t %s --platform %s %s", input.ImageTag, input.Platform, input.ContextDir)
logger.Infof("%sdocker build -t %s --platform %s %s", logPrefix, input.ImageTag, input.Platform, input.ContextDir)
} else {
logger.Infof("docker build -t %s %s", input.ImageTag, input.ContextDir)
logger.Infof("%sdocker build -t %s %s", logPrefix, input.ImageTag, input.ContextDir)
}
if common.Dryrun(ctx) {
return nil
@@ -44,19 +42,13 @@ func NewDockerBuildExecutor(input NewDockerBuildExecutorInput) common.Executor {
logger.Debugf("Building image from '%v'", input.ContextDir)
tags := []string{input.ImageTag}
options := client.ImageBuildOptions{
options := types.ImageBuildOptions{
Tags: tags,
Remove: true,
Platform: input.Platform,
AuthConfigs: LoadDockerAuthConfigs(ctx),
Dockerfile: input.Dockerfile,
}
platform, err := parsePlatform(input.Platform)
if err != nil {
return err
}
if platform != nil {
options.Platforms = []specs.Platform{*platform}
}
var buildContext io.ReadCloser
if input.BuildContext != nil {
buildContext = io.NopCloser(input.BuildContext)
@@ -84,7 +76,7 @@ func createBuildContext(ctx context.Context, contextDir, relDockerfile string) (
common.Logger(ctx).Debugf("Creating archive for build context dir '%s' with relative dockerfile '%s'", contextDir, relDockerfile)
// And canonicalize dockerfile name to a platform-independent one
relDockerfile = filepath.ToSlash(relDockerfile)
relDockerfile = archive.CanonicalTarNameForPath(relDockerfile)
f, err := os.Open(filepath.Join(contextDir, ".dockerignore"))
if err != nil && !os.IsNotExist(err) {
@@ -94,7 +86,7 @@ func createBuildContext(ctx context.Context, contextDir, relDockerfile string) (
var excludes []string
if err == nil {
excludes, err = ignorefile.ReadAll(f)
excludes, err = dockerignore.ReadAll(f) //nolint:staticcheck // pre-existing issue from nektos/act
if err != nil {
return nil, err
}
@@ -114,8 +106,9 @@ func createBuildContext(ctx context.Context, contextDir, relDockerfile string) (
includes = append(includes, ".dockerignore", relDockerfile)
}
compression := archive.Uncompressed
buildCtx, err := archive.TarWithOptions(contextDir, &archive.TarOptions{
Compression: compression.None,
Compression: compression,
ExcludePatterns: excludes,
IncludeFiles: includes,
})

File diff suppressed because it is too large Load Diff

View File

@@ -16,18 +16,15 @@ package container
import (
"fmt"
"io"
"net/netip"
"os"
"runtime"
"strings"
"testing"
"time"
"github.com/docker/docker/api/types/container"
networktypes "github.com/docker/docker/api/types/network"
"github.com/docker/go-connections/nat"
"github.com/google/go-cmp/cmp"
"github.com/google/go-cmp/cmp/cmpopts"
"github.com/moby/moby/api/types/container"
networktypes "github.com/moby/moby/api/types/network"
"github.com/pkg/errors"
"github.com/spf13/pflag"
"gotest.tools/v3/assert"
@@ -80,21 +77,21 @@ func setupRunFlags() (*pflag.FlagSet, *containerOptions) {
return flags, copts
}
func mustParse(t *testing.T, args string) (*container.Config, *container.HostConfig, *networktypes.NetworkingConfig) {
func mustParse(t *testing.T, args string) (*container.Config, *container.HostConfig) {
t.Helper()
config, hostConfig, networkingConfig, err := parseRun(append(strings.Split(args, " "), "ubuntu", "bash"))
config, hostConfig, _, err := parseRun(append(strings.Split(args, " "), "ubuntu", "bash"))
assert.NilError(t, err)
return config, hostConfig, networkingConfig
return config, hostConfig
}
func TestParseRunLinks(t *testing.T) {
if _, hostConfig, _ := mustParse(t, "--link a:b"); len(hostConfig.Links) == 0 || hostConfig.Links[0] != "a:b" {
if _, hostConfig := mustParse(t, "--link a:b"); len(hostConfig.Links) == 0 || hostConfig.Links[0] != "a:b" {
t.Fatalf("Error parsing links. Expected []string{\"a:b\"}, received: %v", hostConfig.Links)
}
if _, hostConfig, _ := mustParse(t, "--link a:b --link c:d"); len(hostConfig.Links) < 2 || hostConfig.Links[0] != "a:b" || hostConfig.Links[1] != "c:d" {
if _, hostConfig := mustParse(t, "--link a:b --link c:d"); len(hostConfig.Links) < 2 || hostConfig.Links[0] != "a:b" || hostConfig.Links[1] != "c:d" {
t.Fatalf("Error parsing links. Expected []string{\"a:b\", \"c:d\"}, received: %v", hostConfig.Links)
}
if _, hostConfig, _ := mustParse(t, ""); len(hostConfig.Links) != 0 {
if _, hostConfig := mustParse(t, ""); len(hostConfig.Links) != 0 {
t.Fatalf("Error parsing links. No link expected, received: %v", hostConfig.Links)
}
}
@@ -143,7 +140,7 @@ func TestParseRunAttach(t *testing.T) {
}
for _, tc := range tests {
t.Run(tc.input, func(t *testing.T) {
config, _, _ := mustParse(t, tc.input)
config, _ := mustParse(t, tc.input)
assert.Equal(t, config.AttachStdin, tc.expected.AttachStdin)
assert.Equal(t, config.AttachStdout, tc.expected.AttachStdout)
assert.Equal(t, config.AttachStderr, tc.expected.AttachStderr)
@@ -197,10 +194,10 @@ func TestParseRunWithInvalidArgs(t *testing.T) {
}
}
func TestParseWithVolumes(t *testing.T) { //nolint:gocyclo // verbatim copy from docker/cli tests
func TestParseWithVolumes(t *testing.T) {
// A single volume
arr, tryit := setupPlatformVolume([]string{`/tmp`}, []string{`c:\tmp`})
if config, hostConfig, _ := mustParse(t, tryit); hostConfig.Binds != nil {
if config, hostConfig := mustParse(t, tryit); hostConfig.Binds != nil {
t.Fatalf("Error parsing volume flags, %q should not mount-bind anything. Received %v", tryit, hostConfig.Binds)
} else if _, exists := config.Volumes[arr[0]]; !exists {
t.Fatalf("Error parsing volume flags, %q is missing from volumes. Received %v", tryit, config.Volumes)
@@ -208,7 +205,7 @@ func TestParseWithVolumes(t *testing.T) { //nolint:gocyclo // verbatim copy from
// Two volumes
arr, tryit = setupPlatformVolume([]string{`/tmp`, `/var`}, []string{`c:\tmp`, `c:\var`})
if config, hostConfig, _ := mustParse(t, tryit); hostConfig.Binds != nil {
if config, hostConfig := mustParse(t, tryit); hostConfig.Binds != nil {
t.Fatalf("Error parsing volume flags, %q should not mount-bind anything. Received %v", tryit, hostConfig.Binds)
} else if _, exists := config.Volumes[arr[0]]; !exists {
t.Fatalf("Error parsing volume flags, %s is missing from volumes. Received %v", arr[0], config.Volumes)
@@ -218,13 +215,13 @@ func TestParseWithVolumes(t *testing.T) { //nolint:gocyclo // verbatim copy from
// A single bind mount
arr, tryit = setupPlatformVolume([]string{`/hostTmp:/containerTmp`}, []string{os.Getenv("TEMP") + `:c:\containerTmp`})
if config, hostConfig, _ := mustParse(t, tryit); hostConfig.Binds == nil || hostConfig.Binds[0] != arr[0] {
if config, hostConfig := mustParse(t, tryit); hostConfig.Binds == nil || hostConfig.Binds[0] != arr[0] {
t.Fatalf("Error parsing volume flags, %q should mount-bind the path before the colon into the path after the colon. Received %v %v", arr[0], hostConfig.Binds, config.Volumes)
}
// Two bind mounts.
arr, tryit = setupPlatformVolume([]string{`/hostTmp:/containerTmp`, `/hostVar:/containerVar`}, []string{os.Getenv("ProgramData") + `:c:\ContainerPD`, os.Getenv("TEMP") + `:c:\containerTmp`})
if _, hostConfig, _ := mustParse(t, tryit); hostConfig.Binds == nil || compareRandomizedStrings(hostConfig.Binds[0], hostConfig.Binds[1], arr[0], arr[1]) != nil {
if _, hostConfig := mustParse(t, tryit); hostConfig.Binds == nil || compareRandomizedStrings(hostConfig.Binds[0], hostConfig.Binds[1], arr[0], arr[1]) != nil {
t.Fatalf("Error parsing volume flags, `%s and %s` did not mount-bind correctly. Received %v", arr[0], arr[1], hostConfig.Binds)
}
@@ -233,26 +230,26 @@ func TestParseWithVolumes(t *testing.T) { //nolint:gocyclo // verbatim copy from
arr, tryit = setupPlatformVolume(
[]string{`/hostTmp:/containerTmp:ro`, `/hostVar:/containerVar:rw`},
[]string{os.Getenv("TEMP") + `:c:\containerTmp:rw`, os.Getenv("ProgramData") + `:c:\ContainerPD:rw`})
if _, hostConfig, _ := mustParse(t, tryit); hostConfig.Binds == nil || compareRandomizedStrings(hostConfig.Binds[0], hostConfig.Binds[1], arr[0], arr[1]) != nil {
if _, hostConfig := mustParse(t, tryit); hostConfig.Binds == nil || compareRandomizedStrings(hostConfig.Binds[0], hostConfig.Binds[1], arr[0], arr[1]) != nil {
t.Fatalf("Error parsing volume flags, `%s and %s` did not mount-bind correctly. Received %v", arr[0], arr[1], hostConfig.Binds)
}
// Similar to previous test but with alternate modes which are only supported by Linux
if runtime.GOOS != "windows" {
arr, tryit = setupPlatformVolume([]string{`/hostTmp:/containerTmp:ro,Z`, `/hostVar:/containerVar:rw,Z`}, []string{})
if _, hostConfig, _ := mustParse(t, tryit); hostConfig.Binds == nil || compareRandomizedStrings(hostConfig.Binds[0], hostConfig.Binds[1], arr[0], arr[1]) != nil {
if _, hostConfig := mustParse(t, tryit); hostConfig.Binds == nil || compareRandomizedStrings(hostConfig.Binds[0], hostConfig.Binds[1], arr[0], arr[1]) != nil {
t.Fatalf("Error parsing volume flags, `%s and %s` did not mount-bind correctly. Received %v", arr[0], arr[1], hostConfig.Binds)
}
arr, tryit = setupPlatformVolume([]string{`/hostTmp:/containerTmp:Z`, `/hostVar:/containerVar:z`}, []string{})
if _, hostConfig, _ := mustParse(t, tryit); hostConfig.Binds == nil || compareRandomizedStrings(hostConfig.Binds[0], hostConfig.Binds[1], arr[0], arr[1]) != nil {
if _, hostConfig := mustParse(t, tryit); hostConfig.Binds == nil || compareRandomizedStrings(hostConfig.Binds[0], hostConfig.Binds[1], arr[0], arr[1]) != nil {
t.Fatalf("Error parsing volume flags, `%s and %s` did not mount-bind correctly. Received %v", arr[0], arr[1], hostConfig.Binds)
}
}
// One bind mount and one volume
arr, tryit = setupPlatformVolume([]string{`/hostTmp:/containerTmp`, `/containerVar`}, []string{os.Getenv("TEMP") + `:c:\containerTmp`, `c:\containerTmp`})
if config, hostConfig, _ := mustParse(t, tryit); hostConfig.Binds == nil || len(hostConfig.Binds) > 1 || hostConfig.Binds[0] != arr[0] {
if config, hostConfig := mustParse(t, tryit); hostConfig.Binds == nil || len(hostConfig.Binds) > 1 || hostConfig.Binds[0] != arr[0] {
t.Fatalf("Error parsing volume flags, %s and %s should only one and only one bind mount %s. Received %s", arr[0], arr[1], arr[0], hostConfig.Binds)
} else if _, exists := config.Volumes[arr[1]]; !exists {
t.Fatalf("Error parsing volume flags %s and %s. %s is missing from volumes. Received %v", arr[0], arr[1], arr[1], config.Volumes)
@@ -261,7 +258,7 @@ func TestParseWithVolumes(t *testing.T) { //nolint:gocyclo // verbatim copy from
// Root to non-c: drive letter (Windows specific)
if runtime.GOOS == "windows" {
arr, tryit = setupPlatformVolume([]string{}, []string{os.Getenv("SystemDrive") + `\:d:`})
if config, hostConfig, _ := mustParse(t, tryit); hostConfig.Binds == nil || len(hostConfig.Binds) > 1 || hostConfig.Binds[0] != arr[0] || len(config.Volumes) != 0 {
if config, hostConfig := mustParse(t, tryit); hostConfig.Binds == nil || len(hostConfig.Binds) > 1 || hostConfig.Binds[0] != arr[0] || len(config.Volumes) != 0 {
t.Fatalf("Error parsing %s. Should have a single bind mount and no volumes", arr[0])
}
}
@@ -297,36 +294,6 @@ func compareRandomizedStrings(a, b, c, d string) error {
return errors.Errorf("strings don't match")
}
func mustNetworkPort(t *testing.T, value string) networktypes.Port {
t.Helper()
port, err := networktypes.ParsePort(value)
if err != nil {
t.Fatalf("failed to parse network port %q: %v", value, err)
}
return port
}
func mustAddr(t *testing.T, value string) netip.Addr {
t.Helper()
addr, err := netip.ParseAddr(value)
if err != nil {
t.Fatalf("failed to parse address %q: %v", value, err)
}
return addr
}
func mustAddrs(t *testing.T, values ...string) []netip.Addr {
t.Helper()
addrs := make([]netip.Addr, 0, len(values))
for _, value := range values {
addrs = append(addrs, mustAddr(t, value))
}
return addrs
}
// Simple parse with MacAddress validation
func TestParseWithMacAddress(t *testing.T) {
invalidMacAddress := "--mac-address=invalidMacAddress"
@@ -334,10 +301,9 @@ func TestParseWithMacAddress(t *testing.T) {
if _, _, _, err := parseRun([]string{invalidMacAddress, "img", "cmd"}); err != nil && err.Error() != "invalidMacAddress is not a valid mac address" {
t.Fatalf("Expected an error with %v mac-address, got %v", invalidMacAddress, err)
}
_, hostConfig, networkingConfig := mustParse(t, validMacAddress)
endpoint := networkingConfig.EndpointsConfig[string(hostConfig.NetworkMode)]
assert.Check(t, endpoint != nil)
assert.Equal(t, "92:d0:c6:0a:29:33", endpoint.MacAddress.String())
if config, _ := mustParse(t, validMacAddress); config.MacAddress != "92:d0:c6:0a:29:33" { //nolint:staticcheck // pre-existing issue from nektos/act
t.Fatalf("Expected the config to have '92:d0:c6:0a:29:33' as MacAddress, got '%v'", config.MacAddress) //nolint:staticcheck // pre-existing issue from nektos/act
}
}
func TestRunFlagsParseWithMemory(t *testing.T) {
@@ -346,7 +312,7 @@ func TestRunFlagsParseWithMemory(t *testing.T) {
err := flags.Parse(args)
assert.ErrorContains(t, err, `invalid argument "invalid" for "-m, --memory" flag`)
_, hostconfig, _ := mustParse(t, "--memory=1G")
_, hostconfig := mustParse(t, "--memory=1G")
assert.Check(t, is.Equal(int64(1073741824), hostconfig.Memory))
}
@@ -356,10 +322,10 @@ func TestParseWithMemorySwap(t *testing.T) {
err := flags.Parse(args)
assert.ErrorContains(t, err, `invalid argument "invalid" for "--memory-swap" flag`)
_, hostconfig, _ := mustParse(t, "--memory-swap=1G")
_, hostconfig := mustParse(t, "--memory-swap=1G")
assert.Check(t, is.Equal(int64(1073741824), hostconfig.MemorySwap))
_, hostconfig, _ = mustParse(t, "--memory-swap=-1")
_, hostconfig = mustParse(t, "--memory-swap=-1")
assert.Check(t, is.Equal(int64(-1), hostconfig.MemorySwap))
}
@@ -374,14 +340,14 @@ func TestParseHostname(t *testing.T) {
hostnameWithDomain := "--hostname=hostname.domainname"
hostnameWithDomainTld := "--hostname=hostname.domainname.tld"
for hostname, expectedHostname := range validHostnames {
if config, _, _ := mustParse(t, "--hostname="+hostname); config.Hostname != expectedHostname {
if config, _ := mustParse(t, "--hostname="+hostname); config.Hostname != expectedHostname {
t.Fatalf("Expected the config to have 'hostname' as %q, got %q", expectedHostname, config.Hostname)
}
}
if config, _, _ := mustParse(t, hostnameWithDomain); config.Hostname != "hostname.domainname" || config.Domainname != "" {
if config, _ := mustParse(t, hostnameWithDomain); config.Hostname != "hostname.domainname" || config.Domainname != "" {
t.Fatalf("Expected the config to have 'hostname' as hostname.domainname, got %q", config.Hostname)
}
if config, _, _ := mustParse(t, hostnameWithDomainTld); config.Hostname != "hostname.domainname.tld" || config.Domainname != "" {
if config, _ := mustParse(t, hostnameWithDomainTld); config.Hostname != "hostname.domainname.tld" || config.Domainname != "" {
t.Fatalf("Expected the config to have 'hostname' as hostname.domainname.tld, got %q", config.Hostname)
}
}
@@ -395,28 +361,26 @@ func TestParseHostnameDomainname(t *testing.T) {
"domainname-63-bytes-long-should-be-valid-and-without-any-errors": "domainname-63-bytes-long-should-be-valid-and-without-any-errors",
}
for domainname, expectedDomainname := range validDomainnames {
if config, _, _ := mustParse(t, "--domainname="+domainname); config.Domainname != expectedDomainname {
if config, _ := mustParse(t, "--domainname="+domainname); config.Domainname != expectedDomainname {
t.Fatalf("Expected the config to have 'domainname' as %q, got %q", expectedDomainname, config.Domainname)
}
}
if config, _, _ := mustParse(t, "--hostname=some.prefix --domainname=domainname"); config.Hostname != "some.prefix" || config.Domainname != "domainname" {
if config, _ := mustParse(t, "--hostname=some.prefix --domainname=domainname"); config.Hostname != "some.prefix" || config.Domainname != "domainname" {
t.Fatalf("Expected the config to have 'hostname' as 'some.prefix' and 'domainname' as 'domainname', got %q and %q", config.Hostname, config.Domainname)
}
if config, _, _ := mustParse(t, "--hostname=another-prefix --domainname=domainname.tld"); config.Hostname != "another-prefix" || config.Domainname != "domainname.tld" {
if config, _ := mustParse(t, "--hostname=another-prefix --domainname=domainname.tld"); config.Hostname != "another-prefix" || config.Domainname != "domainname.tld" {
t.Fatalf("Expected the config to have 'hostname' as 'another-prefix' and 'domainname' as 'domainname.tld', got %q and %q", config.Hostname, config.Domainname)
}
}
func TestParseWithExpose(t *testing.T) {
invalids := []string{
":",
"8080:9090",
"/tcp",
"/udp",
"NaN/tcp",
"NaN-NaN/tcp",
"8080-NaN/tcp",
"1234567890-8080/tcp",
invalids := map[string]string{
":": "invalid port format for --expose: :",
"8080:9090": "invalid port format for --expose: 8080:9090",
"NaN/tcp": `invalid range format for --expose: NaN/tcp, error: strconv.ParseUint: parsing "NaN": invalid syntax`,
"NaN-NaN/tcp": `invalid range format for --expose: NaN-NaN/tcp, error: strconv.ParseUint: parsing "NaN": invalid syntax`,
"8080-NaN/tcp": `invalid range format for --expose: 8080-NaN/tcp, error: strconv.ParseUint: parsing "NaN": invalid syntax`,
"1234567890-8080/tcp": `invalid range format for --expose: 1234567890-8080/tcp, error: strconv.ParseUint: parsing "1234567890": value out of range`,
}
valids := map[string][]nat.Port{
"8080/tcp": {"8080/tcp"},
@@ -425,9 +389,9 @@ func TestParseWithExpose(t *testing.T) {
"8080-8080/udp": {"8080/udp"},
"8080-8082/tcp": {"8080/tcp", "8081/tcp", "8082/tcp"},
}
for _, expose := range invalids {
if _, _, _, err := parseRun([]string{fmt.Sprintf("--expose=%v", expose), "img", "cmd"}); err == nil {
t.Fatalf("Expected error with '--expose=%v', got none", expose)
for expose, expectedError := range invalids {
if _, _, _, err := parseRun([]string{fmt.Sprintf("--expose=%v", expose), "img", "cmd"}); err == nil || err.Error() != expectedError {
t.Fatalf("Expected error '%v' with '--expose=%v', got '%v'", expectedError, expose, err)
}
}
for expose, exposedPorts := range valids {
@@ -439,7 +403,7 @@ func TestParseWithExpose(t *testing.T) {
t.Fatalf("Expected %v exposed port, got %v", len(exposedPorts), len(config.ExposedPorts))
}
for _, port := range exposedPorts {
if _, ok := config.ExposedPorts[mustNetworkPort(t, string(port))]; !ok {
if _, ok := config.ExposedPorts[port]; !ok {
t.Fatalf("Expected %v, got %v", exposedPorts, config.ExposedPorts)
}
}
@@ -454,7 +418,7 @@ func TestParseWithExpose(t *testing.T) {
}
ports := []nat.Port{"80/tcp", "81/tcp"}
for _, port := range ports {
if _, ok := config.ExposedPorts[mustNetworkPort(t, string(port))]; !ok {
if _, ok := config.ExposedPorts[port]; !ok {
t.Fatalf("Expected %v, got %v", ports, config.ExposedPorts)
}
}
@@ -534,9 +498,9 @@ func TestParseNetworkConfig(t *testing.T) {
expected: map[string]*networktypes.EndpointSettings{
"net1": {
IPAMConfig: &networktypes.EndpointIPAMConfig{
IPv4Address: mustAddr(t, "172.20.88.22"),
IPv6Address: mustAddr(t, "2001:db8::8822"),
LinkLocalIPs: mustAddrs(t, "169.254.2.2", "fe80::169:254:2:2"),
IPv4Address: "172.20.88.22",
IPv6Address: "2001:db8::8822",
LinkLocalIPs: []string{"169.254.2.2", "fe80::169:254:2:2"},
},
Links: []string{"foo:bar", "bar:baz"},
Aliases: []string{"web1", "web2"},
@@ -563,9 +527,9 @@ func TestParseNetworkConfig(t *testing.T) {
"net1": {
DriverOpts: map[string]string{"field1": "value1"},
IPAMConfig: &networktypes.EndpointIPAMConfig{
IPv4Address: mustAddr(t, "172.20.88.22"),
IPv6Address: mustAddr(t, "2001:db8::8822"),
LinkLocalIPs: mustAddrs(t, "169.254.2.2", "fe80::169:254:2:2"),
IPv4Address: "172.20.88.22",
IPv6Address: "2001:db8::8822",
LinkLocalIPs: []string{"169.254.2.2", "fe80::169:254:2:2"},
},
Links: []string{"foo:bar", "bar:baz"},
Aliases: []string{"web1", "web2"},
@@ -574,8 +538,8 @@ func TestParseNetworkConfig(t *testing.T) {
"net3": {
DriverOpts: map[string]string{"field3": "value3"},
IPAMConfig: &networktypes.EndpointIPAMConfig{
IPv4Address: mustAddr(t, "172.20.88.22"),
IPv6Address: mustAddr(t, "2001:db8::8822"),
IPv4Address: "172.20.88.22",
IPv6Address: "2001:db8::8822",
},
Aliases: []string{"web3"},
},
@@ -592,8 +556,8 @@ func TestParseNetworkConfig(t *testing.T) {
"field2": "value2",
},
IPAMConfig: &networktypes.EndpointIPAMConfig{
IPv4Address: mustAddr(t, "172.20.88.22"),
IPv6Address: mustAddr(t, "2001:db8::8822"),
IPv4Address: "172.20.88.22",
IPv6Address: "2001:db8::8822",
},
Aliases: []string{"web1", "web2"},
},
@@ -646,9 +610,7 @@ func TestParseNetworkConfig(t *testing.T) {
assert.NilError(t, err)
assert.DeepEqual(t, hConfig.NetworkMode, tc.expectedCfg.NetworkMode)
if diff := cmp.Diff(tc.expected, nwConfig.EndpointsConfig, cmpopts.EquateComparable(netip.Addr{})); diff != "" {
t.Fatalf("unexpected endpoints (-want +got):\n%s", diff)
}
assert.DeepEqual(t, nwConfig.EndpointsConfig, tc.expected)
})
}
}
@@ -669,7 +631,7 @@ func TestParseModes(t *testing.T) {
}
// uts ko
_, _, _, err = parseRun([]string{"--uts=container:", "img", "cmd"}) //nolint:dogsled // verbatim copy from docker/cli tests
_, _, _, err = parseRun([]string{"--uts=container:", "img", "cmd"})
assert.ErrorContains(t, err, "--uts: invalid UTS mode")
// uts ok
@@ -729,9 +691,10 @@ func TestParseRestartPolicy(t *testing.T) {
}
func TestParseRestartPolicyAutoRemove(t *testing.T) {
_, _, _, err := parseRun([]string{"--rm", "--restart=always", "img", "cmd"}) //nolint:dogsled // verbatim copy from docker/cli tests
if err == nil {
t.Fatal("Expected error for conflicting --restart and --rm, but got none")
expected := "Conflicting options: --restart and --rm"
_, _, _, err := parseRun([]string{"--rm", "--restart=always", "img", "cmd"})
if err == nil || err.Error() != expected {
t.Fatalf("Expected error %v, but got none", expected)
}
}
@@ -789,7 +752,7 @@ func TestParseLoggingOpts(t *testing.T) {
}
}
func TestParseEnvfileVariables(t *testing.T) { //nolint:dupl // verbatim copy from docker/cli tests
func TestParseEnvfileVariables(t *testing.T) { //nolint:dupl // pre-existing issue from nektos/act
e := "open nonexistent: no such file or directory"
if runtime.GOOS == "windows" {
e = "open nonexistent: The system cannot find the file specified."
@@ -832,7 +795,7 @@ func TestParseEnvfileVariablesWithBOMUnicode(t *testing.T) {
}
// UTF16 with BOM
e := "invalid env file"
e := "contains invalid utf8 bytes at line"
if _, _, _, err := parseRun([]string{"--env-file=testdata/utf16.env", "img", "cmd"}); err == nil || !strings.Contains(err.Error(), e) {
t.Fatalf("Expected an error with message '%s', got %v", e, err)
}
@@ -842,7 +805,7 @@ func TestParseEnvfileVariablesWithBOMUnicode(t *testing.T) {
}
}
func TestParseLabelfileVariables(t *testing.T) { //nolint:dupl // verbatim copy from docker/cli tests
func TestParseLabelfileVariables(t *testing.T) { //nolint:dupl // pre-existing issue from nektos/act
e := "open nonexistent: no such file or directory"
if runtime.GOOS == "windows" {
e = "open nonexistent: The system cannot find the file specified."

View File

@@ -10,8 +10,8 @@ import (
"context"
"fmt"
cerrdefs "github.com/containerd/errdefs"
"github.com/moby/moby/client"
"github.com/docker/docker/api/types"
"github.com/docker/docker/client"
)
// ImageExistsLocally returns a boolean indicating if an image with the
@@ -23,8 +23,8 @@ func ImageExistsLocally(ctx context.Context, imageName, platform string) (bool,
}
defer cli.Close()
inspectImage, err := cli.ImageInspect(ctx, imageName)
if cerrdefs.IsNotFound(err) {
inspectImage, _, err := cli.ImageInspectWithRaw(ctx, imageName)
if client.IsErrNotFound(err) {
return false, nil
} else if err != nil {
return false, err
@@ -46,14 +46,14 @@ func RemoveImage(ctx context.Context, imageName string, force, pruneChildren boo
}
defer cli.Close()
inspectImage, err := cli.ImageInspect(ctx, imageName)
if cerrdefs.IsNotFound(err) {
inspectImage, _, err := cli.ImageInspectWithRaw(ctx, imageName)
if client.IsErrNotFound(err) {
return false, nil
} else if err != nil {
return false, err
}
if _, err = cli.ImageRemove(ctx, inspectImage.ID, client.ImageRemoveOptions{
if _, err = cli.ImageRemove(ctx, inspectImage.ID, types.ImageRemoveOptions{
Force: force,
PruneChildren: pruneChildren,
}); err != nil {

View File

@@ -6,64 +6,66 @@ package container
import (
"context"
"fmt"
"os"
"os/exec"
"strings"
"io"
"testing"
"github.com/docker/docker/api/types"
"github.com/docker/docker/client"
log "github.com/sirupsen/logrus"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func init() {
log.SetLevel(log.DebugLevel)
}
// buildScratchImage builds a tiny empty image for the given platform locally (FROM scratch, no
// network or emulation since there is nothing to run) and returns its tag, removing it after
// the test.
func buildScratchImage(t *testing.T, platform string) string {
t.Helper()
tag := fmt.Sprintf("act-test-exists-%s:latest", strings.TrimPrefix(platform, "linux/"))
cmd := exec.Command("docker", "build", "--platform", platform, "-t", tag, "-")
cmd.Stdin = strings.NewReader("FROM scratch\nLABEL act-test=1\n")
// Force BuildKit: it records the requested architecture in the image config for a
// FROM-scratch build, whereas the classic builder ignores --platform and tags it with the
// host arch, which would break the per-platform existence assertions below.
cmd.Env = append(os.Environ(), "DOCKER_BUILDKIT=1")
out, err := cmd.CombinedOutput()
require.NoError(t, err, string(out))
t.Cleanup(func() { _ = exec.Command("docker", "rmi", "-f", tag).Run() })
return tag
}
func TestImageExistsLocally(t *testing.T) {
requireDocker(t)
if testing.Short() {
t.Skip("skipping integration test")
}
ctx := context.Background()
// to help make this test reliable and not flaky, we need to have
// an image that will exist, and onew that won't exist
// a non-existent image is reported absent
missing, err := ImageExistsLocally(ctx, "library/alpine:this-random-tag-will-never-exist", "linux/amd64")
// Test if image exists with specific tag
invalidImageTag, err := ImageExistsLocally(ctx, "library/alpine:this-random-tag-will-never-exist", "linux/amd64")
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
assert.False(t, missing)
assert.False(t, invalidImageTag)
// Build tiny images for two architectures locally so per-platform existence can be checked
// offline (formerly pulled node:24-bookworm-slim for amd64 and arm64 over the network).
amd64Ref := buildScratchImage(t, "linux/amd64")
arm64Ref := buildScratchImage(t, "linux/arm64")
amd64Exists, err := ImageExistsLocally(ctx, amd64Ref, "linux/amd64")
// Test if image exists with specific architecture (image platform)
invalidImagePlatform, err := ImageExistsLocally(ctx, "alpine:latest", "windows/amd64")
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
assert.True(t, amd64Exists)
assert.False(t, invalidImagePlatform)
// a non-host architecture image is detected for its own architecture
arm64Exists, err := ImageExistsLocally(ctx, arm64Ref, "linux/arm64")
// pull an image
cli, err := client.NewClientWithOpts(client.FromEnv)
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
assert.True(t, arm64Exists)
cli.NegotiateAPIVersion(context.Background())
// a present image is reported absent for a different platform
wrongPlatform, err := ImageExistsLocally(ctx, amd64Ref, "linux/arm64")
// Chose alpine latest because it's so small
// maybe we should build an image instead so that tests aren't reliable on dockerhub
readerDefault, err := cli.ImagePull(ctx, "node:16-buster-slim", types.ImagePullOptions{
Platform: "linux/amd64",
})
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
assert.False(t, wrongPlatform)
defer readerDefault.Close()
_, err = io.ReadAll(readerDefault)
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
imageDefaultArchExists, err := ImageExistsLocally(ctx, "node:16-buster-slim", "linux/amd64")
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
assert.True(t, imageDefaultArchExists)
// Validate if another architecture platform can be pulled
readerArm64, err := cli.ImagePull(ctx, "node:16-buster-slim", types.ImagePullOptions{
Platform: "linux/arm64",
})
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
defer readerArm64.Close()
_, err = io.ReadAll(readerArm64)
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
imageArm64Exists, err := ImageExistsLocally(ctx, "node:16-buster-slim", "linux/arm64")
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
assert.True(t, imageArm64Exists)
}

View File

@@ -26,6 +26,8 @@ type dockerMessage struct {
Progress string `json:"progress"`
}
const logPrefix = " \U0001F433 "
func logDockerResponse(logger logrus.FieldLogger, dockerResponse io.ReadCloser, isError bool) error {
if dockerResponse == nil {
return nil

View File

@@ -11,7 +11,7 @@ import (
"gitea.com/gitea/runner/act/common"
"github.com/moby/moby/client"
"github.com/docker/docker/api/types"
)
func NewDockerNetworkCreateExecutor(name string) common.Executor {
@@ -23,20 +23,20 @@ func NewDockerNetworkCreateExecutor(name string) common.Executor {
defer cli.Close()
// Only create the network if it doesn't exist
networks, err := cli.NetworkList(ctx, client.NetworkListOptions{})
networks, err := cli.NetworkList(ctx, types.NetworkListOptions{})
if err != nil {
return err
}
// For Gitea, reduce log noise
// common.Logger(ctx).Debugf("%v", networks)
for _, n := range networks.Items {
if n.Name == name {
for _, network := range networks {
if network.Name == name {
common.Logger(ctx).Debugf("Network %v exists", name)
return nil
}
}
_, err = cli.NetworkCreate(ctx, name, client.NetworkCreateOptions{
_, err = cli.NetworkCreate(ctx, name, types.NetworkCreate{
Driver: "bridge",
Scope: "local",
})
@@ -56,23 +56,23 @@ func NewDockerNetworkRemoveExecutor(name string) common.Executor {
}
defer cli.Close()
// Make sure that all network of the specified name are removed
// Make shure that all network of the specified name are removed
// cli.NetworkRemove refuses to remove a network if there are duplicates
networks, err := cli.NetworkList(ctx, client.NetworkListOptions{})
networks, err := cli.NetworkList(ctx, types.NetworkListOptions{})
if err != nil {
return err
}
// For Gitea, reduce log noise
// common.Logger(ctx).Debugf("%v", networks)
for _, n := range networks.Items {
if n.Name == name {
result, err := cli.NetworkInspect(ctx, n.ID, client.NetworkInspectOptions{})
for _, network := range networks {
if network.Name == name {
result, err := cli.NetworkInspect(ctx, network.ID, types.NetworkInspectOptions{})
if err != nil {
return err
}
if len(result.Network.Containers) == 0 {
if _, err = cli.NetworkRemove(ctx, n.ID, client.NetworkRemoveOptions{}); err != nil {
if len(result.Containers) == 0 {
if err = cli.NetworkRemove(ctx, network.ID); err != nil {
common.Logger(ctx).Debugf("%v", err)
}
} else {

View File

@@ -1,39 +0,0 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// Copyright 2025 The nektos/act Authors. All rights reserved.
// SPDX-License-Identifier: MIT
//go:build !(WITHOUT_DOCKER || !(linux || darwin || windows || netbsd))
package container
import (
"fmt"
"strings"
specs "github.com/opencontainers/image-spec/specs-go/v1"
)
// parsePlatform parses an "os/arch[/variant]" string into a Platform. An empty input
// returns (nil, nil), meaning "no platform constraint". A non-empty but malformed
// string is rejected explicitly so it cannot silently fall through to the daemon's
// default architecture.
func parsePlatform(platform string) (*specs.Platform, error) {
if platform == "" {
return nil, nil //nolint:nilnil // no platform constraint requested
}
parts := strings.Split(platform, "/")
if len(parts) < 2 || len(parts) > 3 || parts[0] == "" || parts[1] == "" || (len(parts) == 3 && parts[2] == "") {
return nil, fmt.Errorf("invalid platform %q: expected os/arch[/variant]", platform)
}
spec := &specs.Platform{
OS: strings.ToLower(parts[0]),
Architecture: strings.ToLower(parts[1]),
}
if len(parts) == 3 {
spec.Variant = strings.ToLower(parts[2])
}
return spec, nil
}

View File

@@ -1,63 +0,0 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package container
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestParsePlatform(t *testing.T) {
t.Run("empty input returns nil platform without error", func(t *testing.T) {
got, err := parsePlatform("")
require.NoError(t, err)
assert.Nil(t, got)
})
t.Run("os/arch", func(t *testing.T) {
got, err := parsePlatform("linux/amd64")
require.NoError(t, err)
require.NotNil(t, got)
assert.Equal(t, "linux", got.OS)
assert.Equal(t, "amd64", got.Architecture)
assert.Empty(t, got.Variant)
})
t.Run("os/arch/variant", func(t *testing.T) {
got, err := parsePlatform("linux/arm/v7")
require.NoError(t, err)
require.NotNil(t, got)
assert.Equal(t, "linux", got.OS)
assert.Equal(t, "arm", got.Architecture)
assert.Equal(t, "v7", got.Variant)
})
t.Run("input is lowercased", func(t *testing.T) {
got, err := parsePlatform("Linux/AMD64/V8")
require.NoError(t, err)
require.NotNil(t, got)
assert.Equal(t, "linux", got.OS)
assert.Equal(t, "amd64", got.Architecture)
assert.Equal(t, "v8", got.Variant)
})
for _, bad := range []string{
"amd64",
"linux",
"linux/",
"/amd64",
"/",
"//",
"linux/arm/",
"linux/arm/v7/extra",
} {
t.Run("rejects "+bad, func(t *testing.T) {
got, err := parsePlatform(bad)
require.Error(t, err)
assert.Nil(t, got)
})
}
}

View File

@@ -8,23 +8,23 @@ package container
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"strings"
"gitea.com/gitea/runner/act/common"
"github.com/distribution/reference"
"github.com/moby/moby/api/pkg/authconfig"
"github.com/moby/moby/api/types/registry"
"github.com/moby/moby/client"
specs "github.com/opencontainers/image-spec/specs-go/v1"
"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/registry"
)
// NewDockerPullExecutor function to create a run executor for the container
func NewDockerPullExecutor(input NewDockerPullExecutorInput) common.Executor {
return func(ctx context.Context) error {
logger := common.Logger(ctx)
logger.Debugf("docker pull %v", input.Image)
logger.Debugf("%sdocker pull %v", logPrefix, input.Image)
if common.Dryrun(ctx) {
return nil
@@ -78,29 +78,26 @@ func NewDockerPullExecutor(input NewDockerPullExecutorInput) common.Executor {
}
}
func getImagePullOptions(ctx context.Context, input NewDockerPullExecutorInput) (client.ImagePullOptions, error) {
imagePullOptions := client.ImagePullOptions{}
platform, err := parsePlatform(input.Platform)
if err != nil {
return imagePullOptions, err
}
if platform != nil {
imagePullOptions.Platforms = []specs.Platform{*platform}
func getImagePullOptions(ctx context.Context, input NewDockerPullExecutorInput) (types.ImagePullOptions, error) {
imagePullOptions := types.ImagePullOptions{
Platform: input.Platform,
}
logger := common.Logger(ctx)
if input.Username != "" && input.Password != "" {
logger.Debugf("using authentication for docker pull")
encodedAuth, err := authconfig.Encode(registry.AuthConfig{
authConfig := registry.AuthConfig{
Username: input.Username,
Password: input.Password,
})
}
encodedJSON, err := json.Marshal(authConfig)
if err != nil {
return imagePullOptions, err
}
imagePullOptions.RegistryAuth = encodedAuth
imagePullOptions.RegistryAuth = base64.URLEncoding.EncodeToString(encodedJSON)
} else {
authConfig, err := LoadDockerAuthConfig(ctx, input.Image)
if err != nil {
@@ -111,17 +108,19 @@ func getImagePullOptions(ctx context.Context, input NewDockerPullExecutorInput)
}
logger.Info("using DockerAuthConfig authentication for docker pull")
imagePullOptions.RegistryAuth, err = authconfig.Encode(authConfig)
encodedJSON, err := json.Marshal(authConfig)
if err != nil {
return imagePullOptions, err
}
imagePullOptions.RegistryAuth = base64.URLEncoding.EncodeToString(encodedJSON)
}
return imagePullOptions, nil
}
func cleanImage(ctx context.Context, imageName string) string {
ref, err := reference.ParseAnyReference(imageName)
func cleanImage(ctx context.Context, image string) string {
ref, err := reference.ParseAnyReference(image)
if err != nil {
common.Logger(ctx).Error(err)
return ""

View File

@@ -40,9 +40,6 @@ func TestCleanImage(t *testing.T) {
func TestGetImagePullOptions(t *testing.T) {
ctx := context.Background()
orig := config.Dir()
t.Cleanup(func() { config.SetDir(orig) })
config.SetDir("/non-existent/docker")
options, err := getImagePullOptions(ctx, NewDockerPullExecutorInput{})

View File

@@ -17,32 +17,31 @@ import (
"path/filepath"
"regexp"
"runtime"
"slices"
"strconv"
"strings"
"gitea.com/gitea/runner/act/common"
"gitea.com/gitea/runner/act/filecollector"
"dario.cat/mergo"
"github.com/Masterminds/semver"
cerrdefs "github.com/containerd/errdefs"
"github.com/docker/cli/cli/compose/loader"
"github.com/docker/cli/cli/connhelper"
"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/mount"
"github.com/docker/docker/api/types/network"
"github.com/docker/docker/client"
"github.com/docker/docker/pkg/stdcopy"
"github.com/go-git/go-billy/v5/helper/polyfill"
"github.com/go-git/go-billy/v5/osfs"
"github.com/go-git/go-git/v5/plumbing/format/gitignore"
"github.com/gobwas/glob"
"github.com/imdario/mergo"
"github.com/joho/godotenv"
"github.com/kballard/go-shellquote"
"github.com/moby/moby/api/pkg/stdcopy"
"github.com/moby/moby/api/types/container"
"github.com/moby/moby/api/types/mount"
"github.com/moby/moby/api/types/network"
"github.com/moby/moby/api/types/system"
"github.com/moby/moby/client"
specs "github.com/opencontainers/image-spec/specs-go/v1"
"github.com/spf13/pflag"
"golang.org/x/term"
)
// NewContainer creates a reference to a container
@@ -54,7 +53,7 @@ func NewContainer(input *NewContainerInput) ExecutionsEnvironment {
func (cr *containerReference) ConnectToNetwork(name string) common.Executor {
return common.
NewDebugExecutor("docker network connect %s %s", name, cr.input.Name).
NewDebugExecutor("%sdocker network connect %s %s", logPrefix, name, cr.input.Name).
Then(
common.NewPipelineExecutor(
cr.connect(),
@@ -65,13 +64,9 @@ func (cr *containerReference) ConnectToNetwork(name string) common.Executor {
func (cr *containerReference) connectToNetwork(name string, aliases []string) common.Executor {
return func(ctx context.Context) error {
_, err := cr.cli.NetworkConnect(ctx, name, client.NetworkConnectOptions{
Container: cr.input.Name,
EndpointConfig: &network.EndpointSettings{
Aliases: aliases,
},
return cr.cli.NetworkConnect(ctx, name, cr.input.Name, &network.EndpointSettings{
Aliases: aliases,
})
return err
}
}
@@ -79,7 +74,7 @@ func (cr *containerReference) connectToNetwork(name string, aliases []string) co
// API version is 1.41 and beyond
func supportsContainerImagePlatform(ctx context.Context, cli client.APIClient) bool {
logger := common.Logger(ctx)
ver, err := cli.ServerVersion(ctx, client.ServerVersionOptions{})
ver, err := cli.ServerVersion(ctx)
if err != nil {
logger.Panicf("Failed to get Docker API Version: %s", err)
return false
@@ -95,7 +90,7 @@ func supportsContainerImagePlatform(ctx context.Context, cli client.APIClient) b
func (cr *containerReference) Create(capAdd, capDrop []string) common.Executor {
return common.
NewInfoExecutor("docker create image=%s platform=%s entrypoint=%+q cmd=%+q network=%+q", cr.input.Image, cr.input.Platform, cr.input.Entrypoint, cr.input.Cmd, cr.input.NetworkMode).
NewInfoExecutor("%sdocker create image=%s platform=%s entrypoint=%+q cmd=%+q network=%+q", logPrefix, cr.input.Image, cr.input.Platform, cr.input.Entrypoint, cr.input.Cmd, cr.input.NetworkMode).
Then(
common.NewPipelineExecutor(
cr.connect(),
@@ -107,7 +102,7 @@ func (cr *containerReference) Create(capAdd, capDrop []string) common.Executor {
func (cr *containerReference) Start(attach bool) common.Executor {
return common.
NewInfoExecutor("docker run image=%s platform=%s entrypoint=%+q cmd=%+q network=%+q", cr.input.Image, cr.input.Platform, cr.input.Entrypoint, cr.input.Cmd, cr.input.NetworkMode).
NewInfoExecutor("%sdocker run image=%s platform=%s entrypoint=%+q cmd=%+q network=%+q", logPrefix, cr.input.Image, cr.input.Platform, cr.input.Entrypoint, cr.input.Cmd, cr.input.NetworkMode).
Then(
common.NewPipelineExecutor(
cr.connect(),
@@ -130,7 +125,7 @@ func (cr *containerReference) Start(attach bool) common.Executor {
func (cr *containerReference) Pull(forcePull bool) common.Executor {
return common.
NewInfoExecutor("docker pull image=%s platform=%s username=%s forcePull=%t", cr.input.Image, cr.input.Platform, cr.input.Username, forcePull).
NewInfoExecutor("%sdocker pull image=%s platform=%s username=%s forcePull=%t", logPrefix, cr.input.Image, cr.input.Platform, cr.input.Username, forcePull).
Then(
NewDockerPullExecutor(NewDockerPullExecutorInput{
Image: cr.input.Image,
@@ -152,9 +147,7 @@ func (cr *containerReference) Copy(destPath string, files ...*FileEntry) common.
func (cr *containerReference) CopyDir(destPath, srcPath string, useGitIgnore bool) common.Executor {
return common.NewPipelineExecutor(
common.NewInfoExecutor("docker cp src=%s dst=%s", srcPath, destPath),
cr.connect(),
cr.find(),
common.NewInfoExecutor("%sdocker cp src=%s dst=%s", logPrefix, srcPath, destPath),
cr.copyDir(destPath, srcPath, useGitIgnore),
func(ctx context.Context) error {
// If this fails, then folders have wrong permissions on non root container
@@ -170,21 +163,8 @@ func (cr *containerReference) GetContainerArchive(ctx context.Context, srcPath s
if common.Dryrun(ctx) {
return nil, errors.New("DRYRUN is not supported in GetContainerArchive")
}
// Direct entry point (no pipeline) — revalidate cr.id ourselves.
if err := cr.connect()(ctx); err != nil {
return nil, err
}
if err := cr.find()(ctx); err != nil {
return nil, err
}
if cr.id == "" {
return nil, cr.missingContainerError("get archive %s", srcPath)
}
result, err := cr.cli.CopyFromContainer(ctx, cr.id, client.CopyFromContainerOptions{SourcePath: srcPath})
if err != nil {
return nil, err
}
return result.Content, nil
a, _, err := cr.cli.CopyFromContainer(ctx, cr.id, srcPath)
return a, err
}
func (cr *containerReference) UpdateFromEnv(srcPath string, env *map[string]string) common.Executor {
@@ -197,7 +177,7 @@ func (cr *containerReference) UpdateFromImageEnv(env *map[string]string) common.
func (cr *containerReference) Exec(command []string, env map[string]string, user, workdir string) common.Executor {
return common.NewPipelineExecutor(
common.NewInfoExecutor("docker exec cmd=[%s] user=%s workdir=%s", strings.Join(command, " "), user, workdir),
common.NewInfoExecutor("%sdocker exec cmd=[%s] user=%s workdir=%s", logPrefix, strings.Join(command, " "), user, workdir),
cr.connect(),
cr.find(),
cr.exec(command, env, user, workdir),
@@ -242,27 +222,22 @@ func GetDockerClient(ctx context.Context) (cli client.APIClient, err error) {
if err != nil {
return nil, err
}
cli, err = client.New(
cli, err = client.NewClientWithOpts(
client.WithHost(helper.Host),
client.WithDialContext(helper.Dialer),
)
} else {
cli, err = client.New(client.FromEnv)
cli, err = client.NewClientWithOpts(client.FromEnv)
}
if err != nil {
return nil, fmt.Errorf("failed to connect to docker daemon: %w", err)
}
// Best-effort API version negotiation, matching the old client.NegotiateAPIVersion
// behaviour. Ping failures here are non-fatal: the connection is exercised again on
// the first real call, and the client falls back to the daemon's default API version.
if _, err := cli.Ping(ctx, client.PingOptions{NegotiateAPIVersion: true}); err != nil {
common.Logger(ctx).Warnf("docker daemon ping during version negotiation failed, continuing: %v", err)
}
cli.NegotiateAPIVersion(ctx)
return cli, nil
}
func GetHostInfo(ctx context.Context) (info system.Info, err error) {
func GetHostInfo(ctx context.Context) (info types.Info, err error) { //nolint:staticcheck // pre-existing issue from nektos/act
var cli client.APIClient
cli, err = GetDockerClient(ctx)
if err != nil {
@@ -270,12 +245,12 @@ func GetHostInfo(ctx context.Context) (info system.Info, err error) {
}
defer cli.Close()
result, err := cli.Info(ctx, client.InfoOptions{})
info, err = cli.Info(ctx)
if err != nil {
return info, err
}
return result.Info, nil
return info, nil
}
// Arch fetches values from docker info and translates architecture to
@@ -327,31 +302,19 @@ func (cr *containerReference) Close() common.Executor {
}
}
// missingContainerError is the shared "container X does not exist" error
// used by ops that need a live cr.id.
func (cr *containerReference) missingContainerError(format string, args ...any) error {
return fmt.Errorf("container %q does not exist; cannot "+format, append([]any{cr.input.Name}, args...)...)
}
func (cr *containerReference) find() common.Executor {
return func(ctx context.Context) error {
if cr.id != "" {
// Validate cached id; clear only on definitive NotFound so a
// transient daemon error doesn't abort cleanup pipelines.
_, err := cr.cli.ContainerInspect(ctx, cr.id, client.ContainerInspectOptions{})
if !cerrdefs.IsNotFound(err) {
return nil
}
cr.id = ""
return nil
}
containers, err := cr.cli.ContainerList(ctx, client.ContainerListOptions{
containers, err := cr.cli.ContainerList(ctx, types.ContainerListOptions{ //nolint:staticcheck // pre-existing issue from nektos/act
All: true,
})
if err != nil {
return fmt.Errorf("failed to list containers: %w", err)
}
for _, c := range containers.Items {
for _, c := range containers {
for _, name := range c.Names {
if name[1:] == cr.input.Name {
cr.id = c.ID
@@ -360,6 +323,7 @@ func (cr *containerReference) find() common.Executor {
}
}
cr.id = ""
return nil
}
}
@@ -371,7 +335,7 @@ func (cr *containerReference) remove() common.Executor {
}
logger := common.Logger(ctx)
_, err := cr.cli.ContainerRemove(ctx, cr.id, client.ContainerRemoveOptions{
err := cr.cli.ContainerRemove(ctx, cr.id, types.ContainerRemoveOptions{ //nolint:staticcheck // pre-existing issue from nektos/act
RemoveVolumes: true,
Force: true,
})
@@ -474,22 +438,15 @@ func (cr *containerReference) create(capAdd, capDrop []string) common.Executor {
return nil
}
logger := common.Logger(ctx)
isTerminal := term.IsTerminal(int(os.Stdout.Fd()))
input := cr.input
exposedPorts, err := convertPortSet(input.ExposedPorts)
if err != nil {
return err
}
portBindings, err := convertPortMap(input.PortBindings)
if err != nil {
return err
}
config := &container.Config{
Image: input.Image,
WorkingDir: input.WorkingDir,
Env: input.Env,
ExposedPorts: exposedPorts,
Tty: input.AllocatePTY,
ExposedPorts: input.ExposedPorts,
Tty: isTerminal,
}
// For Gitea, reduce log noise
// logger.Debugf("Common container.Config ==> %+v", config)
@@ -513,9 +470,15 @@ func (cr *containerReference) create(capAdd, capDrop []string) common.Executor {
var platSpecs *specs.Platform
if supportsContainerImagePlatform(ctx, cr.cli) && cr.input.Platform != "" {
platSpecs, err = parsePlatform(cr.input.Platform)
if err != nil {
return err
desiredPlatform := strings.SplitN(cr.input.Platform, `/`, 2)
if len(desiredPlatform) != 2 {
return fmt.Errorf("incorrect container platform option '%s'", cr.input.Platform)
}
platSpecs = &specs.Platform{
Architecture: desiredPlatform[1],
OS: desiredPlatform[0],
}
}
@@ -527,13 +490,13 @@ func (cr *containerReference) create(capAdd, capDrop []string) common.Executor {
NetworkMode: container.NetworkMode(input.NetworkMode),
Privileged: input.Privileged,
UsernsMode: container.UsernsMode(input.UsernsMode),
PortBindings: portBindings,
PortBindings: input.PortBindings,
AutoRemove: input.AutoRemove,
}
// For Gitea, reduce log noise
// logger.Debugf("Common container.HostConfig ==> %+v", hostConfig)
config, hostConfig, err = cr.mergeContainerConfigs(ctx, config, hostConfig)
config, hostConfig, err := cr.mergeContainerConfigs(ctx, config, hostConfig)
if err != nil {
return err
}
@@ -557,13 +520,7 @@ func (cr *containerReference) create(capAdd, capDrop []string) common.Executor {
}
}
resp, err := cr.cli.ContainerCreate(ctx, client.ContainerCreateOptions{
Config: config,
HostConfig: hostConfig,
NetworkingConfig: networkingConfig,
Platform: platSpecs,
Name: input.Name,
})
resp, err := cr.cli.ContainerCreate(ctx, config, hostConfig, networkingConfig, platSpecs, input.Name)
if err != nil {
return fmt.Errorf("failed to create container: '%w'", err)
}
@@ -581,7 +538,7 @@ func (cr *containerReference) extractFromImageEnv(env *map[string]string) common
return func(ctx context.Context) error {
logger := common.Logger(ctx)
inspect, err := cr.cli.ImageInspect(ctx, cr.input.Image)
inspect, _, err := cr.cli.ImageInspectWithRaw(ctx, cr.input.Image)
if err != nil {
logger.Error(err)
return fmt.Errorf("inspect image: %w", err)
@@ -616,9 +573,6 @@ func (cr *containerReference) extractFromImageEnv(env *map[string]string) common
func (cr *containerReference) exec(cmd []string, env map[string]string, user, workdir string) common.Executor {
return func(ctx context.Context) error {
if cr.id == "" {
return cr.missingContainerError("exec %v", cmd)
}
logger := common.Logger(ctx)
// Fix slashes when running on Windows
if runtime.GOOS == "windows" {
@@ -630,7 +584,7 @@ func (cr *containerReference) exec(cmd []string, env map[string]string, user, wo
}
logger.Debugf("Exec command '%s'", cmd)
isTerminal := cr.input.AllocatePTY
isTerminal := term.IsTerminal(int(os.Stdout.Fd()))
envList := make([]string, 0)
for k, v := range env {
envList = append(envList, fmt.Sprintf("%s=%s", k, v))
@@ -648,12 +602,12 @@ func (cr *containerReference) exec(cmd []string, env map[string]string, user, wo
}
logger.Debugf("Working directory '%s'", wd)
idResp, err := cr.cli.ExecCreate(ctx, cr.id, client.ExecCreateOptions{
idResp, err := cr.cli.ContainerExecCreate(ctx, cr.id, types.ExecConfig{
User: user,
Cmd: cmd,
WorkingDir: wd,
Env: envList,
TTY: isTerminal,
Tty: isTerminal,
AttachStderr: true,
AttachStdout: true,
})
@@ -661,34 +615,38 @@ func (cr *containerReference) exec(cmd []string, env map[string]string, user, wo
return fmt.Errorf("failed to create exec: %w", err)
}
resp, err := cr.cli.ExecAttach(ctx, idResp.ID, client.ExecAttachOptions{
TTY: isTerminal,
resp, err := cr.cli.ContainerExecAttach(ctx, idResp.ID, types.ExecStartCheck{
Tty: isTerminal,
})
if err != nil {
return fmt.Errorf("failed to attach to exec: %w", err)
}
defer resp.Close()
err = cr.waitForCommand(ctx, isTerminal, resp.HijackedResponse, idResp, user, workdir)
err = cr.waitForCommand(ctx, isTerminal, resp, idResp, user, workdir)
if err != nil {
return err
}
inspectResp, err := cr.cli.ExecInspect(ctx, idResp.ID, client.ExecInspectOptions{})
inspectResp, err := cr.cli.ContainerExecInspect(ctx, idResp.ID)
if err != nil {
return fmt.Errorf("failed to inspect exec: %w", err)
}
if inspectResp.ExitCode == 0 {
switch inspectResp.ExitCode {
case 0:
return nil
case 127:
return fmt.Errorf("exitcode '%d': command not found, please refer to https://github.com/nektos/act/issues/107 for more information", inspectResp.ExitCode)
default:
return fmt.Errorf("exitcode '%d': failure", inspectResp.ExitCode)
}
return ExitCodeError(inspectResp.ExitCode)
}
}
func (cr *containerReference) tryReadID(opt string, cbk func(id int)) common.Executor {
return func(ctx context.Context) error {
idResp, err := cr.cli.ExecCreate(ctx, cr.id, client.ExecCreateOptions{
idResp, err := cr.cli.ContainerExecCreate(ctx, cr.id, types.ExecConfig{
Cmd: []string{"id", opt},
AttachStdout: true,
AttachStderr: true,
@@ -697,7 +655,7 @@ func (cr *containerReference) tryReadID(opt string, cbk func(id int)) common.Exe
return nil
}
resp, err := cr.cli.ExecAttach(ctx, idResp.ID, client.ExecAttachOptions{})
resp, err := cr.cli.ContainerExecAttach(ctx, idResp.ID, types.ExecStartCheck{})
if err != nil {
return nil
}
@@ -727,7 +685,7 @@ func (cr *containerReference) tryReadGID() common.Executor {
return cr.tryReadID("-g", func(id int) { cr.GID = id })
}
func (cr *containerReference) waitForCommand(ctx context.Context, isTerminal bool, resp client.HijackedResponse, _ client.ExecCreateResult, _, _ string) error {
func (cr *containerReference) waitForCommand(ctx context.Context, isTerminal bool, resp types.HijackedResponse, _ types.IDResponse, _, _ string) error {
logger := common.Logger(ctx)
cmdResponse := make(chan error)
@@ -773,9 +731,6 @@ func (cr *containerReference) waitForCommand(ctx context.Context, isTerminal boo
}
func (cr *containerReference) CopyTarStream(ctx context.Context, destPath string, tarStream io.Reader) error {
if cr.id == "" {
return cr.missingContainerError("copy to %s", destPath)
}
// Mkdir
buf := &bytes.Buffer{}
tw := tar.NewWriter(buf)
@@ -785,18 +740,12 @@ func (cr *containerReference) CopyTarStream(ctx context.Context, destPath string
Typeflag: tar.TypeDir,
})
tw.Close()
_, err := cr.cli.CopyToContainer(ctx, cr.id, client.CopyToContainerOptions{
DestinationPath: "/",
Content: buf,
})
err := cr.cli.CopyToContainer(ctx, cr.id, "/", buf, types.CopyToContainerOptions{})
if err != nil {
return fmt.Errorf("failed to mkdir to copy content to container: %w", err)
}
// Copy Content
_, err = cr.cli.CopyToContainer(ctx, cr.id, client.CopyToContainerOptions{
DestinationPath: destPath,
Content: tarStream,
})
err = cr.cli.CopyToContainer(ctx, cr.id, destPath, tarStream, types.CopyToContainerOptions{})
if err != nil {
return fmt.Errorf("failed to copy content to container: %w", err)
}
@@ -809,9 +758,6 @@ func (cr *containerReference) CopyTarStream(ctx context.Context, destPath string
func (cr *containerReference) copyDir(dstPath, srcPath string, useGitIgnore bool) common.Executor {
return func(ctx context.Context) error {
if cr.id == "" {
return cr.missingContainerError("copy directory to %s", dstPath)
}
logger := common.Logger(ctx)
tarFile, err := os.CreateTemp("", "act")
if err != nil {
@@ -873,10 +819,7 @@ func (cr *containerReference) copyDir(dstPath, srcPath string, useGitIgnore bool
if err != nil {
return fmt.Errorf("failed to seek tar archive: %w", err)
}
_, err = cr.cli.CopyToContainer(ctx, cr.id, client.CopyToContainerOptions{
DestinationPath: "/",
Content: tarFile,
})
err = cr.cli.CopyToContainer(ctx, cr.id, "/", tarFile, types.CopyToContainerOptions{})
if err != nil {
return fmt.Errorf("failed to copy content to container: %w", err)
}
@@ -886,9 +829,6 @@ func (cr *containerReference) copyDir(dstPath, srcPath string, useGitIgnore bool
func (cr *containerReference) copyContent(dstPath string, files ...*FileEntry) common.Executor {
return func(ctx context.Context) error {
if cr.id == "" {
return cr.missingContainerError("copy to %s", dstPath)
}
logger := common.Logger(ctx)
var buf bytes.Buffer
tw := tar.NewWriter(&buf)
@@ -913,10 +853,7 @@ func (cr *containerReference) copyContent(dstPath string, files ...*FileEntry) c
}
logger.Debugf("Extracting content to '%s'", dstPath)
_, err := cr.cli.CopyToContainer(ctx, cr.id, client.CopyToContainerOptions{
DestinationPath: dstPath,
Content: &buf,
})
err := cr.cli.CopyToContainer(ctx, cr.id, dstPath, &buf, types.CopyToContainerOptions{})
if err != nil {
return fmt.Errorf("failed to copy content to container: %w", err)
}
@@ -926,7 +863,7 @@ func (cr *containerReference) copyContent(dstPath string, files ...*FileEntry) c
func (cr *containerReference) attach() common.Executor {
return func(ctx context.Context) error {
out, err := cr.cli.ContainerAttach(ctx, cr.id, client.ContainerAttachOptions{
out, err := cr.cli.ContainerAttach(ctx, cr.id, types.ContainerAttachOptions{ //nolint:staticcheck // pre-existing issue from nektos/act
Stream: true,
Stdout: true,
Stderr: true,
@@ -934,7 +871,7 @@ func (cr *containerReference) attach() common.Executor {
if err != nil {
return fmt.Errorf("failed to attach to container: %w", err)
}
isTerminal := cr.input.AllocatePTY
isTerminal := term.IsTerminal(int(os.Stdout.Fd()))
var outWriter io.Writer
outWriter = cr.input.Stdout
@@ -964,7 +901,7 @@ func (cr *containerReference) start() common.Executor {
logger := common.Logger(ctx)
logger.Debugf("Starting container: %v", cr.id)
if _, err := cr.cli.ContainerStart(ctx, cr.id, client.ContainerStartOptions{}); err != nil {
if err := cr.cli.ContainerStart(ctx, cr.id, types.ContainerStartOptions{}); err != nil { //nolint:staticcheck // pre-existing issue from nektos/act
return fmt.Errorf("failed to start container: %w", err)
}
@@ -976,16 +913,14 @@ func (cr *containerReference) start() common.Executor {
func (cr *containerReference) wait() common.Executor {
return func(ctx context.Context) error {
logger := common.Logger(ctx)
waitResult := cr.cli.ContainerWait(ctx, cr.id, client.ContainerWaitOptions{
Condition: container.WaitConditionNotRunning,
})
statusCh, errCh := cr.cli.ContainerWait(ctx, cr.id, container.WaitConditionNotRunning)
var statusCode int64
select {
case err := <-waitResult.Error:
case err := <-errCh:
if err != nil {
return fmt.Errorf("failed to wait for container: %w", err)
}
case status := <-waitResult.Result:
case status := <-statusCh:
statusCode = status.StatusCode
}
@@ -995,7 +930,7 @@ func (cr *containerReference) wait() common.Executor {
return nil
}
return ExitCodeError(statusCode)
return fmt.Errorf("exit with `FAILURE`: %v", statusCode)
}
}
@@ -1005,7 +940,22 @@ func (cr *containerReference) sanitizeConfig(ctx context.Context, config *contai
logger := common.Logger(ctx)
if len(cr.input.ValidVolumes) > 0 {
matcher := newValidVolumeMatcher(ctx, cr.input.ValidVolumes)
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
}
// sanitize binds
sanitizedBinds := make([]string, 0, len(hostConfig.Binds))
for _, bind := range hostConfig.Binds {
@@ -1019,7 +969,7 @@ func (cr *containerReference) sanitizeConfig(ctx context.Context, config *contai
sanitizedBinds = append(sanitizedBinds, bind)
continue
}
if matcher.isValid(parsed.Source, mount.Type(parsed.Type)) {
if isValid(parsed.Source) {
sanitizedBinds = append(sanitizedBinds, bind)
} else {
logger.Warnf("[%s] is not a valid volume, will be ignored", parsed.Source)
@@ -1029,7 +979,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 matcher.isValid(mt.Source, mt.Type) {
if isValid(mt.Source) {
sanitizedMounts = append(sanitizedMounts, mt)
} else {
logger.Warnf("[%s] is not a valid volume, will be ignored", mt.Source)
@@ -1043,129 +993,3 @@ 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
}
}

View File

@@ -11,28 +11,24 @@ import (
"errors"
"io"
"net"
"os"
"path/filepath"
"strings"
"testing"
"time"
"gitea.com/gitea/runner/act/common"
cerrdefs "github.com/containerd/errdefs"
"github.com/moby/moby/api/types/container"
mobyclient "github.com/moby/moby/client"
"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/client"
"github.com/sirupsen/logrus/hooks/test"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
)
func TestDocker(t *testing.T) {
requireDocker(t)
ctx := context.Background()
client, err := GetDockerClient(ctx)
require.NoError(t, err)
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
defer client.Close()
dockerBuild := NewDockerBuildExecutor(NewDockerBuildExecutorInput{
@@ -70,43 +66,28 @@ func TestDocker(t *testing.T) {
}
type mockDockerClient struct {
mobyclient.APIClient
client.APIClient
mock.Mock
}
func (m *mockDockerClient) ExecCreate(ctx context.Context, id string, opts mobyclient.ExecCreateOptions) (mobyclient.ExecCreateResult, error) {
func (m *mockDockerClient) ContainerExecCreate(ctx context.Context, id string, opts types.ExecConfig) (types.IDResponse, error) {
args := m.Called(ctx, id, opts)
return args.Get(0).(mobyclient.ExecCreateResult), args.Error(1)
return args.Get(0).(types.IDResponse), args.Error(1)
}
func (m *mockDockerClient) ExecAttach(ctx context.Context, id string, opts mobyclient.ExecAttachOptions) (mobyclient.ExecAttachResult, error) {
func (m *mockDockerClient) ContainerExecAttach(ctx context.Context, id string, opts types.ExecStartCheck) (types.HijackedResponse, error) {
args := m.Called(ctx, id, opts)
return args.Get(0).(mobyclient.ExecAttachResult), args.Error(1)
return args.Get(0).(types.HijackedResponse), args.Error(1)
}
func (m *mockDockerClient) ExecInspect(ctx context.Context, execID string, opts mobyclient.ExecInspectOptions) (mobyclient.ExecInspectResult, error) {
args := m.Called(ctx, execID, opts)
return args.Get(0).(mobyclient.ExecInspectResult), args.Error(1)
func (m *mockDockerClient) ContainerExecInspect(ctx context.Context, execID string) (types.ContainerExecInspect, error) {
args := m.Called(ctx, execID)
return args.Get(0).(types.ContainerExecInspect), args.Error(1)
}
func (m *mockDockerClient) ContainerWait(ctx context.Context, containerID string, opts mobyclient.ContainerWaitOptions) mobyclient.ContainerWaitResult {
args := m.Called(ctx, containerID, opts)
return args.Get(0).(mobyclient.ContainerWaitResult)
}
func (m *mockDockerClient) CopyToContainer(ctx context.Context, id string, options mobyclient.CopyToContainerOptions) (mobyclient.CopyToContainerResult, error) {
args := m.Called(ctx, id, options)
return args.Get(0).(mobyclient.CopyToContainerResult), args.Error(1)
}
func (m *mockDockerClient) ContainerInspect(ctx context.Context, id string, opts mobyclient.ContainerInspectOptions) (mobyclient.ContainerInspectResult, error) {
args := m.Called(ctx, id, opts)
return args.Get(0).(mobyclient.ContainerInspectResult), args.Error(1)
}
func (m *mockDockerClient) ContainerList(ctx context.Context, opts mobyclient.ContainerListOptions) (mobyclient.ContainerListResult, error) {
args := m.Called(ctx, opts)
return args.Get(0).(mobyclient.ContainerListResult), args.Error(1)
func (m *mockDockerClient) CopyToContainer(ctx context.Context, id, path string, content io.Reader, options types.CopyToContainerOptions) error {
args := m.Called(ctx, id, path, content, options)
return args.Error(0)
}
type endlessReader struct {
@@ -138,12 +119,10 @@ func TestDockerExecAbort(t *testing.T) {
conn.On("Write", mock.AnythingOfType("[]uint8")).Return(1, nil)
client := &mockDockerClient{}
client.On("ExecCreate", ctx, "123", mock.AnythingOfType("client.ExecCreateOptions")).Return(mobyclient.ExecCreateResult{ID: "id"}, nil)
client.On("ExecAttach", ctx, "id", mock.AnythingOfType("client.ExecAttachOptions")).Return(mobyclient.ExecAttachResult{
HijackedResponse: mobyclient.HijackedResponse{
Conn: conn,
Reader: bufio.NewReader(endlessReader{}),
},
client.On("ContainerExecCreate", ctx, "123", mock.AnythingOfType("types.ExecConfig")).Return(types.IDResponse{ID: "id"}, nil)
client.On("ContainerExecAttach", ctx, "id", mock.AnythingOfType("types.ExecStartCheck")).Return(types.HijackedResponse{
Conn: conn,
Reader: bufio.NewReader(endlessReader{}),
}, nil)
cr := &containerReference{
@@ -177,14 +156,12 @@ func TestDockerExecFailure(t *testing.T) {
conn := &mockConn{}
client := &mockDockerClient{}
client.On("ExecCreate", ctx, "123", mock.AnythingOfType("client.ExecCreateOptions")).Return(mobyclient.ExecCreateResult{ID: "id"}, nil)
client.On("ExecAttach", ctx, "id", mock.AnythingOfType("client.ExecAttachOptions")).Return(mobyclient.ExecAttachResult{
HijackedResponse: mobyclient.HijackedResponse{
Conn: conn,
Reader: bufio.NewReader(strings.NewReader("output")),
},
client.On("ContainerExecCreate", ctx, "123", mock.AnythingOfType("types.ExecConfig")).Return(types.IDResponse{ID: "id"}, nil)
client.On("ContainerExecAttach", ctx, "id", mock.AnythingOfType("types.ExecStartCheck")).Return(types.HijackedResponse{
Conn: conn,
Reader: bufio.NewReader(strings.NewReader("output")),
}, nil)
client.On("ExecInspect", ctx, "id", mobyclient.ExecInspectOptions{}).Return(mobyclient.ExecInspectResult{
client.On("ContainerExecInspect", ctx, "id").Return(types.ContainerExecInspect{
ExitCode: 1,
}, nil)
@@ -197,56 +174,20 @@ func TestDockerExecFailure(t *testing.T) {
}
err := cr.exec([]string{""}, map[string]string{}, "user", "workdir")(ctx)
var exitErr ExitCodeError
require.ErrorAs(t, err, &exitErr)
assert.Equal(t, ExitCodeError(1), exitErr)
assert.Equal(t, "Process completed with exit code 1.", err.Error())
assert.Error(t, err, "exit with `FAILURE`: 1") //nolint:testifylint // pre-existing issue from nektos/act
conn.AssertExpectations(t)
client.AssertExpectations(t)
}
func TestDockerWaitFailure(t *testing.T) {
ctx := context.Background()
statusCh := make(chan container.WaitResponse, 1)
statusCh <- container.WaitResponse{StatusCode: 2}
errCh := make(chan error, 1)
client := &mockDockerClient{}
client.On("ContainerWait", ctx, "123", mobyclient.ContainerWaitOptions{Condition: container.WaitConditionNotRunning}).
Return(mobyclient.ContainerWaitResult{
Result: (<-chan container.WaitResponse)(statusCh),
Error: (<-chan error)(errCh),
})
cr := &containerReference{
id: "123",
cli: client,
input: &NewContainerInput{
Image: "image",
},
}
err := cr.wait()(ctx)
var exitErr ExitCodeError
require.ErrorAs(t, err, &exitErr)
assert.Equal(t, ExitCodeError(2), exitErr)
assert.Equal(t, "Process completed with exit code 2.", err.Error())
client.AssertExpectations(t)
}
func TestDockerCopyTarStream(t *testing.T) {
ctx := context.Background()
conn := &mockConn{}
client := &mockDockerClient{}
client.On("CopyToContainer", ctx, "123", mock.MatchedBy(func(opts mobyclient.CopyToContainerOptions) bool {
return opts.DestinationPath == "/" && opts.Content != nil
})).Return(mobyclient.CopyToContainerResult{}, nil)
client.On("CopyToContainer", ctx, "123", mock.MatchedBy(func(opts mobyclient.CopyToContainerOptions) bool {
return opts.DestinationPath == "/var/run/act" && opts.Content != nil
})).Return(mobyclient.CopyToContainerResult{}, nil)
client.On("CopyToContainer", ctx, "123", "/", mock.Anything, mock.AnythingOfType("types.CopyToContainerOptions")).Return(nil)
client.On("CopyToContainer", ctx, "123", "/var/run/act", mock.Anything, mock.AnythingOfType("types.CopyToContainerOptions")).Return(nil)
cr := &containerReference{
id: "123",
cli: client,
@@ -257,18 +198,20 @@ func TestDockerCopyTarStream(t *testing.T) {
_ = cr.CopyTarStream(ctx, "/var/run/act", &bytes.Buffer{})
conn.AssertExpectations(t)
client.AssertExpectations(t)
}
func TestDockerCopyTarStreamErrorInCopyFiles(t *testing.T) {
ctx := context.Background()
conn := &mockConn{}
merr := errors.New("Failure")
client := &mockDockerClient{}
client.On("CopyToContainer", ctx, "123", mock.MatchedBy(func(opts mobyclient.CopyToContainerOptions) bool {
return opts.DestinationPath == "/" && opts.Content != nil
})).Return(mobyclient.CopyToContainerResult{}, merr)
client.On("CopyToContainer", ctx, "123", "/", mock.Anything, mock.AnythingOfType("types.CopyToContainerOptions")).Return(merr)
client.On("CopyToContainer", ctx, "123", "/", mock.Anything, mock.AnythingOfType("types.CopyToContainerOptions")).Return(merr)
cr := &containerReference{
id: "123",
cli: client,
@@ -280,21 +223,20 @@ func TestDockerCopyTarStreamErrorInCopyFiles(t *testing.T) {
err := cr.CopyTarStream(ctx, "/var/run/act", &bytes.Buffer{})
assert.ErrorIs(t, err, merr) //nolint:testifylint // pre-existing issue from nektos/act
conn.AssertExpectations(t)
client.AssertExpectations(t)
}
func TestDockerCopyTarStreamErrorInMkdir(t *testing.T) {
ctx := context.Background()
conn := &mockConn{}
merr := errors.New("Failure")
client := &mockDockerClient{}
client.On("CopyToContainer", ctx, "123", mock.MatchedBy(func(opts mobyclient.CopyToContainerOptions) bool {
return opts.DestinationPath == "/" && opts.Content != nil
})).Return(mobyclient.CopyToContainerResult{}, nil)
client.On("CopyToContainer", ctx, "123", mock.MatchedBy(func(opts mobyclient.CopyToContainerOptions) bool {
return opts.DestinationPath == "/var/run/act" && opts.Content != nil
})).Return(mobyclient.CopyToContainerResult{}, merr)
client.On("CopyToContainer", ctx, "123", "/", mock.Anything, mock.AnythingOfType("types.CopyToContainerOptions")).Return(nil)
client.On("CopyToContainer", ctx, "123", "/var/run/act", mock.Anything, mock.AnythingOfType("types.CopyToContainerOptions")).Return(merr)
cr := &containerReference{
id: "123",
cli: client,
@@ -306,137 +248,10 @@ func TestDockerCopyTarStreamErrorInMkdir(t *testing.T) {
err := cr.CopyTarStream(ctx, "/var/run/act", &bytes.Buffer{})
assert.ErrorIs(t, err, merr) //nolint:testifylint // pre-existing issue from nektos/act
conn.AssertExpectations(t)
client.AssertExpectations(t)
}
// find() must drop a stale cached id so later Copy/Exec don't hit the
// daemon with a torn-down container.
func TestFindRevalidatesStaleID(t *testing.T) {
ctx := context.Background()
notFound := cerrdefs.ErrNotFound.WithMessage("No such container")
boom := errors.New("daemon unreachable")
newCR := func(id string) (*containerReference, *mockDockerClient) {
client := &mockDockerClient{}
return &containerReference{id: id, cli: client, input: &NewContainerInput{Name: "job-1"}}, client
}
listOpts := mobyclient.ContainerListOptions{All: true}
inspectOpts := mobyclient.ContainerInspectOptions{}
t.Run("stale id cleared, name lookup empty", func(t *testing.T) {
cr, client := newCR("stale")
client.On("ContainerInspect", ctx, "stale", inspectOpts).Return(mobyclient.ContainerInspectResult{}, notFound)
client.On("ContainerList", ctx, listOpts).Return(mobyclient.ContainerListResult{}, nil)
require.NoError(t, cr.find()(ctx))
assert.Empty(t, cr.id)
client.AssertExpectations(t)
})
t.Run("stale id cleared, name lookup repopulates", func(t *testing.T) {
cr, client := newCR("stale")
client.On("ContainerInspect", ctx, "stale", inspectOpts).Return(mobyclient.ContainerInspectResult{}, notFound)
client.On("ContainerList", ctx, listOpts).Return(mobyclient.ContainerListResult{Items: []container.Summary{
{ID: "other", Names: []string{"/somebody-else"}},
{ID: "fresh", Names: []string{"/job-1"}},
}}, nil)
require.NoError(t, cr.find()(ctx))
assert.Equal(t, "fresh", cr.id)
client.AssertExpectations(t)
})
t.Run("live id kept", func(t *testing.T) {
cr, client := newCR("live")
client.On("ContainerInspect", ctx, "live", inspectOpts).Return(mobyclient.ContainerInspectResult{}, nil)
require.NoError(t, cr.find()(ctx))
assert.Equal(t, "live", cr.id)
client.AssertExpectations(t)
})
t.Run("transient inspect error trusts cache", func(t *testing.T) {
cr, client := newCR("live")
client.On("ContainerInspect", ctx, "live", inspectOpts).Return(mobyclient.ContainerInspectResult{}, boom)
require.NoError(t, cr.find()(ctx))
assert.Equal(t, "live", cr.id)
client.AssertExpectations(t)
})
t.Run("list error propagates", func(t *testing.T) {
cr, client := newCR("")
client.On("ContainerList", ctx, listOpts).Return(mobyclient.ContainerListResult{}, boom)
require.ErrorIs(t, cr.find()(ctx), boom)
client.AssertExpectations(t)
})
}
// Every daemon entry point fails fast with a clear, container-named
// error when no live cr.id is known.
func TestRejectsMissingContainer(t *testing.T) {
ctx := context.Background()
client := &mockDockerClient{}
client.On("ContainerList", ctx, mobyclient.ContainerListOptions{All: true}).Return(mobyclient.ContainerListResult{}, nil)
cr := &containerReference{cli: client, input: &NewContainerInput{Name: "job-1"}}
check := func(op string, err error) {
t.Helper()
require.Error(t, err, op)
assert.Contains(t, err.Error(), `container "job-1" does not exist`, op)
}
check("copyContent", cr.copyContent("/var/run/act", &FileEntry{Name: "x", Mode: 0o644})(ctx))
check("copyDir", cr.copyDir("/var/run/act", "/src", false)(ctx))
check("CopyTarStream", cr.CopyTarStream(ctx, "/var/run/act", &bytes.Buffer{}))
check("exec", cr.exec([]string{"echo"}, nil, "", "")(ctx))
_, err := cr.GetContainerArchive(ctx, "/var/run/act/x")
check("GetContainerArchive", err)
}
// End-to-end: a stale cr.id is cleared, repopulated from name lookup,
// and the Copy completes against the fresh id.
func TestPublicCopyPipelineHandlesStaleID(t *testing.T) {
ctx := context.Background()
client := &mockDockerClient{}
client.On("ContainerInspect", ctx, "stale", mobyclient.ContainerInspectOptions{}).
Return(mobyclient.ContainerInspectResult{}, cerrdefs.ErrNotFound.WithMessage("gone"))
client.On("ContainerList", ctx, mobyclient.ContainerListOptions{All: true}).
Return(mobyclient.ContainerListResult{Items: []container.Summary{
{ID: "fresh", Names: []string{"/job-1"}},
}}, nil)
client.On("CopyToContainer", ctx, "fresh", mock.MatchedBy(func(opts mobyclient.CopyToContainerOptions) bool {
return opts.DestinationPath == "/var/run/act"
})).Return(mobyclient.CopyToContainerResult{}, nil)
cr := &containerReference{id: "stale", cli: client, input: &NewContainerInput{Name: "job-1"}}
require.NoError(t, cr.Copy("/var/run/act", &FileEntry{Name: "x", Mode: 0o644})(ctx))
assert.Equal(t, "fresh", cr.id)
client.AssertExpectations(t)
}
// TestDockerCopyToSymlinkPath is a regression test for gitea/runner#981. Most base images
// symlink /var/run to /run, so copying into /var/run/act traverses that symlink. The broken
// docker 29.5.1 daemon fails the extraction with "mkdirat var/run: file exists" (fixed in
// 29.5.2). Running against the daemon shipped in the dind image, this catches a bad bump.
func TestDockerCopyToSymlinkPath(t *testing.T) {
requireDocker(t)
ctx := context.Background()
rc := NewContainer(&NewContainerInput{
Image: "alpine:latest",
Entrypoint: []string{"sleep", "30"},
Name: "act-test-symlink-" + time.Now().Format("20060102150405.000000"),
AutoRemove: true,
})
require.NoError(t, rc.Pull(false)(ctx))
require.NoError(t, rc.Create(nil, nil)(ctx))
require.NoError(t, rc.Start(false)(ctx))
t.Cleanup(func() {
_ = rc.Remove()(ctx)
_ = rc.Close()(ctx)
})
// CopyTarStream first creates the destination directory by extracting a tar at "/",
// which makes the daemon mkdir var, then var/run (the symlink), then act — the exact
// step that fails on the broken daemon.
err := rc.CopyTarStream(ctx, "/var/run/act", &bytes.Buffer{})
require.NoError(t, err)
}
// Type assert containerReference implements ExecutionsEnvironment
var _ ExecutionsEnvironment = &containerReference{}
@@ -512,40 +327,3 @@ 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

@@ -18,19 +18,9 @@ func init() {
var originalCommonSocketLocations = CommonSocketLocations
func isolateSocketEnv(t *testing.T) {
t.Helper()
t.Cleanup(func() { CommonSocketLocations = originalCommonSocketLocations })
if host, ok := os.LookupEnv("DOCKER_HOST"); ok {
t.Setenv("DOCKER_HOST", host)
} else {
t.Cleanup(func() { os.Unsetenv("DOCKER_HOST") })
}
}
func TestGetSocketAndHostWithSocket(t *testing.T) {
// Arrange
isolateSocketEnv(t)
CommonSocketLocations = originalCommonSocketLocations
dockerHost := "unix:///my/docker/host.sock"
socketURI := "/path/to/my.socket"
t.Setenv("DOCKER_HOST", dockerHost)
@@ -58,9 +48,9 @@ func TestGetSocketAndHostNoSocket(t *testing.T) {
func TestGetSocketAndHostOnlySocket(t *testing.T) {
// Arrange
isolateSocketEnv(t)
socketURI := "/path/to/my.socket"
os.Unsetenv("DOCKER_HOST")
CommonSocketLocations = originalCommonSocketLocations
defaultSocket, defaultSocketFound := socketLocation()
// Act
@@ -75,7 +65,7 @@ func TestGetSocketAndHostOnlySocket(t *testing.T) {
func TestGetSocketAndHostDontMount(t *testing.T) {
// Arrange
isolateSocketEnv(t)
CommonSocketLocations = originalCommonSocketLocations
dockerHost := "unix:///my/docker/host.sock"
t.Setenv("DOCKER_HOST", dockerHost)
@@ -89,7 +79,7 @@ func TestGetSocketAndHostDontMount(t *testing.T) {
func TestGetSocketAndHostNoHostNoSocket(t *testing.T) {
// Arrange
isolateSocketEnv(t)
CommonSocketLocations = originalCommonSocketLocations
os.Unsetenv("DOCKER_HOST")
defaultSocket, found := socketLocation()
@@ -107,7 +97,6 @@ func TestGetSocketAndHostNoHostNoSocket(t *testing.T) {
// > This happens if neither DOCKER_HOST nor --container-daemon-socket has a value, but socketLocation() returns a URI
func TestGetSocketAndHostNoHostNoSocketDefaultLocation(t *testing.T) {
// Arrange
isolateSocketEnv(t)
mySocketFile, tmpErr := os.CreateTemp(t.TempDir(), "act-*.sock")
mySocket := mySocketFile.Name()
unixSocket := "unix://" + mySocket
@@ -130,7 +119,6 @@ func TestGetSocketAndHostNoHostNoSocketDefaultLocation(t *testing.T) {
func TestGetSocketAndHostNoHostInvalidSocket(t *testing.T) {
// Arrange
isolateSocketEnv(t)
os.Unsetenv("DOCKER_HOST")
mySocket := "/my/socket/path.sock"
CommonSocketLocations = []string{"/unusual", "/socket", "/location"}
@@ -148,7 +136,6 @@ func TestGetSocketAndHostNoHostInvalidSocket(t *testing.T) {
func TestGetSocketAndHostOnlySocketValidButUnusualLocation(t *testing.T) {
// Arrange
isolateSocketEnv(t)
socketURI := "unix:///path/to/my.socket"
CommonSocketLocations = []string{"/unusual", "/location"}
os.Unsetenv("DOCKER_HOST")

View File

@@ -12,7 +12,7 @@ import (
"gitea.com/gitea/runner/act/common"
"github.com/moby/moby/api/types/system"
"github.com/docker/docker/api/types"
"github.com/pkg/errors"
)
@@ -51,8 +51,8 @@ func RunnerArch(ctx context.Context) string {
return runtime.GOOS
}
func GetHostInfo(ctx context.Context) (info system.Info, err error) {
return system.Info{}, nil
func GetHostInfo(ctx context.Context) (info types.Info, err error) {
return types.Info{}, nil
}
func NewDockerVolumeRemoveExecutor(volume string, force bool) common.Executor {

View File

@@ -11,7 +11,8 @@ import (
"gitea.com/gitea/runner/act/common"
"github.com/moby/moby/client"
"github.com/docker/docker/api/types/filters"
"github.com/docker/docker/api/types/volume"
)
func NewDockerVolumeRemoveExecutor(volumeName string, force bool) common.Executor {
@@ -22,12 +23,12 @@ func NewDockerVolumeRemoveExecutor(volumeName string, force bool) common.Executo
}
defer cli.Close()
list, err := cli.VolumeList(ctx, client.VolumeListOptions{})
list, err := cli.VolumeList(ctx, volume.ListOptions{Filters: filters.NewArgs()})
if err != nil {
return err
}
for _, vol := range list.Items {
for _, vol := range list.Volumes {
if vol.Name == volumeName {
return removeExecutor(volumeName, force)(ctx)
}
@@ -41,7 +42,7 @@ func NewDockerVolumeRemoveExecutor(volumeName string, force bool) common.Executo
func removeExecutor(volume string, force bool) common.Executor {
return func(ctx context.Context) error {
logger := common.Logger(ctx)
logger.Debugf("docker volume rm %s", volume)
logger.Debugf("%sdocker volume rm %s", logPrefix, volume)
if common.Dryrun(ctx) {
return nil
@@ -53,7 +54,6 @@ func removeExecutor(volume string, force bool) common.Executor {
}
defer cli.Close()
_, err = cli.VolumeRemove(ctx, volume, client.VolumeRemoveOptions{Force: force})
return err
return cli.VolumeRemove(ctx, volume, force)
}
}

View File

@@ -1,27 +0,0 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package container
import (
"context"
"testing"
mobyclient "github.com/moby/moby/client"
)
// requireDocker skips the test unless a reachable docker daemon is available.
// GetDockerClient succeeds even without a running daemon (its ping is best-effort),
// so the daemon has to be pinged explicitly here to decide whether to skip.
func requireDocker(t *testing.T) {
t.Helper()
ctx := context.Background()
cli, err := GetDockerClient(ctx)
if err != nil {
t.Skipf("skipping: docker client unavailable: %v", err)
}
defer cli.Close()
if _, err := cli.Ping(ctx, mobyclient.PingOptions{}); err != nil {
t.Skipf("skipping: docker daemon unreachable: %v", err)
}
}

View File

@@ -17,7 +17,6 @@ import (
"path/filepath"
"runtime"
"strings"
"sync/atomic"
"time"
"gitea.com/gitea/runner/act/common"
@@ -35,13 +34,9 @@ type HostEnvironment struct {
TmpDir string
ToolCache string
Workdir string
// 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
ActPath string
CleanUp func()
StdOut io.Writer
}
func (e *HostEnvironment) Create(_, _ []string) common.Executor {
@@ -197,12 +192,12 @@ func (e *HostEnvironment) Start(_ bool) common.Executor {
type ptyWriter struct {
Out io.Writer
AutoStop atomic.Bool
AutoStop bool
dirtyLine bool
}
func (w *ptyWriter) Write(buf []byte) (int, error) {
if w.AutoStop.Load() && len(buf) > 0 && buf[len(buf)-1] == 4 {
if w.AutoStop && len(buf) > 0 && buf[len(buf)-1] == 4 {
n, err := w.Out.Write(buf[:len(buf)-1])
if err != nil {
return n, err
@@ -332,39 +327,36 @@ func (e *HostEnvironment) exec(ctx context.Context, command []string, cmdline st
tty.Close()
}
}()
if e.AllocatePTY {
if true /* allocate Terminal */ {
var err error
ppty, tty, err = setupPty(cmd, cmdline)
if err != nil {
common.Logger(ctx).Debugf("Failed to setup Pty %v\n", err.Error())
}
}
var writer *ptyWriter
var logctx context.Context
writer := &ptyWriter{Out: e.StdOut}
logctx, finishLog := context.WithCancel(context.Background())
if ppty != nil {
writer = &ptyWriter{Out: e.StdOut}
var finishLog context.CancelFunc
logctx, finishLog = context.WithCancel(context.Background())
go copyPtyOutput(writer, ppty, finishLog)
} else {
finishLog()
}
if ppty != nil {
go writeKeepAlive(ppty)
}
if err := cmd.Start(); err != nil {
return err
}
err = cmd.Wait()
err = cmd.Run()
if err != nil {
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
return ExitCodeError(exitErr.ExitCode())
}
return err
}
if tty != nil {
writer.AutoStop.Store(true)
writer.AutoStop = true
if _, err := tty.WriteString("\x04"); err != nil {
common.Logger(ctx).Debug("Failed to write EOT")
}
<-logctx.Done()
}
<-logctx.Done()
if ppty != nil {
ppty.Close()
ppty = nil
}
@@ -393,139 +385,12 @@ func (e *HostEnvironment) UpdateFromEnv(srcPath string, env *map[string]string)
return parseEnvFile(e, srcPath, env)
}
func removePathWithRetry(ctx context.Context, path string) error {
if path == "" {
return nil
}
attempts := 1
delay := time.Duration(0)
if runtime.GOOS == "windows" {
attempts = 5
delay = 200 * time.Millisecond
}
var lastErr error
for i := 0; i < attempts; i++ {
if i > 0 {
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(delay):
}
}
lastErr = os.RemoveAll(path)
if lastErr == nil {
return nil
}
}
return lastErr
}
// buildWindowsWorkspaceKillScript builds a PowerShell command that `taskkill
// /T /F`s every process tree whose ExecutablePath or CommandLine references one
// of the given absolute workspace dirs, releasing file handles for cleanup.
//
// Win32_Process is used because it exposes both ExecutablePath and CommandLine
// (Get-Process doesn't, wmic is deprecated). Both match the dir+separator
// prefix, so a sibling dir sharing a name prefix (job1 vs job10) is spared.
// Ordinal String methods, not -like, so path metacharacters ([ ] ? *) stay
// literal.
//
// Pure function so the quote-escaping can be unit-tested without PowerShell.
func buildWindowsWorkspaceKillScript(dirs []string) string {
quoted := make([]string, len(dirs))
for i, d := range dirs {
// Single-quoted PowerShell literal; escape ' by doubling it.
quoted[i] = "'" + strings.ReplaceAll(d, "'", "''") + "'"
}
return `$paths = @(` + strings.Join(quoted, ",") + `)
$selfPid = $PID
Get-CimInstance Win32_Process -ErrorAction SilentlyContinue | Where-Object {
if ($_.ProcessId -eq $selfPid) { return $false }
foreach ($p in $paths) {
$prefix = $p + '\'
if ($_.ExecutablePath -and $_.ExecutablePath.StartsWith($prefix, [System.StringComparison]::OrdinalIgnoreCase)) { return $true }
if ($_.CommandLine -and $_.CommandLine.IndexOf($prefix, [System.StringComparison]::OrdinalIgnoreCase) -ge 0) { return $true }
}
return $false
} | ForEach-Object {
& taskkill.exe /PID $_.ProcessId /T /F 2>$null | Out-Null
}
`
}
func (e *HostEnvironment) terminateRunningProcesses(ctx context.Context) {
if runtime.GOOS != "windows" {
return
}
// Detached: exec.CommandContext won't start on a cancelled ctx, and a
// server cancel has already cancelled the parent ctx.
killCtx, killCancel := context.WithTimeout(context.Background(), 30*time.Second)
defer killCancel()
logger := common.Logger(ctx)
// Workspace dirs we own. Any process running from or referencing one is a
// leftover job process. ToolCache is shared across jobs; Workdir only when
// we own it (else it's a caller-provided checkout, e.g. act local mode).
owned := []string{e.Path, e.TmpDir}
if e.CleanWorkdir {
owned = append(owned, e.Workdir)
}
dirs := make([]string, 0, len(owned))
for _, d := range owned {
if d == "" {
continue
}
abs, err := filepath.Abs(d)
if err != nil {
continue
}
dirs = append(dirs, abs)
}
if len(dirs) == 0 {
return
}
script := buildWindowsWorkspaceKillScript(dirs)
cmd := exec.CommandContext(killCtx, "powershell.exe", "-NoProfile", "-NonInteractive", "-Command", script)
out, err := cmd.CombinedOutput()
if err != nil {
logger.Debugf("workspace process-tree kill via PowerShell failed: %v output=%s", err, strings.TrimSpace(string(out)))
}
}
func (e *HostEnvironment) Remove() common.Executor {
return func(ctx context.Context) error {
// Ensure any lingering child processes are ended before attempting
// to remove the workspace (Windows file locks otherwise prevent cleanup).
e.terminateRunningProcesses(ctx)
// Only removes per-job misc state. Must not remove the cache/toolcache root.
if e.CleanUp != nil {
e.CleanUp()
}
// Detach: a cancelled ctx would skip removePathWithRetry's retries,
// which absorb Windows file-handle release lag after the kill above.
rmCtx, rmCancel := context.WithTimeout(context.Background(), 30*time.Second)
defer rmCancel()
logger := common.Logger(ctx)
var errs []error
if err := removePathWithRetry(rmCtx, e.Path); err != nil {
logger.Warnf("failed to remove host misc state %s: %v", e.Path, err)
errs = append(errs, err)
}
if e.CleanWorkdir {
if err := removePathWithRetry(rmCtx, e.Workdir); err != nil {
logger.Warnf("failed to remove host workspace %s: %v", e.Workdir, err)
errs = append(errs, err)
}
}
return errors.Join(errs...)
return os.RemoveAll(e.Path)
}
}

View File

@@ -6,21 +6,14 @@ package container
import (
"archive/tar"
"bytes"
"context"
"io"
"os"
"path"
"path/filepath"
"runtime"
"strings"
"testing"
"gitea.com/gitea/runner/act/common"
"github.com/sirupsen/logrus"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// Type assert HostEnvironment implements ExecutionsEnvironment
@@ -76,175 +69,3 @@ func TestGetContainerArchive(t *testing.T) {
_, err = reader.Next()
assert.ErrorIs(t, err, io.EOF)
}
func TestHostEnvironmentExecExitCode(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("uses POSIX shell")
}
dir := t.TempDir()
ctx := context.Background()
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: io.Discard,
Workdir: filepath.Join(dir, "path"),
}
for _, p := range []string{e.Path, e.TmpDir, e.ToolCache, e.ActPath} {
assert.NoError(t, os.MkdirAll(p, 0o700)) //nolint:testifylint // test setup
}
err := e.Exec([]string{"sh", "-c", "exit 3"}, map[string]string{"PATH": os.Getenv("PATH")}, "", "")(ctx)
var exitErr ExitCodeError
require.ErrorAs(t, err, &exitErr)
assert.Equal(t, ExitCodeError(3), exitErr)
assert.Equal(t, "Process completed with exit code 3.", err.Error())
}
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()
ctx := common.WithLogger(context.Background(), logrus.NewEntry(logger))
base := t.TempDir()
miscRoot := filepath.Join(base, "misc")
path := filepath.Join(miscRoot, "hostexecutor")
require.NoError(t, os.MkdirAll(path, 0o700))
workdir := filepath.Join(base, "workspace", "owner", "repo")
require.NoError(t, os.MkdirAll(workdir, 0o700))
e := &HostEnvironment{
Path: path,
Workdir: workdir,
CleanUp: func() {
_ = os.RemoveAll(miscRoot)
},
StdOut: os.Stdout,
}
require.NoError(t, e.Remove()(ctx))
_, err := os.Stat(workdir)
require.NoError(t, err)
}
func TestHostEnvironmentRemoveCleansWorkdirWhenOwned(t *testing.T) {
logger := logrus.New()
ctx := common.WithLogger(context.Background(), logrus.NewEntry(logger))
base := t.TempDir()
miscRoot := filepath.Join(base, "misc")
path := filepath.Join(miscRoot, "hostexecutor")
require.NoError(t, os.MkdirAll(path, 0o700))
workdir := filepath.Join(base, "workspace", "123", "owner", "repo")
require.NoError(t, os.MkdirAll(workdir, 0o700))
e := &HostEnvironment{
Path: path,
Workdir: workdir,
CleanWorkdir: true,
CleanUp: func() {
_ = os.RemoveAll(miscRoot)
},
StdOut: os.Stdout,
}
require.NoError(t, e.Remove()(ctx))
_, err := os.Stat(workdir)
assert.ErrorIs(t, err, os.ErrNotExist)
}
func TestBuildWindowsWorkspaceKillScript(t *testing.T) {
t.Run("single dir", func(t *testing.T) {
s := buildWindowsWorkspaceKillScript([]string{`C:\workspace\job1`})
assert.Contains(t, s, `$paths = @('C:\workspace\job1')`)
// Self-PID guard is essential — without it the script could taskkill
// the PowerShell process running it.
assert.Contains(t, s, "$selfPid = $PID")
assert.Contains(t, s, "$_.ProcessId -eq $selfPid")
// Must match both ExecutablePath (binaries from the workspace) and
// CommandLine (system binaries invoked with workspace paths in args),
// both bounded by dir+separator so a name-prefix sibling is spared.
assert.Contains(t, s, `$prefix = $p + '\'`)
assert.Contains(t, s, "$_.ExecutablePath.StartsWith($prefix")
assert.Contains(t, s, "$_.CommandLine.IndexOf($prefix")
// Each matched PID must be tree-killed, not just stopped.
assert.Contains(t, s, "taskkill.exe /PID $_.ProcessId /T /F")
})
t.Run("multiple dirs comma-separated", func(t *testing.T) {
s := buildWindowsWorkspaceKillScript([]string{
`C:\work\path`,
`C:\work\workdir`,
`C:\Users\runner\AppData\Local\Temp\job-42`,
})
assert.Contains(t, s, `'C:\work\path'`)
assert.Contains(t, s, `'C:\work\workdir'`)
assert.Contains(t, s, `'C:\Users\runner\AppData\Local\Temp\job-42'`)
// Commas between entries — no trailing comma, no leading comma.
assert.Contains(t, s, `'C:\work\path','C:\work\workdir',`)
})
t.Run("path with single quote is escaped", func(t *testing.T) {
// In PowerShell single-quoted strings the only special char is the
// quote itself, escaped by doubling. A workspace path that ever
// contained `'` would inject a command into the script otherwise.
s := buildWindowsWorkspaceKillScript([]string{`C:\work\it's\path`})
assert.Contains(t, s, `'C:\work\it''s\path'`)
// And it must NOT appear unescaped — otherwise the quote would
// terminate the literal early.
assert.NotContains(t, s, `'C:\work\it's\path'`)
})
t.Run("path with wildcard metacharacters is matched literally", func(t *testing.T) {
// A path containing [ ] ? * must be embedded verbatim and matched with
// ordinal String methods, not -like, otherwise the metacharacters would
// be interpreted as wildcards and the leftover process could escape.
s := buildWindowsWorkspaceKillScript([]string{`C:\work\[job]?1`})
assert.Contains(t, s, `'C:\work\[job]?1'`)
assert.NotContains(t, s, "-like")
assert.Contains(t, s, "StartsWith")
assert.Contains(t, s, "IndexOf")
})
t.Run("empty dir list still produces a valid script", func(t *testing.T) {
s := buildWindowsWorkspaceKillScript(nil)
// Empty array literal — script runs, matches nothing, is a no-op.
assert.Contains(t, s, "$paths = @()")
assert.Contains(t, s, "Get-CimInstance Win32_Process")
})
}

View File

@@ -29,8 +29,6 @@ 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, "=")
@@ -52,9 +50,6 @@ 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)
}
@@ -63,9 +58,6 @@ 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
}

View File

@@ -1,75 +0,0 @@
// 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

@@ -251,7 +251,7 @@ func (impl *interperterImpl) evaluateArrayDeref(arrayDerefNode *actionlint.Array
func (impl *interperterImpl) getPropertyValue(left reflect.Value, property string) (value any, err error) {
switch left.Kind() {
case reflect.Pointer:
case reflect.Ptr:
return impl.getPropertyValue(left.Elem(), property)
case reflect.Struct:
@@ -321,7 +321,7 @@ func (impl *interperterImpl) getPropertyValue(left reflect.Value, property strin
}
func (impl *interperterImpl) getMapValue(value reflect.Value) (any, error) {
if value.Kind() == reflect.Pointer {
if value.Kind() == reflect.Ptr {
return impl.getMapValue(value.Elem())
}

View File

@@ -73,16 +73,10 @@ func (cc *CopyCollector) WriteFile(fpath string, fi fs.FileInfo, linkName string
if err := os.MkdirAll(filepath.Dir(fdestpath), 0o777); err != nil {
return err
}
// Remove any existing destination so we can overwrite read-only files
// (e.g. git pack files at mode 0444 trip EACCES on macOS and "Access is
// denied" on Windows when reopened with O_WRONLY) and so os.Symlink does
// not fail with EEXIST. os.Remove clears the Windows read-only attribute
// internally; on Unix unlink only needs write permission on the parent.
_ = os.Remove(fdestpath)
if f == nil {
return os.Symlink(linkName, fdestpath)
}
df, err := os.OpenFile(fdestpath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, fi.Mode())
df, err := os.OpenFile(fdestpath, os.O_CREATE|os.O_WRONLY, fi.Mode())
if err != nil {
return err
}

View File

@@ -8,9 +8,7 @@ import (
"archive/tar"
"context"
"io"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
@@ -22,7 +20,6 @@ import (
"github.com/go-git/go-git/v5/plumbing/format/index"
"github.com/go-git/go-git/v5/storage/filesystem"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
type memoryFs struct {
@@ -177,47 +174,3 @@ func TestSymlinks(t *testing.T) {
assert.Equal(t, ".env", files["test.env"].Linkname)
assert.ErrorIs(t, err, io.EOF, "tar must be read cleanly to EOF")
}
// Regression for https://gitea.com/gitea/runner/issues/876 and /941:
// re-copying an action directory must overwrite a pre-existing read-only
// file (e.g. a git pack .idx at mode 0444) instead of failing with EACCES
// on macOS or "Access is denied" on Windows.
func TestCopyCollectorWriteFileOverwritesReadOnlyFile(t *testing.T) {
dst := t.TempDir()
target := filepath.Join(dst, "sub", "pack.idx")
require.NoError(t, os.MkdirAll(filepath.Dir(target), 0o755))
require.NoError(t, os.WriteFile(target, []byte("old"), 0o444))
src := filepath.Join(t.TempDir(), "pack.idx")
require.NoError(t, os.WriteFile(src, []byte("new"), 0o444))
fi, err := os.Stat(src)
require.NoError(t, err)
cc := &CopyCollector{DstDir: dst}
require.NoError(t, cc.WriteFile("sub/pack.idx", fi, "", strings.NewReader("new")))
got, err := os.ReadFile(target)
require.NoError(t, err)
assert.Equal(t, "new", string(got))
}
// Without the destination removal, os.Symlink fails with EEXIST when the
// path already holds a regular file from an earlier copy of the action.
func TestCopyCollectorWriteFileOverwritesFileWithSymlink(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("creating symlinks requires elevated privileges on Windows")
}
dst := t.TempDir()
target := filepath.Join(dst, "link")
require.NoError(t, os.WriteFile(target, []byte("stale"), 0o644))
fi, err := os.Lstat(target)
require.NoError(t, err)
cc := &CopyCollector{DstDir: dst}
require.NoError(t, cc.WriteFile("link", fi, "target", nil))
resolved, err := os.Readlink(target)
require.NoError(t, err)
assert.Equal(t, "target", resolved)
}

View File

@@ -4,6 +4,18 @@
package lookpath
import "os"
type Env interface {
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

@@ -5,7 +5,7 @@ jobs:
with-volumes:
runs-on: ubuntu-latest
container:
image: node:24-bookworm-slim
image: node:16-buster-slim
volumes:
- my_docker_volume:/path/to/volume
- /path/to/nonexist/directory

View File

@@ -325,20 +325,14 @@ func (j *Job) Needs() []string {
// RunsOn list for Job
func (j *Job) RunsOn() []string {
return RunsOnFromNode(j.RawRunsOn)
}
// RunsOnFromNode parses the runs-on labels from a raw runs-on node, so callers can evaluate a
// copy of the node (avoiding mutation of the shared Job) before reading the labels.
func RunsOnFromNode(rawRunsOn yaml.Node) []string {
switch rawRunsOn.Kind {
switch j.RawRunsOn.Kind {
case yaml.MappingNode:
var val struct {
Group string
Labels yaml.Node
}
if !decodeNode(rawRunsOn, &val) {
if !decodeNode(j.RawRunsOn, &val) {
return nil
}
@@ -350,7 +344,7 @@ func RunsOnFromNode(rawRunsOn yaml.Node) []string {
return labels
default:
return nodeAsStringSlice(rawRunsOn)
return nodeAsStringSlice(j.RawRunsOn)
}
}
@@ -651,33 +645,6 @@ type Step struct {
TimeoutMinutes string `yaml:"timeout-minutes"`
}
// Clone returns a deep copy safe to mutate independently of s. Job steps are shared across
// parallel matrix runs, which mutate per-job fields (ID, Number, Shell) and evaluate the If/Env
// yaml.Nodes in place, so each job must own its copy.
func (s *Step) Clone() *Step {
clone := *s
clone.If = CloneYamlNode(s.If)
clone.Env = CloneYamlNode(s.Env)
clone.With = maps.Clone(s.With)
return &clone
}
// CloneYamlNode returns a deep copy of a yaml.Node so callers can evaluate it in place without
// mutating a node shared across parallel jobs.
func CloneYamlNode(n yaml.Node) yaml.Node {
clone := n
if n.Content != nil {
clone.Content = make([]*yaml.Node, len(n.Content))
for i, child := range n.Content {
if child != nil {
childClone := CloneYamlNode(*child)
clone.Content[i] = &childClone
}
}
}
return clone
}
// String gets the name of step
func (s *Step) String() string {
if s.Name != "" {

View File

@@ -9,29 +9,9 @@ import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.yaml.in/yaml/v4"
)
// TestStepCloneIsolatesMutableFields guards the parallel-matrix race fix: combinations share the
// job's *Step, and Clone() must hand each a copy whose If/Env nodes and With map can be mutated
// independently. A shallow copy would share Env.Content's backing array (and the With map) and
// leak writes across combinations.
func TestStepCloneIsolatesMutableFields(t *testing.T) {
var orig Step
require.NoError(t, yaml.Unmarshal([]byte("if: ${{ env.X == 'a' }}\nenv:\n KEY: original\nwith:\n arg: original\n"), &orig))
require.Len(t, orig.Env.Content, 2) // [key, value]
clone := orig.Clone()
clone.If.Value = "changed"
clone.Env.Content[1].Value = "changed"
clone.With["arg"] = "changed"
assert.Equal(t, "${{ env.X == 'a' }}", orig.If.Value, "If must not be shared with the clone")
assert.Equal(t, "original", orig.Env.Content[1].Value, "Env nodes must not be shared with the clone")
assert.Equal(t, "original", orig.With["arg"], "With map must not be shared with the clone")
}
func TestReadWorkflow_ScheduleEvent(t *testing.T) {
yaml := `
name: local-action-docker-url

View File

@@ -19,7 +19,6 @@ import (
"strings"
"gitea.com/gitea/runner/act/common"
"gitea.com/gitea/runner/act/common/git"
"gitea.com/gitea/runner/act/container"
"gitea.com/gitea/runner/act/model"
@@ -45,11 +44,6 @@ type runAction func(step actionStep, actionDir string, remoteAction *remoteActio
//go:embed res/trampoline.js
var trampoline embed.FS
var (
ContainerImageExistsLocally = container.ImageExistsLocally
ContainerNewDockerBuildExecutor = container.NewDockerBuildExecutor
)
func readActionImpl(ctx context.Context, step *model.Step, actionDir, actionPath string, readFile actionYamlReader, writeFile fileWriter) (*model.Action, error) {
logger := common.Logger(ctx)
allErrors := []error{}
@@ -154,8 +148,6 @@ func maybeCopyToActionDir(ctx context.Context, step actionStep, actionDir, actio
return rc.JobContainer.CopyTarStream(ctx, containerActionDirCopy, ta)
}
defer git.AcquireCloneLock(actionDir)()
if err := removeGitIgnore(ctx, actionDir); err != nil {
return err
}
@@ -205,7 +197,7 @@ func runActionImpl(step actionStep, actionDir string, remoteAction *remoteAction
if remoteAction == nil {
location = containerActionDir
}
return execAsDocker(ctx, step, actionName, actionDir, location, remoteAction == nil)
return execAsDocker(ctx, step, actionName, location, remoteAction == nil)
case x.IsComposite():
if err := maybeCopyToActionDir(ctx, step, actionDir, actionPath, containerActionDir); err != nil {
return err
@@ -273,7 +265,7 @@ func removeGitIgnore(ctx context.Context, directory string) error {
}
// TODO: break out parts of function to reduce complexicity
func execAsDocker(ctx context.Context, step actionStep, actionName, actionDir, basedir string, localAction bool) error {
func execAsDocker(ctx context.Context, step actionStep, actionName, basedir string, localAction bool) error {
logger := common.Logger(ctx)
rc := step.getRunContext()
action := step.getActionModel()
@@ -292,12 +284,12 @@ func execAsDocker(ctx context.Context, step actionStep, actionName, actionDir, b
image = strings.ToLower(image)
contextDir, fileName := filepath.Split(filepath.Join(basedir, action.Runs.Image))
anyArchExists, err := ContainerImageExistsLocally(ctx, image, "any")
anyArchExists, err := container.ImageExistsLocally(ctx, image, "any")
if err != nil {
return err
}
correctArchExists, err := ContainerImageExistsLocally(ctx, image, rc.Config.ContainerArchitecture)
correctArchExists, err := container.ImageExistsLocally(ctx, image, rc.Config.ContainerArchitecture)
if err != nil {
return err
}
@@ -329,21 +321,13 @@ func execAsDocker(ctx context.Context, step actionStep, actionName, actionDir, b
}
defer buildContext.Close()
}
prepImage = ContainerNewDockerBuildExecutor(container.NewDockerBuildExecutorInput{
prepImage = container.NewDockerBuildExecutor(container.NewDockerBuildExecutorInput{
ContextDir: contextDir,
Dockerfile: fileName,
ImageTag: image,
BuildContext: buildContext,
Platform: rc.Config.ContainerArchitecture,
})
if buildContext == nil {
// Held across the whole build: the daemon drains contextDir lazily.
inner := prepImage
prepImage = func(ctx context.Context) error {
defer git.AcquireCloneLock(actionDir)()
return inner(ctx)
}
}
} else {
logger.Debugf("image '%s' for architecture '%s' already exists", image, rc.Config.ContainerArchitecture)
}
@@ -455,8 +439,7 @@ func newStepContainer(ctx context.Context, step step, image string, cmd, entrypo
Platform: rc.Config.ContainerArchitecture,
Options: rc.Config.ContainerOptions,
AutoRemove: rc.Config.AutoRemove,
ValidVolumes: rc.validVolumes(),
AllocatePTY: rc.Config.AllocatePTY,
ValidVolumes: rc.Config.ValidVolumes,
})
return stepContainer
}

View File

@@ -0,0 +1,45 @@
// 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

@@ -8,139 +8,64 @@ import (
"archive/tar"
"bytes"
"context"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
"gitea.com/gitea/runner/act/common"
"gitea.com/gitea/runner/act/model"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func runGit(t *testing.T, dir string, args ...string) {
t.Helper()
if dir != "" {
args = append([]string{"-C", dir}, args...)
}
cmd := exec.Command("git", args...)
// Fixed identity and host-config isolation so commits succeed offline regardless of the
// host's git config (mirrors gitCmd in act/common/git).
cmd.Env = append(os.Environ(),
"GIT_AUTHOR_NAME=test", "GIT_AUTHOR_EMAIL=test@example.com",
"GIT_COMMITTER_NAME=test", "GIT_COMMITTER_EMAIL=test@example.com",
"GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null",
)
out, err := cmd.CombinedOutput()
require.NoError(t, err, string(out))
}
// TestShortShaActionRejected verifies a `uses` ref that is a shortened commit SHA is rejected
// with a clear error. The action is resolved from a local repo (via DefaultActionInstance) so
// this runs offline.
func TestShortShaActionRejected(t *testing.T) {
// a local "remote" action repo at <root>/actions/hello-world-docker-action
actionRoot := t.TempDir()
repo := filepath.Join(actionRoot, "actions", "hello-world-docker-action")
require.NoError(t, os.MkdirAll(repo, 0o755))
runGit(t, "", "init", "--initial-branch=main", repo)
require.NoError(t, os.WriteFile(filepath.Join(repo, "action.yml"),
[]byte("name: hello\nruns:\n using: node24\n main: index.js\n"), 0o644))
runGit(t, repo, "add", ".")
runGit(t, repo, "commit", "-m", "initial")
out, err := exec.Command("git", "-C", repo, "rev-parse", "HEAD").Output()
require.NoError(t, err)
shortSha := strings.TrimSpace(string(out))[:7]
// a workflow that uses the action at the short SHA
wfDir := filepath.Join(t.TempDir(), "wf")
require.NoError(t, os.MkdirAll(wfDir, 0o755))
wf := fmt.Sprintf("on: push\njobs:\n test:\n runs-on: ubuntu-latest\n steps:\n - uses: actions/hello-world-docker-action@%s\n", shortSha)
require.NoError(t, os.WriteFile(filepath.Join(wfDir, "push.yml"), []byte(wf), 0o644))
runner, err := New(&Config{
Workdir: wfDir,
EventName: "push",
Platforms: map[string]string{"ubuntu-latest": baseImage},
GitHubInstance: "github.com",
DefaultActionInstance: actionRoot,
ContainerMaxLifetime: time.Hour,
})
require.NoError(t, err)
planner, err := model.NewWorkflowPlanner(wfDir, true)
require.NoError(t, err)
plan, err := planner.PlanEvent("push")
require.NoError(t, err)
err = runner.NewPlanExecutor(plan)(common.WithDryrun(context.Background(), true))
require.Error(t, err)
assert.Contains(t, err.Error(), "shortened version of a commit SHA")
}
func TestActionCache(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test")
}
a := assert.New(t)
ctx := context.Background()
// Build a local bare repo with a `js` action dir so this runs offline (formerly cloned
// github.com/nektos/act-test-actions over the network). allowAnySHA1InWant lets the
// "Fetch Sha" case fetch a commit hash directly.
remoteDir := t.TempDir()
runGit(t, "", "init", "--bare", "--initial-branch=main", remoteDir)
runGit(t, remoteDir, "config", "uploadpack.allowAnySHA1InWant", "true")
workDir := t.TempDir()
runGit(t, "", "clone", remoteDir, workDir)
require.NoError(t, os.MkdirAll(filepath.Join(workDir, "js"), 0o755))
require.NoError(t, os.WriteFile(filepath.Join(workDir, "js", "action.yml"),
[]byte("name: js\nruns:\n using: node24\n main: index.js\n"), 0o644))
require.NoError(t, os.WriteFile(filepath.Join(workDir, "js", "index.js"),
[]byte("console.log('hello');\n"), 0o644))
runGit(t, workDir, "add", ".")
runGit(t, workDir, "commit", "-m", "initial")
runGit(t, workDir, "push", "-u", "origin", "main")
out, err := exec.Command("git", "-C", workDir, "rev-parse", "main").Output()
require.NoError(t, err)
fullSha := strings.TrimSpace(string(out))
cache := &GoGitActionCache{
Path: t.TempDir(),
}
cacheDir := "local/act-test-actions"
ctx := context.Background()
cacheDir := "nektos/act-test-actions"
repo := "https://github.com/nektos/act-test-actions"
refs := []struct {
Name string
Ref string
Name string
CacheDir string
Repo string
Ref string
}{
{Name: "Fetch Branch Name", Ref: "main"},
{Name: "Fetch Branch Name Absolutely", Ref: "refs/heads/main"},
{Name: "Fetch HEAD", Ref: "HEAD"},
{Name: "Fetch Sha", Ref: fullSha},
{
Name: "Fetch Branch Name",
CacheDir: cacheDir,
Repo: repo,
Ref: "main",
},
{
Name: "Fetch Branch Name Absolutely",
CacheDir: cacheDir,
Repo: repo,
Ref: "refs/heads/main",
},
{
Name: "Fetch HEAD",
CacheDir: cacheDir,
Repo: repo,
Ref: "HEAD",
},
{
Name: "Fetch Sha",
CacheDir: cacheDir,
Repo: repo,
Ref: "de984ca37e4df4cb9fd9256435a3b82c4a2662b1",
},
}
for _, c := range refs {
t.Run(c.Name, func(t *testing.T) {
sha, err := cache.Fetch(ctx, cacheDir, remoteDir, c.Ref, "")
sha, err := cache.Fetch(ctx, c.CacheDir, c.Repo, c.Ref, "")
if !a.NoError(err) || !a.NotEmpty(sha) { //nolint:testifylint // pre-existing issue from nektos/act
return
}
atar, err := cache.GetTarArchive(ctx, cacheDir, sha, "js")
// NotNil, not NotEmpty: atar is a live io.PipeReader whose producer goroutine is
// writing concurrently; NotEmpty deep-reflects over its internals and races.
if !a.NoError(err) || !a.NotNil(atar) { //nolint:testifylint // pre-existing issue from nektos/act
atar, err := cache.GetTarArchive(ctx, c.CacheDir, sha, "js")
if !a.NoError(err) || !a.NotEmpty(atar) { //nolint:testifylint // pre-existing issue from nektos/act
return
}
// GetTarArchive streams from a background goroutine walking the shared repo.
// Drain and close so it finishes before the next subtest fetches into the same
// repo; otherwise the lingering walk races with that fetch.
defer func() {
_, _ = io.Copy(io.Discard, atar)
_ = atar.Close()
}()
mytar := tar.NewReader(atar)
th, err := mytar.Next()
if !a.NoError(err) || !a.NotEqual(0, th.Size) { //nolint:testifylint // pre-existing issue from nektos/act

View File

@@ -9,13 +9,8 @@ import (
"io"
"io/fs"
"strings"
"sync"
"testing"
"time"
"gitea.com/gitea/runner/act/common"
"gitea.com/gitea/runner/act/common/git"
"gitea.com/gitea/runner/act/container"
"gitea.com/gitea/runner/act/model"
"github.com/stretchr/testify/assert"
@@ -257,153 +252,3 @@ func TestActionRunner(t *testing.T) {
})
}
}
func TestMaybeCopyToActionDirHoldsCloneLock(t *testing.T) {
ctx := context.Background()
actionDir := t.TempDir()
releaseCopy := make(chan struct{})
release := sync.OnceFunc(func() { close(releaseCopy) })
defer release()
copyEntered := make(chan struct{})
cm := &containerMock{}
cm.On("CopyDir", "/var/run/act/actions/", actionDir+"/", false).Return(func(ctx context.Context) error {
close(copyEntered)
<-releaseCopy
return nil
})
step := &stepActionRemote{
Step: &model.Step{Uses: "remote/action@v1"},
RunContext: &RunContext{
Config: &Config{},
JobContainer: cm,
},
}
copyDone := make(chan error, 1)
go func() {
copyDone <- maybeCopyToActionDir(ctx, step, actionDir, "", "/var/run/act/actions/")
}()
select {
case <-copyEntered:
case err := <-copyDone:
t.Fatalf("maybeCopyToActionDir returned before CopyDir was entered: %v", err)
case <-time.After(time.Second):
t.Fatal("CopyDir was not entered within 1 second")
}
peerAcquired := make(chan struct{})
go func() {
unlock := git.AcquireCloneLock(actionDir)
close(peerAcquired)
unlock()
}()
select {
case <-peerAcquired:
t.Fatal("peer AcquireCloneLock returned while CopyDir was running")
case <-time.After(50 * time.Millisecond):
}
release()
select {
case err := <-copyDone:
if err != nil {
t.Fatalf("maybeCopyToActionDir returned error: %v", err)
}
case <-time.After(time.Second):
t.Fatal("maybeCopyToActionDir did not return after CopyDir was unblocked")
}
select {
case <-peerAcquired:
case <-time.After(time.Second):
t.Fatal("peer AcquireCloneLock did not proceed after lock released")
}
cm.AssertExpectations(t)
}
func TestExecAsDockerHoldsCloneLockForRemoteUncached(t *testing.T) {
actionDir := t.TempDir()
unlockOnce := sync.OnceFunc(git.AcquireCloneLock(actionDir))
defer unlockOnce()
innerEntered := make(chan struct{})
releaseInner := make(chan struct{})
releaseOnce := sync.OnceFunc(func() { close(releaseInner) })
defer releaseOnce()
origImageExists := ContainerImageExistsLocally
ContainerImageExistsLocally = func(_ context.Context, _, _ string) (bool, error) {
return false, nil
}
defer func() { ContainerImageExistsLocally = origImageExists }()
origBuildExec := ContainerNewDockerBuildExecutor
ContainerNewDockerBuildExecutor = func(_ container.NewDockerBuildExecutorInput) common.Executor {
return func(_ context.Context) error {
close(innerEntered)
<-releaseInner
return nil
}
}
defer func() { ContainerNewDockerBuildExecutor = origBuildExec }()
step := &stepActionRemote{
Step: &model.Step{ID: "1", Uses: "remote/action@v1", With: map[string]string{}},
RunContext: &RunContext{
Config: &Config{},
Run: &model.Run{
JobID: "1",
Workflow: &model.Workflow{
Name: "wf",
Jobs: map[string]*model.Job{"1": {}},
},
},
JobContainer: &containerMock{},
},
action: &model.Action{Runs: model.ActionRuns{Using: "docker", Image: "Dockerfile"}},
env: map[string]string{},
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
done := make(chan error, 1)
go func() { done <- execAsDocker(ctx, step, "test-action", actionDir, actionDir, false) }()
select {
case <-innerEntered:
t.Fatal("inner build executor ran before clone lock was released")
case err := <-done:
t.Fatalf("execAsDocker returned before inner was entered: %v", err)
case <-time.After(50 * time.Millisecond):
}
unlockOnce()
select {
case <-innerEntered:
case err := <-done:
t.Fatalf("execAsDocker returned without entering inner: %v", err)
case <-time.After(time.Second):
t.Fatal("inner build executor not entered after lock released")
}
cancel()
releaseOnce()
select {
case <-done:
case <-time.After(time.Second):
t.Fatal("execAsDocker did not return after inner was released and ctx was canceled")
}
}

View File

@@ -51,7 +51,7 @@ func (rc *RunContext) commandHandler(ctx context.Context) common.LineHandler {
logger.Infof("%s", line)
return false
}
arg = UnescapeCommandData(arg)
arg = unescapeCommandData(arg)
kvPairs = unescapeKvPairs(kvPairs)
switch command {
case "set-env":
@@ -120,7 +120,7 @@ func (rc *RunContext) setOutput(ctx context.Context, kvPairs map[string]string,
result, ok := rc.StepResults[stepID]
if !ok {
logger.Infof("No outputs registered for step '%s'", stepID)
logger.Infof(" \U00002757 no outputs used step '%s'", stepID)
return
}
@@ -151,7 +151,7 @@ func parseKeyValuePairs(kvPairs, separator string) map[string]string {
return rtn
}
func UnescapeCommandData(arg string) string {
func unescapeCommandData(arg string) string {
escapeMap := map[string]string{
"%25": "%",
"%0D": "\r",

View File

@@ -562,15 +562,15 @@ func getWorkflowSecrets(ctx context.Context, rc *RunContext) map[string]string {
secrets = rc.caller.runContext.Config.Secrets
}
// Interpolate into a new map. secrets may be the shared Config.Secrets (or the job's
// map), which other parallel jobs read concurrently (e.g. log masking), so mutating it
// in place is a data race.
interpolated := make(map[string]string, len(secrets))
for k, v := range secrets {
interpolated[k] = rc.caller.runContext.ExprEval.Interpolate(ctx, v)
if secrets == nil {
secrets = map[string]string{}
}
return interpolated
for k, v := range secrets {
secrets[k] = rc.caller.runContext.ExprEval.Interpolate(ctx, v)
}
return secrets
}
return rc.Config.Secrets

View File

@@ -1,66 +0,0 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package runner
import (
"context"
"net"
"os/exec"
"runtime"
"testing"
"time"
"gitea.com/gitea/runner/act/container"
mobyclient "github.com/moby/moby/client"
)
// requireLinuxDocker skips on non-Linux hosts. Some integration workflows need Docker features
// that only a Linux daemon provides (host networking, host /proc bind mounts); Docker Desktop
// on macOS/Windows does not, so those tests can only run on Linux.
func requireLinuxDocker(t *testing.T) {
t.Helper()
if runtime.GOOS != "linux" {
t.Skip("skipping: requires a Linux Docker host")
}
}
// requireDocker skips the test unless a reachable docker daemon is available.
// GetDockerClient succeeds even without a running daemon (its ping is best-effort),
// so the daemon has to be pinged explicitly here to decide whether to skip.
func requireDocker(t *testing.T) {
t.Helper()
ctx := context.Background()
cli, err := container.GetDockerClient(ctx)
if err != nil {
t.Skipf("skipping: docker client unavailable: %v", err)
}
defer cli.Close()
if _, err := cli.Ping(ctx, mobyclient.PingOptions{}); err != nil {
t.Skipf("skipping: docker daemon unreachable: %v", err)
}
}
// requireNetwork skips the test unless github.com is reachable. A few tests exercise behaviour
// that inherently needs the network (force-pulling an image, resolving a remote short-sha ref);
// gating lets the rest of the suite run offline without these failing.
func requireNetwork(t *testing.T) {
t.Helper()
conn, err := net.DialTimeout("tcp", "github.com:443", 3*time.Second)
if err != nil {
t.Skipf("skipping: network unavailable: %v", err)
}
_ = conn.Close()
}
// requireHostTools skips the test unless every named executable is on PATH. Used by the
// self-hosted (host environment) suite, which runs steps directly on the host.
func requireHostTools(t *testing.T, tools ...string) {
t.Helper()
for _, tool := range tools {
if _, err := exec.LookPath(tool); err != nil {
t.Skipf("skipping: required host tool %q not found: %v", tool, err)
}
}
}

View File

@@ -24,13 +24,6 @@ type jobInfo interface {
result(result string)
}
// reportStepError emits the GitHub Actions ##[error] annotation and records
// the error against the job so the job is reported as failed.
func reportStepError(ctx context.Context, err error) {
common.Logger(ctx).Errorf("##[error]%v", err)
common.SetJobError(ctx, err)
}
func newJobExecutor(info jobInfo, sf stepFactory, rc *RunContext) common.Executor {
steps := make([]common.Executor, 0)
preSteps := make([]common.Executor, 0)
@@ -39,7 +32,7 @@ func newJobExecutor(info jobInfo, sf stepFactory, rc *RunContext) common.Executo
steps = append(steps, func(ctx context.Context) error {
logger := common.Logger(ctx)
if len(info.matrix()) > 0 {
logger.Infof("Matrix: %v", info.matrix())
logger.Infof("\U0001F9EA Matrix: %v", info.matrix())
}
return nil
})
@@ -82,36 +75,33 @@ func newJobExecutor(info jobInfo, sf stepFactory, rc *RunContext) common.Executo
preExec := step.pre()
preSteps = append(preSteps, useStepLogger(rc, stepModel, stepStagePre, func(ctx context.Context) error {
logger := common.Logger(ctx)
preErr := preExec(ctx)
if preErr != nil {
reportStepError(ctx, preErr)
logger.Errorf("%v", preErr)
common.SetJobError(ctx, preErr)
} else if ctx.Err() != nil {
reportStepError(ctx, ctx.Err())
logger.Errorf("%v", ctx.Err())
common.SetJobError(ctx, ctx.Err())
}
return preErr
}))
stepExec := step.main()
steps = append(steps, useStepLogger(rc, stepModel, stepStageMain, func(ctx context.Context) error {
logger := common.Logger(ctx)
err := stepExec(ctx)
if err != nil {
reportStepError(ctx, err)
logger.Errorf("%v", err)
common.SetJobError(ctx, err)
} else if ctx.Err() != nil {
reportStepError(ctx, ctx.Err())
logger.Errorf("%v", ctx.Err())
common.SetJobError(ctx, ctx.Err())
}
return nil
}))
postFn := step.post()
postExec := useStepLogger(rc, stepModel, stepStagePost, func(ctx context.Context) error {
err := postFn(ctx)
if err != nil {
reportStepError(ctx, err)
} else if ctx.Err() != nil {
reportStepError(ctx, ctx.Err())
}
return err
})
postExec := useStepLogger(rc, stepModel, stepStagePost, step.post())
if postExecutor != nil {
// run the post executor in reverse order
postExecutor = postExec.Finally(postExecutor)
@@ -183,25 +173,18 @@ func newJobExecutor(info jobInfo, sf stepFactory, rc *RunContext) common.Executo
func setJobResult(ctx context.Context, info jobInfo, rc *RunContext, success bool) {
logger := common.Logger(ctx)
// Matrix combinations share one *model.Job and run in parallel; serialize the
// read-modify-write of the job result so a failing combination is not lost-updated by a
// concurrent succeeding one.
job := rc.Run.Job()
jobResult := func() string {
defer lockJob(job)()
result := "success"
// we have only one result for a whole matrix build, so we need
// to keep an existing result state if we run a matrix
if len(info.matrix()) > 0 && job.Result != "" {
result = job.Result
}
if !success {
result = "failure"
}
info.result(result)
return result
}()
jobResult := "success"
// we have only one result for a whole matrix build, so we need
// to keep an existing result state if we run a matrix
if len(info.matrix()) > 0 && rc.Run.Job().Result != "" {
jobResult = rc.Run.Job().Result
}
if !success {
jobResult = "failure"
}
info.result(jobResult)
if rc.caller != nil {
// set reusable workflow job result
rc.caller.setReusedWorkflowJobResult(rc.JobName, jobResult) // For Gitea
@@ -213,7 +196,7 @@ func setJobResult(ctx context.Context, info jobInfo, rc *RunContext, success boo
jobResultMessage = "failed"
}
logger.WithField("jobResult", jobResult).Infof("Job %s", jobResultMessage)
logger.WithField("jobResult", jobResult).Infof("\U0001F3C1 Job %s", jobResultMessage)
}
func setJobOutputs(ctx context.Context, rc *RunContext) {
@@ -227,11 +210,7 @@ func setJobOutputs(ctx context.Context, rc *RunContext) {
callerOutputs[k] = ee.Interpolate(ctx, ee.Interpolate(ctx, v.Value))
}
// Matrix combinations of a reusable-workflow caller share the caller's *model.Job;
// serialize the write so parallel combos don't race on its Outputs field.
callerJob := rc.caller.runContext.Run.Job()
defer lockJob(callerJob)()
callerJob.Outputs = callerOutputs
rc.caller.runContext.Run.Job().Outputs = callerOutputs
}
}

View File

@@ -21,13 +21,18 @@ import (
)
func TestJobExecutor(t *testing.T) {
// Dryrun only checks syntax/planning; all cases resolve locally, so this runs offline.
if testing.Short() {
t.Skip("skipping integration test")
}
tables := []TestJobFileInfo{
{workdir, "uses-and-run-in-one-step", "push", "Invalid run/uses syntax for job:test step:Test", platforms, secrets},
{workdir, "uses-github-empty", "push", "Expected format {org}/{repo}[/path]@ref", platforms, secrets},
{workdir, "uses-github-noref", "push", "Expected format {org}/{repo}[/path]@ref", platforms, secrets},
{workdir, "uses-github-root", "push", "", platforms, secrets},
{workdir, "uses-github-path", "push", "", platforms, secrets},
{workdir, "uses-docker-url", "push", "", platforms, secrets},
{workdir, "uses-github-full-sha", "push", "", platforms, secrets},
{workdir, "uses-github-short-sha", "push", "Unable to resolve action `actions/hello-world-docker-action@b136eb8`, the provided ref `b136eb8` is the shortened version of a commit SHA, which is not supported. Please use the full commit SHA `b136eb8894c5cb1dd5807da824be97ccdf9b5423` instead", platforms, secrets},
{workdir, "job-nil-step", "push", "invalid Step 0: missing run or uses key", platforms, secrets},
}
// These tests are sufficient to only check syntax.

View File

@@ -10,7 +10,6 @@ import (
"fmt"
"io"
"os"
"slices"
"strings"
"sync"
@@ -167,29 +166,9 @@ func withStepLogger(ctx context.Context, stepNumber int, stepID, stepName, stage
type entryProcessor func(entry *logrus.Entry) *logrus.Entry
func AppendSecretMasker(oldnew []string, v string) []string {
ret := oldnew
for l := range strings.SplitSeq(v, "\n") {
tm := strings.TrimSpace(l)
// formatted JSON secrets could otherwise mask {,[,],} everywhere
if len(tm) > 1 {
ret = append(ret, tm, "***")
}
}
return ret
}
// valueMasker applies secrets and ::add-mask:: patterns to every log entry, including
// raw_output (command/stream) lines; there is no bypass by field.
func valueMasker(insecureSecrets bool, secrets map[string]string) entryProcessor {
var oldnew []string
for _, v := range secrets {
oldnew = AppendSecretMasker(oldnew, v)
}
oldnew = slices.Clip(oldnew)
defReplacer := strings.NewReplacer(oldnew...)
return func(entry *logrus.Entry) *logrus.Entry {
if insecureSecrets {
return entry
@@ -197,16 +176,16 @@ func valueMasker(insecureSecrets bool, secrets map[string]string) entryProcessor
masks := Masks(entry.Context)
if len(*masks) == 0 {
entry.Message = defReplacer.Replace(entry.Message)
} else {
cmasker := oldnew
for _, v := range *masks {
cmasker = AppendSecretMasker(cmasker, v)
for _, v := range secrets {
if v != "" {
entry.Message = strings.ReplaceAll(entry.Message, v, "***")
}
}
entry.Message = strings.NewReplacer(cmasker...).Replace(entry.Message)
for _, v := range *masks {
if v != "" {
entry.Message = strings.ReplaceAll(entry.Message, v, "***")
}
}
return entry

View File

@@ -1,52 +0,0 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package runner
import (
"strings"
"testing"
"github.com/sirupsen/logrus"
"github.com/stretchr/testify/assert"
)
func TestValueMasker(t *testing.T) {
table := []struct {
name string
lines string
secrets map[string]string
masks []string
disallowed []string
}{
{
name: "Multiline Private Key",
lines: "cat << EOF > private.key\nPRIVATE_KEY_BEGIN\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\nPRIVATE_KEY_END\nEOF",
secrets: map[string]string{
"PRIVATE_KEY": "PRIVATE_KEY_BEGIN\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\nPRIVATE_KEY_END",
},
disallowed: []string{"KEY", "dsdfseffefsefes", "PRIVATE_KEY_END"},
},
{
name: "Multiline Private Key in masks",
lines: "cat << EOF > private.key\nPRIVATE_KEY_BEGIN\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\nPRIVATE_KEY_END\nEOF",
masks: []string{"PRIVATE_KEY_BEGIN\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\nPRIVATE_KEY_END"},
disallowed: []string{"KEY", "dsdfseffefsefes", "PRIVATE_KEY_END"},
},
}
for _, entry := range table {
t.Run(entry.name, func(t *testing.T) {
ctx := WithMasks(t.Context(), &entry.masks)
masker := valueMasker(false, entry.secrets)
for line := range strings.SplitSeq(entry.lines, "\n") {
lentry := masker(&logrus.Entry{
Context: ctx,
Message: line,
})
for _, line := range entry.disallowed {
assert.NotContains(t, lentry.Message, line)
}
}
})
}
}

View File

@@ -7,12 +7,15 @@ package runner
import (
"archive/tar"
"context"
"errors"
"fmt"
"io/fs"
"net/url"
"os"
"path"
"path/filepath"
"regexp"
"strings"
"sync"
"gitea.com/gitea/runner/act/common"
"gitea.com/gitea/runner/act/common/git"
@@ -28,9 +31,7 @@ func newLocalReusableWorkflowExecutor(rc *RunContext) common.Executor {
workflowDir = strings.TrimPrefix(workflowDir, "./")
return common.NewPipelineExecutor(
// resolve the local workflow against the workspace root, not the process
// working directory, so it is found regardless of where the runner is invoked
newReusableWorkflowExecutor(rc, filepath.Join(rc.Config.Workdir, workflowDir), fileName),
newReusableWorkflowExecutor(rc, workflowDir, fileName),
)
}
@@ -50,7 +51,7 @@ func newLocalReusableWorkflowExecutor(rc *RunContext) common.Executor {
token := rc.Config.GetToken()
return common.NewPipelineExecutor(
cloneRemoteReusableWorkflow(rc, remoteReusableWorkflow.CloneURL(), remoteReusableWorkflow.Ref, workflowDir, token),
newMutexExecutor(cloneIfRequired(rc, *remoteReusableWorkflow, workflowDir, token)),
newReusableWorkflowExecutor(rc, workflowDir, remoteReusableWorkflow.FilePath()),
)
}
@@ -84,7 +85,7 @@ func newRemoteReusableWorkflowExecutor(rc *RunContext) common.Executor {
token := getGitCloneToken(rc.Config, remoteReusableWorkflow.CloneURL())
return common.NewPipelineExecutor(
cloneRemoteReusableWorkflow(rc, remoteReusableWorkflow.CloneURL(), remoteReusableWorkflow.Ref, workflowDir, token),
newMutexExecutor(cloneIfRequired(rc, *remoteReusableWorkflow, workflowDir, token)),
newReusableWorkflowExecutor(rc, workflowDir, remoteReusableWorkflow.FilePath()),
)
}
@@ -124,37 +125,46 @@ func newActionCacheReusableWorkflowExecutor(rc *RunContext, filename string, rem
}
}
// cloneRemoteReusableWorkflow always invokes the clone executor — moving refs
// (branches, tags) must be re-resolved each run, matching GitHub Actions.
//
// Callers must not change remoteReusableWorkflow.URL, because:
// 1. Gitea doesn't support specifying GithubContext.ServerURL by the GITHUB_SERVER_URL env
// 2. Gitea has already full URL with rc.Config.GitHubInstance when calling newRemoteReusableWorkflowWithPlat
//
// remoteReusableWorkflow.URL = rc.getGithubContext(ctx).ServerURL
func cloneRemoteReusableWorkflow(rc *RunContext, cloneURL, ref, targetDirectory, token string) common.Executor {
var executorLock sync.Mutex
func newMutexExecutor(executor common.Executor) common.Executor {
return func(ctx context.Context) error {
cloneURL = rc.NewExpressionEvaluator(ctx).Interpolate(ctx, cloneURL)
return git.NewGitCloneExecutor(git.NewGitCloneExecutorInput{
URL: cloneURL,
Ref: ref,
Dir: targetDirectory,
Token: token,
OfflineMode: rc.Config.ActionOfflineMode,
})(ctx)
executorLock.Lock()
defer executorLock.Unlock()
return executor(ctx)
}
}
var modelNewWorkflowPlanner = model.NewWorkflowPlanner
func cloneIfRequired(rc *RunContext, remoteReusableWorkflow remoteReusableWorkflow, targetDirectory, token string) common.Executor {
return common.NewConditionalExecutor(
func(ctx context.Context) bool {
_, err := os.Stat(targetDirectory)
notExists := errors.Is(err, fs.ErrNotExist)
return notExists
},
func(ctx context.Context) error {
// interpolate the cloneURL
cloneURL := rc.NewExpressionEvaluator(ctx).Interpolate(ctx, remoteReusableWorkflow.CloneURL())
// Do not change the remoteReusableWorkflow.URL, because:
// 1. Gitea doesn't support specifying GithubContext.ServerURL by the GITHUB_SERVER_URL env
// 2. Gitea has already full URL with rc.Config.GitHubInstance when calling newRemoteReusableWorkflowWithPlat
// remoteReusableWorkflow.URL = rc.getGithubContext(ctx).ServerURL
return git.NewGitCloneExecutor(git.NewGitCloneExecutorInput{
URL: cloneURL,
Ref: remoteReusableWorkflow.Ref,
Dir: targetDirectory,
Token: token,
OfflineMode: rc.Config.ActionOfflineMode,
})(ctx)
},
nil,
)
}
func newReusableWorkflowExecutor(rc *RunContext, directory, workflow string) common.Executor {
return func(ctx context.Context) error {
// Scoped to the yaml read so concurrent invocations don't serialize
// on the whole job run.
planner, err := func() (model.WorkflowPlanner, error) {
defer git.AcquireCloneLock(directory)()
return modelNewWorkflowPlanner(path.Join(directory, workflow), true)
}()
planner, err := model.NewWorkflowPlanner(path.Join(directory, workflow), true)
if err != nil {
return err
}
@@ -287,12 +297,8 @@ func setReusedWorkflowCallerResult(rc *RunContext, runner Runner) common.Executo
if rc.caller != nil {
rc.caller.setReusedWorkflowJobResult(rc.JobName, reusedWorkflowJobResult)
} else {
// Serialize this shared Job.Result write against the other matrix combos
// and setJobResult (same lockJob key).
unlock := lockJob(rc.Run.Job())
rc.result(reusedWorkflowJobResult)
unlock()
logger.WithField("jobResult", reusedWorkflowJobResult).Infof("Job %s", reusedWorkflowJobResultMessage)
logger.WithField("jobResult", reusedWorkflowJobResult).Infof("\U0001F3C1 Job %s", reusedWorkflowJobResultMessage)
}
}
@@ -315,11 +321,6 @@ func getGitCloneToken(conf *Config, cloneURL string) string {
// 1. cloneURL is from the same Gitea instance that the runner is registered to
// 2. the cloneURL does not have basic auth embedded
func shouldCloneURLUseToken(instanceURL, cloneURL string) bool {
if !strings.HasPrefix(instanceURL, "http://") &&
!strings.HasPrefix(instanceURL, "https://") {
instanceURL = "https://" + instanceURL
}
u1, err1 := url.Parse(instanceURL)
u2, err2 := url.Parse(cloneURL)
if err1 != nil || err2 != nil {

View File

@@ -1,193 +0,0 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package runner
import (
"context"
"errors"
"os"
"os/exec"
"path/filepath"
"sync"
"testing"
"time"
"gitea.com/gitea/runner/act/common/git"
"gitea.com/gitea/runner/act/model"
"github.com/stretchr/testify/require"
)
// Regression test for go-gitea/gitea#37483: a remote reusable workflow at a moving
// ref (branch/tag) must reflect the new tip on every invocation, not stay pinned
// to the cache populated on the first run.
func TestReusableWorkflowCachedBranchRefRefreshes(t *testing.T) {
if _, err := exec.LookPath("git"); err != nil {
t.Skip("git not available in PATH")
}
remoteDir := t.TempDir()
gitMust(t, "", "init", "--bare", "--initial-branch=master", remoteDir)
workDir := t.TempDir()
gitMust(t, "", "clone", remoteDir, workDir)
gitMust(t, workDir, "config", "user.email", "test@test")
gitMust(t, workDir, "config", "user.name", "test")
gitMust(t, workDir, "checkout", "-b", "master")
const workflowPath = ".gitea/workflows/reusable.yml"
tmpl := func(tag string) string {
return "name: reusable\non:\n workflow_call:\njobs:\n build:\n runs-on: ubuntu-latest\n steps:\n - run: echo " + tag + "\n"
}
require.NoError(t, os.MkdirAll(filepath.Join(workDir, ".gitea/workflows"), 0o755))
require.NoError(t, os.WriteFile(filepath.Join(workDir, workflowPath), []byte(tmpl("v1")), 0o644))
gitMust(t, workDir, "add", workflowPath)
gitMust(t, workDir, "commit", "-m", "v1")
gitMust(t, workDir, "push", "-u", "origin", "master")
rc := &RunContext{
Config: &Config{},
Run: &model.Run{
JobID: "j1",
Workflow: &model.Workflow{
Name: "wf",
Jobs: map[string]*model.Job{"j1": {}},
},
},
}
cacheDir := t.TempDir()
require.NoError(t, cloneRemoteReusableWorkflow(rc, remoteDir, "master", cacheDir, "")(context.Background()))
got, err := os.ReadFile(filepath.Join(cacheDir, workflowPath))
require.NoError(t, err)
require.Equal(t, tmpl("v1"), string(got))
// Branch tip moves; cache key (cacheDir) does not.
require.NoError(t, os.WriteFile(filepath.Join(workDir, workflowPath), []byte(tmpl("v2")), 0o644))
gitMust(t, workDir, "commit", "-am", "v2")
gitMust(t, workDir, "push", "origin", "master")
require.NoError(t, cloneRemoteReusableWorkflow(rc, remoteDir, "master", cacheDir, "")(context.Background()))
got, err = os.ReadFile(filepath.Join(cacheDir, workflowPath))
require.NoError(t, err)
require.Equal(t, tmpl("v2"), string(got), "cached workflow file must reflect the updated branch tip")
}
func TestNewReusableWorkflowExecutorHoldsCloneLock(t *testing.T) {
workflowDir := t.TempDir()
unlockOnce := sync.OnceFunc(git.AcquireCloneLock(workflowDir))
defer unlockOnce()
plannerCalled := make(chan struct{})
origPlanner := modelNewWorkflowPlanner
modelNewWorkflowPlanner = func(string, bool) (model.WorkflowPlanner, error) {
close(plannerCalled)
return nil, errors.New("stop")
}
defer func() { modelNewWorkflowPlanner = origPlanner }()
rc := &RunContext{
Config: &Config{},
Run: &model.Run{Workflow: &model.Workflow{Jobs: map[string]*model.Job{}}},
}
exec := newReusableWorkflowExecutor(rc, workflowDir, "reusable.yml")
done := make(chan error, 1)
go func() { done <- exec(context.Background()) }()
select {
case <-plannerCalled:
t.Fatal("planner ran while clone lock was held")
case err := <-done:
t.Fatalf("executor returned before planner was reached: %v", err)
case <-time.After(50 * time.Millisecond):
}
unlockOnce()
select {
case <-plannerCalled:
case <-time.After(time.Second):
t.Fatal("planner not called after lock was released")
}
select {
case err := <-done:
require.Error(t, err)
case <-time.After(time.Second):
t.Fatal("executor did not return after planner ran")
}
}
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) {
t.Helper()
cmd := exec.Command("git", args...)
if dir != "" {
cmd.Dir = dir
}
out, err := cmd.CombinedOutput()
require.NoError(t, err, "git %v: %s", args, string(out))
}

View File

@@ -20,9 +20,7 @@ import (
"path/filepath"
"regexp"
"runtime"
"slices"
"strings"
"sync"
"time"
"gitea.com/gitea/runner/act/common"
@@ -57,10 +55,6 @@ type RunContext struct {
Masks []string
cleanUpJobContainer common.Executor
caller *caller // job calling this RunContext (reusable workflows)
// outputTemplate is this combination's pristine snapshot of the job's output expressions,
// captured before execution so each matrix combo interpolates from the originals rather
// than from a sibling's already-resolved values written into the shared Job.Outputs.
outputTemplate map[string]string
}
func (rc *RunContext) AddMask(mask string) {
@@ -136,34 +130,17 @@ func getDockerDaemonSocketMountPath(daemonPath string) string {
return daemonPath
}
// containerDaemonSocket returns the configured Docker daemon socket, applying the default
// without mutating the shared Config. Parallel jobs in a plan share one *Config, so a job
// must never write to it.
func (rc *RunContext) containerDaemonSocket() string {
if rc.Config.ContainerDaemonSocket == "" {
return "/var/run/docker.sock"
}
return rc.Config.ContainerDaemonSocket
}
// validVolumes returns the volumes allowed on this job's containers: the configured base
// plus the volumes the runner mounts automatically. It derives a fresh slice every call and
// never mutates the shared Config (see containerDaemonSocket).
func (rc *RunContext) validVolumes() []string {
name := rc.jobContainerName()
volumes := slices.Clone(rc.Config.ValidVolumes)
// TODO: add a new configuration to control whether the docker daemon can be mounted
return append(volumes, "act-toolcache", name, name+"-env",
getDockerDaemonSocketMountPath(rc.containerDaemonSocket()))
}
// Returns the binds and mounts for the container, resolving paths as appopriate
func (rc *RunContext) GetBindsAndMounts() ([]string, map[string]string) {
name := rc.jobContainerName()
if rc.Config.ContainerDaemonSocket == "" {
rc.Config.ContainerDaemonSocket = "/var/run/docker.sock"
}
binds := []string{}
if daemonSocket := rc.containerDaemonSocket(); daemonSocket != "-" {
daemonPath := getDockerDaemonSocketMountPath(daemonSocket)
if rc.Config.ContainerDaemonSocket != "-" {
daemonPath := getDockerDaemonSocketMountPath(rc.Config.ContainerDaemonSocket)
binds = append(binds, fmt.Sprintf("%s:%s", daemonPath, "/var/run/docker.sock"))
}
@@ -202,13 +179,21 @@ func (rc *RunContext) GetBindsAndMounts() ([]string, map[string]string) {
mounts[name] = ext.ToContainerPath(rc.Config.Workdir)
}
// For Gitea
// add some default binds and mounts to ValidVolumes
rc.Config.ValidVolumes = append(rc.Config.ValidVolumes, "act-toolcache")
rc.Config.ValidVolumes = append(rc.Config.ValidVolumes, name)
rc.Config.ValidVolumes = append(rc.Config.ValidVolumes, name+"-env")
// TODO: add a new configuration to control whether the docker daemon can be mounted
rc.Config.ValidVolumes = append(rc.Config.ValidVolumes, getDockerDaemonSocketMountPath(rc.Config.ContainerDaemonSocket))
return binds, mounts
}
func (rc *RunContext) startHostEnvironment() common.Executor {
return func(ctx context.Context) error {
logger := common.Logger(ctx)
rawLogger := logger.WithField(rawOutputField, true)
rawLogger := logger.WithField("raw_output", true)
logWriter := common.NewLineWriter(rc.commandHandler(ctx), func(s string) bool {
if rc.Config.LogOutput {
rawLogger.Infof("%s", s)
@@ -235,17 +220,15 @@ func (rc *RunContext) startHostEnvironment() common.Executor {
}
toolCache := filepath.Join(cacheDir, "tool_cache")
rc.JobContainer = &container.HostEnvironment{
Path: path,
TmpDir: runnerTmp,
ToolCache: toolCache,
Workdir: rc.Config.Workdir,
CleanWorkdir: rc.Config.CleanWorkdir,
ActPath: actPath,
Path: path,
TmpDir: runnerTmp,
ToolCache: toolCache,
Workdir: rc.Config.Workdir,
ActPath: actPath,
CleanUp: func() {
os.RemoveAll(miscpath)
},
StdOut: logWriter,
AllocatePTY: rc.Config.AllocatePTY,
StdOut: logWriter,
}
rc.cleanUpJobContainer = rc.JobContainer.Remove()
for k, v := range rc.JobContainer.GetRunnerContext(ctx) {
@@ -276,24 +259,11 @@ func (rc *RunContext) startHostEnvironment() common.Executor {
}
}
// printStartJobContainerGroup mirrors actions/runner's "Starting job container"
// section: emit the group header and summary, return a closer for ::endgroup::.
func printStartJobContainerGroup(ctx context.Context, image, name, network string) func() {
rawLogger := common.Logger(ctx).WithField(rawOutputField, true)
rawLogger.Infof("::group::Starting job container")
rawLogger.Infof("image: %s", image)
rawLogger.Infof("name: %s", name)
rawLogger.Infof("network: %s", network)
return func() {
rawLogger.Infof("::endgroup::")
}
}
func (rc *RunContext) startJobContainer() common.Executor {
return func(ctx context.Context) error {
logger := common.Logger(ctx)
image := rc.platformImage(ctx)
rawLogger := logger.WithField(rawOutputField, true)
rawLogger := logger.WithField("raw_output", true)
logWriter := common.NewLineWriter(rc.commandHandler(ctx), func(s string) bool {
if rc.Config.LogOutput {
rawLogger.Infof("%s", s)
@@ -308,6 +278,7 @@ func (rc *RunContext) startJobContainer() common.Executor {
return fmt.Errorf("failed to handle credentials: %s", err)
}
logger.Infof("\U0001f680 Start image=%s", image)
name := rc.jobContainerName()
// For gitea, to support --volumes-from <container_name_or_id> in options.
// We need to set the container name to the environment variable.
@@ -387,7 +358,6 @@ func (rc *RunContext) startJobContainer() common.Executor {
NetworkAliases: []string{serviceID},
ExposedPorts: exposedPorts,
PortBindings: portBindings,
AllocatePTY: rc.Config.AllocatePTY,
})
rc.ServiceContainers = append(rc.ServiceContainers, c)
}
@@ -447,14 +417,12 @@ func (rc *RunContext) startJobContainer() common.Executor {
Platform: rc.Config.ContainerArchitecture,
Options: rc.options(ctx),
AutoRemove: rc.Config.AutoRemove,
ValidVolumes: rc.validVolumes(),
AllocatePTY: rc.Config.AllocatePTY,
ValidVolumes: rc.Config.ValidVolumes,
})
if rc.JobContainer == nil {
return errors.New("Failed to create job container")
}
defer printStartJobContainerGroup(ctx, image, name, networkName)()
return common.NewPipelineExecutor(
rc.pullServicesImages(rc.Config.ForcePull),
rc.JobContainer.Pull(rc.Config.ForcePull),
@@ -601,29 +569,14 @@ func (rc *RunContext) ActionCacheDir() string {
}
// Interpolate outputs after a job is done
// jobMutexes serializes per-job result/output aggregation across the matrix combinations that
// share one *model.Job and run in parallel. Keyed by the shared *model.Job (mirrors the
// per-directory AcquireCloneLock pattern).
var jobMutexes sync.Map // key: *model.Job; value: *sync.Mutex
func lockJob(job *model.Job) func() {
v, _ := jobMutexes.LoadOrStore(job, &sync.Mutex{})
mu := v.(*sync.Mutex)
mu.Lock()
return mu.Unlock
}
func (rc *RunContext) interpolateOutputs() common.Executor {
return func(ctx context.Context) error {
ee := rc.NewExpressionEvaluator(ctx)
job := rc.Run.Job()
// Matrix combinations share this Job and its Outputs map. Interpolate from this combo's
// pristine snapshot (outputTemplate) and write under the lock, so each combo overwrites
// with its own resolved values (last wins, as on GitHub) instead of the first combo's
// resolved values freezing the shared template against later combos.
defer lockJob(job)()
for k, v := range rc.outputTemplate {
job.Outputs[k] = ee.Interpolate(ctx, v)
for k, v := range rc.Run.Job().Outputs {
interpolated := ee.Interpolate(ctx, v)
if v != interpolated {
rc.Run.Job().Outputs[k] = interpolated
}
}
return nil
}
@@ -631,34 +584,10 @@ func (rc *RunContext) interpolateOutputs() common.Executor {
func (rc *RunContext) startContainer() common.Executor {
return func(ctx context.Context) error {
var err error
if rc.IsHostEnv(ctx) {
err = rc.startHostEnvironment()(ctx)
} else {
err = rc.startJobContainer()(ctx)
return rc.startHostEnvironment()(ctx)
}
if err != nil {
// The job executor's teardown only runs after a successful start, so a failed
// start would otherwise leak the per-job network and container.
rc.cleanupFailedStart(ctx)
}
return err
}
}
func (rc *RunContext) cleanupFailedStart(ctx context.Context) {
if rc.cleanUpJobContainer == nil {
return
}
cleanCtx := ctx
if ctx.Err() != nil {
// the start likely failed because ctx was cancelled, detach so teardown still runs
var cancel context.CancelFunc
cleanCtx, cancel = context.WithTimeout(common.WithLogger(context.Background(), common.Logger(ctx)), time.Minute)
defer cancel()
}
if err := rc.cleanUpJobContainer(cleanCtx); err != nil {
common.Logger(ctx).Errorf("Error while cleaning up after failed container start for job %s: %v", rc.JobName, err)
return rc.startJobContainer()(ctx)
}
}
@@ -690,18 +619,7 @@ func (rc *RunContext) result(result string) {
}
func (rc *RunContext) steps() []*model.Step {
// Return per-job copies of the steps. Matrix combinations run in parallel and share the
// workflow model, but step execution mutates per-job fields and evaluates the If/Env nodes
// in place, so the *model.Step instances must not be shared across jobs (see Step.Clone).
shared := rc.Run.Job().Steps
steps := make([]*model.Step, len(shared))
for i, step := range shared {
if step == nil {
continue
}
steps[i] = step.Clone()
}
return steps
return rc.Run.Job().Steps
}
// Executor returns a pipeline executor for all the steps in the job
@@ -778,15 +696,12 @@ func (rc *RunContext) runsOnPlatformNames(ctx context.Context) []string {
return []string{}
}
// Evaluate a copy: RawRunsOn is shared across parallel matrix jobs, so interpolating it in
// place would race and leak one matrix combination's runs-on into the others.
rawRunsOn := model.CloneYamlNode(job.RawRunsOn)
if err := rc.ExprEval.EvaluateYamlNode(ctx, &rawRunsOn); err != nil {
if err := rc.ExprEval.EvaluateYamlNode(ctx, &job.RawRunsOn); err != nil {
common.Logger(ctx).Errorf("Error while evaluating runs-on: %v", err)
return []string{}
}
return model.RunsOnFromNode(rawRunsOn)
return job.RunsOn()
}
func (rc *RunContext) platformImage(ctx context.Context) string {
@@ -814,7 +729,7 @@ func (rc *RunContext) isEnabled(ctx context.Context) (bool, error) {
jobType, jobTypeErr := job.Type()
if runJobErr != nil {
return false, fmt.Errorf("if-expression %q evaluation failed: %s", job.If.Value, runJobErr)
return false, fmt.Errorf(" \u274C Error in if-expression: \"if: %s\" (%s)", job.If.Value, runJobErr)
}
if jobType == model.JobTypeInvalid {
@@ -837,7 +752,7 @@ func (rc *RunContext) isEnabled(ctx context.Context) (bool, error) {
img := rc.platformImage(ctx)
if img == "" {
for _, platformName := range rc.runsOnPlatformNames(ctx) {
l.Infof("Skipping unsupported platform -- Try running with `-P %+v=...`", platformName)
l.Infof("\U0001F6A7 Skipping unsupported platform -- Try running with `-P %+v=...`", platformName)
}
return false, nil
}
@@ -1209,9 +1124,12 @@ func (rc *RunContext) handleServiceCredentials(ctx context.Context, creds map[st
// GetServiceBindsAndMounts returns the binds and mounts for the service container, resolving paths as appopriate
func (rc *RunContext) GetServiceBindsAndMounts(svcVolumes []string) ([]string, map[string]string) {
if rc.Config.ContainerDaemonSocket == "" {
rc.Config.ContainerDaemonSocket = "/var/run/docker.sock"
}
binds := []string{}
if daemonSocket := rc.containerDaemonSocket(); daemonSocket != "-" {
daemonPath := getDockerDaemonSocketMountPath(daemonSocket)
if rc.Config.ContainerDaemonSocket != "-" {
daemonPath := getDockerDaemonSocketMountPath(rc.Config.ContainerDaemonSocket)
binds = append(binds, fmt.Sprintf("%s:%s", daemonPath, "/var/run/docker.sock"))
}

View File

@@ -5,7 +5,6 @@
package runner
import (
"bytes"
"context"
"fmt"
"os"
@@ -13,13 +12,11 @@ import (
"strings"
"testing"
"gitea.com/gitea/runner/act/common"
"gitea.com/gitea/runner/act/exprparser"
"gitea.com/gitea/runner/act/model"
log "github.com/sirupsen/logrus"
assert "github.com/stretchr/testify/assert"
require "github.com/stretchr/testify/require"
yaml "go.yaml.in/yaml/v4"
)
@@ -281,44 +278,6 @@ func TestRunContext_GetBindsAndMounts(t *testing.T) {
})
}
func TestRunContextValidVolumes(t *testing.T) {
rc := &RunContext{
Name: "job",
Run: &model.Run{Workflow: &model.Workflow{Name: "wf"}},
Config: &Config{ValidVolumes: []string{"my-vol", "/host/path"}},
}
name := rc.jobContainerName()
got := rc.validVolumes()
// the configured volumes plus the four the runner mounts automatically
assert.Subset(t, got, []string{"my-vol", "/host/path", "act-toolcache", name, name + "-env", "/var/run/docker.sock"})
// deriving the list must never mutate or grow the shared Config slice: parallel matrix
// combinations share one *Config, and the previous in-place append was a data race.
assert.Equal(t, []string{"my-vol", "/host/path"}, rc.Config.ValidVolumes)
assert.Len(t, rc.validVolumes(), len(got), "repeated calls must be stable, not accumulate")
}
// TestInterpolateOutputsIsPerMatrixCombo guards the matrix-output fix: combinations share one
// *model.Job, so each must interpolate from its own pristine snapshot. Otherwise the first
// combo's resolved value freezes the shared template and later combos can't resolve their own.
func TestInterpolateOutputsIsPerMatrixCombo(t *testing.T) {
job := &model.Job{Outputs: map[string]string{"o": "${{ matrix.v }}"}}
run := &model.Run{JobID: "j", Workflow: &model.Workflow{Name: "w", Jobs: map[string]*model.Job{"j": job}}}
r := &runnerImpl{config: &Config{}}
ctx := context.Background()
rcA := r.newRunContext(ctx, run, map[string]any{"v": "a"})
rcB := r.newRunContext(ctx, run, map[string]any{"v": "b"})
require.NoError(t, rcA.interpolateOutputs()(ctx))
require.NoError(t, rcB.interpolateOutputs()(ctx))
// Last combo wins (matching GitHub) instead of being frozen to combo A's "a".
require.Equal(t, "b", job.Outputs["o"])
}
func TestGetGitHubContext(t *testing.T) {
log.SetLevel(log.DebugLevel)
@@ -676,75 +635,3 @@ func TestCreateContainerNameBoundedForLongMatrixInput(t *testing.T) {
assert.LessOrEqual(t, len(name+"-network"), 255)
assert.LessOrEqual(t, len(name+"-job1234567890"), 255)
}
func TestPrintStartJobContainerGroupGolden(t *testing.T) {
buf := &bytes.Buffer{}
logger := log.New()
logger.SetOutput(buf)
logger.SetLevel(log.InfoLevel)
logger.SetFormatter(&jobLogFormatter{color: cyan})
entry := logger.WithFields(log.Fields{"job": "j1"})
ctx := common.WithLogger(context.Background(), entry)
printStartJobContainerGroup(ctx, "node:20", "GITEA-WORKFLOW-build-JOB-test", "gitea-runner-network")()
want := strings.Join([]string{
"[j1] | ::group::Starting job container",
"[j1] | image: node:20",
"[j1] | name: GITEA-WORKFLOW-build-JOB-test",
"[j1] | network: gitea-runner-network",
"[j1] | ::endgroup::",
"",
}, "\n")
assert.Equal(t, want, buf.String())
}
func TestRunContext_cleanupFailedStart(t *testing.T) {
type ctxKey string
const sentinel = ctxKey("sentinel")
// the fresh context is cancelled via defer on return, so capture state inside the stub
type capture struct {
calls int
err error
sentinel any
}
newRC := func(c *capture) *RunContext {
return &RunContext{
JobName: "job",
cleanUpJobContainer: func(ctx context.Context) error {
c.calls++
c.err = ctx.Err()
c.sentinel = ctx.Value(sentinel)
return nil
},
}
}
t.Run("runs teardown on the live context", func(t *testing.T) {
var c capture
ctx := context.WithValue(context.Background(), sentinel, "v")
newRC(&c).cleanupFailedStart(ctx)
assert.Equal(t, 1, c.calls)
require.NoError(t, c.err)
assert.Equal(t, "v", c.sentinel)
})
t.Run("falls back to a fresh context when the input is done", func(t *testing.T) {
var c capture
ctx, cancel := context.WithCancel(context.WithValue(context.Background(), sentinel, "v"))
cancel()
newRC(&c).cleanupFailedStart(ctx)
assert.Equal(t, 1, c.calls)
require.NoError(t, c.err)
assert.Nil(t, c.sentinel)
})
t.Run("no-op when there is nothing to clean up", func(t *testing.T) {
assert.NotPanics(t, func() { (&RunContext{}).cleanupFailedStart(context.Background()) })
})
}

View File

@@ -8,7 +8,6 @@ import (
"context"
"encoding/json"
"fmt"
"maps"
"os"
"runtime"
"sync"
@@ -17,7 +16,7 @@ import (
"gitea.com/gitea/runner/act/common"
"gitea.com/gitea/runner/act/model"
docker_container "github.com/moby/moby/api/types/container"
docker_container "github.com/docker/docker/api/types/container"
log "github.com/sirupsen/logrus"
)
@@ -31,7 +30,7 @@ type Config struct {
Actor string // the user that triggered the event
Workdir string // path to working directory
ActionCacheDir string // path used for caching action contents
ActionOfflineMode bool // when offline, use cached action contents
ActionOfflineMode bool // when offline, use caching action contents
BindWorkdir bool // bind the workdir to the job container
EventName string // name of event to run
EventPath string // path to JSON file to use for event.json in containers
@@ -74,14 +73,12 @@ type Config struct {
EventJSON string // the content of JSON file to use for event.json in containers, overrides EventPath
ContainerNamePrefix string // the prefix of container name
ContainerMaxLifetime time.Duration // the max lifetime of job containers
CleanWorkdir bool // remove host executor workdir on teardown
DefaultActionInstance string // the default actions web site
PlatformPicker func(labels []string) string // platform picker, it will take precedence over Platforms if isn't nil
JobLoggerLevel *log.Level // the level of job logger
ValidVolumes []string // only volumes (and bind mounts) in this slice can be mounted on the job container or service containers
InsecureSkipTLS bool // whether to skip verifying TLS certificate of the Gitea instance
MaxParallel int // max parallel jobs to run across all workflows (0 = no limit, uses CPU count)
AllocatePTY bool // allocate a pseudo-TTY for each step's process
}
// GetToken: Adapt to Gitea
@@ -93,17 +90,6 @@ func (c Config) GetToken() string {
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 {
runContext *RunContext
@@ -251,14 +237,7 @@ func (runner *runnerImpl) NewPlanExecutor(plan *model.Plan) common.Executor {
return executor(common.WithJobErrorContainer(WithJobLogger(ctx, rc.Run.JobID, jobName, rc.Config, &rc.Masks, matrix)))
})
}
// Run all matrix combinations of this job, then drop its aggregation mutex: the
// combos are the only users of it, so once they finish the jobMutexes entry can be
// released, keeping the map from growing unbounded over a long-lived runner.
stageParallel := common.NewParallelExecutor(maxParallel, stageExecutor...)
pipeline = append(pipeline, func(ctx context.Context) error {
defer jobMutexes.Delete(job)
return stageParallel(ctx)
})
pipeline = append(pipeline, common.NewParallelExecutor(maxParallel, stageExecutor...))
}
// For pipeline execution:
@@ -342,11 +321,6 @@ func (runner *runnerImpl) newRunContext(ctx context.Context, run *model.Run, mat
}
rc.ExprEval = rc.NewExpressionEvaluator(ctx)
rc.Name = rc.ExprEval.Interpolate(ctx, run.String())
// Snapshot the job's pristine output expressions now, before any matrix combo runs and
// rewrites the shared Job.Outputs (see interpolateOutputs).
if job := run.Job(); job != nil {
rc.outputTemplate = maps.Clone(job.Outputs)
}
return rc
}

View File

@@ -15,7 +15,6 @@ import (
"runtime"
"strings"
"testing"
"time"
"gitea.com/gitea/runner/act/common"
"gitea.com/gitea/runner/act/model"
@@ -27,7 +26,7 @@ import (
)
var (
baseImage = "node:24-bookworm-slim"
baseImage = "node:16-buster-slim"
platforms map[string]string
logLevel = log.DebugLevel
workdir = "testdata"
@@ -188,17 +187,13 @@ func (j *TestJobFileInfo) runTest(ctx context.Context, t *testing.T, cfg *Config
EventPath: cfg.EventPath,
Platforms: j.platforms,
ReuseContainers: false,
ForceRebuild: true,
Env: cfg.Env,
Secrets: cfg.Secrets,
Inputs: cfg.Inputs,
GitHubInstance: "github.com",
DefaultActionInstance: cfg.DefaultActionInstance,
ContainerArchitecture: cfg.ContainerArchitecture,
ContainerMaxLifetime: time.Hour,
Matrix: cfg.Matrix,
ActionCache: cfg.ActionCache,
ValidVolumes: []string{"**"}, // allow workflow-declared volumes (e.g. container-volumes)
}
runner, err := New(runnerConfig)
@@ -226,14 +221,20 @@ type TestConfig struct {
}
func TestRunEvent(t *testing.T) {
requireDocker(t)
if testing.Short() {
t.Skip("skipping integration test")
}
ctx := context.Background()
tables := []TestJobFileInfo{
// Shells
{workdir, "shells/defaults", "push", "", platforms, secrets},
// TODO: figure out why it fails
// {workdir, "shells/custom", "push", "", map[string]string{"ubuntu-latest": "catthehacker/ubuntu:pwsh-latest"}, }, // custom image with pwsh
{workdir, "shells/pwsh", "push", "", map[string]string{"ubuntu-latest": "catthehacker/ubuntu:pwsh-latest"}, secrets}, // custom image with pwsh
{workdir, "shells/bash", "push", "", platforms, secrets},
{workdir, "shells/python", "push", "", map[string]string{"ubuntu-latest": "node:16-buster"}, secrets}, // slim doesn't have python
{workdir, "shells/sh", "push", "", platforms, secrets},
// Local action
@@ -245,6 +246,11 @@ func TestRunEvent(t *testing.T) {
// Uses
{workdir, "uses-composite", "push", "", platforms, secrets},
{workdir, "uses-composite-with-error", "push", "Job 'failing-composite-action' failed", platforms, secrets},
{workdir, "uses-nested-composite", "push", "", platforms, secrets},
{workdir, "remote-action-composite-js-pre-with-defaults", "push", "", platforms, secrets},
{workdir, "remote-action-composite-action-ref", "push", "", platforms, secrets},
{workdir, "uses-workflow", "push", "", platforms, map[string]string{"secret": "keep_it_private"}},
{workdir, "uses-workflow", "pull_request", "", platforms, map[string]string{"secret": "keep_it_private"}},
{workdir, "uses-docker-url", "push", "", platforms, secrets},
{workdir, "act-composite-env-test", "push", "", platforms, secrets},
@@ -254,15 +260,21 @@ func TestRunEvent(t *testing.T) {
{workdir, "evalmatrixneeds2", "push", "", platforms, secrets},
{workdir, "evalmatrix-merge-map", "push", "", platforms, secrets},
{workdir, "evalmatrix-merge-array", "push", "", platforms, secrets},
{workdir, "issue-1195", "push", "", platforms, secrets},
{workdir, "basic", "push", "", platforms, secrets},
{workdir, "fail", "push", "exit with `FAILURE`: 1", platforms, secrets},
{workdir, "runs-on", "push", "", platforms, secrets},
{workdir, "checkout", "push", "", platforms, secrets},
{workdir, "job-container", "push", "", platforms, secrets},
{workdir, "job-container-non-root", "push", "", platforms, secrets},
{workdir, "job-container-invalid-credentials", "push", "failed to handle credentials: failed to interpolate container.credentials.password", platforms, secrets},
{workdir, "container-hostname", "push", "", platforms, secrets},
{workdir, "remote-action-docker", "push", "", platforms, secrets},
{workdir, "remote-action-js", "push", "", platforms, secrets},
{workdir, "remote-action-js-node-user", "push", "", platforms, secrets}, // Test if this works with non root container
{workdir, "matrix", "push", "", platforms, secrets},
{workdir, "matrix-include-exclude", "push", "", platforms, secrets},
{workdir, "matrix-exitcode", "push", "Job 'test' failed", platforms, secrets},
{workdir, "commands", "push", "", platforms, secrets},
{workdir, "workdir", "push", "", platforms, secrets},
@@ -283,6 +295,7 @@ func TestRunEvent(t *testing.T) {
{workdir, "job-status-check", "push", "job 'fail' failed", platforms, secrets},
{workdir, "if-expressions", "push", "Job 'mytest' failed", platforms, secrets},
{workdir, "actions-environment-and-context-tests", "push", "", platforms, secrets},
{workdir, "uses-action-with-pre-and-post-step", "push", "", platforms, secrets},
{workdir, "evalenv", "push", "", platforms, secrets},
{workdir, "docker-action-custom-path", "push", "", platforms, secrets},
{workdir, "GITHUB_ENV-use-in-env-ctx", "push", "", platforms, secrets},
@@ -293,6 +306,7 @@ func TestRunEvent(t *testing.T) {
{workdir, "workflow_dispatch-scalar", "workflow_dispatch", "", platforms, secrets},
{workdir, "workflow_dispatch-scalar-composite-action", "workflow_dispatch", "", platforms, secrets},
{workdir, "job-needs-context-contains-result", "push", "", platforms, secrets},
{"../model/testdata", "strategy", "push", "", platforms, secrets}, // TODO: move all testdata into pkg so we can validate it with planner and runner
{"../model/testdata", "container-volumes", "push", "", platforms, secrets},
{workdir, "path-handling", "push", "", platforms, secrets},
{workdir, "do-not-leak-step-env-in-composite", "push", "", platforms, secrets},
@@ -302,6 +316,7 @@ func TestRunEvent(t *testing.T) {
// services
{workdir, "services", "push", "", platforms, secrets},
{workdir, "services-host-network", "push", "", platforms, secrets},
{workdir, "services-with-container", "push", "", platforms, secrets},
// local remote action overrides
@@ -310,11 +325,6 @@ func TestRunEvent(t *testing.T) {
for _, table := range tables {
t.Run(table.workflowPath, func(t *testing.T) {
if table.workflowPath == "container-volumes" {
// host /proc bind mounts are Linux-Docker-only
requireLinuxDocker(t)
}
config := &Config{
Secrets: table.secrets,
}
@@ -346,12 +356,9 @@ func TestRunEvent(t *testing.T) {
}
func TestRunEventHostEnvironment(t *testing.T) {
// Runs steps directly on the host (the "-self-hosted" platform), so it needs the shells
// and tools the workflows invoke. No network gate: every action these workflows reference
// is a local `./` fixture or the skipped actions/checkout, so the suite runs offline (same
// as TestRunEvent). Only the broadly-used interpreters are required up front; the pwsh- and
// nix-specific cases gate on their own tool below so a missing pwsh/nix skips just those.
requireHostTools(t, "bash", "node")
if testing.Short() {
t.Skip("skipping integration test")
}
ctx := context.Background()
@@ -367,6 +374,7 @@ func TestRunEventHostEnvironment(t *testing.T) {
{workdir, "shells/defaults", "push", "", platforms, secrets},
{workdir, "shells/pwsh", "push", "", platforms, secrets},
{workdir, "shells/bash", "push", "", platforms, secrets},
{workdir, "shells/python", "push", "", platforms, secrets},
{workdir, "shells/sh", "push", "", platforms, secrets},
// Local action
@@ -375,6 +383,7 @@ func TestRunEventHostEnvironment(t *testing.T) {
// Uses
{workdir, "uses-composite", "push", "", platforms, secrets},
{workdir, "uses-composite-with-error", "push", "Job 'failing-composite-action' failed", platforms, secrets},
{workdir, "uses-nested-composite", "push", "", platforms, secrets},
{workdir, "act-composite-env-test", "push", "", platforms, secrets},
// Eval
@@ -383,10 +392,14 @@ func TestRunEventHostEnvironment(t *testing.T) {
{workdir, "evalmatrixneeds2", "push", "", platforms, secrets},
{workdir, "evalmatrix-merge-map", "push", "", platforms, secrets},
{workdir, "evalmatrix-merge-array", "push", "", platforms, secrets},
{workdir, "issue-1195", "push", "", platforms, secrets},
{workdir, "fail", "push", "exit with `FAILURE`: 1", platforms, secrets},
{workdir, "runs-on", "push", "", platforms, secrets},
{workdir, "checkout", "push", "", platforms, secrets},
{workdir, "remote-action-js", "push", "", platforms, secrets},
{workdir, "matrix", "push", "", platforms, secrets},
{workdir, "matrix-include-exclude", "push", "", platforms, secrets},
{workdir, "commands", "push", "", platforms, secrets},
{workdir, "defaults-run", "push", "", platforms, secrets},
{workdir, "composite-fail-with-output", "push", "", platforms, secrets},
@@ -400,6 +413,7 @@ func TestRunEventHostEnvironment(t *testing.T) {
{workdir, "steps-context/outcome", "push", "", platforms, secrets},
{workdir, "job-status-check", "push", "job 'fail' failed", platforms, secrets},
{workdir, "if-expressions", "push", "Job 'mytest' failed", platforms, secrets},
{workdir, "uses-action-with-pre-and-post-step", "push", "", platforms, secrets},
{workdir, "evalenv", "push", "", platforms, secrets},
{workdir, "ensure-post-steps", "push", "Job 'second-post-step-should-fail' failed", platforms, secrets},
}...)
@@ -432,26 +446,24 @@ func TestRunEventHostEnvironment(t *testing.T) {
for _, table := range tables {
t.Run(table.workflowPath, func(t *testing.T) {
switch table.workflowPath {
case "shells/pwsh":
requireHostTools(t, "pwsh")
case "nix-prepend-path":
requireHostTools(t, "nix")
}
table.runTest(ctx, t, &Config{})
})
}
}
func TestDryrunEvent(t *testing.T) {
// Dryrun plans without containers or network (shells and local actions only).
if testing.Short() {
t.Skip("skipping integration test")
}
ctx := common.WithDryrun(context.Background(), true)
tables := []TestJobFileInfo{
// Shells
{workdir, "shells/defaults", "push", "", platforms, secrets},
{workdir, "shells/pwsh", "push", "", platforms, secrets},
{workdir, "shells/pwsh", "push", "", map[string]string{"ubuntu-latest": "catthehacker/ubuntu:pwsh-latest"}, secrets}, // custom image with pwsh
{workdir, "shells/bash", "push", "", platforms, secrets},
{workdir, "shells/python", "push", "", map[string]string{"ubuntu-latest": "node:16-buster"}, secrets}, // slim doesn't have python
{workdir, "shells/sh", "push", "", platforms, secrets},
// Local action
@@ -468,18 +480,10 @@ func TestDryrunEvent(t *testing.T) {
}
}
// TestReusableWorkflowCaller exercises the reusable-workflow caller path against a local
// reusable workflow (typed inputs, secrets as both a map and `inherit`, and reading the called
// workflow's outputs via `needs`).
func TestReusableWorkflowCaller(t *testing.T) {
requireDocker(t)
table := TestJobFileInfo{workdir, "uses-workflow", "push", "", platforms, map[string]string{"secret": "keep_it_private"}}
table.runTest(context.Background(), t, &Config{Secrets: table.secrets})
}
func TestDockerActionForcePullForceRebuild(t *testing.T) {
requireDocker(t)
requireNetwork(t) // force-pulls a docker action image
if testing.Short() {
t.Skip("skipping integration test")
}
ctx := context.Background()
@@ -500,6 +504,22 @@ func TestDockerActionForcePullForceRebuild(t *testing.T) {
}
}
func TestRunDifferentArchitecture(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test")
}
tjfi := TestJobFileInfo{
workdir: workdir,
workflowPath: "basic",
eventName: "push",
errorMessage: "",
platforms: platforms,
}
tjfi.runTest(context.Background(), t, &Config{ContainerArchitecture: "linux/arm64"})
}
type maskJobLoggerFactory struct {
Output bytes.Buffer
}
@@ -520,7 +540,9 @@ func TestMaskValues(t *testing.T) {
assert.False(t, strings.Contains(text, "composite secret")) //nolint:testifylint // pre-existing issue from nektos/act
}
requireDocker(t)
if testing.Short() {
t.Skip("skipping integration test")
}
log.SetLevel(log.DebugLevel)
@@ -541,7 +563,9 @@ func TestMaskValues(t *testing.T) {
}
func TestRunEventSecrets(t *testing.T) {
requireDocker(t)
if testing.Short() {
t.Skip("skipping integration test")
}
workflowPath := "secrets"
tjfi := TestJobFileInfo{
@@ -561,13 +585,15 @@ func TestRunEventSecrets(t *testing.T) {
}
func TestRunWithService(t *testing.T) {
requireDocker(t)
if testing.Short() {
t.Skip("skipping integration test")
}
log.SetLevel(log.DebugLevel)
ctx := context.Background()
platforms := map[string]string{
"ubuntu-latest": "node:24-bookworm-slim",
"ubuntu-latest": "node:12.20.1-buster-slim",
}
workflowPath := "services"
@@ -577,11 +603,10 @@ func TestRunWithService(t *testing.T) {
assert.NoError(t, err, workflowPath) //nolint:testifylint // pre-existing issue from nektos/act
runnerConfig := &Config{
Workdir: workdir,
EventName: eventName,
Platforms: platforms,
ReuseContainers: false,
ContainerMaxLifetime: time.Hour, // otherwise the job container is `sleep 0` and exits at once
Workdir: workdir,
EventName: eventName,
Platforms: platforms,
ReuseContainers: false,
}
runner, err := New(runnerConfig)
assert.NoError(t, err, workflowPath) //nolint:testifylint // pre-existing issue from nektos/act
@@ -597,7 +622,9 @@ func TestRunWithService(t *testing.T) {
}
func TestRunActionInputs(t *testing.T) {
requireDocker(t)
if testing.Short() {
t.Skip("skipping integration test")
}
workflowPath := "input-from-cli"
tjfi := TestJobFileInfo{
@@ -616,7 +643,9 @@ func TestRunActionInputs(t *testing.T) {
}
func TestRunEventPullRequest(t *testing.T) {
requireDocker(t)
if testing.Short() {
t.Skip("skipping integration test")
}
workflowPath := "pull-request"
@@ -632,7 +661,9 @@ func TestRunEventPullRequest(t *testing.T) {
}
func TestRunMatrixWithUserDefinedInclusions(t *testing.T) {
requireDocker(t)
if testing.Short() {
t.Skip("skipping integration test")
}
workflowPath := "matrix-with-user-inclusions"
tjfi := TestJobFileInfo{

View File

@@ -107,7 +107,7 @@ func runStepExecutor(step step, stage stepStage, executor common.Executor) commo
if strings.Contains(stepString, "::add-mask::") {
stepString = "add-mask command"
}
logger.Infof("Run %s %s", stage, stepString)
logger.Infof("\u2B50 Run %s %s", stage, stepString)
// Prepare and clean Runner File Commands
actPath := rc.JobContainer.GetActPath()
@@ -158,7 +158,7 @@ func runStepExecutor(step step, stage stepStage, executor common.Executor) commo
err = executor(timeoutctx)
if err == nil {
logger.WithField("stepResult", stepResult.Outcome).Infof("Success - %s %s", stage, stepString)
logger.WithField("stepResult", stepResult.Outcome).Infof(" \u2705 Success - %s %s", stage, stepString)
} else {
stepResult.Outcome = model.StepStatusFailure
@@ -169,7 +169,6 @@ func runStepExecutor(step step, stage stepStage, executor common.Executor) commo
}
if continueOnError {
logger.Errorf("##[error]%v", err)
logger.Infof("Failed but continue next step")
err = nil
stepResult.Conclusion = model.StepStatusSuccess
@@ -177,9 +176,7 @@ func runStepExecutor(step step, stage stepStage, executor common.Executor) commo
stepResult.Conclusion = model.StepStatusFailure
}
// Infof: Errorf entries are promoted to the user log by the reporter,
// which would duplicate the ##[error] annotation emitted elsewhere.
logger.WithField("stepResult", stepResult.Outcome).Infof("Failure - %s %s", stage, stepString)
logger.WithField("stepResult", stepResult.Outcome).Errorf(" \u274C Failure - %s %s", stage, stepString)
}
// Process Runner File Commands
orgerr := err
@@ -271,7 +268,7 @@ func isStepEnabled(ctx context.Context, expr string, step step, stage stepStage)
runStep, err := EvalBool(ctx, rc.NewStepExpressionEvaluator(ctx, step), expr, defaultStatusCheck)
if err != nil {
return false, fmt.Errorf("if-expression %q evaluation failed: %s", expr, err)
return false, fmt.Errorf(" \u274C Error in if-expression: \"if: %s\" (%s)", expr, err)
}
return runStep, nil
@@ -287,7 +284,7 @@ func isContinueOnError(ctx context.Context, expr string, step step, _ stepStage)
continueOnError, err := EvalBool(ctx, rc.NewStepExpressionEvaluator(ctx, step), expr, exprparser.DefaultStatusCheckNone)
if err != nil {
return false, fmt.Errorf("continue-on-error expression %q evaluation failed: %s", expr, err)
return false, fmt.Errorf(" \u274C Error in continue-on-error-expression: \"continue-on-error: %s\" (%s)", expr, err)
}
return continueOnError, nil

View File

@@ -113,10 +113,9 @@ func (sar *stepActionRemote) prepareActionExecutor() common.Executor {
}
actionDir := fmt.Sprintf("%s/%s", sar.RunContext.ActionCacheDir(), sar.Step.UsesHash())
defaultActionURL := sar.RunContext.Config.DefaultActionURL()
token := getGitCloneToken(sar.getRunContext().Config, sar.remoteAction.CloneURL(defaultActionURL))
token := getGitCloneToken(sar.getRunContext().Config, sar.remoteAction.CloneURL(sar.RunContext.Config.DefaultActionInstance))
gitClone := stepActionRemoteNewCloneExecutor(git.NewGitCloneExecutorInput{
URL: sar.remoteAction.CloneURL(defaultActionURL),
URL: sar.remoteAction.CloneURL(sar.RunContext.Config.DefaultActionInstance),
Ref: sar.remoteAction.Ref,
Dir: actionDir,
Token: token,
@@ -146,7 +145,6 @@ func (sar *stepActionRemote) prepareActionExecutor() common.Executor {
return common.NewPipelineExecutor(
ntErr,
func(ctx context.Context) error {
defer git.AcquireCloneLock(actionDir)()
actionModel, err := sar.readAction(ctx, sar.Step, actionDir, sar.remoteAction.Path, remoteReader(ctx), os.WriteFile)
sar.action = actionModel
return err
@@ -275,7 +273,7 @@ func (sar *stepActionRemote) cloneSkipTLS() bool {
if sar.remoteAction.URL == "" {
// 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 sar.RunContext.Config.DefaultActionURL() == sar.RunContext.Config.GitHubInstance
return sar.RunContext.Config.DefaultActionInstance == sar.RunContext.Config.GitHubInstance
}
// 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
@@ -291,9 +289,7 @@ type remoteAction struct {
func (ra *remoteAction) CloneURL(u string) string {
if ra.URL == "" {
// keep an absolute local path as-is (used by tests to resolve actions from a local
// repo); only bare host names get the https:// scheme prepended
if !strings.HasPrefix(u, "http://") && !strings.HasPrefix(u, "https://") && !filepath.IsAbs(u) {
if !strings.HasPrefix(u, "http://") && !strings.HasPrefix(u, "https://") {
u = "https://" + u
}
} else {

View File

@@ -20,7 +20,6 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
"go.yaml.in/yaml/v4"
)
@@ -435,57 +434,6 @@ 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) {
table := []struct {
name string

View File

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

View File

@@ -109,55 +109,6 @@ func TestStepDockerMain(t *testing.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) {
ctx := context.Background()
sd := &stepDocker{}

View File

@@ -1,34 +0,0 @@
name: local-reusable-workflow
on:
workflow_call:
inputs:
string_required:
required: true
type: string
bool_required:
required: true
type: boolean
number_required:
required: true
type: number
secrets:
secret:
required: true
outputs:
output:
value: ${{ jobs.reusable.outputs.output }}
jobs:
reusable:
runs-on: ubuntu-latest
outputs:
output: ${{ steps.gen.outputs.output }}
steps:
- name: check inputs and secret arrived
run: |
[ "${{ inputs.string_required }}" = "string" ]
[ "${{ inputs.bool_required }}" = "true" ]
[ "${{ inputs.number_required }}" = "1" ]
[ "${{ secrets.secret }}" = "keep_it_private" ]
- id: gen
run: echo "output=${{ inputs.string_required }}" >> $GITHUB_OUTPUT

View File

@@ -5,11 +5,10 @@ jobs:
env:
MYGLOBALENV3: myglobalval3
steps:
- uses: actions/checkout@v4
- run: |
echo MYGLOBALENV1=myglobalval1 > $GITHUB_ENV
echo "::set-env name=MYGLOBALENV2::myglobalval2"
- uses: ./actions/script
- uses: nektos/act-test-actions/script@main
with:
main: |
env

View File

@@ -1,31 +1,48 @@
on: push
jobs:
# State saved in main (via the $GITHUB_STATE file and the ::save-state command) must surface
# as $STATE_* in the action's post step.
_:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: ./actions/script
- uses: nektos/act-test-actions/script@main
with:
pre: |
env
echo mystate0=mystateval > $GITHUB_STATE
echo "::save-state name=mystate1::mystateval"
main: |
env
echo mystate2=mystateval > $GITHUB_STATE
echo "::save-state name=mystate3::mystateval"
post: |
env
[ "$STATE_mystate0" = "mystateval" ]
[ "$STATE_mystate1" = "mystateval" ]
[ "$STATE_mystate2" = "mystateval" ]
[ "$STATE_mystate3" = "mystateval" ]
# State must be isolated per action instance even when two steps use the same action.
test-id-collision-bug:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: ./actions/script
- uses: nektos/act-test-actions/script@main
id: script
with:
main: echo mystate=val1 > $GITHUB_STATE
post: '[ "$STATE_mystate" = "val1" ]'
- uses: ./actions/script
pre: |
env
echo mystate0=mystateval > $GITHUB_STATE
echo "::save-state name=mystate1::mystateval"
main: |
env
echo mystate2=mystateval > $GITHUB_STATE
echo "::save-state name=mystate3::mystateval"
post: |
env
[ "$STATE_mystate0" = "mystateval" ]
[ "$STATE_mystate1" = "mystateval" ]
[ "$STATE_mystate2" = "mystateval" ]
[ "$STATE_mystate3" = "mystateval" ]
- uses: nektos/act-test-actions/script@main
id: pre-script
with:
main: echo mystate=val2 > $GITHUB_STATE
post: '[ "$STATE_mystate" = "val2" ]'
main: |
env
echo mystate0=mystateerror > $GITHUB_STATE
echo "::save-state name=mystate1::mystateerror"

View File

@@ -1,4 +1,4 @@
FROM alpine:3.23
FROM alpine:3
COPY entrypoint.sh /entrypoint.sh

View File

@@ -1,5 +1,5 @@
name: 'Test'
description: 'Test'
runs:
using: 'node24'
using: 'node12'
main: 'index.js'

View File

@@ -9,3 +9,7 @@ jobs:
- uses: actions/checkout@v3
- uses: './actions-environment-and-context-tests/js'
- uses: './actions-environment-and-context-tests/docker'
- uses: 'nektos/act-test-actions/js@main'
- uses: 'nektos/act-test-actions/docker@main'
- uses: 'nektos/act-test-actions/docker-file@main'
- uses: 'nektos/act-test-actions/docker-relative-context/action@main'

View File

@@ -1 +1 @@
FROM ubuntu:24.04
FROM ubuntu:18.04

View File

@@ -1,5 +1,5 @@
# Container image that runs your code
FROM node:24-bookworm-slim
FROM node:12-buster-slim
# Copies your code file from your action repository to the filesystem path `/` of the container
COPY entrypoint.sh /entrypoint.sh

View File

@@ -1,5 +1,5 @@
# Container image that runs your code
FROM node:24-bookworm-slim
FROM node:16-buster-slim
# Copies your code file from your action repository to the filesystem path `/` of the container
COPY entrypoint.sh /entrypoint.sh

View File

@@ -8,7 +8,7 @@ inputs:
default: World
runs:
using: docker
image: docker://node:24-bookworm-slim
image: docker://node:16-buster-slim
entrypoint: /bin/sh -c
env:
TEST: enabled

View File

@@ -9,5 +9,5 @@ outputs:
time: # id of output
description: 'The time we greeted you'
runs:
using: 'node24'
main: 'index.js'
using: 'node12'
main: 'dist/index.js'

View File

@@ -0,0 +1,15 @@
const core = require('@actions/core');
const github = require('@actions/github');
try {
// `who-to-greet` input defined in action metadata file
const nameToGreet = core.getInput('who-to-greet');
console.log(`Hello ${nameToGreet}!`);
const time = (new Date()).toTimeString();
core.setOutput("time", time);
// Get the JSON webhook payload for the event that triggered the workflow
const payload = JSON.stringify(github.context.payload, undefined, 2)
console.log(`The event payload: ${payload}`);
} catch (error) {
core.setFailed(error.message);
}

View File

@@ -0,0 +1 @@
../@vercel/ncc/dist/ncc/cli.js

View File

@@ -0,0 +1 @@
../uuid/dist/bin/uuid

View File

@@ -0,0 +1,244 @@
{
"name": "node12",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"node_modules/@actions/core": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@actions/core/-/core-1.10.0.tgz",
"integrity": "sha512-2aZDDa3zrrZbP5ZYg159sNoLRb61nQ7awl5pSvIq5Qpj81vwDzdMRKzkWJGJuwVvWpvZKx7vspJALyvaaIQyug==",
"dependencies": {
"@actions/http-client": "^2.0.1",
"uuid": "^8.3.2"
}
},
"node_modules/@actions/github": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@actions/github/-/github-4.0.0.tgz",
"integrity": "sha512-Ej/Y2E+VV6sR9X7pWL5F3VgEWrABaT292DRqRU6R4hnQjPtC/zD3nagxVdXWiRQvYDh8kHXo7IDmG42eJ/dOMA==",
"dependencies": {
"@actions/http-client": "^1.0.8",
"@octokit/core": "^3.0.0",
"@octokit/plugin-paginate-rest": "^2.2.3",
"@octokit/plugin-rest-endpoint-methods": "^4.0.0"
}
},
"node_modules/@actions/github/node_modules/@actions/http-client": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/@actions/http-client/-/http-client-1.0.11.tgz",
"integrity": "sha512-VRYHGQV1rqnROJqdMvGUbY/Kn8vriQe/F9HR2AlYHzmKuM/p3kjNuXhmdBfcVgsvRWTz5C5XW5xvndZrVBuAYg==",
"dependencies": {
"tunnel": "0.0.6"
}
},
"node_modules/@actions/http-client": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/@actions/http-client/-/http-client-2.1.1.tgz",
"integrity": "sha512-qhrkRMB40bbbLo7gF+0vu+X+UawOvQQqNAA/5Unx774RS8poaOhThDOG6BGmxvAnxhQnDp2BG/ZUm65xZILTpw==",
"dependencies": {
"tunnel": "^0.0.6"
}
},
"node_modules/@octokit/auth-token": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/@octokit/auth-token/-/auth-token-2.5.0.tgz",
"integrity": "sha512-r5FVUJCOLl19AxiuZD2VRZ/ORjp/4IN98Of6YJoJOkY75CIBuYfmiNHGrDwXr+aLGG55igl9QrxX3hbiXlLb+g==",
"dependencies": {
"@octokit/types": "^6.0.3"
}
},
"node_modules/@octokit/core": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/@octokit/core/-/core-3.6.0.tgz",
"integrity": "sha512-7RKRKuA4xTjMhY+eG3jthb3hlZCsOwg3rztWh75Xc+ShDWOfDDATWbeZpAHBNRpm4Tv9WgBMOy1zEJYXG6NJ7Q==",
"dependencies": {
"@octokit/auth-token": "^2.4.4",
"@octokit/graphql": "^4.5.8",
"@octokit/request": "^5.6.3",
"@octokit/request-error": "^2.0.5",
"@octokit/types": "^6.0.3",
"before-after-hook": "^2.2.0",
"universal-user-agent": "^6.0.0"
}
},
"node_modules/@octokit/endpoint": {
"version": "6.0.12",
"resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-6.0.12.tgz",
"integrity": "sha512-lF3puPwkQWGfkMClXb4k/eUT/nZKQfxinRWJrdZaJO85Dqwo/G0yOC434Jr2ojwafWJMYqFGFa5ms4jJUgujdA==",
"dependencies": {
"@octokit/types": "^6.0.3",
"is-plain-object": "^5.0.0",
"universal-user-agent": "^6.0.0"
}
},
"node_modules/@octokit/graphql": {
"version": "4.8.0",
"resolved": "https://registry.npmjs.org/@octokit/graphql/-/graphql-4.8.0.tgz",
"integrity": "sha512-0gv+qLSBLKF0z8TKaSKTsS39scVKF9dbMxJpj3U0vC7wjNWFuIpL/z76Qe2fiuCbDRcJSavkXsVtMS6/dtQQsg==",
"dependencies": {
"@octokit/request": "^5.6.0",
"@octokit/types": "^6.0.3",
"universal-user-agent": "^6.0.0"
}
},
"node_modules/@octokit/openapi-types": {
"version": "12.11.0",
"resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-12.11.0.tgz",
"integrity": "sha512-VsXyi8peyRq9PqIz/tpqiL2w3w80OgVMwBHltTml3LmVvXiphgeqmY9mvBw9Wu7e0QWk/fqD37ux8yP5uVekyQ=="
},
"node_modules/@octokit/plugin-paginate-rest": {
"version": "2.21.3",
"resolved": "https://registry.npmjs.org/@octokit/plugin-paginate-rest/-/plugin-paginate-rest-2.21.3.tgz",
"integrity": "sha512-aCZTEf0y2h3OLbrgKkrfFdjRL6eSOo8komneVQJnYecAxIej7Bafor2xhuDJOIFau4pk0i/P28/XgtbyPF0ZHw==",
"dependencies": {
"@octokit/types": "^6.40.0"
},
"peerDependencies": {
"@octokit/core": ">=2"
}
},
"node_modules/@octokit/plugin-rest-endpoint-methods": {
"version": "4.15.1",
"resolved": "https://registry.npmjs.org/@octokit/plugin-rest-endpoint-methods/-/plugin-rest-endpoint-methods-4.15.1.tgz",
"integrity": "sha512-4gQg4ySoW7ktKB0Mf38fHzcSffVZd6mT5deJQtpqkuPuAqzlED5AJTeW8Uk7dPRn7KaOlWcXB0MedTFJU1j4qA==",
"dependencies": {
"@octokit/types": "^6.13.0",
"deprecation": "^2.3.1"
},
"peerDependencies": {
"@octokit/core": ">=3"
}
},
"node_modules/@octokit/request": {
"version": "5.6.3",
"resolved": "https://registry.npmjs.org/@octokit/request/-/request-5.6.3.tgz",
"integrity": "sha512-bFJl0I1KVc9jYTe9tdGGpAMPy32dLBXXo1dS/YwSCTL/2nd9XeHsY616RE3HPXDVk+a+dBuzyz5YdlXwcDTr2A==",
"dependencies": {
"@octokit/endpoint": "^6.0.1",
"@octokit/request-error": "^2.1.0",
"@octokit/types": "^6.16.1",
"is-plain-object": "^5.0.0",
"node-fetch": "^2.6.7",
"universal-user-agent": "^6.0.0"
}
},
"node_modules/@octokit/request-error": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-2.1.0.tgz",
"integrity": "sha512-1VIvgXxs9WHSjicsRwq8PlR2LR2x6DwsJAaFgzdi0JfJoGSO8mYI/cHJQ+9FbN21aa+DrgNLnwObmyeSC8Rmpg==",
"dependencies": {
"@octokit/types": "^6.0.3",
"deprecation": "^2.0.0",
"once": "^1.4.0"
}
},
"node_modules/@octokit/types": {
"version": "6.41.0",
"resolved": "https://registry.npmjs.org/@octokit/types/-/types-6.41.0.tgz",
"integrity": "sha512-eJ2jbzjdijiL3B4PrSQaSjuF2sPEQPVCPzBvTHJD9Nz+9dw2SGH4K4xeQJ77YfTq5bRQ+bD8wT11JbeDPmxmGg==",
"dependencies": {
"@octokit/openapi-types": "^12.11.0"
}
},
"node_modules/@vercel/ncc": {
"version": "0.24.1",
"resolved": "https://registry.npmjs.org/@vercel/ncc/-/ncc-0.24.1.tgz",
"integrity": "sha512-r9m7brz2hNmq5TF3sxrK4qR/FhXn44XIMglQUir4sT7Sh5GOaYXlMYikHFwJStf8rmQGTlvOoBXt4yHVonRG8A==",
"dev": true,
"bin": {
"ncc": "dist/ncc/cli.js"
}
},
"node_modules/before-after-hook": {
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-2.2.3.tgz",
"integrity": "sha512-NzUnlZexiaH/46WDhANlyR2bXRopNg4F/zuSA3OpZnllCUgRaOF2znDioDWrmbNVsuZk6l9pMquQB38cfBZwkQ=="
},
"node_modules/deprecation": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/deprecation/-/deprecation-2.3.1.tgz",
"integrity": "sha512-xmHIy4F3scKVwMsQ4WnVaS8bHOx0DmVwRywosKhaILI0ywMDWPtBSku2HNxRvF7jtwDRsoEwYQSfbxj8b7RlJQ=="
},
"node_modules/is-plain-object": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz",
"integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/node-fetch": {
"version": "2.6.12",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.12.tgz",
"integrity": "sha512-C/fGU2E8ToujUivIO0H+tpQ6HWo4eEmchoPIoXtxCrVghxdKq+QOHqEZW7tuP3KlV3bC8FRMO5nMCC7Zm1VP6g==",
"dependencies": {
"whatwg-url": "^5.0.0"
},
"engines": {
"node": "4.x || >=6.0.0"
},
"peerDependencies": {
"encoding": "^0.1.0"
},
"peerDependenciesMeta": {
"encoding": {
"optional": true
}
}
},
"node_modules/once": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
"dependencies": {
"wrappy": "1"
}
},
"node_modules/tr46": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="
},
"node_modules/tunnel": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/tunnel/-/tunnel-0.0.6.tgz",
"integrity": "sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg==",
"engines": {
"node": ">=0.6.11 <=0.7.0 || >=0.7.3"
}
},
"node_modules/universal-user-agent": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-6.0.0.tgz",
"integrity": "sha512-isyNax3wXoKaulPDZWHQqbmIx1k2tb9fb3GGDBRxCscfYV2Ch7WxPArBsFEG8s/safwXTT7H4QGhaIkTp9447w=="
},
"node_modules/uuid": {
"version": "8.3.2",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
"bin": {
"uuid": "dist/bin/uuid"
}
},
"node_modules/webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="
},
"node_modules/whatwg-url": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
"dependencies": {
"tr46": "~0.0.3",
"webidl-conversions": "^3.0.0"
}
},
"node_modules/wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="
}
}
}

View File

@@ -0,0 +1,9 @@
The MIT License (MIT)
Copyright 2019 GitHub
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

View File

@@ -0,0 +1,15 @@
export interface CommandProperties {
[key: string]: any;
}
/**
* Commands
*
* Command Format:
* ::name key=value,key=value::message
*
* Examples:
* ::warning::This is the message
* ::set-env name=MY_VAR::some value
*/
export declare function issueCommand(command: string, properties: CommandProperties, message: any): void;
export declare function issue(name: string, message?: string): void;

View File

@@ -0,0 +1,92 @@
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
Object.defineProperty(o, k2, { enumerable: true, get: function() { return m[k]; } });
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k in mod) if (k !== "default" && Object.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
__setModuleDefault(result, mod);
return result;
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.issue = exports.issueCommand = void 0;
const os = __importStar(require("os"));
const utils_1 = require("./utils");
/**
* Commands
*
* Command Format:
* ::name key=value,key=value::message
*
* Examples:
* ::warning::This is the message
* ::set-env name=MY_VAR::some value
*/
function issueCommand(command, properties, message) {
const cmd = new Command(command, properties, message);
process.stdout.write(cmd.toString() + os.EOL);
}
exports.issueCommand = issueCommand;
function issue(name, message = '') {
issueCommand(name, {}, message);
}
exports.issue = issue;
const CMD_STRING = '::';
class Command {
constructor(command, properties, message) {
if (!command) {
command = 'missing.command';
}
this.command = command;
this.properties = properties;
this.message = message;
}
toString() {
let cmdStr = CMD_STRING + this.command;
if (this.properties && Object.keys(this.properties).length > 0) {
cmdStr += ' ';
let first = true;
for (const key in this.properties) {
if (this.properties.hasOwnProperty(key)) {
const val = this.properties[key];
if (val) {
if (first) {
first = false;
}
else {
cmdStr += ',';
}
cmdStr += `${key}=${escapeProperty(val)}`;
}
}
}
}
cmdStr += `${CMD_STRING}${escapeData(this.message)}`;
return cmdStr;
}
}
function escapeData(s) {
return utils_1.toCommandValue(s)
.replace(/%/g, '%25')
.replace(/\r/g, '%0D')
.replace(/\n/g, '%0A');
}
function escapeProperty(s) {
return utils_1.toCommandValue(s)
.replace(/%/g, '%25')
.replace(/\r/g, '%0D')
.replace(/\n/g, '%0A')
.replace(/:/g, '%3A')
.replace(/,/g, '%2C');
}
//# sourceMappingURL=command.js.map

View File

@@ -0,0 +1 @@
{"version":3,"file":"command.js","sourceRoot":"","sources":["../src/command.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;AAAA,uCAAwB;AACxB,mCAAsC;AAWtC;;;;;;;;;GASG;AACH,SAAgB,YAAY,CAC1B,OAAe,EACf,UAA6B,EAC7B,OAAY;IAEZ,MAAM,GAAG,GAAG,IAAI,OAAO,CAAC,OAAO,EAAE,UAAU,EAAE,OAAO,CAAC,CAAA;IACrD,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,GAAG,CAAC,QAAQ,EAAE,GAAG,EAAE,CAAC,GAAG,CAAC,CAAA;AAC/C,CAAC;AAPD,oCAOC;AAED,SAAgB,KAAK,CAAC,IAAY,EAAE,OAAO,GAAG,EAAE;IAC9C,YAAY,CAAC,IAAI,EAAE,EAAE,EAAE,OAAO,CAAC,CAAA;AACjC,CAAC;AAFD,sBAEC;AAED,MAAM,UAAU,GAAG,IAAI,CAAA;AAEvB,MAAM,OAAO;IAKX,YAAY,OAAe,EAAE,UAA6B,EAAE,OAAe;QACzE,IAAI,CAAC,OAAO,EAAE;YACZ,OAAO,GAAG,iBAAiB,CAAA;SAC5B;QAED,IAAI,CAAC,OAAO,GAAG,OAAO,CAAA;QACtB,IAAI,CAAC,UAAU,GAAG,UAAU,CAAA;QAC5B,IAAI,CAAC,OAAO,GAAG,OAAO,CAAA;IACxB,CAAC;IAED,QAAQ;QACN,IAAI,MAAM,GAAG,UAAU,GAAG,IAAI,CAAC,OAAO,CAAA;QAEtC,IAAI,IAAI,CAAC,UAAU,IAAI,MAAM,CAAC,IAAI,CAAC,IAAI,CAAC,UAAU,CAAC,CAAC,MAAM,GAAG,CAAC,EAAE;YAC9D,MAAM,IAAI,GAAG,CAAA;YACb,IAAI,KAAK,GAAG,IAAI,CAAA;YAChB,KAAK,MAAM,GAAG,IAAI,IAAI,CAAC,UAAU,EAAE;gBACjC,IAAI,IAAI,CAAC,UAAU,CAAC,cAAc,CAAC,GAAG,CAAC,EAAE;oBACvC,MAAM,GAAG,GAAG,IAAI,CAAC,UAAU,CAAC,GAAG,CAAC,CAAA;oBAChC,IAAI,GAAG,EAAE;wBACP,IAAI,KAAK,EAAE;4BACT,KAAK,GAAG,KAAK,CAAA;yBACd;6BAAM;4BACL,MAAM,IAAI,GAAG,CAAA;yBACd;wBAED,MAAM,IAAI,GAAG,GAAG,IAAI,cAAc,CAAC,GAAG,CAAC,EAAE,CAAA;qBAC1C;iBACF;aACF;SACF;QAED,MAAM,IAAI,GAAG,UAAU,GAAG,UAAU,CAAC,IAAI,CAAC,OAAO,CAAC,EAAE,CAAA;QACpD,OAAO,MAAM,CAAA;IACf,CAAC;CACF;AAED,SAAS,UAAU,CAAC,CAAM;IACxB,OAAO,sBAAc,CAAC,CAAC,CAAC;SACrB,OAAO,CAAC,IAAI,EAAE,KAAK,CAAC;SACpB,OAAO,CAAC,KAAK,EAAE,KAAK,CAAC;SACrB,OAAO,CAAC,KAAK,EAAE,KAAK,CAAC,CAAA;AAC1B,CAAC;AAED,SAAS,cAAc,CAAC,CAAM;IAC5B,OAAO,sBAAc,CAAC,CAAC,CAAC;SACrB,OAAO,CAAC,IAAI,EAAE,KAAK,CAAC;SACpB,OAAO,CAAC,KAAK,EAAE,KAAK,CAAC;SACrB,OAAO,CAAC,KAAK,EAAE,KAAK,CAAC;SACrB,OAAO,CAAC,IAAI,EAAE,KAAK,CAAC;SACpB,OAAO,CAAC,IAAI,EAAE,KAAK,CAAC,CAAA;AACzB,CAAC"}

View File

@@ -0,0 +1,198 @@
/**
* Interface for getInput options
*/
export interface InputOptions {
/** Optional. Whether the input is required. If required and not present, will throw. Defaults to false */
required?: boolean;
/** Optional. Whether leading/trailing whitespace will be trimmed for the input. Defaults to true */
trimWhitespace?: boolean;
}
/**
* The code to exit an action
*/
export declare enum ExitCode {
/**
* A code indicating that the action was successful
*/
Success = 0,
/**
* A code indicating that the action was a failure
*/
Failure = 1
}
/**
* Optional properties that can be sent with annotatation commands (notice, error, and warning)
* See: https://docs.github.com/en/rest/reference/checks#create-a-check-run for more information about annotations.
*/
export interface AnnotationProperties {
/**
* A title for the annotation.
*/
title?: string;
/**
* The path of the file for which the annotation should be created.
*/
file?: string;
/**
* The start line for the annotation.
*/
startLine?: number;
/**
* The end line for the annotation. Defaults to `startLine` when `startLine` is provided.
*/
endLine?: number;
/**
* The start column for the annotation. Cannot be sent when `startLine` and `endLine` are different values.
*/
startColumn?: number;
/**
* The start column for the annotation. Cannot be sent when `startLine` and `endLine` are different values.
* Defaults to `startColumn` when `startColumn` is provided.
*/
endColumn?: number;
}
/**
* Sets env variable for this action and future actions in the job
* @param name the name of the variable to set
* @param val the value of the variable. Non-string values will be converted to a string via JSON.stringify
*/
export declare function exportVariable(name: string, val: any): void;
/**
* Registers a secret which will get masked from logs
* @param secret value of the secret
*/
export declare function setSecret(secret: string): void;
/**
* Prepends inputPath to the PATH (for this action and future actions)
* @param inputPath
*/
export declare function addPath(inputPath: string): void;
/**
* Gets the value of an input.
* Unless trimWhitespace is set to false in InputOptions, the value is also trimmed.
* Returns an empty string if the value is not defined.
*
* @param name name of the input to get
* @param options optional. See InputOptions.
* @returns string
*/
export declare function getInput(name: string, options?: InputOptions): string;
/**
* Gets the values of an multiline input. Each value is also trimmed.
*
* @param name name of the input to get
* @param options optional. See InputOptions.
* @returns string[]
*
*/
export declare function getMultilineInput(name: string, options?: InputOptions): string[];
/**
* Gets the input value of the boolean type in the YAML 1.2 "core schema" specification.
* Support boolean input list: `true | True | TRUE | false | False | FALSE` .
* The return value is also in boolean type.
* ref: https://yaml.org/spec/1.2/spec.html#id2804923
*
* @param name name of the input to get
* @param options optional. See InputOptions.
* @returns boolean
*/
export declare function getBooleanInput(name: string, options?: InputOptions): boolean;
/**
* Sets the value of an output.
*
* @param name name of the output to set
* @param value value to store. Non-string values will be converted to a string via JSON.stringify
*/
export declare function setOutput(name: string, value: any): void;
/**
* Enables or disables the echoing of commands into stdout for the rest of the step.
* Echoing is disabled by default if ACTIONS_STEP_DEBUG is not set.
*
*/
export declare function setCommandEcho(enabled: boolean): void;
/**
* Sets the action status to failed.
* When the action exits it will be with an exit code of 1
* @param message add error issue message
*/
export declare function setFailed(message: string | Error): void;
/**
* Gets whether Actions Step Debug is on or not
*/
export declare function isDebug(): boolean;
/**
* Writes debug message to user log
* @param message debug message
*/
export declare function debug(message: string): void;
/**
* Adds an error issue
* @param message error issue message. Errors will be converted to string via toString()
* @param properties optional properties to add to the annotation.
*/
export declare function error(message: string | Error, properties?: AnnotationProperties): void;
/**
* Adds a warning issue
* @param message warning issue message. Errors will be converted to string via toString()
* @param properties optional properties to add to the annotation.
*/
export declare function warning(message: string | Error, properties?: AnnotationProperties): void;
/**
* Adds a notice issue
* @param message notice issue message. Errors will be converted to string via toString()
* @param properties optional properties to add to the annotation.
*/
export declare function notice(message: string | Error, properties?: AnnotationProperties): void;
/**
* Writes info to log with console.log.
* @param message info message
*/
export declare function info(message: string): void;
/**
* Begin an output group.
*
* Output until the next `groupEnd` will be foldable in this group
*
* @param name The name of the output group
*/
export declare function startGroup(name: string): void;
/**
* End an output group.
*/
export declare function endGroup(): void;
/**
* Wrap an asynchronous function call in a group.
*
* Returns the same type as the function itself.
*
* @param name The name of the group
* @param fn The function to wrap in the group
*/
export declare function group<T>(name: string, fn: () => Promise<T>): Promise<T>;
/**
* Saves state for current action, the state can only be retrieved by this action's post job execution.
*
* @param name name of the state to store
* @param value value to store. Non-string values will be converted to a string via JSON.stringify
*/
export declare function saveState(name: string, value: any): void;
/**
* Gets the value of an state set by this action's main execution.
*
* @param name name of the state to get
* @returns string
*/
export declare function getState(name: string): string;
export declare function getIDToken(aud?: string): Promise<string>;
/**
* Summary exports
*/
export { summary } from './summary';
/**
* @deprecated use core.summary
*/
export { markdownSummary } from './summary';
/**
* Path exports
*/
export { toPosixPath, toWin32Path, toPlatformPath } from './path-utils';

View File

@@ -0,0 +1,336 @@
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
Object.defineProperty(o, k2, { enumerable: true, get: function() { return m[k]; } });
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k in mod) if (k !== "default" && Object.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
__setModuleDefault(result, mod);
return result;
};
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
return new (P || (P = Promise))(function (resolve, reject) {
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
step((generator = generator.apply(thisArg, _arguments || [])).next());
});
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.getIDToken = exports.getState = exports.saveState = exports.group = exports.endGroup = exports.startGroup = exports.info = exports.notice = exports.warning = exports.error = exports.debug = exports.isDebug = exports.setFailed = exports.setCommandEcho = exports.setOutput = exports.getBooleanInput = exports.getMultilineInput = exports.getInput = exports.addPath = exports.setSecret = exports.exportVariable = exports.ExitCode = void 0;
const command_1 = require("./command");
const file_command_1 = require("./file-command");
const utils_1 = require("./utils");
const os = __importStar(require("os"));
const path = __importStar(require("path"));
const oidc_utils_1 = require("./oidc-utils");
/**
* The code to exit an action
*/
var ExitCode;
(function (ExitCode) {
/**
* A code indicating that the action was successful
*/
ExitCode[ExitCode["Success"] = 0] = "Success";
/**
* A code indicating that the action was a failure
*/
ExitCode[ExitCode["Failure"] = 1] = "Failure";
})(ExitCode = exports.ExitCode || (exports.ExitCode = {}));
//-----------------------------------------------------------------------
// Variables
//-----------------------------------------------------------------------
/**
* Sets env variable for this action and future actions in the job
* @param name the name of the variable to set
* @param val the value of the variable. Non-string values will be converted to a string via JSON.stringify
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function exportVariable(name, val) {
const convertedVal = utils_1.toCommandValue(val);
process.env[name] = convertedVal;
const filePath = process.env['GITHUB_ENV'] || '';
if (filePath) {
return file_command_1.issueFileCommand('ENV', file_command_1.prepareKeyValueMessage(name, val));
}
command_1.issueCommand('set-env', { name }, convertedVal);
}
exports.exportVariable = exportVariable;
/**
* Registers a secret which will get masked from logs
* @param secret value of the secret
*/
function setSecret(secret) {
command_1.issueCommand('add-mask', {}, secret);
}
exports.setSecret = setSecret;
/**
* Prepends inputPath to the PATH (for this action and future actions)
* @param inputPath
*/
function addPath(inputPath) {
const filePath = process.env['GITHUB_PATH'] || '';
if (filePath) {
file_command_1.issueFileCommand('PATH', inputPath);
}
else {
command_1.issueCommand('add-path', {}, inputPath);
}
process.env['PATH'] = `${inputPath}${path.delimiter}${process.env['PATH']}`;
}
exports.addPath = addPath;
/**
* Gets the value of an input.
* Unless trimWhitespace is set to false in InputOptions, the value is also trimmed.
* Returns an empty string if the value is not defined.
*
* @param name name of the input to get
* @param options optional. See InputOptions.
* @returns string
*/
function getInput(name, options) {
const val = process.env[`INPUT_${name.replace(/ /g, '_').toUpperCase()}`] || '';
if (options && options.required && !val) {
throw new Error(`Input required and not supplied: ${name}`);
}
if (options && options.trimWhitespace === false) {
return val;
}
return val.trim();
}
exports.getInput = getInput;
/**
* Gets the values of an multiline input. Each value is also trimmed.
*
* @param name name of the input to get
* @param options optional. See InputOptions.
* @returns string[]
*
*/
function getMultilineInput(name, options) {
const inputs = getInput(name, options)
.split('\n')
.filter(x => x !== '');
if (options && options.trimWhitespace === false) {
return inputs;
}
return inputs.map(input => input.trim());
}
exports.getMultilineInput = getMultilineInput;
/**
* Gets the input value of the boolean type in the YAML 1.2 "core schema" specification.
* Support boolean input list: `true | True | TRUE | false | False | FALSE` .
* The return value is also in boolean type.
* ref: https://yaml.org/spec/1.2/spec.html#id2804923
*
* @param name name of the input to get
* @param options optional. See InputOptions.
* @returns boolean
*/
function getBooleanInput(name, options) {
const trueValue = ['true', 'True', 'TRUE'];
const falseValue = ['false', 'False', 'FALSE'];
const val = getInput(name, options);
if (trueValue.includes(val))
return true;
if (falseValue.includes(val))
return false;
throw new TypeError(`Input does not meet YAML 1.2 "Core Schema" specification: ${name}\n` +
`Support boolean input list: \`true | True | TRUE | false | False | FALSE\``);
}
exports.getBooleanInput = getBooleanInput;
/**
* Sets the value of an output.
*
* @param name name of the output to set
* @param value value to store. Non-string values will be converted to a string via JSON.stringify
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function setOutput(name, value) {
const filePath = process.env['GITHUB_OUTPUT'] || '';
if (filePath) {
return file_command_1.issueFileCommand('OUTPUT', file_command_1.prepareKeyValueMessage(name, value));
}
process.stdout.write(os.EOL);
command_1.issueCommand('set-output', { name }, utils_1.toCommandValue(value));
}
exports.setOutput = setOutput;
/**
* Enables or disables the echoing of commands into stdout for the rest of the step.
* Echoing is disabled by default if ACTIONS_STEP_DEBUG is not set.
*
*/
function setCommandEcho(enabled) {
command_1.issue('echo', enabled ? 'on' : 'off');
}
exports.setCommandEcho = setCommandEcho;
//-----------------------------------------------------------------------
// Results
//-----------------------------------------------------------------------
/**
* Sets the action status to failed.
* When the action exits it will be with an exit code of 1
* @param message add error issue message
*/
function setFailed(message) {
process.exitCode = ExitCode.Failure;
error(message);
}
exports.setFailed = setFailed;
//-----------------------------------------------------------------------
// Logging Commands
//-----------------------------------------------------------------------
/**
* Gets whether Actions Step Debug is on or not
*/
function isDebug() {
return process.env['RUNNER_DEBUG'] === '1';
}
exports.isDebug = isDebug;
/**
* Writes debug message to user log
* @param message debug message
*/
function debug(message) {
command_1.issueCommand('debug', {}, message);
}
exports.debug = debug;
/**
* Adds an error issue
* @param message error issue message. Errors will be converted to string via toString()
* @param properties optional properties to add to the annotation.
*/
function error(message, properties = {}) {
command_1.issueCommand('error', utils_1.toCommandProperties(properties), message instanceof Error ? message.toString() : message);
}
exports.error = error;
/**
* Adds a warning issue
* @param message warning issue message. Errors will be converted to string via toString()
* @param properties optional properties to add to the annotation.
*/
function warning(message, properties = {}) {
command_1.issueCommand('warning', utils_1.toCommandProperties(properties), message instanceof Error ? message.toString() : message);
}
exports.warning = warning;
/**
* Adds a notice issue
* @param message notice issue message. Errors will be converted to string via toString()
* @param properties optional properties to add to the annotation.
*/
function notice(message, properties = {}) {
command_1.issueCommand('notice', utils_1.toCommandProperties(properties), message instanceof Error ? message.toString() : message);
}
exports.notice = notice;
/**
* Writes info to log with console.log.
* @param message info message
*/
function info(message) {
process.stdout.write(message + os.EOL);
}
exports.info = info;
/**
* Begin an output group.
*
* Output until the next `groupEnd` will be foldable in this group
*
* @param name The name of the output group
*/
function startGroup(name) {
command_1.issue('group', name);
}
exports.startGroup = startGroup;
/**
* End an output group.
*/
function endGroup() {
command_1.issue('endgroup');
}
exports.endGroup = endGroup;
/**
* Wrap an asynchronous function call in a group.
*
* Returns the same type as the function itself.
*
* @param name The name of the group
* @param fn The function to wrap in the group
*/
function group(name, fn) {
return __awaiter(this, void 0, void 0, function* () {
startGroup(name);
let result;
try {
result = yield fn();
}
finally {
endGroup();
}
return result;
});
}
exports.group = group;
//-----------------------------------------------------------------------
// Wrapper action state
//-----------------------------------------------------------------------
/**
* Saves state for current action, the state can only be retrieved by this action's post job execution.
*
* @param name name of the state to store
* @param value value to store. Non-string values will be converted to a string via JSON.stringify
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function saveState(name, value) {
const filePath = process.env['GITHUB_STATE'] || '';
if (filePath) {
return file_command_1.issueFileCommand('STATE', file_command_1.prepareKeyValueMessage(name, value));
}
command_1.issueCommand('save-state', { name }, utils_1.toCommandValue(value));
}
exports.saveState = saveState;
/**
* Gets the value of an state set by this action's main execution.
*
* @param name name of the state to get
* @returns string
*/
function getState(name) {
return process.env[`STATE_${name}`] || '';
}
exports.getState = getState;
function getIDToken(aud) {
return __awaiter(this, void 0, void 0, function* () {
return yield oidc_utils_1.OidcClient.getIDToken(aud);
});
}
exports.getIDToken = getIDToken;
/**
* Summary exports
*/
var summary_1 = require("./summary");
Object.defineProperty(exports, "summary", { enumerable: true, get: function () { return summary_1.summary; } });
/**
* @deprecated use core.summary
*/
var summary_2 = require("./summary");
Object.defineProperty(exports, "markdownSummary", { enumerable: true, get: function () { return summary_2.markdownSummary; } });
/**
* Path exports
*/
var path_utils_1 = require("./path-utils");
Object.defineProperty(exports, "toPosixPath", { enumerable: true, get: function () { return path_utils_1.toPosixPath; } });
Object.defineProperty(exports, "toWin32Path", { enumerable: true, get: function () { return path_utils_1.toWin32Path; } });
Object.defineProperty(exports, "toPlatformPath", { enumerable: true, get: function () { return path_utils_1.toPlatformPath; } });
//# sourceMappingURL=core.js.map

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,2 @@
export declare function issueFileCommand(command: string, message: any): void;
export declare function prepareKeyValueMessage(key: string, value: any): string;

View File

@@ -0,0 +1,58 @@
"use strict";
// For internal use, subject to change.
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
Object.defineProperty(o, k2, { enumerable: true, get: function() { return m[k]; } });
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k in mod) if (k !== "default" && Object.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
__setModuleDefault(result, mod);
return result;
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.prepareKeyValueMessage = exports.issueFileCommand = void 0;
// We use any as a valid input type
/* eslint-disable @typescript-eslint/no-explicit-any */
const fs = __importStar(require("fs"));
const os = __importStar(require("os"));
const uuid_1 = require("uuid");
const utils_1 = require("./utils");
function issueFileCommand(command, message) {
const filePath = process.env[`GITHUB_${command}`];
if (!filePath) {
throw new Error(`Unable to find environment variable for file command ${command}`);
}
if (!fs.existsSync(filePath)) {
throw new Error(`Missing file at path: ${filePath}`);
}
fs.appendFileSync(filePath, `${utils_1.toCommandValue(message)}${os.EOL}`, {
encoding: 'utf8'
});
}
exports.issueFileCommand = issueFileCommand;
function prepareKeyValueMessage(key, value) {
const delimiter = `ghadelimiter_${uuid_1.v4()}`;
const convertedValue = utils_1.toCommandValue(value);
// These should realistically never happen, but just in case someone finds a
// way to exploit uuid generation let's not allow keys or values that contain
// the delimiter.
if (key.includes(delimiter)) {
throw new Error(`Unexpected input: name should not contain the delimiter "${delimiter}"`);
}
if (convertedValue.includes(delimiter)) {
throw new Error(`Unexpected input: value should not contain the delimiter "${delimiter}"`);
}
return `${key}<<${delimiter}${os.EOL}${convertedValue}${os.EOL}${delimiter}`;
}
exports.prepareKeyValueMessage = prepareKeyValueMessage;
//# sourceMappingURL=file-command.js.map

View File

@@ -0,0 +1 @@
{"version":3,"file":"file-command.js","sourceRoot":"","sources":["../src/file-command.ts"],"names":[],"mappings":";AAAA,uCAAuC;;;;;;;;;;;;;;;;;;;;;;AAEvC,mCAAmC;AACnC,uDAAuD;AAEvD,uCAAwB;AACxB,uCAAwB;AACxB,+BAAiC;AACjC,mCAAsC;AAEtC,SAAgB,gBAAgB,CAAC,OAAe,EAAE,OAAY;IAC5D,MAAM,QAAQ,GAAG,OAAO,CAAC,GAAG,CAAC,UAAU,OAAO,EAAE,CAAC,CAAA;IACjD,IAAI,CAAC,QAAQ,EAAE;QACb,MAAM,IAAI,KAAK,CACb,wDAAwD,OAAO,EAAE,CAClE,CAAA;KACF;IACD,IAAI,CAAC,EAAE,CAAC,UAAU,CAAC,QAAQ,CAAC,EAAE;QAC5B,MAAM,IAAI,KAAK,CAAC,yBAAyB,QAAQ,EAAE,CAAC,CAAA;KACrD;IAED,EAAE,CAAC,cAAc,CAAC,QAAQ,EAAE,GAAG,sBAAc,CAAC,OAAO,CAAC,GAAG,EAAE,CAAC,GAAG,EAAE,EAAE;QACjE,QAAQ,EAAE,MAAM;KACjB,CAAC,CAAA;AACJ,CAAC;AAdD,4CAcC;AAED,SAAgB,sBAAsB,CAAC,GAAW,EAAE,KAAU;IAC5D,MAAM,SAAS,GAAG,gBAAgB,SAAM,EAAE,EAAE,CAAA;IAC5C,MAAM,cAAc,GAAG,sBAAc,CAAC,KAAK,CAAC,CAAA;IAE5C,4EAA4E;IAC5E,6EAA6E;IAC7E,iBAAiB;IACjB,IAAI,GAAG,CAAC,QAAQ,CAAC,SAAS,CAAC,EAAE;QAC3B,MAAM,IAAI,KAAK,CACb,4DAA4D,SAAS,GAAG,CACzE,CAAA;KACF;IAED,IAAI,cAAc,CAAC,QAAQ,CAAC,SAAS,CAAC,EAAE;QACtC,MAAM,IAAI,KAAK,CACb,6DAA6D,SAAS,GAAG,CAC1E,CAAA;KACF;IAED,OAAO,GAAG,GAAG,KAAK,SAAS,GAAG,EAAE,CAAC,GAAG,GAAG,cAAc,GAAG,EAAE,CAAC,GAAG,GAAG,SAAS,EAAE,CAAA;AAC9E,CAAC;AApBD,wDAoBC"}

View File

@@ -0,0 +1,7 @@
export declare class OidcClient {
private static createHttpClient;
private static getRequestToken;
private static getIDTokenUrl;
private static getCall;
static getIDToken(audience?: string): Promise<string>;
}

Some files were not shown because too many files have changed in this diff Show More