mirror of
https://gitea.com/gitea/act_runner.git
synced 2026-06-09 18:44:23 +02:00
Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f17b6b9fc3 | ||
|
|
c7c4bd600a | ||
|
|
abec931d98 | ||
|
|
270ea41232 | ||
|
|
0b9f251b6a | ||
|
|
273f6b4247 | ||
|
|
47ee45412a | ||
|
|
38b69bb214 | ||
|
|
1c62c0635f | ||
|
|
0e0c54b272 | ||
|
|
d6fbe75721 | ||
|
|
b30204aa94 |
27
.gitea/workflows/pull-pr-title.yml
Normal file
27
.gitea/workflows/pull-pr-title.yml
Normal file
@@ -0,0 +1,27 @@
|
||||
name: pr-title
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types:
|
||||
- opened
|
||||
- edited
|
||||
- reopened
|
||||
- synchronize
|
||||
- ready_for_review
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
lint-pr-title:
|
||||
if: github.event.pull_request.draft == false
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24
|
||||
- run: make lint-pr-title
|
||||
env:
|
||||
PR_TITLE: ${{ github.event.pull_request.title }}
|
||||
@@ -9,14 +9,36 @@ 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
|
||||
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
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1,5 +1,6 @@
|
||||
/gitea-runner
|
||||
.env
|
||||
!/act/runner/testdata/secrets/.env
|
||||
.runner
|
||||
coverage.txt
|
||||
/config.yaml
|
||||
@@ -10,4 +11,4 @@ coverage.txt
|
||||
.vscode
|
||||
__debug_bin
|
||||
# gorelease binary folder
|
||||
dist
|
||||
/dist
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
### BUILDER STAGE
|
||||
#
|
||||
#
|
||||
FROM golang:1.26-alpine AS builder
|
||||
FROM golang:1.26-alpine3.23 AS builder
|
||||
|
||||
# Do not remove `git` here, it is required for getting runner version when executing `make build`
|
||||
RUN apk add --no-cache make git
|
||||
@@ -17,7 +17,7 @@ RUN make clean && make build
|
||||
### DIND VARIANT
|
||||
#
|
||||
#
|
||||
FROM docker:29-dind AS dind
|
||||
FROM docker:29.5.2-dind AS dind
|
||||
|
||||
ARG VERSION=dev
|
||||
|
||||
@@ -37,7 +37,7 @@ ENTRYPOINT ["s6-svscan","/etc/s6"]
|
||||
### DIND-ROOTLESS VARIANT
|
||||
#
|
||||
#
|
||||
FROM docker:29-dind-rootless AS dind-rootless
|
||||
FROM docker:29.5.2-dind-rootless AS dind-rootless
|
||||
|
||||
ARG VERSION=dev
|
||||
|
||||
@@ -63,7 +63,7 @@ ENTRYPOINT ["s6-svscan","/etc/s6"]
|
||||
### BASIC VARIANT
|
||||
#
|
||||
#
|
||||
FROM alpine AS basic
|
||||
FROM alpine:3.23 AS basic
|
||||
|
||||
ARG VERSION=dev
|
||||
|
||||
|
||||
12
Makefile
12
Makefile
@@ -118,6 +118,10 @@ lint-go: ## lint go files
|
||||
lint-go-fix: ## lint go files and fix issues
|
||||
$(GO) run $(GOLANGCI_LINT_PACKAGE) run --fix
|
||||
|
||||
.PHONY: lint-pr-title
|
||||
lint-pr-title: ## lint PR title against Conventional Commits (set PR_TITLE=...)
|
||||
@node ./tools/lint-pr-title.ts
|
||||
|
||||
.PHONY: security-check
|
||||
security-check: deps-tools
|
||||
GOEXPERIMENT= $(GO) run $(GOVULNCHECK_PACKAGE) -show color ./... || true
|
||||
@@ -136,8 +140,12 @@ tidy-check: tidy
|
||||
fi
|
||||
|
||||
.PHONY: test
|
||||
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
|
||||
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)
|
||||
|
||||
.PHONY: install
|
||||
install: $(GOFILES) ## install the runner binary via `go install`
|
||||
|
||||
@@ -5,24 +5,25 @@
|
||||
package artifacts
|
||||
|
||||
import (
|
||||
"context"
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"maps"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
|
||||
"gitea.com/gitea/runner/act/model"
|
||||
"gitea.com/gitea/runner/act/runner"
|
||||
"time"
|
||||
|
||||
"github.com/julienschmidt/httprouter"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type writableMapFile struct {
|
||||
@@ -234,89 +235,133 @@ func TestDownloadArtifactFile(t *testing.T) {
|
||||
assert.Equal("content", string(data))
|
||||
}
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
// 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.
|
||||
func TestArtifactFlow(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test")
|
||||
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
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
t.Run("upload-and-download", func(t *testing.T) {
|
||||
const runID, item, content = "1", "my-artifact/data.txt", "hello artifact\n"
|
||||
|
||||
cancel := Serve(ctx, artifactsPath, artifactsAddr, artifactsPort)
|
||||
defer cancel()
|
||||
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)
|
||||
|
||||
platforms := map[string]string{
|
||||
"ubuntu-latest": "node:24-bookworm", // Don't use node:24-bookworm-slim because it doesn't have curl command, which is used in the tests
|
||||
}
|
||||
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)
|
||||
|
||||
tables := []TestJobFileInfo{
|
||||
{"testdata", "upload-and-download", "push", "", platforms, ""},
|
||||
{"testdata", "GHSL-2023-004", "push", "", platforms, ""},
|
||||
}
|
||||
log.SetLevel(log.DebugLevel)
|
||||
status, data = request(t, http.MethodPatch, baseURL+"/_apis/pipelines/workflows/"+runID+"/artifacts", nil, nil)
|
||||
require.Equal(t, http.StatusOK, status, string(data))
|
||||
|
||||
for _, table := range tables {
|
||||
runTestJobFile(ctx, t, table)
|
||||
}
|
||||
}
|
||||
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)
|
||||
|
||||
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, 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)
|
||||
|
||||
if err := os.RemoveAll(artifactsPath); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
status, data = request(t, http.MethodGet, items.Value[0].ContentLocation, nil, nil)
|
||||
require.Equal(t, http.StatusOK, status)
|
||||
require.Equal(t, content, string(data))
|
||||
|
||||
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,
|
||||
}
|
||||
stored, err := os.ReadFile(filepath.Join(artifactPath, runID, "my-artifact", "data.txt"))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, content, string(stored))
|
||||
})
|
||||
|
||||
runner, err := runner.New(runnerConfig)
|
||||
assert.NoError(t, err, tjfi.workflowPath) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
t.Run("gzip-roundtrip", func(t *testing.T) {
|
||||
const runID, item, content = "2", "logs/app.log", "compressed payload\n"
|
||||
|
||||
planner, err := model.NewWorkflowPlanner(fullWorkflowPath, true)
|
||||
assert.NoError(t, err, fullWorkflowPath) //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())
|
||||
|
||||
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)
|
||||
}
|
||||
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))
|
||||
|
||||
fmt.Println("::endgroup::") //nolint:forbidigo // pre-existing issue from nektos/act
|
||||
// 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))
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
|
||||
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
|
||||
@@ -1,230 +0,0 @@
|
||||
|
||||
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
|
||||
@@ -170,68 +170,6 @@ 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) {
|
||||
|
||||
@@ -66,8 +66,21 @@ 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(
|
||||
@@ -99,10 +112,13 @@ 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
|
||||
}
|
||||
@@ -174,6 +190,8 @@ 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"
|
||||
}
|
||||
|
||||
@@ -16,7 +16,6 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
@@ -50,10 +49,6 @@ 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)
|
||||
@@ -78,8 +73,7 @@ func cleanGitHooks(dir string) error {
|
||||
func TestFindGitRemoteURL(t *testing.T) {
|
||||
assert := assert.New(t)
|
||||
|
||||
basedir := testDir(t)
|
||||
gitConfig()
|
||||
basedir := t.TempDir()
|
||||
err := gitCmd("init", basedir)
|
||||
assert.NoError(err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
err = cleanGitHooks(basedir)
|
||||
@@ -102,8 +96,7 @@ func TestFindGitRemoteURL(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestGitFindRef(t *testing.T) {
|
||||
basedir := testDir(t)
|
||||
gitConfig()
|
||||
basedir := t.TempDir()
|
||||
|
||||
for name, tt := range map[string]struct {
|
||||
Prepare func(t *testing.T, dir string)
|
||||
@@ -180,36 +173,55 @@ 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
|
||||
URL, Ref string
|
||||
Err error
|
||||
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,
|
||||
URL: "https://github.com/actions/checkout",
|
||||
Ref: "5a4ac9002d0be2fb38bd78e4b4dbde5606d7042f", // v2
|
||||
Ref: fullSha,
|
||||
},
|
||||
"short-sha": {
|
||||
Err: &Error{ErrShortRef, "5a4ac9002d0be2fb38bd78e4b4dbde5606d7042f"},
|
||||
URL: "https://github.com/actions/checkout",
|
||||
Ref: "5a4ac90", // v2
|
||||
Err: &Error{ErrShortRef, fullSha},
|
||||
Ref: fullSha[:7],
|
||||
},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
clone := NewGitCloneExecutor(NewGitCloneExecutorInput{
|
||||
URL: tt.URL,
|
||||
URL: remoteDir,
|
||||
Ref: tt.Ref,
|
||||
Dir: testDir(t),
|
||||
Dir: t.TempDir(),
|
||||
})
|
||||
|
||||
err := clone(context.Background())
|
||||
@@ -228,8 +240,6 @@ 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))
|
||||
@@ -280,8 +290,6 @@ func TestGitCloneExecutorNonFastForwardRef(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestGitCloneExecutorOfflineMode(t *testing.T) {
|
||||
gitConfig()
|
||||
|
||||
// Build a local "remote" with a single commit on main.
|
||||
remoteDir := t.TempDir()
|
||||
require.NoError(t, gitCmd("init", "--bare", "--initial-branch=main", remoteDir))
|
||||
@@ -327,22 +335,21 @@ func TestGitCloneExecutorOfflineMode(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
@@ -13,7 +13,6 @@ import (
|
||||
|
||||
"github.com/distribution/reference"
|
||||
"github.com/docker/cli/cli/config"
|
||||
"github.com/docker/cli/cli/config/credentials"
|
||||
"github.com/moby/moby/api/types/registry"
|
||||
)
|
||||
|
||||
@@ -26,10 +25,6 @@ func LoadDockerAuthConfig(ctx context.Context, image string) (registry.AuthConfi
|
||||
logger.Warnf("Could not load docker config: %v", err)
|
||||
return registry.AuthConfig{}, err
|
||||
}
|
||||
if !cfg.ContainsAuth() {
|
||||
cfg.CredentialsStore = credentials.DetectDefaultStore(cfg.CredentialsStore)
|
||||
}
|
||||
|
||||
registryKey := registryAuthConfigKey("docker.io")
|
||||
if image != "" {
|
||||
if registryRef, refErr := reference.ParseNormalizedNamed(image); refErr != nil {
|
||||
@@ -55,10 +50,6 @@ func LoadDockerAuthConfigs(ctx context.Context) map[string]registry.AuthConfig {
|
||||
logger.Warnf("Could not load docker config: %v", err)
|
||||
return nil
|
||||
}
|
||||
if !cfg.ContainsAuth() {
|
||||
cfg.CredentialsStore = credentials.DetectDefaultStore(cfg.CredentialsStore)
|
||||
}
|
||||
|
||||
creds, err := cfg.GetAllCredentials()
|
||||
if err != nil {
|
||||
logger.Warnf("Could not get docker auth configs: %v", err)
|
||||
|
||||
@@ -6,66 +6,64 @@ package container
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/moby/moby/client"
|
||||
specs "github.com/opencontainers/image-spec/specs-go/v1"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func init() {
|
||||
log.SetLevel(log.DebugLevel)
|
||||
}
|
||||
|
||||
func TestImageExistsLocally(t *testing.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
|
||||
|
||||
// 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, invalidImageTag)
|
||||
|
||||
// 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.False(t, invalidImagePlatform)
|
||||
|
||||
// pull an image
|
||||
cli, err := client.New(client.FromEnv)
|
||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
defer cli.Close()
|
||||
|
||||
// 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:24-bookworm-slim", client.ImagePullOptions{
|
||||
Platforms: []specs.Platform{{OS: "linux", Architecture: "amd64"}},
|
||||
})
|
||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
defer readerDefault.Close()
|
||||
_, err = io.ReadAll(readerDefault)
|
||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
|
||||
imageDefaultArchExists, err := ImageExistsLocally(ctx, "node:24-bookworm-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:24-bookworm-slim", client.ImagePullOptions{
|
||||
Platforms: []specs.Platform{{OS: "linux", Architecture: "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:24-bookworm-slim", "linux/arm64")
|
||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
assert.True(t, imageArm64Exists)
|
||||
// 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)
|
||||
ctx := context.Background()
|
||||
|
||||
// a non-existent image is reported absent
|
||||
missing, 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)
|
||||
|
||||
// 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")
|
||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
assert.True(t, amd64Exists)
|
||||
|
||||
// a non-host architecture image is detected for its own architecture
|
||||
arm64Exists, err := ImageExistsLocally(ctx, arm64Ref, "linux/arm64")
|
||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
assert.True(t, arm64Exists)
|
||||
|
||||
// a present image is reported absent for a different platform
|
||||
wrongPlatform, err := ImageExistsLocally(ctx, amd64Ref, "linux/arm64")
|
||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
assert.False(t, wrongPlatform)
|
||||
}
|
||||
|
||||
@@ -40,6 +40,9 @@ 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{})
|
||||
|
||||
@@ -26,6 +26,7 @@ import (
|
||||
|
||||
"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/go-git/go-billy/v5/helper/polyfill"
|
||||
@@ -152,6 +153,8 @@ 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(),
|
||||
cr.copyDir(destPath, srcPath, useGitIgnore),
|
||||
func(ctx context.Context) error {
|
||||
// If this fails, then folders have wrong permissions on non root container
|
||||
@@ -167,6 +170,16 @@ 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
|
||||
@@ -314,10 +327,22 @@ 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 != "" {
|
||||
return nil
|
||||
// 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 = ""
|
||||
}
|
||||
containers, err := cr.cli.ContainerList(ctx, client.ContainerListOptions{
|
||||
All: true,
|
||||
@@ -335,7 +360,6 @@ func (cr *containerReference) find() common.Executor {
|
||||
}
|
||||
}
|
||||
|
||||
cr.id = ""
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -592,6 +616,9 @@ 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" {
|
||||
@@ -746,6 +773,9 @@ 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)
|
||||
@@ -779,6 +809,9 @@ 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 {
|
||||
@@ -853,6 +886,9 @@ 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)
|
||||
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
|
||||
"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/sirupsen/logrus/hooks/test"
|
||||
@@ -28,14 +29,10 @@ import (
|
||||
)
|
||||
|
||||
func TestDocker(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test")
|
||||
}
|
||||
requireDocker(t)
|
||||
ctx := context.Background()
|
||||
client, err := GetDockerClient(ctx)
|
||||
if err != nil {
|
||||
t.Skipf("skipping integration test: %v", err)
|
||||
}
|
||||
require.NoError(t, err)
|
||||
defer client.Close()
|
||||
|
||||
dockerBuild := NewDockerBuildExecutor(NewDockerBuildExecutorInput{
|
||||
@@ -102,6 +99,16 @@ func (m *mockDockerClient) CopyToContainer(ctx context.Context, id string, optio
|
||||
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)
|
||||
}
|
||||
|
||||
type endlessReader struct {
|
||||
io.Reader
|
||||
}
|
||||
@@ -302,6 +309,134 @@ func TestDockerCopyTarStreamErrorInMkdir(t *testing.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{}
|
||||
|
||||
|
||||
@@ -18,9 +18,19 @@ 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
|
||||
CommonSocketLocations = originalCommonSocketLocations
|
||||
isolateSocketEnv(t)
|
||||
dockerHost := "unix:///my/docker/host.sock"
|
||||
socketURI := "/path/to/my.socket"
|
||||
t.Setenv("DOCKER_HOST", dockerHost)
|
||||
@@ -48,9 +58,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
|
||||
@@ -65,7 +75,7 @@ func TestGetSocketAndHostOnlySocket(t *testing.T) {
|
||||
|
||||
func TestGetSocketAndHostDontMount(t *testing.T) {
|
||||
// Arrange
|
||||
CommonSocketLocations = originalCommonSocketLocations
|
||||
isolateSocketEnv(t)
|
||||
dockerHost := "unix:///my/docker/host.sock"
|
||||
t.Setenv("DOCKER_HOST", dockerHost)
|
||||
|
||||
@@ -79,7 +89,7 @@ func TestGetSocketAndHostDontMount(t *testing.T) {
|
||||
|
||||
func TestGetSocketAndHostNoHostNoSocket(t *testing.T) {
|
||||
// Arrange
|
||||
CommonSocketLocations = originalCommonSocketLocations
|
||||
isolateSocketEnv(t)
|
||||
os.Unsetenv("DOCKER_HOST")
|
||||
defaultSocket, found := socketLocation()
|
||||
|
||||
@@ -97,6 +107,7 @@ 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
|
||||
@@ -119,6 +130,7 @@ 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"}
|
||||
@@ -136,6 +148,7 @@ 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")
|
||||
|
||||
27
act/container/helpers_test.go
Normal file
27
act/container/helpers_test.go
Normal file
@@ -0,0 +1,27 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@@ -16,9 +16,7 @@ import (
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
@@ -44,9 +42,6 @@ type HostEnvironment struct {
|
||||
CleanUp func()
|
||||
StdOut io.Writer
|
||||
AllocatePTY bool // allocate a pseudo-TTY for each step's process
|
||||
|
||||
mu sync.Mutex
|
||||
runningPIDs map[int]struct{}
|
||||
}
|
||||
|
||||
func (e *HostEnvironment) Create(_, _ []string) common.Executor {
|
||||
@@ -353,24 +348,9 @@ func (e *HostEnvironment) exec(ctx context.Context, command []string, cmdline st
|
||||
go copyPtyOutput(writer, ppty, finishLog)
|
||||
go writeKeepAlive(ppty)
|
||||
}
|
||||
// Split Start/Wait so the PID can be registered before the process can exit;
|
||||
// cmd.Run() would block until exit, by which time the PID may have been reused.
|
||||
if err := cmd.Start(); err != nil {
|
||||
return err
|
||||
}
|
||||
if cmd.Process != nil {
|
||||
e.mu.Lock()
|
||||
if e.runningPIDs == nil {
|
||||
e.runningPIDs = map[int]struct{}{}
|
||||
}
|
||||
e.runningPIDs[cmd.Process.Pid] = struct{}{}
|
||||
e.mu.Unlock()
|
||||
defer func(pid int) {
|
||||
e.mu.Lock()
|
||||
delete(e.runningPIDs, pid)
|
||||
e.mu.Unlock()
|
||||
}(cmd.Process.Pid)
|
||||
}
|
||||
err = cmd.Wait()
|
||||
if err != nil {
|
||||
var exitErr *exec.ExitError
|
||||
@@ -440,30 +420,80 @@ func removePathWithRetry(ctx context.Context, path string) error {
|
||||
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
|
||||
}
|
||||
e.mu.Lock()
|
||||
pids := make([]int, 0, len(e.runningPIDs))
|
||||
for pid := range e.runningPIDs {
|
||||
pids = append(pids, pid)
|
||||
}
|
||||
e.mu.Unlock()
|
||||
|
||||
if len(pids) == 0 {
|
||||
// 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
|
||||
}
|
||||
|
||||
logger := common.Logger(ctx)
|
||||
for _, pid := range pids {
|
||||
// Best-effort: forcibly terminate process tree to release file handles
|
||||
// so that workspace cleanup can succeed on Windows.
|
||||
cmd := exec.CommandContext(ctx, "taskkill", "/PID", strconv.Itoa(pid), "/T", "/F")
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
logger.Debugf("taskkill failed for pid=%d: %v output=%s", pid, err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
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)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -477,14 +507,20 @@ func (e *HostEnvironment) Remove() common.Executor {
|
||||
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(ctx, e.Path); err != nil {
|
||||
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(ctx, e.Workdir); err != nil {
|
||||
if err := removePathWithRetry(rmCtx, e.Workdir); err != nil {
|
||||
logger.Warnf("failed to remove host workspace %s: %v", e.Workdir, err)
|
||||
errs = append(errs, err)
|
||||
}
|
||||
|
||||
@@ -187,3 +187,64 @@ func TestHostEnvironmentRemoveCleansWorkdirWhenOwned(t *testing.T) {
|
||||
_, 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")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -325,14 +325,20 @@ func (j *Job) Needs() []string {
|
||||
|
||||
// RunsOn list for Job
|
||||
func (j *Job) RunsOn() []string {
|
||||
switch j.RawRunsOn.Kind {
|
||||
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 {
|
||||
case yaml.MappingNode:
|
||||
var val struct {
|
||||
Group string
|
||||
Labels yaml.Node
|
||||
}
|
||||
|
||||
if !decodeNode(j.RawRunsOn, &val) {
|
||||
if !decodeNode(rawRunsOn, &val) {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -344,7 +350,7 @@ func (j *Job) RunsOn() []string {
|
||||
|
||||
return labels
|
||||
default:
|
||||
return nodeAsStringSlice(j.RawRunsOn)
|
||||
return nodeAsStringSlice(rawRunsOn)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -645,6 +651,33 @@ 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 != "" {
|
||||
|
||||
@@ -9,9 +9,29 @@ 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
|
||||
|
||||
@@ -455,7 +455,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.Config.ValidVolumes,
|
||||
ValidVolumes: rc.validVolumes(),
|
||||
AllocatePTY: rc.Config.AllocatePTY,
|
||||
})
|
||||
return stepContainer
|
||||
|
||||
@@ -8,64 +8,139 @@ 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 TestActionCache(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test")
|
||||
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) {
|
||||
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(),
|
||||
}
|
||||
ctx := context.Background()
|
||||
cacheDir := "nektos/act-test-actions"
|
||||
repo := "https://github.com/nektos/act-test-actions"
|
||||
cacheDir := "local/act-test-actions"
|
||||
refs := []struct {
|
||||
Name string
|
||||
CacheDir string
|
||||
Repo string
|
||||
Ref string
|
||||
Name string
|
||||
Ref string
|
||||
}{
|
||||
{
|
||||
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",
|
||||
},
|
||||
{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},
|
||||
}
|
||||
for _, c := range refs {
|
||||
t.Run(c.Name, func(t *testing.T) {
|
||||
sha, err := cache.Fetch(ctx, c.CacheDir, c.Repo, c.Ref, "")
|
||||
sha, err := cache.Fetch(ctx, cacheDir, remoteDir, c.Ref, "")
|
||||
if !a.NoError(err) || !a.NotEmpty(sha) { //nolint:testifylint // pre-existing issue from nektos/act
|
||||
return
|
||||
}
|
||||
atar, err := cache.GetTarArchive(ctx, c.CacheDir, sha, "js")
|
||||
if !a.NoError(err) || !a.NotEmpty(atar) { //nolint:testifylint // pre-existing issue from nektos/act
|
||||
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
|
||||
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
|
||||
|
||||
@@ -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":
|
||||
@@ -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",
|
||||
|
||||
@@ -562,15 +562,15 @@ func getWorkflowSecrets(ctx context.Context, rc *RunContext) map[string]string {
|
||||
secrets = rc.caller.runContext.Config.Secrets
|
||||
}
|
||||
|
||||
if secrets == nil {
|
||||
secrets = map[string]string{}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
secrets[k] = rc.caller.runContext.ExprEval.Interpolate(ctx, v)
|
||||
interpolated[k] = rc.caller.runContext.ExprEval.Interpolate(ctx, v)
|
||||
}
|
||||
|
||||
return secrets
|
||||
return interpolated
|
||||
}
|
||||
|
||||
return rc.Config.Secrets
|
||||
|
||||
66
act/runner/helpers_test.go
Normal file
66
act/runner/helpers_test.go
Normal file
@@ -0,0 +1,66 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -183,18 +183,25 @@ 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)
|
||||
|
||||
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
|
||||
}
|
||||
// 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
|
||||
}()
|
||||
|
||||
if !success {
|
||||
jobResult = "failure"
|
||||
}
|
||||
|
||||
info.result(jobResult)
|
||||
if rc.caller != nil {
|
||||
// set reusable workflow job result
|
||||
rc.caller.setReusedWorkflowJobResult(rc.JobName, jobResult) // For Gitea
|
||||
@@ -220,7 +227,11 @@ func setJobOutputs(ctx context.Context, rc *RunContext) {
|
||||
callerOutputs[k] = ee.Interpolate(ctx, ee.Interpolate(ctx, v.Value))
|
||||
}
|
||||
|
||||
rc.caller.runContext.Run.Job().Outputs = callerOutputs
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -21,18 +21,13 @@ import (
|
||||
)
|
||||
|
||||
func TestJobExecutor(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test")
|
||||
}
|
||||
// Dryrun only checks syntax/planning; all cases resolve locally, so this runs offline.
|
||||
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.
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
@@ -166,9 +167,29 @@ 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
|
||||
@@ -176,16 +197,16 @@ func valueMasker(insecureSecrets bool, secrets map[string]string) entryProcessor
|
||||
|
||||
masks := Masks(entry.Context)
|
||||
|
||||
for _, v := range secrets {
|
||||
if v != "" {
|
||||
entry.Message = strings.ReplaceAll(entry.Message, v, "***")
|
||||
}
|
||||
}
|
||||
if len(*masks) == 0 {
|
||||
entry.Message = defReplacer.Replace(entry.Message)
|
||||
} else {
|
||||
cmasker := oldnew
|
||||
|
||||
for _, v := range *masks {
|
||||
if v != "" {
|
||||
entry.Message = strings.ReplaceAll(entry.Message, v, "***")
|
||||
for _, v := range *masks {
|
||||
cmasker = AppendSecretMasker(cmasker, v)
|
||||
}
|
||||
|
||||
entry.Message = strings.NewReplacer(cmasker...).Replace(entry.Message)
|
||||
}
|
||||
|
||||
return entry
|
||||
|
||||
52
act/runner/logger_test.go
Normal file
52
act/runner/logger_test.go
Normal file
@@ -0,0 +1,52 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
@@ -27,7 +28,9 @@ func newLocalReusableWorkflowExecutor(rc *RunContext) common.Executor {
|
||||
workflowDir = strings.TrimPrefix(workflowDir, "./")
|
||||
|
||||
return common.NewPipelineExecutor(
|
||||
newReusableWorkflowExecutor(rc, workflowDir, fileName),
|
||||
// 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),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -284,7 +287,11 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,9 @@ import (
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gitea.com/gitea/runner/act/common"
|
||||
@@ -55,6 +57,10 @@ 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) {
|
||||
@@ -130,17 +136,34 @@ 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 rc.Config.ContainerDaemonSocket != "-" {
|
||||
daemonPath := getDockerDaemonSocketMountPath(rc.Config.ContainerDaemonSocket)
|
||||
if daemonSocket := rc.containerDaemonSocket(); daemonSocket != "-" {
|
||||
daemonPath := getDockerDaemonSocketMountPath(daemonSocket)
|
||||
binds = append(binds, fmt.Sprintf("%s:%s", daemonPath, "/var/run/docker.sock"))
|
||||
}
|
||||
|
||||
@@ -179,14 +202,6 @@ 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
|
||||
}
|
||||
|
||||
@@ -432,7 +447,7 @@ func (rc *RunContext) startJobContainer() common.Executor {
|
||||
Platform: rc.Config.ContainerArchitecture,
|
||||
Options: rc.options(ctx),
|
||||
AutoRemove: rc.Config.AutoRemove,
|
||||
ValidVolumes: rc.Config.ValidVolumes,
|
||||
ValidVolumes: rc.validVolumes(),
|
||||
AllocatePTY: rc.Config.AllocatePTY,
|
||||
})
|
||||
if rc.JobContainer == nil {
|
||||
@@ -586,14 +601,29 @@ 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)
|
||||
for k, v := range rc.Run.Job().Outputs {
|
||||
interpolated := ee.Interpolate(ctx, v)
|
||||
if v != interpolated {
|
||||
rc.Run.Job().Outputs[k] = interpolated
|
||||
}
|
||||
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)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -601,10 +631,34 @@ 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) {
|
||||
return rc.startHostEnvironment()(ctx)
|
||||
err = rc.startHostEnvironment()(ctx)
|
||||
} else {
|
||||
err = rc.startJobContainer()(ctx)
|
||||
}
|
||||
return rc.startJobContainer()(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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -636,7 +690,18 @@ func (rc *RunContext) result(result string) {
|
||||
}
|
||||
|
||||
func (rc *RunContext) steps() []*model.Step {
|
||||
return rc.Run.Job().Steps
|
||||
// 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
|
||||
}
|
||||
|
||||
// Executor returns a pipeline executor for all the steps in the job
|
||||
@@ -713,12 +778,15 @@ func (rc *RunContext) runsOnPlatformNames(ctx context.Context) []string {
|
||||
return []string{}
|
||||
}
|
||||
|
||||
if err := rc.ExprEval.EvaluateYamlNode(ctx, &job.RawRunsOn); err != nil {
|
||||
// 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 {
|
||||
common.Logger(ctx).Errorf("Error while evaluating runs-on: %v", err)
|
||||
return []string{}
|
||||
}
|
||||
|
||||
return job.RunsOn()
|
||||
return model.RunsOnFromNode(rawRunsOn)
|
||||
}
|
||||
|
||||
func (rc *RunContext) platformImage(ctx context.Context) string {
|
||||
@@ -1141,12 +1209,9 @@ 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 rc.Config.ContainerDaemonSocket != "-" {
|
||||
daemonPath := getDockerDaemonSocketMountPath(rc.Config.ContainerDaemonSocket)
|
||||
if daemonSocket := rc.containerDaemonSocket(); daemonSocket != "-" {
|
||||
daemonPath := getDockerDaemonSocketMountPath(daemonSocket)
|
||||
binds = append(binds, fmt.Sprintf("%s:%s", daemonPath, "/var/run/docker.sock"))
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
assert "github.com/stretchr/testify/assert"
|
||||
require "github.com/stretchr/testify/require"
|
||||
yaml "go.yaml.in/yaml/v4"
|
||||
)
|
||||
|
||||
@@ -280,6 +281,44 @@ 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)
|
||||
|
||||
@@ -659,3 +698,53 @@ func TestPrintStartJobContainerGroupGolden(t *testing.T) {
|
||||
}, "\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()) })
|
||||
})
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"maps"
|
||||
"os"
|
||||
"runtime"
|
||||
"sync"
|
||||
@@ -250,7 +251,14 @@ func (runner *runnerImpl) NewPlanExecutor(plan *model.Plan) common.Executor {
|
||||
return executor(common.WithJobErrorContainer(WithJobLogger(ctx, rc.Run.JobID, jobName, rc.Config, &rc.Masks, matrix)))
|
||||
})
|
||||
}
|
||||
pipeline = append(pipeline, common.NewParallelExecutor(maxParallel, stageExecutor...))
|
||||
// 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)
|
||||
})
|
||||
}
|
||||
|
||||
// For pipeline execution:
|
||||
@@ -334,6 +342,11 @@ 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
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.com/gitea/runner/act/common"
|
||||
"gitea.com/gitea/runner/act/model"
|
||||
@@ -187,13 +188,17 @@ 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)
|
||||
@@ -221,18 +226,14 @@ type TestConfig struct {
|
||||
}
|
||||
|
||||
func TestRunEvent(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test")
|
||||
}
|
||||
requireDocker(t)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
tables := []TestJobFileInfo{
|
||||
// Shells
|
||||
{workdir, "shells/defaults", "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:24-bookworm"}, secrets}, // slim doesn't have python
|
||||
{workdir, "shells/sh", "push", "", platforms, secrets},
|
||||
|
||||
// Local action
|
||||
@@ -244,11 +245,6 @@ 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},
|
||||
|
||||
@@ -258,21 +254,15 @@ 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},
|
||||
@@ -293,7 +283,6 @@ 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},
|
||||
@@ -304,7 +293,6 @@ 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},
|
||||
@@ -314,7 +302,6 @@ 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
|
||||
@@ -323,6 +310,11 @@ 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,
|
||||
}
|
||||
@@ -354,9 +346,12 @@ func TestRunEvent(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRunEventHostEnvironment(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test")
|
||||
}
|
||||
// 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")
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
@@ -372,7 +367,6 @@ 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
|
||||
@@ -381,7 +375,6 @@ 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
|
||||
@@ -390,14 +383,10 @@ 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},
|
||||
@@ -411,7 +400,6 @@ 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},
|
||||
}...)
|
||||
@@ -444,24 +432,26 @@ 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) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test")
|
||||
}
|
||||
|
||||
// Dryrun plans without containers or network (shells and local actions only).
|
||||
ctx := common.WithDryrun(context.Background(), true)
|
||||
|
||||
tables := []TestJobFileInfo{
|
||||
// Shells
|
||||
{workdir, "shells/defaults", "push", "", platforms, secrets},
|
||||
{workdir, "shells/pwsh", "push", "", map[string]string{"ubuntu-latest": "catthehacker/ubuntu:pwsh-latest"}, secrets}, // custom image with pwsh
|
||||
{workdir, "shells/pwsh", "push", "", platforms, secrets},
|
||||
{workdir, "shells/bash", "push", "", platforms, secrets},
|
||||
{workdir, "shells/python", "push", "", map[string]string{"ubuntu-latest": "node:24-bookworm"}, secrets}, // slim doesn't have python
|
||||
{workdir, "shells/sh", "push", "", platforms, secrets},
|
||||
|
||||
// Local action
|
||||
@@ -478,10 +468,18 @@ 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) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test")
|
||||
}
|
||||
requireDocker(t)
|
||||
requireNetwork(t) // force-pulls a docker action image
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
@@ -502,22 +500,6 @@ 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
|
||||
}
|
||||
@@ -538,9 +520,7 @@ func TestMaskValues(t *testing.T) {
|
||||
assert.False(t, strings.Contains(text, "composite secret")) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
}
|
||||
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test")
|
||||
}
|
||||
requireDocker(t)
|
||||
|
||||
log.SetLevel(log.DebugLevel)
|
||||
|
||||
@@ -561,9 +541,7 @@ func TestMaskValues(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRunEventSecrets(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test")
|
||||
}
|
||||
requireDocker(t)
|
||||
workflowPath := "secrets"
|
||||
|
||||
tjfi := TestJobFileInfo{
|
||||
@@ -583,9 +561,7 @@ func TestRunEventSecrets(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRunWithService(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test")
|
||||
}
|
||||
requireDocker(t)
|
||||
|
||||
log.SetLevel(log.DebugLevel)
|
||||
ctx := context.Background()
|
||||
@@ -601,10 +577,11 @@ 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,
|
||||
Workdir: workdir,
|
||||
EventName: eventName,
|
||||
Platforms: platforms,
|
||||
ReuseContainers: false,
|
||||
ContainerMaxLifetime: time.Hour, // otherwise the job container is `sleep 0` and exits at once
|
||||
}
|
||||
runner, err := New(runnerConfig)
|
||||
assert.NoError(t, err, workflowPath) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
@@ -620,9 +597,7 @@ func TestRunWithService(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRunActionInputs(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test")
|
||||
}
|
||||
requireDocker(t)
|
||||
workflowPath := "input-from-cli"
|
||||
|
||||
tjfi := TestJobFileInfo{
|
||||
@@ -641,9 +616,7 @@ func TestRunActionInputs(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRunEventPullRequest(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test")
|
||||
}
|
||||
requireDocker(t)
|
||||
|
||||
workflowPath := "pull-request"
|
||||
|
||||
@@ -659,9 +632,7 @@ func TestRunEventPullRequest(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRunMatrixWithUserDefinedInclusions(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test")
|
||||
}
|
||||
requireDocker(t)
|
||||
workflowPath := "matrix-with-user-inclusions"
|
||||
|
||||
tjfi := TestJobFileInfo{
|
||||
|
||||
@@ -291,7 +291,9 @@ type remoteAction struct {
|
||||
|
||||
func (ra *remoteAction) CloneURL(u string) string {
|
||||
if ra.URL == "" {
|
||||
if !strings.HasPrefix(u, "http://") && !strings.HasPrefix(u, "https://") {
|
||||
// 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) {
|
||||
u = "https://" + u
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -138,7 +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.Config.ValidVolumes,
|
||||
ValidVolumes: rc.validVolumes(),
|
||||
AllocatePTY: rc.Config.AllocatePTY,
|
||||
})
|
||||
return stepContainer
|
||||
|
||||
34
act/runner/testdata/.github/workflows/local-reusable-workflow.yml
vendored
Normal file
34
act/runner/testdata/.github/workflows/local-reusable-workflow.yml
vendored
Normal file
@@ -0,0 +1,34 @@
|
||||
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
|
||||
@@ -5,10 +5,11 @@ jobs:
|
||||
env:
|
||||
MYGLOBALENV3: myglobalval3
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- run: |
|
||||
echo MYGLOBALENV1=myglobalval1 > $GITHUB_ENV
|
||||
echo "::set-env name=MYGLOBALENV2::myglobalval2"
|
||||
- uses: nektos/act-test-actions/script@main
|
||||
- uses: ./actions/script
|
||||
with:
|
||||
main: |
|
||||
env
|
||||
|
||||
41
act/runner/testdata/GITHUB_STATE/push.yml
vendored
41
act/runner/testdata/GITHUB_STATE/push.yml
vendored
@@ -1,48 +1,31 @@
|
||||
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: nektos/act-test-actions/script@main
|
||||
- uses: actions/checkout@v4
|
||||
- uses: ./actions/script
|
||||
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: nektos/act-test-actions/script@main
|
||||
- uses: actions/checkout@v4
|
||||
- uses: ./actions/script
|
||||
id: script
|
||||
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" ]
|
||||
- uses: nektos/act-test-actions/script@main
|
||||
main: echo mystate=val1 > $GITHUB_STATE
|
||||
post: '[ "$STATE_mystate" = "val1" ]'
|
||||
- uses: ./actions/script
|
||||
id: pre-script
|
||||
with:
|
||||
main: |
|
||||
env
|
||||
echo mystate0=mystateerror > $GITHUB_STATE
|
||||
echo "::save-state name=mystate1::mystateerror"
|
||||
main: echo mystate=val2 > $GITHUB_STATE
|
||||
post: '[ "$STATE_mystate" = "val2" ]'
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM alpine:3
|
||||
FROM alpine:3.23
|
||||
|
||||
COPY entrypoint.sh /entrypoint.sh
|
||||
|
||||
|
||||
@@ -9,7 +9,3 @@ 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'
|
||||
|
||||
@@ -10,4 +10,4 @@ outputs:
|
||||
description: 'The time we greeted you'
|
||||
runs:
|
||||
using: 'node24'
|
||||
main: 'dist/index.js'
|
||||
main: 'index.js'
|
||||
|
||||
21
act/runner/testdata/actions/node24/index.js
vendored
21
act/runner/testdata/actions/node24/index.js
vendored
@@ -1,11 +1,14 @@
|
||||
import {getInput, setOutput, setFailed} from '@actions/core';
|
||||
import {context} from '@actions/github';
|
||||
import {appendFileSync, readFileSync} from 'node:fs';
|
||||
|
||||
try {
|
||||
const nameToGreet = getInput('who-to-greet');
|
||||
console.log(`Hello ${nameToGreet}!`);
|
||||
setOutput('time', (new Date()).toTimeString());
|
||||
console.log(`The event payload: ${JSON.stringify(context.payload, undefined, 2)}`);
|
||||
} catch (error) {
|
||||
setFailed(error.message);
|
||||
const nameToGreet = process.env['INPUT_WHO-TO-GREET'] || 'World';
|
||||
console.log(`Hello ${nameToGreet}!`);
|
||||
|
||||
if (process.env.GITHUB_OUTPUT) {
|
||||
appendFileSync(process.env.GITHUB_OUTPUT, `time=${new Date().toTimeString()}\n`);
|
||||
}
|
||||
|
||||
let payload = {};
|
||||
if (process.env.GITHUB_EVENT_PATH) {
|
||||
payload = JSON.parse(readFileSync(process.env.GITHUB_EVENT_PATH, 'utf8'));
|
||||
}
|
||||
console.log(`The event payload: ${JSON.stringify(payload, undefined, 2)}`);
|
||||
|
||||
20
act/runner/testdata/actions/node24/package.json
vendored
20
act/runner/testdata/actions/node24/package.json
vendored
@@ -1,21 +1,5 @@
|
||||
{
|
||||
"name": "node24",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "ncc build index.js"
|
||||
},
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@actions/core": "^3.0.1",
|
||||
"@actions/github": "^9.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vercel/ncc": "^0.38.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=24"
|
||||
}
|
||||
"private": true,
|
||||
"type": "module"
|
||||
}
|
||||
|
||||
15
act/runner/testdata/actions/script/action.yml
vendored
Normal file
15
act/runner/testdata/actions/script/action.yml
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
name: 'script'
|
||||
description: 'Run the shell scripts passed as inputs across the pre/main/post lifecycle'
|
||||
inputs:
|
||||
main:
|
||||
description: 'shell script to run in the main step'
|
||||
required: false
|
||||
default: ''
|
||||
post:
|
||||
description: 'shell script to run in the post step'
|
||||
required: false
|
||||
default: ''
|
||||
runs:
|
||||
using: 'node24'
|
||||
main: 'index.js'
|
||||
post: 'post.js'
|
||||
9
act/runner/testdata/actions/script/index.js
vendored
Normal file
9
act/runner/testdata/actions/script/index.js
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
import {execFileSync} from 'node:child_process';
|
||||
|
||||
// Run the `main` input as a bash script; its stdout (workflow commands like
|
||||
// ::set-output / ::save-state) and $GITHUB_ENV / $GITHUB_STATE writes are
|
||||
// processed by the runner, exactly like the remote script action this replaces.
|
||||
const script = process.env.INPUT_MAIN;
|
||||
if (script) {
|
||||
execFileSync('bash', ['-eo', 'pipefail', '-c', script], {stdio: 'inherit'});
|
||||
}
|
||||
5
act/runner/testdata/actions/script/package.json
vendored
Normal file
5
act/runner/testdata/actions/script/package.json
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"name": "script",
|
||||
"private": true,
|
||||
"type": "module"
|
||||
}
|
||||
6
act/runner/testdata/actions/script/post.js
vendored
Normal file
6
act/runner/testdata/actions/script/post.js
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
import {execFileSync} from 'node:child_process';
|
||||
|
||||
const script = process.env.INPUT_POST;
|
||||
if (script) {
|
||||
execFileSync('bash', ['-eo', 'pipefail', '-c', script], {stdio: 'inherit'});
|
||||
}
|
||||
@@ -4,7 +4,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: |
|
||||
FROM ubuntu:latest
|
||||
FROM node:24-bookworm-slim
|
||||
ENV PATH="/opt/texlive/texdir/bin/x86_64-linuxmusl:${PATH}"
|
||||
ENV ORG_PATH="${PATH}"
|
||||
ENTRYPOINT [ "bash", "-c", "echo \"PATH=$PATH\" && echo \"ORG_PATH=$ORG_PATH\" && [[ \"$PATH\" = \"$ORG_PATH\" ]]" ]
|
||||
|
||||
13
act/runner/testdata/issue-1195/push.yml
vendored
13
act/runner/testdata/issue-1195/push.yml
vendored
@@ -1,13 +0,0 @@
|
||||
on: push
|
||||
|
||||
env:
|
||||
variable: "${{ github.repository_owner }}"
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: print env.variable
|
||||
run: |
|
||||
echo ${{ env.variable }}
|
||||
exit ${{ (env.variable == 'nektos') && '0' || '1'}}
|
||||
19
act/runner/testdata/issue-597/spelling.yaml
vendored
19
act/runner/testdata/issue-597/spelling.yaml
vendored
@@ -9,24 +9,13 @@ jobs:
|
||||
steps:
|
||||
- name: My first false step
|
||||
if: "endsWith('Should not', 'o1')"
|
||||
uses: actions/checkout@v2.0.0
|
||||
with:
|
||||
ref: refs/pull/${{github.event.pull_request.number}}/merge
|
||||
fetch-depth: 5
|
||||
run: exit 1
|
||||
- name: My first true step
|
||||
if: ${{endsWith('Hello world', 'ld')}}
|
||||
uses: actions/hello-world-javascript-action@main
|
||||
with:
|
||||
who-to-greet: "Renst the Octocat"
|
||||
run: echo "Renst the Octocat"
|
||||
- name: My second false step
|
||||
if: "endsWith('Should not evaluate', 'o2')"
|
||||
uses: actions/checkout@v2.0.0
|
||||
with:
|
||||
ref: refs/pull/${{github.event.pull_request.number}}/merge
|
||||
fetch-depth: 5
|
||||
run: exit 1
|
||||
- name: My third false step
|
||||
if: ${{endsWith('Should not evaluate', 'o3')}}
|
||||
uses: actions/checkout@v2.0.0
|
||||
with:
|
||||
ref: refs/pull/${{github.event.pull_request.number}}/merge
|
||||
fetch-depth: 5
|
||||
run: exit 1
|
||||
|
||||
22
act/runner/testdata/issue-598/spelling.yml
vendored
22
act/runner/testdata/issue-598/spelling.yml
vendored
@@ -1,31 +1,21 @@
|
||||
name: issue-598
|
||||
on: push
|
||||
|
||||
|
||||
|
||||
jobs:
|
||||
my_first_job:
|
||||
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: My first false step
|
||||
if: "endsWith('Hello world', 'o1')"
|
||||
uses: actions/hello-world-javascript-action@main
|
||||
with:
|
||||
who-to-greet: 'Mona the Octocat'
|
||||
run: exit 1
|
||||
- name: My first true step
|
||||
if: "!endsWith('Hello world', 'od')"
|
||||
uses: actions/hello-world-javascript-action@main
|
||||
with:
|
||||
who-to-greet: "Renst the Octocat"
|
||||
run: echo "Renst the Octocat"
|
||||
- name: My second false step
|
||||
if: "endsWith('Hello world', 'o2')"
|
||||
uses: actions/hello-world-javascript-action@main
|
||||
with:
|
||||
who-to-greet: 'Act the Octocat'
|
||||
run: exit 1
|
||||
- name: My third false step
|
||||
if: "endsWith('Hello world', 'o2')"
|
||||
uses: actions/hello-world-javascript-action@main
|
||||
with:
|
||||
who-to-greet: 'Git the Octocat'
|
||||
|
||||
|
||||
run: exit 1
|
||||
|
||||
@@ -5,6 +5,7 @@ jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: catthehacker/ubuntu:runner-latest # image with user 'runner:runner' built on tag 'act-latest'
|
||||
image: node:24-bookworm-slim
|
||||
options: --user 1000
|
||||
steps:
|
||||
- run: echo PASS
|
||||
|
||||
@@ -24,4 +24,3 @@ jobs:
|
||||
args: ${{format('"{0}"', 'Mona is not the Octocat') }}
|
||||
who-to-greet: 'Mona the Octocat'
|
||||
- run: '[[ "${{ env.SOMEVAR }}" == "Mona is not the Octocat" ]]'
|
||||
- uses: ./localdockerimagetest_
|
||||
|
||||
@@ -30,11 +30,6 @@ runs:
|
||||
who-to-greet: ${{inputs.who-to-greet}}
|
||||
- run: '[[ "${{ env.SOMEVAR }}" == "Mona is not the Octocat" ]]'
|
||||
shell: bash
|
||||
- uses: ./localdockerimagetest_
|
||||
# Also test a remote docker action here
|
||||
- uses: actions/hello-world-docker-action@v2
|
||||
with:
|
||||
who-to-greet: 'Mona the Octocat'
|
||||
# Test if GITHUB_ACTION_PATH is set correctly after all steps
|
||||
- run: stat $GITHUB_ACTION_PATH/push.yml
|
||||
shell: bash
|
||||
|
||||
@@ -5,5 +5,5 @@ jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: nektos/test-override@a
|
||||
- uses: https://github.com/nektos/test-override@a
|
||||
- uses: nektos/test-override@b
|
||||
@@ -1,31 +0,0 @@
|
||||
name: matrix-include-exclude
|
||||
on: push
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: PHP ${{ matrix.os }} ${{ matrix.node}}
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- run: echo ${NODE_VERSION} | grep ${{ matrix.node }}
|
||||
env:
|
||||
NODE_VERSION: ${{ matrix.node }}
|
||||
strategy:
|
||||
matrix:
|
||||
os: [ubuntu-18.04, macos-latest]
|
||||
node: [4, 6, 8, 10]
|
||||
exclude:
|
||||
- os: macos-latest
|
||||
node: 4
|
||||
include:
|
||||
- os: ubuntu-16.04
|
||||
node: 10
|
||||
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
node: [8.x, 10.x, 12.x, 13.x]
|
||||
steps:
|
||||
- run: echo ${NODE_VERSION} | grep ${{ matrix.node }}
|
||||
env:
|
||||
NODE_VERSION: ${{ matrix.node }}
|
||||
@@ -18,12 +18,4 @@ jobs:
|
||||
runs:
|
||||
using: composite
|
||||
shell: cp {0} action.yml
|
||||
- uses: ./
|
||||
remote-invalid-step:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: nektos/act-test-actions/invalid-composite-action/invalid-step@main
|
||||
remote-missing-steps:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: nektos/act-test-actions/invalid-composite-action/missing-steps@main
|
||||
- uses: ./
|
||||
2
act/runner/testdata/path-handling/push.yml
vendored
2
act/runner/testdata/path-handling/push.yml
vendored
@@ -27,7 +27,7 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- uses: nektos/act-test-actions/composite@main
|
||||
- uses: ./path-handling/
|
||||
with:
|
||||
input: some input
|
||||
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
name: remote-action-composite-action-ref
|
||||
on: push
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: nektos/act-test-actions/composite-assert-action-ref-action@main
|
||||
@@ -1,23 +0,0 @@
|
||||
name: remote-action-composite-js-pre-with-defaults
|
||||
on: push
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: nektos/act-test-actions/composite-js-pre-with-defaults/js@main
|
||||
with:
|
||||
in: nix
|
||||
- uses: nektos/act-test-actions/composite-js-pre-with-defaults@main
|
||||
with:
|
||||
in: secretval
|
||||
- uses: nektos/act-test-actions/composite-js-pre-with-defaults@main
|
||||
with:
|
||||
in: secretval
|
||||
- uses: nektos/act-test-actions/composite-js-pre-with-defaults/js@main
|
||||
with:
|
||||
pre: "true"
|
||||
in: nix
|
||||
- uses: nektos/act-test-actions/composite-js-pre-with-defaults/js@main
|
||||
with:
|
||||
in: nix
|
||||
@@ -1,10 +0,0 @@
|
||||
name: remote-action-docker
|
||||
on: push
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/hello-world-docker-action@v1
|
||||
with:
|
||||
who-to-greet: 'Mona the Octocat'
|
||||
@@ -1,30 +0,0 @@
|
||||
name: remote-action-js
|
||||
on: push
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: node:24-bookworm-slim
|
||||
options: --user node
|
||||
steps:
|
||||
- name: check permissions of env files
|
||||
id: test
|
||||
run: |
|
||||
echo "USER: $(id -un) expected: node"
|
||||
[[ "$(id -un)" = "node" ]]
|
||||
echo "TEST=Value" >> $GITHUB_OUTPUT
|
||||
shell: bash
|
||||
|
||||
- name: check if file command worked
|
||||
if: steps.test.outputs.test != 'Value'
|
||||
run: |
|
||||
echo "steps.test.outputs.test=${{ steps.test.outputs.test || 'missing value!' }}"
|
||||
exit 1
|
||||
shell: bash
|
||||
|
||||
- uses: actions/hello-world-javascript-action@v1
|
||||
with:
|
||||
who-to-greet: 'Mona the Octocat'
|
||||
|
||||
- uses: cloudposse/actions/github/slash-command-dispatch@0.14.0
|
||||
12
act/runner/testdata/remote-action-js/push.yml
vendored
12
act/runner/testdata/remote-action-js/push.yml
vendored
@@ -1,12 +0,0 @@
|
||||
name: remote-action-js
|
||||
on: push
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/hello-world-javascript-action@v1
|
||||
with:
|
||||
who-to-greet: 'Mona the Octocat'
|
||||
|
||||
- uses: cloudposse/actions/github/slash-command-dispatch@0.14.0
|
||||
24
act/runner/testdata/runs-on/push.yml
vendored
24
act/runner/testdata/runs-on/push.yml
vendored
@@ -1,24 +0,0 @@
|
||||
name: runs-on
|
||||
on: push
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: env
|
||||
- run: echo ${GITHUB_ACTOR}
|
||||
- run: echo ${GITHUB_ACTOR} | grep nektos/act
|
||||
|
||||
many:
|
||||
runs-on: [ubuntu-latest]
|
||||
steps:
|
||||
- run: env
|
||||
- run: echo ${GITHUB_ACTOR}
|
||||
- run: echo ${GITHUB_ACTOR} | grep nektos/act
|
||||
|
||||
selfmany:
|
||||
runs-on: [self-hosted, ubuntu-latest]
|
||||
steps:
|
||||
- run: env
|
||||
- run: echo ${GITHUB_ACTOR}
|
||||
- run: echo ${GITHUB_ACTOR} | grep nektos/act
|
||||
2
act/runner/testdata/secrets/.env
vendored
Normal file
2
act/runner/testdata/secrets/.env
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
HELLO=WORLD
|
||||
MULTILINE_ENV="foo\nbar\nbaz"
|
||||
@@ -1,14 +0,0 @@
|
||||
name: services-host-network
|
||||
on: push
|
||||
jobs:
|
||||
services-host-network:
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
nginx:
|
||||
image: "nginx:latest"
|
||||
ports:
|
||||
- "8080:80"
|
||||
steps:
|
||||
- run: apt-get -qq update && apt-get -yqq install --no-install-recommends curl net-tools
|
||||
- run: netstat -tlpen
|
||||
- run: curl -v http://localhost:8080
|
||||
@@ -5,12 +5,11 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
# https://docs.github.com/en/actions/using-containerized-services/about-service-containers#running-jobs-in-a-container
|
||||
container:
|
||||
image: "ubuntu:latest"
|
||||
image: "node:24-bookworm-slim"
|
||||
services:
|
||||
nginx:
|
||||
image: "nginx:latest"
|
||||
ports:
|
||||
- "8080:80"
|
||||
image: "nginx:alpine"
|
||||
steps:
|
||||
- run: apt-get -qq update && apt-get -yqq install --no-install-recommends curl
|
||||
# reach the service over the shared job network by its alias, no host port needed
|
||||
- run: curl -v http://nginx:80
|
||||
|
||||
13
act/runner/testdata/services/push.yaml
vendored
13
act/runner/testdata/services/push.yaml
vendored
@@ -6,18 +6,9 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:12
|
||||
env:
|
||||
POSTGRES_USER: runner
|
||||
POSTGRES_PASSWORD: mysecretdbpass
|
||||
POSTGRES_DB: mydb
|
||||
options: >-
|
||||
--health-cmd pg_isready
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
image: nginx:alpine
|
||||
ports:
|
||||
- 5432:5432
|
||||
- 80
|
||||
steps:
|
||||
- name: Echo the Postgres service ID / Network / Ports
|
||||
run: |
|
||||
|
||||
7
act/runner/testdata/shells/pwsh/push.yml
vendored
7
act/runner/testdata/shells/pwsh/push.yml
vendored
@@ -8,13 +8,6 @@ jobs:
|
||||
- shell: ${{ env.MY_SHELL }}
|
||||
run: |
|
||||
$PSVersionTable
|
||||
check-container:
|
||||
runs-on: ubuntu-latest
|
||||
container: catthehacker/ubuntu:pwsh-latest
|
||||
steps:
|
||||
- shell: ${{ env.MY_SHELL }}
|
||||
run: |
|
||||
$PSVersionTable
|
||||
check-job-default:
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
|
||||
28
act/runner/testdata/shells/python/push.yml
vendored
28
act/runner/testdata/shells/python/push.yml
vendored
@@ -1,28 +0,0 @@
|
||||
on: push
|
||||
env:
|
||||
MY_SHELL: python
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- shell: ${{ env.MY_SHELL }}
|
||||
run: |
|
||||
import platform
|
||||
print(platform.python_version())
|
||||
check-container:
|
||||
runs-on: ubuntu-latest
|
||||
container: node:24-bookworm
|
||||
steps:
|
||||
- shell: ${{ env.MY_SHELL }}
|
||||
run: |
|
||||
import platform
|
||||
print(platform.python_version())
|
||||
check-job-default:
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
shell: ${{ env.MY_SHELL }}
|
||||
steps:
|
||||
- run: |
|
||||
import platform
|
||||
print(platform.python_version())
|
||||
@@ -1,7 +0,0 @@
|
||||
name: "last action check"
|
||||
description: "last action check"
|
||||
|
||||
runs:
|
||||
using: "node24"
|
||||
main: main.js
|
||||
post: post.js
|
||||
@@ -1,17 +0,0 @@
|
||||
const pre = process.env['ACTION_OUTPUT_PRE'];
|
||||
const main = process.env['ACTION_OUTPUT_MAIN'];
|
||||
const post = process.env['ACTION_OUTPUT_POST'];
|
||||
|
||||
console.log({pre, main, post});
|
||||
|
||||
if (pre !== 'pre') {
|
||||
throw new Error(`Expected 'pre' but got '${pre}'`);
|
||||
}
|
||||
|
||||
if (main !== 'main') {
|
||||
throw new Error(`Expected 'main' but got '${main}'`);
|
||||
}
|
||||
|
||||
if (post !== 'post') {
|
||||
throw new Error(`Expected 'post' but got '${post}'`);
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
name: uses-action-with-pre-and-post-step
|
||||
on: push
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: ./uses-action-with-pre-and-post-step/last-action
|
||||
- uses: nektos/act-test-actions/js-with-pre-and-post-step@main
|
||||
with:
|
||||
pre: true
|
||||
post: true
|
||||
- run: |
|
||||
cat $GITHUB_ENV
|
||||
@@ -1,7 +0,0 @@
|
||||
name: uses-github-root
|
||||
on: push
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/hello-world-docker-action@b136eb8894c5cb1dd5807da824be97ccdf9b5423
|
||||
@@ -1,7 +0,0 @@
|
||||
name: uses-github-path
|
||||
on: push
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: sergioramos/yarn-actions/install@v6
|
||||
@@ -1,7 +0,0 @@
|
||||
name: uses-github-root
|
||||
on: push
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/hello-world-docker-action@b136eb8
|
||||
@@ -1,63 +0,0 @@
|
||||
---
|
||||
name: "Test Composite Action"
|
||||
description: "Test action uses composite"
|
||||
|
||||
inputs:
|
||||
test_input_optional:
|
||||
description: Test
|
||||
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '24'
|
||||
- run: |
|
||||
console.log(process.version);
|
||||
console.log("Hi from node");
|
||||
console.log("${{ inputs.test_input_optional }}");
|
||||
if("${{ inputs.test_input_optional }}" !== "Test") {
|
||||
console.log("Invalid input test_input_optional expected \"Test\" as value");
|
||||
process.exit(1);
|
||||
}
|
||||
if(!process.version.startsWith('v16')) {
|
||||
console.log("Expected node v16, but got " + process.version);
|
||||
process.exit(1);
|
||||
}
|
||||
shell: node {0}
|
||||
- uses: ./uses-composite/composite_action
|
||||
id: composite
|
||||
with:
|
||||
test_input_required: 'test_input_required_value'
|
||||
test_input_optional: 'test_input_optional_value'
|
||||
test_input_optional_with_default_overriden: 'test_input_optional_with_default_overriden'
|
||||
test_input_required_with_default: 'test_input_optional_value'
|
||||
test_input_required_with_default_overriden: 'test_input_required_with_default_overriden'
|
||||
secret_input: ${{inputs.test_input_optional}}
|
||||
env:
|
||||
secret_input: ${{inputs.test_input_optional}}
|
||||
- run: |
|
||||
echo "steps.composite.outputs.test_output=${{ steps.composite.outputs.test_output }}"
|
||||
[[ "${{steps.composite.outputs.test_output == 'test_output_value'}}" = "true" ]] || exit 1
|
||||
shell: bash
|
||||
- run: |
|
||||
echo "steps.composite.outputs.secret_output=${{ steps.composite.outputs.secret_output }}"
|
||||
[[ "${{steps.composite.outputs.secret_output == format('{0}/{0}', inputs.test_input_optional)}}" = "true" ]] || exit 1
|
||||
shell: bash
|
||||
# Now test again with default values
|
||||
- name: ./uses-composite/composite_action with defaults
|
||||
uses: ./uses-composite/composite_action
|
||||
id: composite2
|
||||
with:
|
||||
test_input_required: 'test_input_required_value'
|
||||
test_input_optional_with_default_overriden: 'test_input_optional_with_default_overriden'
|
||||
test_input_required_with_default_overriden: 'test_input_required_with_default_overriden'
|
||||
|
||||
- run: |
|
||||
echo "steps.composite2.outputs.test_output=${{ steps.composite2.outputs.test_output }}"
|
||||
[[ "${{steps.composite2.outputs.test_output == 'test_output_value'}}" = "true" ]] || exit 1
|
||||
shell: bash
|
||||
- run: |
|
||||
echo "steps.composite.outputs.secret_output=$COMPOSITE_ACTION_ENV_OUTPUT"
|
||||
[[ "${{env.COMPOSITE_ACTION_ENV_OUTPUT == 'my test value' }}" = "true" ]] || exit 1
|
||||
shell: bash
|
||||
@@ -1,15 +0,0 @@
|
||||
name: uses-docker-url
|
||||
on: push
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: ./uses-nested-composite/composite_action2
|
||||
with:
|
||||
test_input_optional: Test
|
||||
- run: |
|
||||
echo "steps.composite.outputs.secret_output=$COMPOSITE_ACTION_ENV_OUTPUT"
|
||||
[[ "${{env.COMPOSITE_ACTION_ENV_OUTPUT == 'my test value' }}" = "true" ]] || exit 1
|
||||
shell: bash
|
||||
@@ -1,42 +0,0 @@
|
||||
name: local-reusable-workflows
|
||||
on: pull_request
|
||||
|
||||
jobs:
|
||||
reusable-workflow:
|
||||
uses: ./.github/workflows/local-reusable-workflow.yml
|
||||
with:
|
||||
string_required: string
|
||||
bool_required: ${{ true }}
|
||||
number_required: 1
|
||||
secrets:
|
||||
secret: keep_it_private
|
||||
|
||||
reusable-workflow-with-inherited-secrets:
|
||||
uses: ./.github/workflows/local-reusable-workflow.yml
|
||||
with:
|
||||
string_required: string
|
||||
bool_required: ${{ true }}
|
||||
number_required: 1
|
||||
secrets: inherit
|
||||
|
||||
reusable-workflow-with-on-string-notation:
|
||||
uses: ./.github/workflows/local-reusable-workflow-no-inputs-string.yml
|
||||
|
||||
reusable-workflow-with-on-array-notation:
|
||||
uses: ./.github/workflows/local-reusable-workflow-no-inputs-array.yml
|
||||
|
||||
output-test:
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- reusable-workflow
|
||||
- reusable-workflow-with-inherited-secrets
|
||||
steps:
|
||||
- name: output with secrets map
|
||||
run: |
|
||||
echo reusable-workflow.output=${{ needs.reusable-workflow.outputs.output }}
|
||||
[[ "${{ needs.reusable-workflow.outputs.output == 'string' }}" = "true" ]] || exit 1
|
||||
|
||||
- name: output with inherited secrets
|
||||
run: |
|
||||
echo reusable-workflow-with-inherited-secrets.output=${{ needs.reusable-workflow-with-inherited-secrets.outputs.output }}
|
||||
[[ "${{ needs.reusable-workflow-with-inherited-secrets.outputs.output == 'string' }}" = "true" ]] || exit 1
|
||||
18
act/runner/testdata/uses-workflow/push.yml
vendored
18
act/runner/testdata/uses-workflow/push.yml
vendored
@@ -1,8 +1,11 @@
|
||||
on: push
|
||||
|
||||
# Exercises the reusable-workflow caller path against a local reusable workflow: passing typed
|
||||
# inputs and secrets (both an explicit map and `inherit`), and reading the called workflow's
|
||||
# outputs back through `needs`.
|
||||
jobs:
|
||||
reusable-workflow:
|
||||
uses: nektos/act-test-actions/.github/workflows/reusable-workflow.yml@main
|
||||
uses: ./.github/workflows/local-reusable-workflow.yml
|
||||
with:
|
||||
string_required: string
|
||||
bool_required: ${{ true }}
|
||||
@@ -11,7 +14,7 @@ jobs:
|
||||
secret: keep_it_private
|
||||
|
||||
reusable-workflow-with-inherited-secrets:
|
||||
uses: nektos/act-test-actions/.github/workflows/reusable-workflow.yml@main
|
||||
uses: ./.github/workflows/local-reusable-workflow.yml
|
||||
with:
|
||||
string_required: string
|
||||
bool_required: ${{ true }}
|
||||
@@ -24,12 +27,5 @@ jobs:
|
||||
- reusable-workflow
|
||||
- reusable-workflow-with-inherited-secrets
|
||||
steps:
|
||||
- name: output with secrets map
|
||||
run: |
|
||||
echo reusable-workflow.output=${{ needs.reusable-workflow.outputs.output }}
|
||||
[[ "${{ needs.reusable-workflow.outputs.output == 'string' }}" = "true" ]] || exit 1
|
||||
|
||||
- name: output with inherited secrets
|
||||
run: |
|
||||
echo reusable-workflow-with-inherited-secrets.output=${{ needs.reusable-workflow-with-inherited-secrets.outputs.output }}
|
||||
[[ "${{ needs.reusable-workflow-with-inherited-secrets.outputs.output == 'string' }}" = "true" ]] || exit 1
|
||||
- run: '[[ "${{ needs.reusable-workflow.outputs.output == ''string'' }}" = "true" ]] || exit 1'
|
||||
- run: '[[ "${{ needs.reusable-workflow-with-inherited-secrets.outputs.output == ''string'' }}" = "true" ]] || exit 1'
|
||||
|
||||
2
go.mod
2
go.mod
@@ -26,7 +26,7 @@ require (
|
||||
github.com/moby/moby/client v0.4.1
|
||||
github.com/moby/patternmatcher v0.6.1
|
||||
github.com/opencontainers/image-spec v1.1.1
|
||||
github.com/opencontainers/selinux v1.14.1
|
||||
github.com/opencontainers/selinux v1.15.0
|
||||
github.com/pkg/errors v0.9.1
|
||||
github.com/prometheus/client_golang v1.23.2
|
||||
github.com/rhysd/actionlint v1.7.12
|
||||
|
||||
2
go.sum
2
go.sum
@@ -151,6 +151,8 @@ github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJw
|
||||
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
|
||||
github.com/opencontainers/selinux v1.14.1 h1:a7XlXV/nN/l5zFP1FWZYoExpClu1QOPMfWUV2CZ8kEQ=
|
||||
github.com/opencontainers/selinux v1.14.1/go.mod h1:LenyElirjUHszfxrjuFqC85HIeXZKumHcKMQtnaDlQQ=
|
||||
github.com/opencontainers/selinux v1.15.0 h1:4Gs40e/R2FvM8PC1HPaPncLLaDor8Y2WDfk5gjU9o5M=
|
||||
github.com/opencontainers/selinux v1.15.0/go.mod h1:LenyElirjUHszfxrjuFqC85HIeXZKumHcKMQtnaDlQQ=
|
||||
github.com/pjbgf/sha1cd v0.6.0 h1:3WJ8Wz8gvDz29quX1OcEmkAlUg9diU4GxJHqs0/XiwU=
|
||||
github.com/pjbgf/sha1cd v0.6.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
|
||||
@@ -60,6 +60,9 @@ runner:
|
||||
# The interval for reporting task state (step status, timing) to the Gitea instance.
|
||||
# State is also reported immediately on step transitions (start/stop).
|
||||
state_report_interval: 5s
|
||||
# Per-attempt deadline for flushing the final logs and task state when a job
|
||||
# finishes, on a detached context so a server cancel can't block the acknowledgement.
|
||||
report_close_timeout: 10s
|
||||
# The github_mirror of a runner is used to specify the mirror address of the github that pulls the action repository.
|
||||
# It works when something like `uses: actions/checkout@v4` is used and DEFAULT_ACTIONS_URL is set to github,
|
||||
# and github_mirror is not empty. In this case,
|
||||
|
||||
@@ -39,6 +39,7 @@ type Runner struct {
|
||||
LogReportMaxLatency time.Duration `yaml:"log_report_max_latency"` // LogReportMaxLatency specifies the max time a log row can wait before being sent.
|
||||
LogReportBatchSize int `yaml:"log_report_batch_size"` // LogReportBatchSize triggers immediate log flush when buffer reaches this size.
|
||||
StateReportInterval time.Duration `yaml:"state_report_interval"` // StateReportInterval specifies the interval for state reporting.
|
||||
ReportCloseTimeout time.Duration `yaml:"report_close_timeout"` // ReportCloseTimeout caps each RPC attempt when flushing the final logs and task state at job completion, on a detached context so a server cancel can't block the acknowledgement.
|
||||
Labels []string `yaml:"labels"` // Labels specify the labels of the runner. Labels are declared on each startup
|
||||
GithubMirror string `yaml:"github_mirror"` // GithubMirror defines what mirrors should be used when using github
|
||||
AllocatePTY bool `yaml:"allocate_pty"` // AllocatePTY allocates a pseudo-TTY for each step's process. Default is false, matching GitHub's actions/runner. Enable only for jobs that need an interactive terminal; tools like docker build emit redrawing progress frames into the captured log when a TTY is present. Applies to both host and docker backends.
|
||||
@@ -183,6 +184,9 @@ func LoadDefault(file string) (*Config, error) {
|
||||
if cfg.Runner.StateReportInterval <= 0 {
|
||||
cfg.Runner.StateReportInterval = 5 * time.Second
|
||||
}
|
||||
if cfg.Runner.ReportCloseTimeout <= 0 {
|
||||
cfg.Runner.ReportCloseTimeout = 10 * time.Second
|
||||
}
|
||||
if cfg.Metrics.Addr == "" {
|
||||
cfg.Metrics.Addr = "127.0.0.1:9101"
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"gitea.com/gitea/runner/act/runner"
|
||||
"gitea.com/gitea/runner/internal/pkg/client"
|
||||
"gitea.com/gitea/runner/internal/pkg/config"
|
||||
"gitea.com/gitea/runner/internal/pkg/metrics"
|
||||
@@ -58,6 +59,9 @@ type Reporter struct {
|
||||
logReportMaxLatency time.Duration
|
||||
logBatchSize int
|
||||
stateReportInterval time.Duration
|
||||
// closeTimeout bounds each RPC attempt in the final flush, on a context
|
||||
// detached from r.ctx so a server cancel can't abort the acknowledgement.
|
||||
closeTimeout time.Duration
|
||||
|
||||
// Event notification channels (non-blocking, buffered 1)
|
||||
logNotify chan struct{} // signal: new log rows arrived
|
||||
@@ -70,13 +74,13 @@ type Reporter struct {
|
||||
func NewReporter(ctx context.Context, cancel context.CancelFunc, client client.Client, task *runnerv1.Task, cfg *config.Config) *Reporter {
|
||||
var oldnew []string
|
||||
if v := task.Context.Fields["token"].GetStringValue(); v != "" {
|
||||
oldnew = append(oldnew, v, "***")
|
||||
oldnew = runner.AppendSecretMasker(oldnew, v)
|
||||
}
|
||||
if v := task.Context.Fields["gitea_runtime_token"].GetStringValue(); v != "" {
|
||||
oldnew = append(oldnew, v, "***")
|
||||
oldnew = runner.AppendSecretMasker(oldnew, v)
|
||||
}
|
||||
for _, v := range task.Secrets {
|
||||
oldnew = append(oldnew, v, "***")
|
||||
oldnew = runner.AppendSecretMasker(oldnew, v)
|
||||
}
|
||||
|
||||
rv := &Reporter{
|
||||
@@ -89,6 +93,7 @@ func NewReporter(ctx context.Context, cancel context.CancelFunc, client client.C
|
||||
logReportMaxLatency: cfg.Runner.LogReportMaxLatency,
|
||||
logBatchSize: cfg.Runner.LogReportBatchSize,
|
||||
stateReportInterval: cfg.Runner.StateReportInterval,
|
||||
closeTimeout: cfg.Runner.ReportCloseTimeout,
|
||||
logNotify: make(chan struct{}, 1),
|
||||
stateNotify: make(chan struct{}, 1),
|
||||
state: &runnerv1.TaskState{
|
||||
@@ -205,7 +210,7 @@ func (r *Reporter) Fire(entry *log.Entry) error {
|
||||
urgentState = true
|
||||
}
|
||||
}
|
||||
if !r.duringSteps() {
|
||||
if r.shouldAppendLogRow(entry) {
|
||||
r.logRows = appendIfNotNil(r.logRows, r.parseLogRow(entry))
|
||||
}
|
||||
r.unlockAndNotify(urgentState)
|
||||
@@ -219,7 +224,7 @@ func (r *Reporter) Fire(entry *log.Entry) error {
|
||||
}
|
||||
}
|
||||
if step == nil {
|
||||
if !r.duringSteps() {
|
||||
if r.shouldAppendLogRow(entry) {
|
||||
r.logRows = appendIfNotNil(r.logRows, r.parseLogRow(entry))
|
||||
}
|
||||
r.unlockAndNotify(false)
|
||||
@@ -246,7 +251,7 @@ func (r *Reporter) Fire(entry *log.Entry) error {
|
||||
r.logRows = append(r.logRows, row)
|
||||
}
|
||||
}
|
||||
} else if !r.duringSteps() {
|
||||
} else if r.shouldAppendLogRow(entry) {
|
||||
r.logRows = appendIfNotNil(r.logRows, r.parseLogRow(entry))
|
||||
}
|
||||
if v, ok := entry.Data["stepResult"]; ok && isJobStepEntry(entry) {
|
||||
@@ -329,6 +334,9 @@ func (r *Reporter) runDaemonLoop() {
|
||||
_ = r.ReportLog(false)
|
||||
|
||||
case <-r.ctx.Done():
|
||||
// Stop heartbeating on cancel so Gitea sees the runner as offline
|
||||
// during cleanup and won't assign an overlapping task. Close() still
|
||||
// delivers the final flush on a detached context (flushFinal).
|
||||
close(r.daemon)
|
||||
return
|
||||
}
|
||||
@@ -431,17 +439,43 @@ func (r *Reporter) Close(lastWords string) error {
|
||||
}
|
||||
r.stateMu.Unlock()
|
||||
|
||||
// Report the job outcome even when all log upload retry attempts have been exhausted
|
||||
// Separate budgets so a slow ReportLog can't starve the ReportState that
|
||||
// carries the cancel acknowledgement.
|
||||
return errors.Join(
|
||||
retry.New(retry.Context(r.ctx)).Do(func() error {
|
||||
return r.ReportLog(true)
|
||||
}),
|
||||
retry.New(retry.Context(r.ctx)).Do(func() error {
|
||||
return r.ReportState(true)
|
||||
}),
|
||||
r.flushFinal(func() error { return r.ReportLog(true) }),
|
||||
r.flushFinal(func() error { return r.ReportState(true) }),
|
||||
)
|
||||
}
|
||||
|
||||
// flushFinal retries fn on a detached, bounded context so a cancelled r.ctx
|
||||
// does not abort the final flush. Each call gets its own fresh budget.
|
||||
func (r *Reporter) flushFinal(fn func() error) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*r.effectiveCloseTimeout())
|
||||
defer cancel()
|
||||
return retry.New(retry.Context(ctx)).Do(fn)
|
||||
}
|
||||
|
||||
// effectiveCloseTimeout returns closeTimeout, or 10s when unset, so a zero
|
||||
// value can't produce an already-expired context for the final flush.
|
||||
func (r *Reporter) effectiveCloseTimeout() time.Duration {
|
||||
if r.closeTimeout <= 0 {
|
||||
return 10 * time.Second
|
||||
}
|
||||
return r.closeTimeout
|
||||
}
|
||||
|
||||
// rpcCtx returns the context for an outbound RPC plus a cancel func. While
|
||||
// r.ctx is alive it's used directly; once cancelled (server RESULT_CANCELLED),
|
||||
// RPCs switch to a fresh bounded context so Close()'s final flush still lands.
|
||||
func (r *Reporter) rpcCtx() (context.Context, context.CancelFunc) {
|
||||
select {
|
||||
case <-r.ctx.Done():
|
||||
return context.WithTimeout(context.Background(), r.effectiveCloseTimeout())
|
||||
default:
|
||||
return r.ctx, func() {}
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Reporter) ReportLog(noMore bool) error {
|
||||
r.clientM.Lock()
|
||||
defer r.clientM.Unlock()
|
||||
@@ -454,8 +488,11 @@ func (r *Reporter) ReportLog(noMore bool) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
rpcCtx, rpcCancel := r.rpcCtx()
|
||||
defer rpcCancel()
|
||||
|
||||
start := time.Now()
|
||||
resp, err := r.client.UpdateLog(r.ctx, connect.NewRequest(&runnerv1.UpdateLogRequest{
|
||||
resp, err := r.client.UpdateLog(rpcCtx, connect.NewRequest(&runnerv1.UpdateLogRequest{
|
||||
TaskId: r.state.Id,
|
||||
Index: int64(r.logOffset),
|
||||
Rows: rows,
|
||||
@@ -526,8 +563,11 @@ func (r *Reporter) ReportState(reportResult bool) error {
|
||||
state.Result = runnerv1.Result_RESULT_UNSPECIFIED
|
||||
}
|
||||
|
||||
rpcCtx, rpcCancel := r.rpcCtx()
|
||||
defer rpcCancel()
|
||||
|
||||
start := time.Now()
|
||||
resp, err := r.client.UpdateTask(r.ctx, connect.NewRequest(&runnerv1.UpdateTaskRequest{
|
||||
resp, err := r.client.UpdateTask(rpcCtx, connect.NewRequest(&runnerv1.UpdateTaskRequest{
|
||||
State: state,
|
||||
Outputs: outputs,
|
||||
}))
|
||||
@@ -576,6 +616,13 @@ func (r *Reporter) duringSteps() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// shouldAppendLogRow reports whether a non-raw_output entry should be written
|
||||
// to the job log: only when we are between steps and the entry's level is
|
||||
// within the globally configured log level.
|
||||
func (r *Reporter) shouldAppendLogRow(entry *log.Entry) bool {
|
||||
return !r.duringSteps() && entry.Level <= log.GetLevel()
|
||||
}
|
||||
|
||||
var stringToResult = map[string]runnerv1.Result{
|
||||
"success": runnerv1.Result_RESULT_SUCCESS,
|
||||
"failure": runnerv1.Result_RESULT_FAILURE,
|
||||
@@ -643,7 +690,7 @@ func (r *Reporter) parseLogRow(entry *log.Entry) *runnerv1.LogRow {
|
||||
|
||||
matches := cmdRegex.FindStringSubmatch(content)
|
||||
if matches != nil {
|
||||
if output := r.handleCommand(content, matches[1], matches[3]); output != nil {
|
||||
if output := r.handleCommand(content, matches[1], runner.UnescapeCommandData(matches[3])); output != nil {
|
||||
content = *output
|
||||
} else {
|
||||
return nil
|
||||
@@ -659,6 +706,6 @@ func (r *Reporter) parseLogRow(entry *log.Entry) *runnerv1.LogRow {
|
||||
}
|
||||
|
||||
func (r *Reporter) addMask(msg string) {
|
||||
r.oldnew = append(r.oldnew, msg, "***")
|
||||
r.oldnew = runner.AppendSecretMasker(r.oldnew, msg)
|
||||
r.logReplacer = strings.NewReplacer(r.oldnew...)
|
||||
}
|
||||
|
||||
@@ -50,6 +50,19 @@ func TestReporter_parseLogRow(t *testing.T) {
|
||||
"foo *** bar",
|
||||
},
|
||||
},
|
||||
{
|
||||
"Add-mask-multiline", false,
|
||||
[]string{
|
||||
"foo mysecret bar",
|
||||
"::add-mask::LINE1%0ALINE2",
|
||||
"foo LINE1 bar",
|
||||
},
|
||||
[]string{
|
||||
"foo mysecret bar",
|
||||
"<nil>",
|
||||
"foo *** bar",
|
||||
},
|
||||
},
|
||||
{
|
||||
"Debug enabled", true,
|
||||
[]string{
|
||||
@@ -219,6 +232,59 @@ func TestReporter_Fire(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestReporter_LogLevelFiltering(t *testing.T) {
|
||||
// Set global level to Info so Debug entries should be filtered.
|
||||
origLevel := log.GetLevel()
|
||||
log.SetLevel(log.InfoLevel)
|
||||
defer log.SetLevel(origLevel)
|
||||
|
||||
client := mocks.NewClient(t)
|
||||
client.On("UpdateLog", mock.Anything, mock.Anything).Return(func(_ context.Context, req *connect_go.Request[runnerv1.UpdateLogRequest]) (*connect_go.Response[runnerv1.UpdateLogResponse], error) {
|
||||
return connect_go.NewResponse(&runnerv1.UpdateLogResponse{
|
||||
AckIndex: req.Msg.Index + int64(len(req.Msg.Rows)),
|
||||
}), nil
|
||||
})
|
||||
client.On("UpdateTask", mock.Anything, mock.Anything).Return(func(_ context.Context, req *connect_go.Request[runnerv1.UpdateTaskRequest]) (*connect_go.Response[runnerv1.UpdateTaskResponse], error) {
|
||||
return connect_go.NewResponse(&runnerv1.UpdateTaskResponse{}), nil
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
taskCtx, err := structpb.NewStruct(map[string]any{})
|
||||
require.NoError(t, err)
|
||||
cfg, _ := config.LoadDefault("")
|
||||
reporter := NewReporter(ctx, cancel, client, &runnerv1.Task{Context: taskCtx}, cfg)
|
||||
reporter.RunDaemon()
|
||||
defer func() {
|
||||
require.NoError(t, reporter.Close(""))
|
||||
}()
|
||||
reporter.ResetSteps(2)
|
||||
|
||||
dataStep0 := log.Fields{"stage": "Main", "stepNumber": 0, "raw_output": true}
|
||||
dataStep0Internal := log.Fields{"stage": "Main", "stepNumber": 0}
|
||||
|
||||
// raw_output entries always appear in job log regardless of level.
|
||||
require.NoError(t, reporter.Fire(&log.Entry{Message: "step output", Data: dataStep0, Level: log.InfoLevel}))
|
||||
require.NoError(t, reporter.Fire(&log.Entry{Message: "step debug output", Data: dataStep0, Level: log.DebugLevel}))
|
||||
assert.Equal(t, int64(2), reporter.state.Steps[0].LogLength, "raw_output entries must always be forwarded")
|
||||
|
||||
// Non-raw_output entries during steps are not added to logRows regardless of level.
|
||||
require.NoError(t, reporter.Fire(&log.Entry{Message: "internal info", Data: dataStep0Internal, Level: log.InfoLevel}))
|
||||
require.NoError(t, reporter.Fire(&log.Entry{Message: "internal debug", Data: dataStep0Internal, Level: log.DebugLevel}))
|
||||
|
||||
// stepResult at DebugLevel (skipped step) must still update state even when filtered from log.
|
||||
require.NoError(t, reporter.Fire(&log.Entry{
|
||||
Message: "Skipping step",
|
||||
Data: log.Fields{
|
||||
"stage": "Main",
|
||||
"stepNumber": 1,
|
||||
"stepResult": "skipped",
|
||||
},
|
||||
Level: log.DebugLevel,
|
||||
}))
|
||||
assert.Equal(t, runnerv1.Result_RESULT_SKIPPED, reporter.state.Steps[1].Result,
|
||||
"stepResult at DebugLevel must update step state even when log entry is filtered from job log output")
|
||||
}
|
||||
|
||||
// TestReporter_EphemeralRunnerDeletion reproduces the exact scenario from
|
||||
// https://gitea.com/gitea/runner/issues/793:
|
||||
//
|
||||
@@ -701,3 +767,86 @@ func TestReporter_StateHeartbeat(t *testing.T) {
|
||||
require.NoError(t, reporter.ReportState(false))
|
||||
assert.Equal(t, int64(2), updateTaskCalls.Load(), "ReportState must heartbeat after stateReportInterval even with no state change")
|
||||
}
|
||||
|
||||
// TestReporter_ServerCancelStillFlushesFinal asserts that when the Gitea server
|
||||
// returns RESULT_CANCELLED on an in-flight UpdateTask (which causes the
|
||||
// reporter to cancel the task context), Close() still successfully sends the
|
||||
// final UpdateLog{NoMore:true} and the final UpdateTask carrying the populated
|
||||
// final state. Before the fix this final flush used r.ctx, which was just
|
||||
// cancelled, so retry-go aborted on its context check and Gitea never received
|
||||
// the runner's acknowledgement of the cancel.
|
||||
func TestReporter_ServerCancelStillFlushesFinal(t *testing.T) {
|
||||
var (
|
||||
updateTaskCalls atomic.Int64
|
||||
finalLogNoMoreSeen atomic.Bool
|
||||
finalTaskStateSeen atomic.Bool
|
||||
)
|
||||
|
||||
client := mocks.NewClient(t)
|
||||
client.On("UpdateLog", mock.Anything, mock.Anything).Return(
|
||||
func(_ context.Context, req *connect_go.Request[runnerv1.UpdateLogRequest]) (*connect_go.Response[runnerv1.UpdateLogResponse], error) {
|
||||
if req.Msg.NoMore {
|
||||
finalLogNoMoreSeen.Store(true)
|
||||
}
|
||||
return connect_go.NewResponse(&runnerv1.UpdateLogResponse{
|
||||
AckIndex: req.Msg.Index + int64(len(req.Msg.Rows)),
|
||||
}), nil
|
||||
},
|
||||
)
|
||||
// The first UpdateTask returns RESULT_CANCELLED — modelling a server-side
|
||||
// cancellation; the reporter must call r.cancel() in response. The final
|
||||
// UpdateTask issued by Close() must still arrive even though r.ctx is now
|
||||
// cancelled.
|
||||
client.On("UpdateTask", mock.Anything, mock.Anything).Return(
|
||||
func(_ context.Context, req *connect_go.Request[runnerv1.UpdateTaskRequest]) (*connect_go.Response[runnerv1.UpdateTaskResponse], error) {
|
||||
n := updateTaskCalls.Add(1)
|
||||
if n == 1 {
|
||||
return connect_go.NewResponse(&runnerv1.UpdateTaskResponse{
|
||||
State: &runnerv1.TaskState{
|
||||
Result: runnerv1.Result_RESULT_CANCELLED,
|
||||
},
|
||||
}), nil
|
||||
}
|
||||
if req.Msg.State != nil && req.Msg.State.Result != runnerv1.Result_RESULT_UNSPECIFIED {
|
||||
finalTaskStateSeen.Store(true)
|
||||
}
|
||||
return connect_go.NewResponse(&runnerv1.UpdateTaskResponse{}), nil
|
||||
},
|
||||
)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
taskCtx, err := structpb.NewStruct(map[string]any{})
|
||||
require.NoError(t, err)
|
||||
cfg, _ := config.LoadDefault("")
|
||||
reporter := NewReporter(ctx, cancel, client, &runnerv1.Task{Context: taskCtx}, cfg)
|
||||
reporter.ResetSteps(1)
|
||||
|
||||
// Force the first ReportState to actually call UpdateTask.
|
||||
reporter.stateMu.Lock()
|
||||
reporter.stateChanged = true
|
||||
reporter.stateMu.Unlock()
|
||||
|
||||
// First ReportState — server returns RESULT_CANCELLED, reporter cancels r.ctx.
|
||||
require.NoError(t, reporter.ReportState(false))
|
||||
require.Equal(t, int64(1), updateTaskCalls.Load())
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
// Expected: reporter called cancel() because the server reported the task as cancelled.
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("expected r.ctx to be cancelled after server returned RESULT_CANCELLED")
|
||||
}
|
||||
|
||||
// The test does not start the daemon goroutine; close(r.daemon) so Close()
|
||||
// proceeds without waiting on its 60s timeout.
|
||||
close(reporter.daemon)
|
||||
|
||||
// Now Close() runs. Before the fix, both final RPCs aborted on the cancelled
|
||||
// r.ctx via retry.Context. After the fix, Close() uses a detached context and
|
||||
// the per-RPC rpcCtx() falls back to a fresh ctx, so both calls succeed.
|
||||
require.NoError(t, reporter.Close("cancelled"))
|
||||
|
||||
assert.True(t, finalLogNoMoreSeen.Load(), "Close() must send a final UpdateLog{NoMore:true} even after server-side cancellation")
|
||||
assert.True(t, finalTaskStateSeen.Load(), "Close() must send a final UpdateTask with the populated final state even after server-side cancellation")
|
||||
}
|
||||
|
||||
96
scripts/test-dind.sh
Executable file
96
scripts/test-dind.sh
Executable file
@@ -0,0 +1,96 @@
|
||||
#!/usr/bin/env bash
|
||||
# Generic docker-in-docker test harness.
|
||||
#
|
||||
# Builds a dind image variant from the repo Dockerfile, starts its docker daemon over a
|
||||
# local TCP port, and runs a Go test command against that daemon via DOCKER_HOST. This
|
||||
# validates the actual docker version and behaviour shipped in the dind image, so any
|
||||
# daemon-level regression surfaces here (e.g. the "docker cp" break in gitea/runner#981).
|
||||
# It is deliberately generic: point it at any package/test to exercise the dind daemon.
|
||||
#
|
||||
# Usage: scripts/test-dind.sh [target] [-- go-test-args...]
|
||||
# target: dind (default) or dind-rootless
|
||||
# go-test-args: passed verbatim to `go test`. The default exercises the daemon-facing tests
|
||||
# that need no registry access (a fresh daemon, e.g. on fork-PR CI, can't
|
||||
# authenticate pulls): the env-extraction build (FROM scratch) and the #981
|
||||
# /var/run symlink copy regression (which reuses a preloaded alpine).
|
||||
#
|
||||
# Env:
|
||||
# DIND_TEST_PORT host port for the daemon (default 32375)
|
||||
# DIND_TEST_IMAGE skip the build and use this prebuilt image instead
|
||||
# DIND_TEST_PRELOAD space-separated images to copy from the host daemon into the fresh one
|
||||
set -euo pipefail
|
||||
|
||||
target="dind"
|
||||
case "${1:-}" in
|
||||
dind|dind-rootless) target="$1"; shift ;;
|
||||
esac
|
||||
[ "${1:-}" = "--" ] && shift
|
||||
[ $# -eq 0 ] && set -- -race -run '^TestDocker$|^TestDockerCopyToSymlinkPath$' ./act/container/
|
||||
|
||||
port="${DIND_TEST_PORT:-32375}"
|
||||
name="gitea-runner-dind-test-$$"
|
||||
image="${DIND_TEST_IMAGE:-gitea-runner-${target}:dind-test}"
|
||||
# The host daemon endpoint, captured before DOCKER_HOST is pointed at the fresh dind daemon.
|
||||
host_docker="${DOCKER_HOST:-unix:///var/run/docker.sock}"
|
||||
|
||||
cleanup() { docker rm -f "$name" >/dev/null 2>&1 || true; }
|
||||
trap cleanup EXIT
|
||||
|
||||
if [ -z "${DIND_TEST_IMAGE:-}" ]; then
|
||||
echo "==> Building ${target} image"
|
||||
docker build --target "$target" -t "$image" .
|
||||
fi
|
||||
|
||||
# Override the image entrypoint (s6) and run only dockerd, exposed over insecure TCP.
|
||||
# We are testing the daemon the image ships, not the runner supervision tree.
|
||||
#
|
||||
# How the test process reaches the daemon depends on where it runs:
|
||||
# - plain host: publish 2375 on loopback and connect to 127.0.0.1.
|
||||
# - inside a container (CI), the daemon is a sibling container, so its published port is on
|
||||
# the host, not our loopback; instead attach it to our own network and reach it by name.
|
||||
self_container=""
|
||||
if [ -f /.dockerenv ]; then
|
||||
self_container="$(cat /proc/sys/kernel/hostname 2>/dev/null || cat /etc/hostname)"
|
||||
fi
|
||||
self_network=""
|
||||
if [ -n "$self_container" ]; then
|
||||
self_network="$(docker inspect -f '{{range $k,$v := .NetworkSettings.Networks}}{{$k}}{{"\n"}}{{end}}' "$self_container" 2>/dev/null | head -1)"
|
||||
fi
|
||||
|
||||
# The two cases differ only in how the daemon is exposed and addressed; everything else
|
||||
# (privileged, name, TLS-off entrypoint, image, --host) is shared, so collect just the
|
||||
# differing run args and the resulting DOCKER_HOST here.
|
||||
if [ -n "$self_network" ]; then
|
||||
echo "==> Starting ${target} daemon on network ${self_network} (reached as ${name}:2375)"
|
||||
run_args=(--network "$self_network")
|
||||
daemon_host="tcp://${name}:2375"
|
||||
else
|
||||
echo "==> Starting ${target} daemon on tcp://127.0.0.1:${port}"
|
||||
run_args=(-p "127.0.0.1:${port}:2375")
|
||||
daemon_host="tcp://127.0.0.1:${port}"
|
||||
fi
|
||||
# Create the dind container on the host daemon first, then repoint DOCKER_HOST at it: exporting
|
||||
# DOCKER_HOST before `docker run` would make this `docker run` target the not-yet-existent dind.
|
||||
docker run -d --privileged --name "$name" "${run_args[@]}" \
|
||||
-e DOCKER_TLS_CERTDIR= \
|
||||
--entrypoint dockerd-entrypoint.sh \
|
||||
"$image" --host=tcp://0.0.0.0:2375 >/dev/null
|
||||
export DOCKER_HOST="$daemon_host"
|
||||
|
||||
echo "==> Waiting for daemon"
|
||||
for _ in $(seq 1 60); do
|
||||
docker version --format 'server docker {{.Server.Version}}' 2>/dev/null && break
|
||||
sleep 1
|
||||
done
|
||||
|
||||
# Seed the fresh daemon with images the host already has (the CI job pulls them in the
|
||||
# preceding `make test`), so the daemon-facing tests run without registry access.
|
||||
echo "==> Seeding daemon with cached host images"
|
||||
for img in ${DIND_TEST_PRELOAD:-alpine:latest}; do
|
||||
if docker -H "$host_docker" image inspect "$img" >/dev/null 2>&1; then
|
||||
docker -H "$host_docker" save "$img" | docker load >/dev/null 2>&1 && echo " loaded $img" || true
|
||||
fi
|
||||
done
|
||||
|
||||
echo "==> Running tests against dind daemon"
|
||||
go test "$@"
|
||||
19
tools/lint-pr-title.ts
Normal file
19
tools/lint-pr-title.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
#!/usr/bin/env node
|
||||
import {env, exit} from 'node:process';
|
||||
|
||||
const allowedTypes = 'build, chore, ci, docs, enhance, feat, fix, perf, refactor, revert, style, test';
|
||||
const title = env.PR_TITLE;
|
||||
|
||||
if (!title) {
|
||||
console.error('Missing PR_TITLE');
|
||||
exit(1);
|
||||
}
|
||||
|
||||
const validTitlePattern = new RegExp(`^(${allowedTypes.replaceAll(', ', '|')})(\\([\\w.-]+\\))?(!)?: .+$`);
|
||||
|
||||
if (!validTitlePattern.test(title)) {
|
||||
console.error(`Invalid PR title: ${title}`);
|
||||
console.error('Expected format: type(scope): subject');
|
||||
console.error(`Allowed types: ${allowedTypes}`);
|
||||
exit(1);
|
||||
}
|
||||
Reference in New Issue
Block a user