mirror of
https://gitea.com/gitea/act_runner.git
synced 2026-06-09 18:44:23 +02:00
Compare commits
2 Commits
v1.0.8
...
lunny/remo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c5d0457615 | ||
|
|
3815aad750 |
@@ -9,36 +9,14 @@ jobs:
|
||||
lint:
|
||||
name: check and test
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
# The runner image ships a stale docker.io login; point docker at an empty config so
|
||||
# image pulls go straight to anonymous instead of attempting (and failing) that auth
|
||||
# first. The path must be a literal: the `runner` context is unavailable in job-level
|
||||
# env, so `${{ runner.temp }}` would resolve to empty and config.Dir() would fall back
|
||||
# to ~/.docker with the stale credentials.
|
||||
DOCKER_CONFIG: /tmp/docker-noauth
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: 'go.mod'
|
||||
- name: prepare anonymous docker config
|
||||
run: mkdir -p "$DOCKER_CONFIG" && echo '{}' > "$DOCKER_CONFIG/config.json"
|
||||
# Pre-pull act/runner's two largest base images so a slow pull can't dominate `make test`;
|
||||
# the rest (alpine/ubuntu) pull on demand, absorbed by the make-test -timeout. The host
|
||||
# daemon retains them between runs, so this is usually a fast manifest re-check.
|
||||
- name: pre-pull test images
|
||||
run: |
|
||||
for img in node:24-bookworm-slim nginx:alpine; do
|
||||
for try in 1 2 3; do docker pull "$img" && break || sleep 5; done
|
||||
done
|
||||
- name: lint
|
||||
run: make lint
|
||||
- name: build
|
||||
run: make build
|
||||
- name: test
|
||||
run: make test
|
||||
# Build the dind image and run the daemon-facing tests against the docker version it
|
||||
# ships, catching daemon-level regressions (e.g. gitea/runner#981) before release. Runs
|
||||
# after `make test` so the images it needs are already present on the host daemon.
|
||||
- name: test against dind image
|
||||
run: make test-dind
|
||||
run: make test
|
||||
8
Makefile
8
Makefile
@@ -140,12 +140,8 @@ tidy-check: tidy
|
||||
fi
|
||||
|
||||
.PHONY: test
|
||||
test: fmt-check security-check ## test everything (integration tests self-skip without docker/network)
|
||||
@$(GO) test -race -timeout 20m -v -cover -coverprofile coverage.txt ./... && echo "\n==>\033[32m Ok\033[m\n" || exit 1
|
||||
|
||||
.PHONY: test-dind
|
||||
test-dind: ## run the daemon-facing tests against the built dind image (TARGET=dind|dind-rootless)
|
||||
@./scripts/test-dind.sh $(TARGET)
|
||||
test: fmt-check security-check ## test everything
|
||||
@$(GO) test -race -short -v -cover -coverprofile coverage.txt ./... && echo "\n==>\033[32m Ok\033[m\n" || exit 1
|
||||
|
||||
.PHONY: install
|
||||
install: $(GOFILES) ## install the runner binary via `go install`
|
||||
|
||||
@@ -5,25 +5,24 @@
|
||||
package artifacts
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"maps"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
"time"
|
||||
|
||||
"gitea.com/gitea/runner/act/model"
|
||||
"gitea.com/gitea/runner/act/runner"
|
||||
|
||||
"github.com/julienschmidt/httprouter"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type writableMapFile struct {
|
||||
@@ -235,133 +234,89 @@ func TestDownloadArtifactFile(t *testing.T) {
|
||||
assert.Equal("content", string(data))
|
||||
}
|
||||
|
||||
// TestArtifactFlow drives the real Serve() artifact server over a loopback socket, exercising
|
||||
// the same upload -> finalize -> list -> download protocol the upload-artifact/download-artifact
|
||||
// actions speak. Running it in-process (rather than from a job container) keeps it network-free
|
||||
// and reachable everywhere, including when the CI job is itself a container.
|
||||
type TestJobFileInfo struct {
|
||||
workdir string
|
||||
workflowPath string
|
||||
eventName string
|
||||
errorMessage string
|
||||
platforms map[string]string
|
||||
containerArchitecture string
|
||||
}
|
||||
|
||||
var (
|
||||
artifactsPath = path.Join(os.TempDir(), "test-artifacts")
|
||||
artifactsAddr = "127.0.0.1"
|
||||
artifactsPort = "12345"
|
||||
)
|
||||
|
||||
func TestArtifactFlow(t *testing.T) {
|
||||
artifactPath := t.TempDir()
|
||||
|
||||
// Serve the exact routes Serve() wires up, on a real loopback socket via httptest. httptest
|
||||
// picks a free port and Close() tears the server down synchronously — avoiding both the
|
||||
// port-rebind race and Serve()'s detached ListenAndServe goroutine, which logger.Fatal()s
|
||||
// (process exit) on a bind error and can outlive the test's temp-dir cleanup.
|
||||
router := httprouter.New()
|
||||
fsys := readWriteFSImpl{}
|
||||
uploads(router, artifactPath, fsys)
|
||||
downloads(router, artifactPath, fsys)
|
||||
server := httptest.NewServer(router)
|
||||
defer server.Close()
|
||||
|
||||
baseURL := server.URL
|
||||
client := server.Client()
|
||||
client.Timeout = 5 * time.Second
|
||||
|
||||
// request performs one HTTP call and returns the status and body. The default transport adds
|
||||
// Accept-Encoding: gzip and transparently decompresses, so gzipped downloads come back plain.
|
||||
request := func(t *testing.T, method, rawURL string, body io.Reader, header http.Header) (int, []byte) {
|
||||
t.Helper()
|
||||
req, err := http.NewRequest(method, rawURL, body)
|
||||
require.NoError(t, err)
|
||||
maps.Copy(req.Header, header)
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
require.NoError(t, err)
|
||||
return resp.StatusCode, data
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test")
|
||||
}
|
||||
|
||||
t.Run("upload-and-download", func(t *testing.T) {
|
||||
const runID, item, content = "1", "my-artifact/data.txt", "hello artifact\n"
|
||||
ctx := context.Background()
|
||||
|
||||
status, data := request(t, http.MethodPost, baseURL+"/_apis/pipelines/workflows/"+runID+"/artifacts", nil, nil)
|
||||
require.Equal(t, http.StatusOK, status, string(data))
|
||||
var prep FileContainerResourceURL
|
||||
require.NoError(t, json.Unmarshal(data, &prep))
|
||||
require.Equal(t, baseURL+"/upload/"+runID, prep.FileContainerResourceURL)
|
||||
cancel := Serve(ctx, artifactsPath, artifactsAddr, artifactsPort)
|
||||
defer cancel()
|
||||
|
||||
status, data = request(t, http.MethodPut, prep.FileContainerResourceURL+"?itemPath="+url.QueryEscape(item), strings.NewReader(content), nil)
|
||||
require.Equal(t, http.StatusOK, status, string(data))
|
||||
var msg ResponseMessage
|
||||
require.NoError(t, json.Unmarshal(data, &msg))
|
||||
require.Equal(t, "success", msg.Message)
|
||||
platforms := map[string]string{
|
||||
"ubuntu-latest": "node: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.MethodPatch, baseURL+"/_apis/pipelines/workflows/"+runID+"/artifacts", nil, nil)
|
||||
require.Equal(t, http.StatusOK, status, string(data))
|
||||
tables := []TestJobFileInfo{
|
||||
{"testdata", "upload-and-download", "push", "", platforms, ""},
|
||||
{"testdata", "GHSL-2023-004", "push", "", platforms, ""},
|
||||
}
|
||||
log.SetLevel(log.DebugLevel)
|
||||
|
||||
status, data = request(t, http.MethodGet, baseURL+"/_apis/pipelines/workflows/"+runID+"/artifacts", nil, nil)
|
||||
require.Equal(t, http.StatusOK, status, string(data))
|
||||
var list NamedFileContainerResourceURLResponse
|
||||
require.NoError(t, json.Unmarshal(data, &list))
|
||||
require.Equal(t, 1, list.Count)
|
||||
require.Equal(t, "my-artifact", list.Value[0].Name)
|
||||
for _, table := range tables {
|
||||
runTestJobFile(ctx, t, table)
|
||||
}
|
||||
}
|
||||
|
||||
status, data = request(t, http.MethodGet, list.Value[0].FileContainerResourceURL+"?itemPath=my-artifact", nil, nil)
|
||||
require.Equal(t, http.StatusOK, status, string(data))
|
||||
var items ContainerItemResponse
|
||||
require.NoError(t, json.Unmarshal(data, &items))
|
||||
require.Len(t, items.Value, 1)
|
||||
require.Equal(t, "file", items.Value[0].ItemType)
|
||||
require.Equal(t, "my-artifact/data.txt", items.Value[0].Path)
|
||||
func runTestJobFile(ctx context.Context, t *testing.T, tjfi TestJobFileInfo) {
|
||||
t.Run(tjfi.workflowPath, func(t *testing.T) {
|
||||
fmt.Printf("::group::%s\n", tjfi.workflowPath) //nolint:forbidigo // pre-existing issue from nektos/act
|
||||
|
||||
status, data = request(t, http.MethodGet, items.Value[0].ContentLocation, nil, nil)
|
||||
require.Equal(t, http.StatusOK, status)
|
||||
require.Equal(t, content, string(data))
|
||||
if err := os.RemoveAll(artifactsPath); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
stored, err := os.ReadFile(filepath.Join(artifactPath, runID, "my-artifact", "data.txt"))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, content, string(stored))
|
||||
})
|
||||
workdir, err := filepath.Abs(tjfi.workdir)
|
||||
assert.NoError(t, err, workdir) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
fullWorkflowPath := filepath.Join(workdir, tjfi.workflowPath)
|
||||
runnerConfig := &runner.Config{
|
||||
Workdir: workdir,
|
||||
BindWorkdir: false,
|
||||
EventName: tjfi.eventName,
|
||||
Platforms: tjfi.platforms,
|
||||
ReuseContainers: false,
|
||||
ContainerArchitecture: tjfi.containerArchitecture,
|
||||
GitHubInstance: "github.com",
|
||||
ArtifactServerPath: artifactsPath,
|
||||
ArtifactServerAddr: artifactsAddr,
|
||||
ArtifactServerPort: artifactsPort,
|
||||
}
|
||||
|
||||
t.Run("gzip-roundtrip", func(t *testing.T) {
|
||||
const runID, item, content = "2", "logs/app.log", "compressed payload\n"
|
||||
runner, err := runner.New(runnerConfig)
|
||||
assert.NoError(t, err, tjfi.workflowPath) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
|
||||
var buf bytes.Buffer
|
||||
gz := gzip.NewWriter(&buf)
|
||||
_, err := gz.Write([]byte(content))
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, gz.Close())
|
||||
planner, err := model.NewWorkflowPlanner(fullWorkflowPath, true)
|
||||
assert.NoError(t, err, fullWorkflowPath) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
|
||||
status, data := request(t, http.MethodPut, baseURL+"/upload/"+runID+"?itemPath="+url.QueryEscape(item),
|
||||
&buf, http.Header{"Content-Encoding": []string{"gzip"}})
|
||||
require.Equal(t, http.StatusOK, status, string(data))
|
||||
plan, err := planner.PlanEvent(tjfi.eventName)
|
||||
if err == nil {
|
||||
err = runner.NewPlanExecutor(plan)(ctx)
|
||||
if tjfi.errorMessage == "" {
|
||||
assert.NoError(t, err, fullWorkflowPath) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
} else {
|
||||
assert.Error(t, err, tjfi.errorMessage) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
}
|
||||
} else {
|
||||
assert.Nil(t, plan)
|
||||
}
|
||||
|
||||
// stored compressed, with the server's gzip marker suffix
|
||||
_, err = os.Stat(filepath.Join(artifactPath, runID, "logs", "app.log.gz__"))
|
||||
require.NoError(t, err)
|
||||
|
||||
status, data = request(t, http.MethodGet, baseURL+"/download/"+runID+"?itemPath=logs", nil, nil)
|
||||
require.Equal(t, http.StatusOK, status, string(data))
|
||||
var items ContainerItemResponse
|
||||
require.NoError(t, json.Unmarshal(data, &items))
|
||||
require.Len(t, items.Value, 1)
|
||||
require.Equal(t, "logs/app.log", items.Value[0].Path)
|
||||
|
||||
status, data = request(t, http.MethodGet, items.Value[0].ContentLocation, nil, nil)
|
||||
require.Equal(t, http.StatusOK, status)
|
||||
require.Equal(t, content, string(data))
|
||||
})
|
||||
|
||||
// GHSL-2023-004: an itemPath that climbs out of the run directory must be neutralised so the
|
||||
// blob cannot be written outside the artifact root.
|
||||
t.Run("GHSL-2023-004", func(t *testing.T) {
|
||||
const runID, content = "3", "contained\n"
|
||||
|
||||
status, data := request(t, http.MethodPut, baseURL+"/upload/"+runID+"?itemPath="+url.QueryEscape("../../escape.txt"),
|
||||
strings.NewReader(content), nil)
|
||||
require.Equal(t, http.StatusOK, status, string(data))
|
||||
|
||||
stored, err := os.ReadFile(filepath.Join(artifactPath, runID, "escape.txt"))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, content, string(stored))
|
||||
|
||||
_, err = os.Stat(filepath.Join(filepath.Dir(artifactPath), "escape.txt"))
|
||||
require.True(t, os.IsNotExist(err), "upload escaped the artifact root")
|
||||
|
||||
status, data = request(t, http.MethodGet, baseURL+"/artifact/"+runID+"/escape.txt", nil, nil)
|
||||
require.Equal(t, http.StatusOK, status)
|
||||
require.Equal(t, content, string(data))
|
||||
fmt.Println("::endgroup::") //nolint:forbidigo // pre-existing issue from nektos/act
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
39
act/artifacts/testdata/GHSL-2023-004/artifacts.yml
vendored
Normal file
39
act/artifacts/testdata/GHSL-2023-004/artifacts.yml
vendored
Normal file
@@ -0,0 +1,39 @@
|
||||
|
||||
name: "GHSL-2023-0004"
|
||||
on: push
|
||||
|
||||
jobs:
|
||||
test-artifacts:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo "hello world" > test.txt
|
||||
- name: curl upload
|
||||
run: curl --silent --show-error --fail ${ACTIONS_RUNTIME_URL}upload/1?itemPath=../../my-artifact/secret.txt --upload-file test.txt
|
||||
- uses: actions/download-artifact@v2
|
||||
with:
|
||||
name: my-artifact
|
||||
path: test-artifacts
|
||||
- name: 'Verify Artifact #1'
|
||||
run: |
|
||||
file="test-artifacts/secret.txt"
|
||||
if [ ! -f $file ] ; then
|
||||
echo "Expected file does not exist"
|
||||
exit 1
|
||||
fi
|
||||
if [ "$(cat $file)" != "hello world" ] ; then
|
||||
echo "File contents of downloaded artifact are incorrect"
|
||||
exit 1
|
||||
fi
|
||||
- name: Verify download should work by clean extra dots
|
||||
run: curl --silent --show-error --fail --path-as-is -o out.txt ${ACTIONS_RUNTIME_URL}artifact/1/../../../1/my-artifact/secret.txt
|
||||
- name: 'Verify download content'
|
||||
run: |
|
||||
file="out.txt"
|
||||
if [ ! -f $file ] ; then
|
||||
echo "Expected file does not exist"
|
||||
exit 1
|
||||
fi
|
||||
if [ "$(cat $file)" != "hello world" ] ; then
|
||||
echo "File contents of downloaded artifact are incorrect"
|
||||
exit 1
|
||||
fi
|
||||
230
act/artifacts/testdata/upload-and-download/artifacts.yml
vendored
Normal file
230
act/artifacts/testdata/upload-and-download/artifacts.yml
vendored
Normal file
@@ -0,0 +1,230 @@
|
||||
|
||||
name: "Test that artifact uploads and downloads succeed"
|
||||
on: push
|
||||
|
||||
jobs:
|
||||
test-artifacts:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: mkdir -p path/to/artifact
|
||||
- run: echo hello > path/to/artifact/world.txt
|
||||
- uses: actions/upload-artifact@v2
|
||||
with:
|
||||
name: my-artifact
|
||||
path: path/to/artifact/world.txt
|
||||
|
||||
- run: rm -rf path
|
||||
|
||||
- uses: actions/download-artifact@v2
|
||||
with:
|
||||
name: my-artifact
|
||||
- name: Display structure of downloaded files
|
||||
run: ls -la
|
||||
|
||||
# Test end-to-end by uploading two artifacts and then downloading them
|
||||
- name: Create artifact files
|
||||
run: |
|
||||
mkdir -p path/to/dir-1
|
||||
mkdir -p path/to/dir-2
|
||||
mkdir -p path/to/dir-3
|
||||
mkdir -p path/to/dir-5
|
||||
mkdir -p path/to/dir-6
|
||||
mkdir -p path/to/dir-7
|
||||
echo "Lorem ipsum dolor sit amet" > path/to/dir-1/file1.txt
|
||||
echo "Hello world from file #2" > path/to/dir-2/file2.txt
|
||||
echo "This is a going to be a test for a large enough file that should get compressed with GZip. The @actions/artifact package uses GZip to upload files. This text should have a compression ratio greater than 100% so it should get uploaded using GZip" > path/to/dir-3/gzip.txt
|
||||
dd if=/dev/random of=path/to/dir-5/file5.rnd bs=1024 count=1024
|
||||
dd if=/dev/random of=path/to/dir-6/file6.rnd bs=1024 count=$((10*1024))
|
||||
dd if=/dev/random of=path/to/dir-7/file7.rnd bs=1024 count=$((10*1024))
|
||||
|
||||
# Upload a single file artifact
|
||||
- name: 'Upload artifact #1'
|
||||
uses: actions/upload-artifact@v2
|
||||
with:
|
||||
name: 'Artifact-A'
|
||||
path: path/to/dir-1/file1.txt
|
||||
|
||||
# Upload using a wildcard pattern, name should default to 'artifact' if not provided
|
||||
- name: 'Upload artifact #2'
|
||||
uses: actions/upload-artifact@v2
|
||||
with:
|
||||
path: path/**/dir*/
|
||||
|
||||
# Upload a directory that contains a file that will be uploaded with GZip
|
||||
- name: 'Upload artifact #3'
|
||||
uses: actions/upload-artifact@v2
|
||||
with:
|
||||
name: 'GZip-Artifact'
|
||||
path: path/to/dir-3/
|
||||
|
||||
# Upload a directory that contains a file that will be uploaded with GZip
|
||||
- name: 'Upload artifact #4'
|
||||
uses: actions/upload-artifact@v2
|
||||
with:
|
||||
name: 'Multi-Path-Artifact'
|
||||
path: |
|
||||
path/to/dir-1/*
|
||||
path/to/dir-[23]/*
|
||||
!path/to/dir-3/*.txt
|
||||
|
||||
# Upload a mid-size file artifact
|
||||
- name: 'Upload artifact #5'
|
||||
uses: actions/upload-artifact@v2
|
||||
with:
|
||||
name: 'Mid-Size-Artifact'
|
||||
path: path/to/dir-5/file5.rnd
|
||||
|
||||
# Upload a big file artifact
|
||||
- name: 'Upload artifact #6'
|
||||
uses: actions/upload-artifact@v2
|
||||
with:
|
||||
name: 'Big-Artifact'
|
||||
path: path/to/dir-6/file6.rnd
|
||||
|
||||
# Upload a big file artifact twice
|
||||
- name: 'Upload artifact #7 (First)'
|
||||
uses: actions/upload-artifact@v2
|
||||
with:
|
||||
name: 'Big-Uploaded-Twice'
|
||||
path: path/to/dir-7/file7.rnd
|
||||
|
||||
# Upload a big file artifact twice
|
||||
- name: 'Upload artifact #7 (Second)'
|
||||
uses: actions/upload-artifact@v2
|
||||
with:
|
||||
name: 'Big-Uploaded-Twice'
|
||||
path: path/to/dir-7/file7.rnd
|
||||
|
||||
# Verify artifacts. Switch to download-artifact@v2 once it's out of preview
|
||||
|
||||
# Download Artifact #1 and verify the correctness of the content
|
||||
- name: 'Download artifact #1'
|
||||
uses: actions/download-artifact@v2
|
||||
with:
|
||||
name: 'Artifact-A'
|
||||
path: some/new/path
|
||||
|
||||
- name: 'Verify Artifact #1'
|
||||
run: |
|
||||
file="some/new/path/file1.txt"
|
||||
if [ ! -f $file ] ; then
|
||||
echo "Expected file does not exist"
|
||||
exit 1
|
||||
fi
|
||||
if [ "$(cat $file)" != "Lorem ipsum dolor sit amet" ] ; then
|
||||
echo "File contents of downloaded artifact are incorrect"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Download Artifact #2 and verify the correctness of the content
|
||||
- name: 'Download artifact #2'
|
||||
uses: actions/download-artifact@v2
|
||||
with:
|
||||
name: 'artifact'
|
||||
path: some/other/path
|
||||
|
||||
- name: 'Verify Artifact #2'
|
||||
run: |
|
||||
file1="some/other/path/to/dir-1/file1.txt"
|
||||
file2="some/other/path/to/dir-2/file2.txt"
|
||||
if [ ! -f $file1 -o ! -f $file2 ] ; then
|
||||
echo "Expected files do not exist"
|
||||
exit 1
|
||||
fi
|
||||
if [ "$(cat $file1)" != "Lorem ipsum dolor sit amet" -o "$(cat $file2)" != "Hello world from file #2" ] ; then
|
||||
echo "File contents of downloaded artifacts are incorrect"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Download Artifact #3 and verify the correctness of the content
|
||||
- name: 'Download artifact #3'
|
||||
uses: actions/download-artifact@v2
|
||||
with:
|
||||
name: 'GZip-Artifact'
|
||||
path: gzip/artifact/path
|
||||
|
||||
# Because a directory was used as input during the upload the parent directories, path/to/dir-3/, should not be included in the uploaded artifact
|
||||
- name: 'Verify Artifact #3'
|
||||
run: |
|
||||
gzipFile="gzip/artifact/path/gzip.txt"
|
||||
if [ ! -f $gzipFile ] ; then
|
||||
echo "Expected file do not exist"
|
||||
exit 1
|
||||
fi
|
||||
if [ "$(cat $gzipFile)" != "This is a going to be a test for a large enough file that should get compressed with GZip. The @actions/artifact package uses GZip to upload files. This text should have a compression ratio greater than 100% so it should get uploaded using GZip" ] ; then
|
||||
echo "File contents of downloaded artifact is incorrect"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: 'Download artifact #4'
|
||||
uses: actions/download-artifact@v2
|
||||
with:
|
||||
name: 'Multi-Path-Artifact'
|
||||
path: multi/artifact
|
||||
|
||||
- name: 'Verify Artifact #4'
|
||||
run: |
|
||||
file1="multi/artifact/dir-1/file1.txt"
|
||||
file2="multi/artifact/dir-2/file2.txt"
|
||||
if [ ! -f $file1 -o ! -f $file2 ] ; then
|
||||
echo "Expected files do not exist"
|
||||
exit 1
|
||||
fi
|
||||
if [ "$(cat $file1)" != "Lorem ipsum dolor sit amet" -o "$(cat $file2)" != "Hello world from file #2" ] ; then
|
||||
echo "File contents of downloaded artifacts are incorrect"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: 'Download artifact #5'
|
||||
uses: actions/download-artifact@v2
|
||||
with:
|
||||
name: 'Mid-Size-Artifact'
|
||||
path: mid-size/artifact/path
|
||||
|
||||
- name: 'Verify Artifact #5'
|
||||
run: |
|
||||
file="mid-size/artifact/path/file5.rnd"
|
||||
if [ ! -f $file ] ; then
|
||||
echo "Expected file does not exist"
|
||||
exit 1
|
||||
fi
|
||||
if ! diff $file path/to/dir-5/file5.rnd ; then
|
||||
echo "File contents of downloaded artifact are incorrect"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: 'Download artifact #6'
|
||||
uses: actions/download-artifact@v2
|
||||
with:
|
||||
name: 'Big-Artifact'
|
||||
path: big/artifact/path
|
||||
|
||||
- name: 'Verify Artifact #6'
|
||||
run: |
|
||||
file="big/artifact/path/file6.rnd"
|
||||
if [ ! -f $file ] ; then
|
||||
echo "Expected file does not exist"
|
||||
exit 1
|
||||
fi
|
||||
if ! diff $file path/to/dir-6/file6.rnd ; then
|
||||
echo "File contents of downloaded artifact are incorrect"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: 'Download artifact #7'
|
||||
uses: actions/download-artifact@v2
|
||||
with:
|
||||
name: 'Big-Uploaded-Twice'
|
||||
path: big-uploaded-twice/artifact/path
|
||||
|
||||
- name: 'Verify Artifact #7'
|
||||
run: |
|
||||
file="big-uploaded-twice/artifact/path/file7.rnd"
|
||||
if [ ! -f $file ] ; then
|
||||
echo "Expected file does not exist"
|
||||
exit 1
|
||||
fi
|
||||
if ! diff $file path/to/dir-7/file7.rnd ; then
|
||||
echo "File contents of downloaded artifact are incorrect"
|
||||
exit 1
|
||||
fi
|
||||
@@ -170,6 +170,68 @@ func TestMaxParallelWithErrors(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
// TestMaxParallelPerformance tests performance characteristics
|
||||
func TestMaxParallelPerformance(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping performance test in short mode")
|
||||
}
|
||||
|
||||
t.Run("ParallelFasterThanSequential", func(t *testing.T) {
|
||||
executors := make([]Executor, 10)
|
||||
for i := range 10 {
|
||||
executors[i] = func(ctx context.Context) error {
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Sequential (max-parallel=1)
|
||||
start := time.Now()
|
||||
err := NewParallelExecutor(1, executors...)(ctx)
|
||||
sequentialDuration := time.Since(start)
|
||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
|
||||
// Parallel (max-parallel=5)
|
||||
start = time.Now()
|
||||
err = NewParallelExecutor(5, executors...)(ctx)
|
||||
parallelDuration := time.Since(start)
|
||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
|
||||
// Parallel should be significantly faster
|
||||
assert.Less(t, parallelDuration, sequentialDuration/2,
|
||||
"Parallel execution should be at least 2x faster")
|
||||
})
|
||||
|
||||
t.Run("OptimalWorkerCount", func(t *testing.T) {
|
||||
executors := make([]Executor, 20)
|
||||
for i := range 20 {
|
||||
executors[i] = func(ctx context.Context) error {
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Test with different worker counts
|
||||
workerCounts := []int{1, 2, 5, 10, 20}
|
||||
durations := make(map[int]time.Duration)
|
||||
|
||||
for _, count := range workerCounts {
|
||||
start := time.Now()
|
||||
err := NewParallelExecutor(count, executors...)(ctx)
|
||||
durations[count] = time.Since(start)
|
||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
}
|
||||
|
||||
// More workers should generally be faster (up to a point)
|
||||
assert.Less(t, durations[5], durations[1], "5 workers should be faster than 1")
|
||||
assert.Less(t, durations[10], durations[2], "10 workers should be faster than 2")
|
||||
})
|
||||
}
|
||||
|
||||
// TestMaxParallelResourceSharing tests resource sharing scenarios
|
||||
func TestMaxParallelResourceSharing(t *testing.T) {
|
||||
t.Run("SharedResourceWithMutex", func(t *testing.T) {
|
||||
|
||||
@@ -66,21 +66,8 @@ func (e *Error) Commit() string {
|
||||
return e.commit
|
||||
}
|
||||
|
||||
// goGitMu serializes go-git repository access across the process. go-git is not safe for
|
||||
// concurrent use of the same repository (even read access decodes packfiles into shared
|
||||
// state), so parallel jobs inspecting the shared workdir repo race without this. The guarded
|
||||
// operations are fast local reads; gitea runs one job per process, so the lock is effectively
|
||||
// uncontended in production.
|
||||
var goGitMu sync.Mutex
|
||||
|
||||
// FindGitRevision get the current git revision
|
||||
func FindGitRevision(ctx context.Context, file string) (shortSha, sha string, err error) {
|
||||
goGitMu.Lock()
|
||||
defer goGitMu.Unlock()
|
||||
return findGitRevision(ctx, file)
|
||||
}
|
||||
|
||||
func findGitRevision(ctx context.Context, file string) (shortSha, sha string, err error) {
|
||||
logger := common.Logger(ctx)
|
||||
|
||||
gitDir, err := git.PlainOpenWithOptions(
|
||||
@@ -112,13 +99,10 @@ func findGitRevision(ctx context.Context, file string) (shortSha, sha string, er
|
||||
|
||||
// FindGitRef get the current git ref
|
||||
func FindGitRef(ctx context.Context, file string) (string, error) {
|
||||
goGitMu.Lock()
|
||||
defer goGitMu.Unlock()
|
||||
|
||||
logger := common.Logger(ctx)
|
||||
|
||||
logger.Debugf("Loading revision from git directory")
|
||||
_, ref, err := findGitRevision(ctx, file)
|
||||
_, ref, err := FindGitRevision(ctx, file)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -190,8 +174,6 @@ func FindGitRef(ctx context.Context, file string) (string, error) {
|
||||
|
||||
// FindGithubRepo get the repo
|
||||
func FindGithubRepo(ctx context.Context, file, githubInstance, remoteName string) (string, error) {
|
||||
goGitMu.Lock()
|
||||
defer goGitMu.Unlock()
|
||||
if remoteName == "" {
|
||||
remoteName = "origin"
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
@@ -49,6 +50,10 @@ func TestFindGitSlug(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func testDir(t *testing.T) string {
|
||||
return t.TempDir()
|
||||
}
|
||||
|
||||
func cleanGitHooks(dir string) error {
|
||||
hooksDir := filepath.Join(dir, ".git", "hooks")
|
||||
files, err := os.ReadDir(hooksDir)
|
||||
@@ -73,7 +78,8 @@ func cleanGitHooks(dir string) error {
|
||||
func TestFindGitRemoteURL(t *testing.T) {
|
||||
assert := assert.New(t)
|
||||
|
||||
basedir := t.TempDir()
|
||||
basedir := testDir(t)
|
||||
gitConfig()
|
||||
err := gitCmd("init", basedir)
|
||||
assert.NoError(err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
err = cleanGitHooks(basedir)
|
||||
@@ -96,7 +102,8 @@ func TestFindGitRemoteURL(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestGitFindRef(t *testing.T) {
|
||||
basedir := t.TempDir()
|
||||
basedir := testDir(t)
|
||||
gitConfig()
|
||||
|
||||
for name, tt := range map[string]struct {
|
||||
Prepare func(t *testing.T, dir string)
|
||||
@@ -173,55 +180,36 @@ func TestGitFindRef(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestGitCloneExecutor(t *testing.T) {
|
||||
// Build a local bare "remote" so this runs offline and fast. The cases below mirror
|
||||
// the tag/branch/sha/short-sha ref paths the executor handles, formerly exercised by
|
||||
// cloning actions/checkout and anchore/scan-action over the network.
|
||||
remoteDir := t.TempDir()
|
||||
require.NoError(t, gitCmd("init", "--bare", "--initial-branch=main", remoteDir))
|
||||
|
||||
workDir := t.TempDir()
|
||||
require.NoError(t, gitCmd("clone", remoteDir, workDir))
|
||||
require.NoError(t, gitCmd("-C", workDir, "checkout", "-b", "main"))
|
||||
require.NoError(t, gitCmd("-C", workDir, "commit", "--allow-empty", "-m", "initial"))
|
||||
require.NoError(t, gitCmd("-C", workDir, "tag", "v2"))
|
||||
require.NoError(t, gitCmd("-C", workDir, "push", "-u", "origin", "main"))
|
||||
require.NoError(t, gitCmd("-C", workDir, "push", "origin", "v2"))
|
||||
|
||||
// A branch with a dash in the name (mirrors the historical scan-action@act-fails case).
|
||||
require.NoError(t, gitCmd("-C", workDir, "checkout", "-b", "act-fails"))
|
||||
require.NoError(t, gitCmd("-C", workDir, "commit", "--allow-empty", "-m", "branch-commit"))
|
||||
require.NoError(t, gitCmd("-C", workDir, "push", "origin", "act-fails"))
|
||||
|
||||
out, err := exec.Command("git", "-C", workDir, "rev-parse", "main").Output()
|
||||
require.NoError(t, err)
|
||||
fullSha := strings.TrimSpace(string(out))
|
||||
|
||||
for name, tt := range map[string]struct {
|
||||
Err error
|
||||
Ref string
|
||||
Err error
|
||||
URL, Ref string
|
||||
}{
|
||||
"tag": {
|
||||
Err: nil,
|
||||
URL: "https://github.com/actions/checkout",
|
||||
Ref: "v2",
|
||||
},
|
||||
"branch": {
|
||||
Err: nil,
|
||||
URL: "https://github.com/anchore/scan-action",
|
||||
Ref: "act-fails",
|
||||
},
|
||||
"sha": {
|
||||
Err: nil,
|
||||
Ref: fullSha,
|
||||
URL: "https://github.com/actions/checkout",
|
||||
Ref: "5a4ac9002d0be2fb38bd78e4b4dbde5606d7042f", // v2
|
||||
},
|
||||
"short-sha": {
|
||||
Err: &Error{ErrShortRef, fullSha},
|
||||
Ref: fullSha[:7],
|
||||
Err: &Error{ErrShortRef, "5a4ac9002d0be2fb38bd78e4b4dbde5606d7042f"},
|
||||
URL: "https://github.com/actions/checkout",
|
||||
Ref: "5a4ac90", // v2
|
||||
},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
clone := NewGitCloneExecutor(NewGitCloneExecutorInput{
|
||||
URL: remoteDir,
|
||||
URL: tt.URL,
|
||||
Ref: tt.Ref,
|
||||
Dir: t.TempDir(),
|
||||
Dir: testDir(t),
|
||||
})
|
||||
|
||||
err := clone(context.Background())
|
||||
@@ -240,6 +228,8 @@ func TestGitCloneExecutorNonFastForwardRef(t *testing.T) {
|
||||
// non-fast-forward between two fetches. Before the fix, the fetch used Force=false,
|
||||
// causing go-git to return ErrForceNeeded and short-circuit the checkout.
|
||||
|
||||
gitConfig()
|
||||
|
||||
// Create a bare "remote" repo with an initial commit on main and a feature branch.
|
||||
remoteDir := t.TempDir()
|
||||
require.NoError(t, gitCmd("init", "--bare", "--initial-branch=main", remoteDir))
|
||||
@@ -290,6 +280,8 @@ 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))
|
||||
@@ -335,21 +327,22 @@ 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,6 +13,7 @@ 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"
|
||||
)
|
||||
|
||||
@@ -25,6 +26,10 @@ 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 {
|
||||
@@ -50,6 +55,10 @@ 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,64 +6,66 @@ package container
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"io"
|
||||
"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)
|
||||
}
|
||||
|
||||
// buildScratchImage builds a tiny empty image for the given platform locally (FROM scratch, no
|
||||
// network or emulation since there is nothing to run) and returns its tag, removing it after
|
||||
// the test.
|
||||
func buildScratchImage(t *testing.T, platform string) string {
|
||||
t.Helper()
|
||||
tag := fmt.Sprintf("act-test-exists-%s:latest", strings.TrimPrefix(platform, "linux/"))
|
||||
cmd := exec.Command("docker", "build", "--platform", platform, "-t", tag, "-")
|
||||
cmd.Stdin = strings.NewReader("FROM scratch\nLABEL act-test=1\n")
|
||||
// Force BuildKit: it records the requested architecture in the image config for a
|
||||
// FROM-scratch build, whereas the classic builder ignores --platform and tags it with the
|
||||
// host arch, which would break the per-platform existence assertions below.
|
||||
cmd.Env = append(os.Environ(), "DOCKER_BUILDKIT=1")
|
||||
out, err := cmd.CombinedOutput()
|
||||
require.NoError(t, err, string(out))
|
||||
t.Cleanup(func() { _ = exec.Command("docker", "rmi", "-f", tag).Run() })
|
||||
return tag
|
||||
}
|
||||
|
||||
func TestImageExistsLocally(t *testing.T) {
|
||||
requireDocker(t)
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test")
|
||||
}
|
||||
ctx := context.Background()
|
||||
// to help make this test reliable and not flaky, we need to have
|
||||
// an image that will exist, and onew that won't exist
|
||||
|
||||
// a non-existent image is reported absent
|
||||
missing, err := ImageExistsLocally(ctx, "library/alpine:this-random-tag-will-never-exist", "linux/amd64")
|
||||
// Test if image exists with specific tag
|
||||
invalidImageTag, err := ImageExistsLocally(ctx, "library/alpine:this-random-tag-will-never-exist", "linux/amd64")
|
||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
assert.False(t, missing)
|
||||
assert.False(t, invalidImageTag)
|
||||
|
||||
// Build tiny images for two architectures locally so per-platform existence can be checked
|
||||
// offline (formerly pulled node:24-bookworm-slim for amd64 and arm64 over the network).
|
||||
amd64Ref := buildScratchImage(t, "linux/amd64")
|
||||
arm64Ref := buildScratchImage(t, "linux/arm64")
|
||||
|
||||
amd64Exists, err := ImageExistsLocally(ctx, amd64Ref, "linux/amd64")
|
||||
// Test if image exists with specific architecture (image platform)
|
||||
invalidImagePlatform, err := ImageExistsLocally(ctx, "alpine:latest", "windows/amd64")
|
||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
assert.True(t, amd64Exists)
|
||||
assert.False(t, invalidImagePlatform)
|
||||
|
||||
// a non-host architecture image is detected for its own architecture
|
||||
arm64Exists, err := ImageExistsLocally(ctx, arm64Ref, "linux/arm64")
|
||||
// pull an image
|
||||
cli, err := client.New(client.FromEnv)
|
||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
assert.True(t, arm64Exists)
|
||||
defer cli.Close()
|
||||
|
||||
// a present image is reported absent for a different platform
|
||||
wrongPlatform, err := ImageExistsLocally(ctx, amd64Ref, "linux/arm64")
|
||||
// Chose alpine latest because it's so small
|
||||
// maybe we should build an image instead so that tests aren't reliable on dockerhub
|
||||
readerDefault, err := cli.ImagePull(ctx, "node:24-bookworm-slim", client.ImagePullOptions{
|
||||
Platforms: []specs.Platform{{OS: "linux", Architecture: "amd64"}},
|
||||
})
|
||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
assert.False(t, wrongPlatform)
|
||||
defer readerDefault.Close()
|
||||
_, err = io.ReadAll(readerDefault)
|
||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
|
||||
imageDefaultArchExists, err := ImageExistsLocally(ctx, "node: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)
|
||||
}
|
||||
|
||||
@@ -8,12 +8,24 @@ package container
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"gitea.com/gitea/runner/act/common"
|
||||
|
||||
"github.com/moby/moby/client"
|
||||
)
|
||||
|
||||
var (
|
||||
dockerNetworkRemoveRetryInterval = 200 * time.Millisecond
|
||||
dockerNetworkRemoveTimeout = 10 * time.Second
|
||||
)
|
||||
|
||||
type dockerNetworkClient interface {
|
||||
NetworkList(ctx context.Context, options client.NetworkListOptions) (client.NetworkListResult, error)
|
||||
NetworkInspect(ctx context.Context, networkID string, options client.NetworkInspectOptions) (client.NetworkInspectResult, error)
|
||||
NetworkRemove(ctx context.Context, networkID string, options client.NetworkRemoveOptions) (client.NetworkRemoveResult, error)
|
||||
}
|
||||
|
||||
func NewDockerNetworkCreateExecutor(name string) common.Executor {
|
||||
return func(ctx context.Context) error {
|
||||
cli, err := GetDockerClient(ctx)
|
||||
@@ -56,31 +68,64 @@ func NewDockerNetworkRemoveExecutor(name string) common.Executor {
|
||||
}
|
||||
defer cli.Close()
|
||||
|
||||
// Make sure that all network of the specified name are removed
|
||||
// cli.NetworkRemove refuses to remove a network if there are duplicates
|
||||
networks, err := cli.NetworkList(ctx, client.NetworkListOptions{})
|
||||
return removeDockerNetworks(ctx, cli, name)
|
||||
}
|
||||
}
|
||||
|
||||
func removeDockerNetworks(ctx context.Context, cli dockerNetworkClient, name string) error {
|
||||
cleanupCtx, cancel := context.WithTimeout(ctx, dockerNetworkRemoveTimeout)
|
||||
defer cancel()
|
||||
|
||||
for {
|
||||
pendingRemoval, err := removeDockerNetworksOnce(cleanupCtx, cli, name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// For Gitea, reduce log noise
|
||||
// common.Logger(ctx).Debugf("%v", networks)
|
||||
for _, n := range networks.Items {
|
||||
if n.Name == name {
|
||||
result, err := cli.NetworkInspect(ctx, n.ID, client.NetworkInspectOptions{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(result.Network.Containers) == 0 {
|
||||
if _, err = cli.NetworkRemove(ctx, n.ID, client.NetworkRemoveOptions{}); err != nil {
|
||||
common.Logger(ctx).Debugf("%v", err)
|
||||
}
|
||||
} else {
|
||||
common.Logger(ctx).Debugf("Refusing to remove network %v because it still has active endpoints", name)
|
||||
}
|
||||
}
|
||||
if !pendingRemoval {
|
||||
return nil
|
||||
}
|
||||
|
||||
return err
|
||||
select {
|
||||
case <-cleanupCtx.Done():
|
||||
common.Logger(ctx).Warnf("Timed out waiting for Docker network %v endpoints to detach; leaving network behind", name)
|
||||
return nil
|
||||
case <-time.After(dockerNetworkRemoveRetryInterval):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func removeDockerNetworksOnce(ctx context.Context, cli dockerNetworkClient, name string) (bool, error) {
|
||||
// Make sure that all network of the specified name are removed.
|
||||
// cli.NetworkRemove refuses to remove a network if there are duplicates.
|
||||
networks, err := cli.NetworkList(ctx, client.NetworkListOptions{})
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
// For Gitea, reduce log noise
|
||||
// common.Logger(ctx).Debugf("%v", networks)
|
||||
|
||||
pendingRemoval := false
|
||||
for _, n := range networks.Items {
|
||||
if n.Name != name {
|
||||
continue
|
||||
}
|
||||
|
||||
result, err := cli.NetworkInspect(ctx, n.ID, client.NetworkInspectOptions{})
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
if len(result.Network.Containers) != 0 {
|
||||
pendingRemoval = true
|
||||
common.Logger(ctx).Debugf("Waiting to remove network %v because it still has active endpoints", name)
|
||||
continue
|
||||
}
|
||||
|
||||
if _, err = cli.NetworkRemove(ctx, n.ID, client.NetworkRemoveOptions{}); err != nil {
|
||||
pendingRemoval = true
|
||||
common.Logger(ctx).Debugf("Retrying Docker network removal for %v: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
return pendingRemoval, nil
|
||||
}
|
||||
|
||||
115
act/container/docker_network_test.go
Normal file
115
act/container/docker_network_test.go
Normal file
@@ -0,0 +1,115 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// Copyright 2026 The nektos/act Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build !(WITHOUT_DOCKER || !(linux || darwin || windows || netbsd))
|
||||
|
||||
package container
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
containernetwork "github.com/moby/moby/api/types/network"
|
||||
"github.com/moby/moby/client"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type fakeDockerNetworkClient struct {
|
||||
listResult client.NetworkListResult
|
||||
inspectByID map[string][]client.NetworkInspectResult
|
||||
inspectCalls map[string]int
|
||||
removeCalls []string
|
||||
removeErrs map[string][]error
|
||||
removeIdx map[string]int
|
||||
}
|
||||
|
||||
func (f *fakeDockerNetworkClient) NetworkList(context.Context, client.NetworkListOptions) (client.NetworkListResult, error) {
|
||||
return f.listResult, nil
|
||||
}
|
||||
|
||||
func (f *fakeDockerNetworkClient) NetworkInspect(_ context.Context, networkID string, _ client.NetworkInspectOptions) (client.NetworkInspectResult, error) {
|
||||
idx := f.inspectCalls[networkID]
|
||||
f.inspectCalls[networkID] = idx + 1
|
||||
results := f.inspectByID[networkID]
|
||||
if len(results) == 0 {
|
||||
return client.NetworkInspectResult{}, nil
|
||||
}
|
||||
if idx >= len(results) {
|
||||
return results[len(results)-1], nil
|
||||
}
|
||||
return results[idx], nil
|
||||
}
|
||||
|
||||
func (f *fakeDockerNetworkClient) NetworkRemove(_ context.Context, networkID string, _ client.NetworkRemoveOptions) (client.NetworkRemoveResult, error) {
|
||||
f.removeCalls = append(f.removeCalls, networkID)
|
||||
idx := f.removeIdx[networkID]
|
||||
f.removeIdx[networkID] = idx + 1
|
||||
if errs := f.removeErrs[networkID]; idx < len(errs) {
|
||||
return client.NetworkRemoveResult{}, errs[idx]
|
||||
}
|
||||
return client.NetworkRemoveResult{}, nil
|
||||
}
|
||||
|
||||
func TestRemoveDockerNetworksRetriesUntilEndpointsDetach(t *testing.T) {
|
||||
originalInterval := dockerNetworkRemoveRetryInterval
|
||||
originalTimeout := dockerNetworkRemoveTimeout
|
||||
dockerNetworkRemoveRetryInterval = time.Millisecond
|
||||
dockerNetworkRemoveTimeout = 50 * time.Millisecond
|
||||
t.Cleanup(func() {
|
||||
dockerNetworkRemoveRetryInterval = originalInterval
|
||||
dockerNetworkRemoveTimeout = originalTimeout
|
||||
})
|
||||
|
||||
cli := &fakeDockerNetworkClient{
|
||||
listResult: client.NetworkListResult{
|
||||
Items: []containernetwork.Summary{{Network: containernetwork.Network{ID: "n1", Name: "test"}}},
|
||||
},
|
||||
inspectByID: map[string][]client.NetworkInspectResult{
|
||||
"n1": {
|
||||
{Network: containernetwork.Inspect{Containers: map[string]containernetwork.EndpointResource{"c1": {}}}},
|
||||
{Network: containernetwork.Inspect{Containers: map[string]containernetwork.EndpointResource{}}},
|
||||
},
|
||||
},
|
||||
inspectCalls: map[string]int{},
|
||||
removeErrs: map[string][]error{},
|
||||
removeIdx: map[string]int{},
|
||||
}
|
||||
|
||||
err := removeDockerNetworks(context.Background(), cli, "test")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []string{"n1"}, cli.removeCalls)
|
||||
assert.GreaterOrEqual(t, cli.inspectCalls["n1"], 2)
|
||||
}
|
||||
|
||||
func TestRemoveDockerNetworksStopsRetryingAfterTimeout(t *testing.T) {
|
||||
originalInterval := dockerNetworkRemoveRetryInterval
|
||||
originalTimeout := dockerNetworkRemoveTimeout
|
||||
dockerNetworkRemoveRetryInterval = time.Millisecond
|
||||
dockerNetworkRemoveTimeout = 5 * time.Millisecond
|
||||
t.Cleanup(func() {
|
||||
dockerNetworkRemoveRetryInterval = originalInterval
|
||||
dockerNetworkRemoveTimeout = originalTimeout
|
||||
})
|
||||
|
||||
cli := &fakeDockerNetworkClient{
|
||||
listResult: client.NetworkListResult{
|
||||
Items: []containernetwork.Summary{{Network: containernetwork.Network{ID: "n1", Name: "test"}}},
|
||||
},
|
||||
inspectByID: map[string][]client.NetworkInspectResult{
|
||||
"n1": {
|
||||
{Network: containernetwork.Inspect{Containers: map[string]containernetwork.EndpointResource{"c1": {}}}},
|
||||
},
|
||||
},
|
||||
inspectCalls: map[string]int{},
|
||||
removeErrs: map[string][]error{},
|
||||
removeIdx: map[string]int{},
|
||||
}
|
||||
|
||||
err := removeDockerNetworks(context.Background(), cli, "test")
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, cli.removeCalls)
|
||||
assert.Positive(t, cli.inspectCalls["n1"])
|
||||
}
|
||||
@@ -40,9 +40,6 @@ func TestCleanImage(t *testing.T) {
|
||||
func TestGetImagePullOptions(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
orig := config.Dir()
|
||||
t.Cleanup(func() { config.SetDir(orig) })
|
||||
|
||||
config.SetDir("/non-existent/docker")
|
||||
|
||||
options, err := getImagePullOptions(ctx, NewDockerPullExecutorInput{})
|
||||
|
||||
@@ -26,7 +26,6 @@ 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"
|
||||
@@ -153,8 +152,6 @@ 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
|
||||
@@ -170,16 +167,6 @@ 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
|
||||
@@ -327,22 +314,10 @@ func (cr *containerReference) Close() common.Executor {
|
||||
}
|
||||
}
|
||||
|
||||
// missingContainerError is the shared "container X does not exist" error
|
||||
// used by ops that need a live cr.id.
|
||||
func (cr *containerReference) missingContainerError(format string, args ...any) error {
|
||||
return fmt.Errorf("container %q does not exist; cannot "+format, append([]any{cr.input.Name}, args...)...)
|
||||
}
|
||||
|
||||
func (cr *containerReference) find() common.Executor {
|
||||
return func(ctx context.Context) error {
|
||||
if cr.id != "" {
|
||||
// Validate cached id; clear only on definitive NotFound so a
|
||||
// transient daemon error doesn't abort cleanup pipelines.
|
||||
_, err := cr.cli.ContainerInspect(ctx, cr.id, client.ContainerInspectOptions{})
|
||||
if !cerrdefs.IsNotFound(err) {
|
||||
return nil
|
||||
}
|
||||
cr.id = ""
|
||||
return nil
|
||||
}
|
||||
containers, err := cr.cli.ContainerList(ctx, client.ContainerListOptions{
|
||||
All: true,
|
||||
@@ -360,6 +335,7 @@ func (cr *containerReference) find() common.Executor {
|
||||
}
|
||||
}
|
||||
|
||||
cr.id = ""
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -616,9 +592,6 @@ func (cr *containerReference) extractFromImageEnv(env *map[string]string) common
|
||||
|
||||
func (cr *containerReference) exec(cmd []string, env map[string]string, user, workdir string) common.Executor {
|
||||
return func(ctx context.Context) error {
|
||||
if cr.id == "" {
|
||||
return cr.missingContainerError("exec %v", cmd)
|
||||
}
|
||||
logger := common.Logger(ctx)
|
||||
// Fix slashes when running on Windows
|
||||
if runtime.GOOS == "windows" {
|
||||
@@ -773,9 +746,6 @@ func (cr *containerReference) waitForCommand(ctx context.Context, isTerminal boo
|
||||
}
|
||||
|
||||
func (cr *containerReference) CopyTarStream(ctx context.Context, destPath string, tarStream io.Reader) error {
|
||||
if cr.id == "" {
|
||||
return cr.missingContainerError("copy to %s", destPath)
|
||||
}
|
||||
// Mkdir
|
||||
buf := &bytes.Buffer{}
|
||||
tw := tar.NewWriter(buf)
|
||||
@@ -809,9 +779,6 @@ func (cr *containerReference) CopyTarStream(ctx context.Context, destPath string
|
||||
|
||||
func (cr *containerReference) copyDir(dstPath, srcPath string, useGitIgnore bool) common.Executor {
|
||||
return func(ctx context.Context) error {
|
||||
if cr.id == "" {
|
||||
return cr.missingContainerError("copy directory to %s", dstPath)
|
||||
}
|
||||
logger := common.Logger(ctx)
|
||||
tarFile, err := os.CreateTemp("", "act")
|
||||
if err != nil {
|
||||
@@ -886,9 +853,6 @@ func (cr *containerReference) copyDir(dstPath, srcPath string, useGitIgnore bool
|
||||
|
||||
func (cr *containerReference) copyContent(dstPath string, files ...*FileEntry) common.Executor {
|
||||
return func(ctx context.Context) error {
|
||||
if cr.id == "" {
|
||||
return cr.missingContainerError("copy to %s", dstPath)
|
||||
}
|
||||
logger := common.Logger(ctx)
|
||||
var buf bytes.Buffer
|
||||
tw := tar.NewWriter(&buf)
|
||||
|
||||
@@ -19,7 +19,6 @@ 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"
|
||||
@@ -29,10 +28,14 @@ import (
|
||||
)
|
||||
|
||||
func TestDocker(t *testing.T) {
|
||||
requireDocker(t)
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test")
|
||||
}
|
||||
ctx := context.Background()
|
||||
client, err := GetDockerClient(ctx)
|
||||
require.NoError(t, err)
|
||||
if err != nil {
|
||||
t.Skipf("skipping integration test: %v", err)
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
dockerBuild := NewDockerBuildExecutor(NewDockerBuildExecutorInput{
|
||||
@@ -99,16 +102,6 @@ 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
|
||||
}
|
||||
@@ -309,134 +302,6 @@ 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,19 +18,9 @@ func init() {
|
||||
|
||||
var originalCommonSocketLocations = CommonSocketLocations
|
||||
|
||||
func isolateSocketEnv(t *testing.T) {
|
||||
t.Helper()
|
||||
t.Cleanup(func() { CommonSocketLocations = originalCommonSocketLocations })
|
||||
if host, ok := os.LookupEnv("DOCKER_HOST"); ok {
|
||||
t.Setenv("DOCKER_HOST", host)
|
||||
} else {
|
||||
t.Cleanup(func() { os.Unsetenv("DOCKER_HOST") })
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetSocketAndHostWithSocket(t *testing.T) {
|
||||
// Arrange
|
||||
isolateSocketEnv(t)
|
||||
CommonSocketLocations = originalCommonSocketLocations
|
||||
dockerHost := "unix:///my/docker/host.sock"
|
||||
socketURI := "/path/to/my.socket"
|
||||
t.Setenv("DOCKER_HOST", dockerHost)
|
||||
@@ -58,9 +48,9 @@ func TestGetSocketAndHostNoSocket(t *testing.T) {
|
||||
|
||||
func TestGetSocketAndHostOnlySocket(t *testing.T) {
|
||||
// Arrange
|
||||
isolateSocketEnv(t)
|
||||
socketURI := "/path/to/my.socket"
|
||||
os.Unsetenv("DOCKER_HOST")
|
||||
CommonSocketLocations = originalCommonSocketLocations
|
||||
defaultSocket, defaultSocketFound := socketLocation()
|
||||
|
||||
// Act
|
||||
@@ -75,7 +65,7 @@ func TestGetSocketAndHostOnlySocket(t *testing.T) {
|
||||
|
||||
func TestGetSocketAndHostDontMount(t *testing.T) {
|
||||
// Arrange
|
||||
isolateSocketEnv(t)
|
||||
CommonSocketLocations = originalCommonSocketLocations
|
||||
dockerHost := "unix:///my/docker/host.sock"
|
||||
t.Setenv("DOCKER_HOST", dockerHost)
|
||||
|
||||
@@ -89,7 +79,7 @@ func TestGetSocketAndHostDontMount(t *testing.T) {
|
||||
|
||||
func TestGetSocketAndHostNoHostNoSocket(t *testing.T) {
|
||||
// Arrange
|
||||
isolateSocketEnv(t)
|
||||
CommonSocketLocations = originalCommonSocketLocations
|
||||
os.Unsetenv("DOCKER_HOST")
|
||||
defaultSocket, found := socketLocation()
|
||||
|
||||
@@ -107,7 +97,6 @@ func TestGetSocketAndHostNoHostNoSocket(t *testing.T) {
|
||||
// > This happens if neither DOCKER_HOST nor --container-daemon-socket has a value, but socketLocation() returns a URI
|
||||
func TestGetSocketAndHostNoHostNoSocketDefaultLocation(t *testing.T) {
|
||||
// Arrange
|
||||
isolateSocketEnv(t)
|
||||
mySocketFile, tmpErr := os.CreateTemp(t.TempDir(), "act-*.sock")
|
||||
mySocket := mySocketFile.Name()
|
||||
unixSocket := "unix://" + mySocket
|
||||
@@ -130,7 +119,6 @@ func TestGetSocketAndHostNoHostNoSocketDefaultLocation(t *testing.T) {
|
||||
|
||||
func TestGetSocketAndHostNoHostInvalidSocket(t *testing.T) {
|
||||
// Arrange
|
||||
isolateSocketEnv(t)
|
||||
os.Unsetenv("DOCKER_HOST")
|
||||
mySocket := "/my/socket/path.sock"
|
||||
CommonSocketLocations = []string{"/unusual", "/socket", "/location"}
|
||||
@@ -148,7 +136,6 @@ func TestGetSocketAndHostNoHostInvalidSocket(t *testing.T) {
|
||||
|
||||
func TestGetSocketAndHostOnlySocketValidButUnusualLocation(t *testing.T) {
|
||||
// Arrange
|
||||
isolateSocketEnv(t)
|
||||
socketURI := "unix:///path/to/my.socket"
|
||||
CommonSocketLocations = []string{"/unusual", "/location"}
|
||||
os.Unsetenv("DOCKER_HOST")
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package container
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
mobyclient "github.com/moby/moby/client"
|
||||
)
|
||||
|
||||
// requireDocker skips the test unless a reachable docker daemon is available.
|
||||
// GetDockerClient succeeds even without a running daemon (its ping is best-effort),
|
||||
// so the daemon has to be pinged explicitly here to decide whether to skip.
|
||||
func requireDocker(t *testing.T) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
cli, err := GetDockerClient(ctx)
|
||||
if err != nil {
|
||||
t.Skipf("skipping: docker client unavailable: %v", err)
|
||||
}
|
||||
defer cli.Close()
|
||||
if _, err := cli.Ping(ctx, mobyclient.PingOptions{}); err != nil {
|
||||
t.Skipf("skipping: docker daemon unreachable: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,9 @@ import (
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
@@ -42,6 +44,9 @@ 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 {
|
||||
@@ -322,30 +327,6 @@ func (e *HostEnvironment) exec(ctx context.Context, command []string, cmdline st
|
||||
cmd.Stderr = e.StdOut
|
||||
cmd.Dir = wd
|
||||
cmd.SysProcAttr = getSysProcAttr(cmdline, false)
|
||||
|
||||
// On Windows a step often launches a process tree (a shell that starts a
|
||||
// child which spawns further GUI or background processes). The default
|
||||
// context cancellation only kills the direct child, leaving the rest of the
|
||||
// tree running; and because the orphans inherit cmd's stdout/stderr pipe,
|
||||
// cmd.Wait() would block forever, hanging the runner. Kill the whole tree
|
||||
// via a Job Object on cancellation, and bound the wait so a leftover pipe
|
||||
// writer can never hang Wait indefinitely.
|
||||
var killer atomic.Pointer[processKiller]
|
||||
if runtime.GOOS == "windows" {
|
||||
cmd.Cancel = func() error {
|
||||
if k := killer.Load(); k != nil {
|
||||
return k.Kill()
|
||||
}
|
||||
if cmd.Process != nil {
|
||||
return cmd.Process.Kill()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
// Once the step process has exited, give its I/O pipes at most this long
|
||||
// to drain before Wait force-closes them and returns (Go's WaitDelay).
|
||||
cmd.WaitDelay = 10 * time.Second
|
||||
}
|
||||
|
||||
var ppty *os.File
|
||||
var tty *os.File
|
||||
defer func() {
|
||||
@@ -372,20 +353,23 @@ 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 runtime.GOOS == "windows" {
|
||||
// Assign the started process to a Job Object so cmd.Cancel can kill the
|
||||
// whole descendant tree. Children spawned afterwards are auto-included.
|
||||
// On failure (e.g. nested-job restrictions) we fall back to the default
|
||||
// single-process kill; WaitDelay + end-of-job cleanup still apply.
|
||||
if k, kerr := newProcessKiller(cmd.Process); kerr != nil {
|
||||
common.Logger(ctx).Warnf("process tree kill setup failed, falling back to single-process kill: %v", kerr)
|
||||
} else {
|
||||
killer.Store(k)
|
||||
defer k.Close()
|
||||
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 {
|
||||
@@ -456,80 +440,30 @@ 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
|
||||
}
|
||||
|
||||
// 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)
|
||||
e.mu.Lock()
|
||||
pids := make([]int, 0, len(e.runningPIDs))
|
||||
for pid := range e.runningPIDs {
|
||||
pids = append(pids, pid)
|
||||
}
|
||||
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 {
|
||||
e.mu.Unlock()
|
||||
|
||||
if len(pids) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
script := buildWindowsWorkspaceKillScript(dirs)
|
||||
|
||||
cmd := exec.CommandContext(killCtx, "powershell.exe", "-NoProfile", "-NonInteractive", "-Command", script)
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
logger.Debugf("workspace process-tree kill via PowerShell failed: %v output=%s", err, strings.TrimSpace(string(out)))
|
||||
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)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -543,20 +477,14 @@ 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(rmCtx, e.Path); err != nil {
|
||||
if err := removePathWithRetry(ctx, e.Path); err != nil {
|
||||
logger.Warnf("failed to remove host misc state %s: %v", e.Path, err)
|
||||
errs = append(errs, err)
|
||||
}
|
||||
if e.CleanWorkdir {
|
||||
if err := removePathWithRetry(rmCtx, e.Workdir); err != nil {
|
||||
if err := removePathWithRetry(ctx, e.Workdir); err != nil {
|
||||
logger.Warnf("failed to remove host workspace %s: %v", e.Workdir, err)
|
||||
errs = append(errs, err)
|
||||
}
|
||||
|
||||
@@ -187,64 +187,3 @@ 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")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build !windows
|
||||
|
||||
package container
|
||||
|
||||
import "os"
|
||||
|
||||
// processKiller is a no-op on non-Windows platforms. The Job Object based
|
||||
// tree-kill is only wired in on Windows (see exec()); elsewhere the default
|
||||
// exec.CommandContext cancellation and Setpgid handling apply.
|
||||
type processKiller struct{}
|
||||
|
||||
func newProcessKiller(_ *os.Process) (*processKiller, error) { return &processKiller{}, nil }
|
||||
|
||||
func (k *processKiller) Kill() error { return nil }
|
||||
|
||||
func (k *processKiller) Close() error { return nil }
|
||||
@@ -1,71 +0,0 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package container
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// processKiller terminates a step process together with its entire descendant
|
||||
// tree via a Windows Job Object.
|
||||
//
|
||||
// Background: a step often launches a process tree (a shell that starts a
|
||||
// child which in turn spawns further GUI or background processes). The default
|
||||
// exec.CommandContext cancellation only kills the direct child, so cancelling a
|
||||
// job left the rest of the tree running. Because those orphans inherited the
|
||||
// step's stdout/stderr pipe, cmd.Wait() also blocked forever and the runner hung.
|
||||
//
|
||||
// Assigning the step process to a Job Object lets us kill the whole tree
|
||||
// atomically on cancellation (TerminateJobObject), which also closes the
|
||||
// inherited pipe handles so cmd.Wait() can return.
|
||||
type processKiller struct {
|
||||
job windows.Handle
|
||||
}
|
||||
|
||||
// newProcessKiller creates a Job Object and assigns p (an already-started
|
||||
// process) to it. Children spawned by p afterwards are automatically part of
|
||||
// the job. The job does NOT use JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, so closing
|
||||
// the handle on normal completion does not kill legitimate background
|
||||
// processes; the tree is only torn down by an explicit Kill (cancellation).
|
||||
func newProcessKiller(p *os.Process) (*processKiller, error) {
|
||||
job, err := windows.CreateJobObject(nil, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
h, err := windows.OpenProcess(windows.PROCESS_SET_QUOTA|windows.PROCESS_TERMINATE, false, uint32(p.Pid))
|
||||
if err != nil {
|
||||
windows.CloseHandle(job)
|
||||
return nil, err
|
||||
}
|
||||
defer windows.CloseHandle(h)
|
||||
|
||||
if err := windows.AssignProcessToJobObject(job, h); err != nil {
|
||||
windows.CloseHandle(job)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &processKiller{job: job}, nil
|
||||
}
|
||||
|
||||
// Kill terminates every process currently assigned to the job (the step process
|
||||
// and all of its descendants).
|
||||
func (k *processKiller) Kill() error {
|
||||
if k == nil || k.job == 0 {
|
||||
return nil
|
||||
}
|
||||
return windows.TerminateJobObject(k.job, 1)
|
||||
}
|
||||
|
||||
// Close releases the job handle. It does not terminate the processes.
|
||||
func (k *processKiller) Close() error {
|
||||
if k == nil || k.job == 0 {
|
||||
return nil
|
||||
}
|
||||
h := k.job
|
||||
k.job = 0
|
||||
return windows.CloseHandle(h)
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package container
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// processAlive reports whether pid refers to a still-running process.
|
||||
func processAlive(pid int) bool {
|
||||
h, err := windows.OpenProcess(windows.PROCESS_QUERY_LIMITED_INFORMATION, false, uint32(pid))
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer windows.CloseHandle(h)
|
||||
var code uint32
|
||||
if err := windows.GetExitCodeProcess(h, &code); err != nil {
|
||||
return false
|
||||
}
|
||||
const stillActive = 259 // STILL_ACTIVE
|
||||
return code == stillActive
|
||||
}
|
||||
|
||||
// TestProcessKillerKillsTree verifies that a process assigned to the Job Object
|
||||
// is terminated together with a child it spawns afterwards. This mirrors a step
|
||||
// that launches a child which spawns further processes, where cancelling the
|
||||
// job must take down the whole tree, not just the direct child.
|
||||
func TestProcessKillerKillsTree(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
pidFile := filepath.Join(dir, "child.pid")
|
||||
|
||||
// Parent powershell spawns a detached, long-lived child powershell (writing
|
||||
// its PID to a file) and then sleeps. The child is launched AFTER the parent
|
||||
// has been assigned to the job, so it must be captured by the job too.
|
||||
script := fmt.Sprintf(
|
||||
`$c = Start-Process powershell -PassThru -ArgumentList '-NoProfile','-Command','Start-Sleep -Seconds 600'; `+
|
||||
`Set-Content -LiteralPath %q -Value $c.Id; Start-Sleep -Seconds 600`, pidFile)
|
||||
cmd := exec.Command("powershell.exe", "-NoProfile", "-Command", script)
|
||||
require.NoError(t, cmd.Start())
|
||||
t.Cleanup(func() { _ = cmd.Process.Kill() })
|
||||
|
||||
killer, err := newProcessKiller(cmd.Process)
|
||||
require.NoError(t, err)
|
||||
defer killer.Close()
|
||||
|
||||
// Wait for the child PID to be reported.
|
||||
var childPID int
|
||||
require.Eventually(t, func() bool {
|
||||
b, e := os.ReadFile(pidFile)
|
||||
if e != nil {
|
||||
return false
|
||||
}
|
||||
s := strings.TrimSpace(string(b))
|
||||
if s == "" {
|
||||
return false
|
||||
}
|
||||
childPID, _ = strconv.Atoi(s)
|
||||
return childPID > 0 && processAlive(childPID)
|
||||
}, 20*time.Second, 200*time.Millisecond, "child process should start")
|
||||
|
||||
// Killing the job must terminate both the parent and the detached child.
|
||||
require.NoError(t, killer.Kill())
|
||||
|
||||
require.Eventually(t, func() bool {
|
||||
return !processAlive(cmd.Process.Pid) && !processAlive(childPID)
|
||||
}, 20*time.Second, 200*time.Millisecond, "parent and child should both be terminated")
|
||||
}
|
||||
@@ -325,20 +325,14 @@ func (j *Job) Needs() []string {
|
||||
|
||||
// RunsOn list for Job
|
||||
func (j *Job) RunsOn() []string {
|
||||
return RunsOnFromNode(j.RawRunsOn)
|
||||
}
|
||||
|
||||
// RunsOnFromNode parses the runs-on labels from a raw runs-on node, so callers can evaluate a
|
||||
// copy of the node (avoiding mutation of the shared Job) before reading the labels.
|
||||
func RunsOnFromNode(rawRunsOn yaml.Node) []string {
|
||||
switch rawRunsOn.Kind {
|
||||
switch j.RawRunsOn.Kind {
|
||||
case yaml.MappingNode:
|
||||
var val struct {
|
||||
Group string
|
||||
Labels yaml.Node
|
||||
}
|
||||
|
||||
if !decodeNode(rawRunsOn, &val) {
|
||||
if !decodeNode(j.RawRunsOn, &val) {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -350,7 +344,7 @@ func RunsOnFromNode(rawRunsOn yaml.Node) []string {
|
||||
|
||||
return labels
|
||||
default:
|
||||
return nodeAsStringSlice(rawRunsOn)
|
||||
return nodeAsStringSlice(j.RawRunsOn)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -651,33 +645,6 @@ type Step struct {
|
||||
TimeoutMinutes string `yaml:"timeout-minutes"`
|
||||
}
|
||||
|
||||
// Clone returns a deep copy safe to mutate independently of s. Job steps are shared across
|
||||
// parallel matrix runs, which mutate per-job fields (ID, Number, Shell) and evaluate the If/Env
|
||||
// yaml.Nodes in place, so each job must own its copy.
|
||||
func (s *Step) Clone() *Step {
|
||||
clone := *s
|
||||
clone.If = CloneYamlNode(s.If)
|
||||
clone.Env = CloneYamlNode(s.Env)
|
||||
clone.With = maps.Clone(s.With)
|
||||
return &clone
|
||||
}
|
||||
|
||||
// CloneYamlNode returns a deep copy of a yaml.Node so callers can evaluate it in place without
|
||||
// mutating a node shared across parallel jobs.
|
||||
func CloneYamlNode(n yaml.Node) yaml.Node {
|
||||
clone := n
|
||||
if n.Content != nil {
|
||||
clone.Content = make([]*yaml.Node, len(n.Content))
|
||||
for i, child := range n.Content {
|
||||
if child != nil {
|
||||
childClone := CloneYamlNode(*child)
|
||||
clone.Content[i] = &childClone
|
||||
}
|
||||
}
|
||||
}
|
||||
return clone
|
||||
}
|
||||
|
||||
// String gets the name of step
|
||||
func (s *Step) String() string {
|
||||
if s.Name != "" {
|
||||
|
||||
@@ -9,29 +9,9 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.yaml.in/yaml/v4"
|
||||
)
|
||||
|
||||
// TestStepCloneIsolatesMutableFields guards the parallel-matrix race fix: combinations share the
|
||||
// job's *Step, and Clone() must hand each a copy whose If/Env nodes and With map can be mutated
|
||||
// independently. A shallow copy would share Env.Content's backing array (and the With map) and
|
||||
// leak writes across combinations.
|
||||
func TestStepCloneIsolatesMutableFields(t *testing.T) {
|
||||
var orig Step
|
||||
require.NoError(t, yaml.Unmarshal([]byte("if: ${{ env.X == 'a' }}\nenv:\n KEY: original\nwith:\n arg: original\n"), &orig))
|
||||
require.Len(t, orig.Env.Content, 2) // [key, value]
|
||||
|
||||
clone := orig.Clone()
|
||||
clone.If.Value = "changed"
|
||||
clone.Env.Content[1].Value = "changed"
|
||||
clone.With["arg"] = "changed"
|
||||
|
||||
assert.Equal(t, "${{ env.X == 'a' }}", orig.If.Value, "If must not be shared with the clone")
|
||||
assert.Equal(t, "original", orig.Env.Content[1].Value, "Env nodes must not be shared with the clone")
|
||||
assert.Equal(t, "original", orig.With["arg"], "With map must not be shared with the clone")
|
||||
}
|
||||
|
||||
func TestReadWorkflow_ScheduleEvent(t *testing.T) {
|
||||
yaml := `
|
||||
name: local-action-docker-url
|
||||
|
||||
@@ -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.validVolumes(),
|
||||
ValidVolumes: rc.Config.ValidVolumes,
|
||||
AllocatePTY: rc.Config.AllocatePTY,
|
||||
})
|
||||
return stepContainer
|
||||
|
||||
@@ -8,139 +8,64 @@ import (
|
||||
"archive/tar"
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.com/gitea/runner/act/common"
|
||||
"gitea.com/gitea/runner/act/model"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func runGit(t *testing.T, dir string, args ...string) {
|
||||
t.Helper()
|
||||
if dir != "" {
|
||||
args = append([]string{"-C", dir}, args...)
|
||||
}
|
||||
cmd := exec.Command("git", args...)
|
||||
// Fixed identity and host-config isolation so commits succeed offline regardless of the
|
||||
// host's git config (mirrors gitCmd in act/common/git).
|
||||
cmd.Env = append(os.Environ(),
|
||||
"GIT_AUTHOR_NAME=test", "GIT_AUTHOR_EMAIL=test@example.com",
|
||||
"GIT_COMMITTER_NAME=test", "GIT_COMMITTER_EMAIL=test@example.com",
|
||||
"GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null",
|
||||
)
|
||||
out, err := cmd.CombinedOutput()
|
||||
require.NoError(t, err, string(out))
|
||||
}
|
||||
|
||||
// TestShortShaActionRejected verifies a `uses` ref that is a shortened commit SHA is rejected
|
||||
// with a clear error. The action is resolved from a local repo (via DefaultActionInstance) so
|
||||
// this runs offline.
|
||||
func TestShortShaActionRejected(t *testing.T) {
|
||||
// a local "remote" action repo at <root>/actions/hello-world-docker-action
|
||||
actionRoot := t.TempDir()
|
||||
repo := filepath.Join(actionRoot, "actions", "hello-world-docker-action")
|
||||
require.NoError(t, os.MkdirAll(repo, 0o755))
|
||||
runGit(t, "", "init", "--initial-branch=main", repo)
|
||||
require.NoError(t, os.WriteFile(filepath.Join(repo, "action.yml"),
|
||||
[]byte("name: hello\nruns:\n using: node24\n main: index.js\n"), 0o644))
|
||||
runGit(t, repo, "add", ".")
|
||||
runGit(t, repo, "commit", "-m", "initial")
|
||||
out, err := exec.Command("git", "-C", repo, "rev-parse", "HEAD").Output()
|
||||
require.NoError(t, err)
|
||||
shortSha := strings.TrimSpace(string(out))[:7]
|
||||
|
||||
// a workflow that uses the action at the short SHA
|
||||
wfDir := filepath.Join(t.TempDir(), "wf")
|
||||
require.NoError(t, os.MkdirAll(wfDir, 0o755))
|
||||
wf := fmt.Sprintf("on: push\njobs:\n test:\n runs-on: ubuntu-latest\n steps:\n - uses: actions/hello-world-docker-action@%s\n", shortSha)
|
||||
require.NoError(t, os.WriteFile(filepath.Join(wfDir, "push.yml"), []byte(wf), 0o644))
|
||||
|
||||
runner, err := New(&Config{
|
||||
Workdir: wfDir,
|
||||
EventName: "push",
|
||||
Platforms: map[string]string{"ubuntu-latest": baseImage},
|
||||
GitHubInstance: "github.com",
|
||||
DefaultActionInstance: actionRoot,
|
||||
ContainerMaxLifetime: time.Hour,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
planner, err := model.NewWorkflowPlanner(wfDir, true)
|
||||
require.NoError(t, err)
|
||||
plan, err := planner.PlanEvent("push")
|
||||
require.NoError(t, err)
|
||||
|
||||
err = runner.NewPlanExecutor(plan)(common.WithDryrun(context.Background(), true))
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "shortened version of a commit SHA")
|
||||
}
|
||||
|
||||
func TestActionCache(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test")
|
||||
}
|
||||
a := assert.New(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// Build a local bare repo with a `js` action dir so this runs offline (formerly cloned
|
||||
// github.com/nektos/act-test-actions over the network). allowAnySHA1InWant lets the
|
||||
// "Fetch Sha" case fetch a commit hash directly.
|
||||
remoteDir := t.TempDir()
|
||||
runGit(t, "", "init", "--bare", "--initial-branch=main", remoteDir)
|
||||
runGit(t, remoteDir, "config", "uploadpack.allowAnySHA1InWant", "true")
|
||||
|
||||
workDir := t.TempDir()
|
||||
runGit(t, "", "clone", remoteDir, workDir)
|
||||
require.NoError(t, os.MkdirAll(filepath.Join(workDir, "js"), 0o755))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(workDir, "js", "action.yml"),
|
||||
[]byte("name: js\nruns:\n using: node24\n main: index.js\n"), 0o644))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(workDir, "js", "index.js"),
|
||||
[]byte("console.log('hello');\n"), 0o644))
|
||||
runGit(t, workDir, "add", ".")
|
||||
runGit(t, workDir, "commit", "-m", "initial")
|
||||
runGit(t, workDir, "push", "-u", "origin", "main")
|
||||
|
||||
out, err := exec.Command("git", "-C", workDir, "rev-parse", "main").Output()
|
||||
require.NoError(t, err)
|
||||
fullSha := strings.TrimSpace(string(out))
|
||||
|
||||
cache := &GoGitActionCache{
|
||||
Path: t.TempDir(),
|
||||
}
|
||||
cacheDir := "local/act-test-actions"
|
||||
ctx := context.Background()
|
||||
cacheDir := "nektos/act-test-actions"
|
||||
repo := "https://github.com/nektos/act-test-actions"
|
||||
refs := []struct {
|
||||
Name string
|
||||
Ref string
|
||||
Name string
|
||||
CacheDir string
|
||||
Repo string
|
||||
Ref string
|
||||
}{
|
||||
{Name: "Fetch Branch Name", Ref: "main"},
|
||||
{Name: "Fetch Branch Name Absolutely", Ref: "refs/heads/main"},
|
||||
{Name: "Fetch HEAD", Ref: "HEAD"},
|
||||
{Name: "Fetch Sha", Ref: fullSha},
|
||||
{
|
||||
Name: "Fetch Branch Name",
|
||||
CacheDir: cacheDir,
|
||||
Repo: repo,
|
||||
Ref: "main",
|
||||
},
|
||||
{
|
||||
Name: "Fetch Branch Name Absolutely",
|
||||
CacheDir: cacheDir,
|
||||
Repo: repo,
|
||||
Ref: "refs/heads/main",
|
||||
},
|
||||
{
|
||||
Name: "Fetch HEAD",
|
||||
CacheDir: cacheDir,
|
||||
Repo: repo,
|
||||
Ref: "HEAD",
|
||||
},
|
||||
{
|
||||
Name: "Fetch Sha",
|
||||
CacheDir: cacheDir,
|
||||
Repo: repo,
|
||||
Ref: "de984ca37e4df4cb9fd9256435a3b82c4a2662b1",
|
||||
},
|
||||
}
|
||||
for _, c := range refs {
|
||||
t.Run(c.Name, func(t *testing.T) {
|
||||
sha, err := cache.Fetch(ctx, cacheDir, remoteDir, c.Ref, "")
|
||||
sha, err := cache.Fetch(ctx, c.CacheDir, c.Repo, c.Ref, "")
|
||||
if !a.NoError(err) || !a.NotEmpty(sha) { //nolint:testifylint // pre-existing issue from nektos/act
|
||||
return
|
||||
}
|
||||
atar, err := cache.GetTarArchive(ctx, cacheDir, sha, "js")
|
||||
// NotNil, not NotEmpty: atar is a live io.PipeReader whose producer goroutine is
|
||||
// writing concurrently; NotEmpty deep-reflects over its internals and races.
|
||||
if !a.NoError(err) || !a.NotNil(atar) { //nolint:testifylint // pre-existing issue from nektos/act
|
||||
atar, err := cache.GetTarArchive(ctx, c.CacheDir, sha, "js")
|
||||
if !a.NoError(err) || !a.NotEmpty(atar) { //nolint:testifylint // pre-existing issue from nektos/act
|
||||
return
|
||||
}
|
||||
// GetTarArchive streams from a background goroutine walking the shared repo.
|
||||
// Drain and close so it finishes before the next subtest fetches into the same
|
||||
// repo; otherwise the lingering walk races with that fetch.
|
||||
defer func() {
|
||||
_, _ = io.Copy(io.Discard, atar)
|
||||
_ = atar.Close()
|
||||
}()
|
||||
mytar := tar.NewReader(atar)
|
||||
th, err := mytar.Next()
|
||||
if !a.NoError(err) || !a.NotEqual(0, th.Size) { //nolint:testifylint // pre-existing issue from nektos/act
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
// Interpolate into a new map. secrets may be the shared Config.Secrets (or the job's
|
||||
// map), which other parallel jobs read concurrently (e.g. log masking), so mutating it
|
||||
// in place is a data race.
|
||||
interpolated := make(map[string]string, len(secrets))
|
||||
for k, v := range secrets {
|
||||
interpolated[k] = rc.caller.runContext.ExprEval.Interpolate(ctx, v)
|
||||
if secrets == nil {
|
||||
secrets = map[string]string{}
|
||||
}
|
||||
|
||||
return interpolated
|
||||
for k, v := range secrets {
|
||||
secrets[k] = rc.caller.runContext.ExprEval.Interpolate(ctx, v)
|
||||
}
|
||||
|
||||
return secrets
|
||||
}
|
||||
|
||||
return rc.Config.Secrets
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package runner
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.com/gitea/runner/act/container"
|
||||
|
||||
mobyclient "github.com/moby/moby/client"
|
||||
)
|
||||
|
||||
// requireLinuxDocker skips on non-Linux hosts. Some integration workflows need Docker features
|
||||
// that only a Linux daemon provides (host networking, host /proc bind mounts); Docker Desktop
|
||||
// on macOS/Windows does not, so those tests can only run on Linux.
|
||||
func requireLinuxDocker(t *testing.T) {
|
||||
t.Helper()
|
||||
if runtime.GOOS != "linux" {
|
||||
t.Skip("skipping: requires a Linux Docker host")
|
||||
}
|
||||
}
|
||||
|
||||
// requireDocker skips the test unless a reachable docker daemon is available.
|
||||
// GetDockerClient succeeds even without a running daemon (its ping is best-effort),
|
||||
// so the daemon has to be pinged explicitly here to decide whether to skip.
|
||||
func requireDocker(t *testing.T) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
cli, err := container.GetDockerClient(ctx)
|
||||
if err != nil {
|
||||
t.Skipf("skipping: docker client unavailable: %v", err)
|
||||
}
|
||||
defer cli.Close()
|
||||
if _, err := cli.Ping(ctx, mobyclient.PingOptions{}); err != nil {
|
||||
t.Skipf("skipping: docker daemon unreachable: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// requireNetwork skips the test unless github.com is reachable. A few tests exercise behaviour
|
||||
// that inherently needs the network (force-pulling an image, resolving a remote short-sha ref);
|
||||
// gating lets the rest of the suite run offline without these failing.
|
||||
func requireNetwork(t *testing.T) {
|
||||
t.Helper()
|
||||
conn, err := net.DialTimeout("tcp", "github.com:443", 3*time.Second)
|
||||
if err != nil {
|
||||
t.Skipf("skipping: network unavailable: %v", err)
|
||||
}
|
||||
_ = conn.Close()
|
||||
}
|
||||
|
||||
// requireHostTools skips the test unless every named executable is on PATH. Used by the
|
||||
// self-hosted (host environment) suite, which runs steps directly on the host.
|
||||
func requireHostTools(t *testing.T, tools ...string) {
|
||||
t.Helper()
|
||||
for _, tool := range tools {
|
||||
if _, err := exec.LookPath(tool); err != nil {
|
||||
t.Skipf("skipping: required host tool %q not found: %v", tool, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,7 @@ func newJobExecutor(info jobInfo, sf stepFactory, rc *RunContext) common.Executo
|
||||
steps := make([]common.Executor, 0)
|
||||
preSteps := make([]common.Executor, 0)
|
||||
var postExecutor common.Executor
|
||||
var startErr error
|
||||
|
||||
steps = append(steps, func(ctx context.Context) error {
|
||||
logger := common.Logger(ctx)
|
||||
@@ -165,7 +166,12 @@ func newJobExecutor(info jobInfo, sf stepFactory, rc *RunContext) common.Executo
|
||||
pipeline = append(pipeline, preSteps...)
|
||||
pipeline = append(pipeline, steps...)
|
||||
|
||||
return common.NewPipelineExecutor(info.startContainer(), common.NewPipelineExecutor(pipeline...).
|
||||
startContainer := func(ctx context.Context) error {
|
||||
startErr = info.startContainer()(ctx)
|
||||
return startErr
|
||||
}
|
||||
|
||||
return common.NewPipelineExecutor(startContainer, common.NewPipelineExecutor(pipeline...).
|
||||
Finally(func(ctx context.Context) error {
|
||||
var cancel context.CancelFunc
|
||||
if ctx.Err() == context.Canceled {
|
||||
@@ -176,32 +182,40 @@ func newJobExecutor(info jobInfo, sf stepFactory, rc *RunContext) common.Executo
|
||||
}
|
||||
return postExecutor(ctx)
|
||||
}).
|
||||
Finally(info.interpolateOutputs()).
|
||||
Finally(info.closeContainer()))
|
||||
Finally(info.interpolateOutputs())).
|
||||
Finally(func(ctx context.Context) error {
|
||||
if startErr == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
cleanupCtx, cancel := context.WithTimeout(common.WithLogger(context.Background(), common.Logger(ctx)), time.Minute)
|
||||
defer cancel()
|
||||
|
||||
logger := common.Logger(cleanupCtx)
|
||||
logger.Infof("Cleaning up container for failed startup of job %s", rc.JobName)
|
||||
if err := info.stopContainer()(cleanupCtx); err != nil {
|
||||
logger.Errorf("Error while cleaning up failed job startup: %v", err)
|
||||
}
|
||||
return nil
|
||||
}).
|
||||
Finally(info.closeContainer())
|
||||
}
|
||||
|
||||
func setJobResult(ctx context.Context, info jobInfo, rc *RunContext, success bool) {
|
||||
logger := common.Logger(ctx)
|
||||
|
||||
// Matrix combinations share one *model.Job and run in parallel; serialize the
|
||||
// read-modify-write of the job result so a failing combination is not lost-updated by a
|
||||
// concurrent succeeding one.
|
||||
job := rc.Run.Job()
|
||||
jobResult := func() string {
|
||||
defer lockJob(job)()
|
||||
result := "success"
|
||||
// we have only one result for a whole matrix build, so we need
|
||||
// to keep an existing result state if we run a matrix
|
||||
if len(info.matrix()) > 0 && job.Result != "" {
|
||||
result = job.Result
|
||||
}
|
||||
if !success {
|
||||
result = "failure"
|
||||
}
|
||||
info.result(result)
|
||||
return result
|
||||
}()
|
||||
jobResult := "success"
|
||||
// we have only one result for a whole matrix build, so we need
|
||||
// to keep an existing result state if we run a matrix
|
||||
if len(info.matrix()) > 0 && rc.Run.Job().Result != "" {
|
||||
jobResult = rc.Run.Job().Result
|
||||
}
|
||||
|
||||
if !success {
|
||||
jobResult = "failure"
|
||||
}
|
||||
|
||||
info.result(jobResult)
|
||||
if rc.caller != nil {
|
||||
// set reusable workflow job result
|
||||
rc.caller.setReusedWorkflowJobResult(rc.JobName, jobResult) // For Gitea
|
||||
@@ -227,11 +241,7 @@ func setJobOutputs(ctx context.Context, rc *RunContext) {
|
||||
callerOutputs[k] = ee.Interpolate(ctx, ee.Interpolate(ctx, v.Value))
|
||||
}
|
||||
|
||||
// Matrix combinations of a reusable-workflow caller share the caller's *model.Job;
|
||||
// serialize the write so parallel combos don't race on its Outputs field.
|
||||
callerJob := rc.caller.runContext.Run.Job()
|
||||
defer lockJob(callerJob)()
|
||||
callerJob.Outputs = callerOutputs
|
||||
rc.caller.runContext.Run.Job().Outputs = callerOutputs
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -18,16 +18,22 @@ import (
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/mock"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestJobExecutor(t *testing.T) {
|
||||
// Dryrun only checks syntax/planning; all cases resolve locally, so this runs offline.
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test")
|
||||
}
|
||||
tables := []TestJobFileInfo{
|
||||
{workdir, "uses-and-run-in-one-step", "push", "Invalid run/uses syntax for job:test step:Test", platforms, secrets},
|
||||
{workdir, "uses-github-empty", "push", "Expected format {org}/{repo}[/path]@ref", platforms, secrets},
|
||||
{workdir, "uses-github-noref", "push", "Expected format {org}/{repo}[/path]@ref", platforms, secrets},
|
||||
{workdir, "uses-github-root", "push", "", platforms, secrets},
|
||||
{workdir, "uses-github-path", "push", "", platforms, secrets},
|
||||
{workdir, "uses-docker-url", "push", "", platforms, secrets},
|
||||
{workdir, "uses-github-full-sha", "push", "", platforms, secrets},
|
||||
{workdir, "uses-github-short-sha", "push", "Unable to resolve action `actions/hello-world-docker-action@b136eb8`, the provided ref `b136eb8` is the shortened version of a commit SHA, which is not supported. Please use the full commit SHA `b136eb8894c5cb1dd5807da824be97ccdf9b5423` instead", platforms, secrets},
|
||||
{workdir, "job-nil-step", "push", "invalid Step 0: missing run or uses key", platforms, secrets},
|
||||
}
|
||||
// These tests are sufficient to only check syntax.
|
||||
@@ -336,3 +342,64 @@ func TestNewJobExecutor(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewJobExecutorCleansUpAfterStartContainerFailure(t *testing.T) {
|
||||
ctx := common.WithJobErrorContainer(context.Background())
|
||||
jim := &jobInfoMock{}
|
||||
sfm := &stepFactoryMock{}
|
||||
rc := &RunContext{
|
||||
JobName: "test",
|
||||
JobContainer: &jobContainerMock{},
|
||||
Run: &model.Run{
|
||||
JobID: "test",
|
||||
Workflow: &model.Workflow{
|
||||
Jobs: map[string]*model.Job{
|
||||
"test": {},
|
||||
},
|
||||
},
|
||||
},
|
||||
Config: &Config{},
|
||||
}
|
||||
rc.ExprEval = rc.NewExpressionEvaluator(ctx)
|
||||
|
||||
executorOrder := make([]string, 0)
|
||||
startErr := errors.New("failed to start container")
|
||||
stepModel := &model.Step{ID: "1"}
|
||||
sm := &stepMock{}
|
||||
|
||||
jim.On("steps").Return([]*model.Step{stepModel})
|
||||
jim.On("startContainer").Return(func(ctx context.Context) error {
|
||||
executorOrder = append(executorOrder, "startContainer")
|
||||
return startErr
|
||||
})
|
||||
jim.On("stopContainer").Return(func(ctx context.Context) error {
|
||||
executorOrder = append(executorOrder, "stopContainer")
|
||||
return nil
|
||||
})
|
||||
jim.On("closeContainer").Return(func(ctx context.Context) error {
|
||||
executorOrder = append(executorOrder, "closeContainer")
|
||||
return nil
|
||||
})
|
||||
jim.On("interpolateOutputs").Return(func(ctx context.Context) error {
|
||||
return nil
|
||||
})
|
||||
sfm.On("newStep", stepModel, rc).Return(sm, nil)
|
||||
sm.On("pre").Return(func(ctx context.Context) error {
|
||||
return nil
|
||||
})
|
||||
sm.On("main").Return(func(ctx context.Context) error {
|
||||
return nil
|
||||
})
|
||||
sm.On("post").Return(func(ctx context.Context) error {
|
||||
return nil
|
||||
})
|
||||
|
||||
executor := newJobExecutor(jim, sfm, rc)
|
||||
err := executor(ctx)
|
||||
require.ErrorIs(t, err, startErr)
|
||||
assert.Equal(t, []string{"startContainer", "stopContainer", "closeContainer"}, executorOrder)
|
||||
|
||||
jim.AssertExpectations(t)
|
||||
sfm.AssertExpectations(t)
|
||||
sm.AssertExpectations(t)
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
@@ -167,29 +166,9 @@ func withStepLogger(ctx context.Context, stepNumber int, stepID, stepName, stage
|
||||
|
||||
type entryProcessor func(entry *logrus.Entry) *logrus.Entry
|
||||
|
||||
func AppendSecretMasker(oldnew []string, v string) []string {
|
||||
ret := oldnew
|
||||
|
||||
for l := range strings.SplitSeq(v, "\n") {
|
||||
tm := strings.TrimSpace(l)
|
||||
// formatted JSON secrets could otherwise mask {,[,],} everywhere
|
||||
if len(tm) > 1 {
|
||||
ret = append(ret, tm, "***")
|
||||
}
|
||||
}
|
||||
|
||||
return ret
|
||||
}
|
||||
|
||||
// valueMasker applies secrets and ::add-mask:: patterns to every log entry, including
|
||||
// raw_output (command/stream) lines; there is no bypass by field.
|
||||
func valueMasker(insecureSecrets bool, secrets map[string]string) entryProcessor {
|
||||
var oldnew []string
|
||||
for _, v := range secrets {
|
||||
oldnew = AppendSecretMasker(oldnew, v)
|
||||
}
|
||||
oldnew = slices.Clip(oldnew)
|
||||
defReplacer := strings.NewReplacer(oldnew...)
|
||||
return func(entry *logrus.Entry) *logrus.Entry {
|
||||
if insecureSecrets {
|
||||
return entry
|
||||
@@ -197,16 +176,16 @@ func valueMasker(insecureSecrets bool, secrets map[string]string) entryProcessor
|
||||
|
||||
masks := Masks(entry.Context)
|
||||
|
||||
if len(*masks) == 0 {
|
||||
entry.Message = defReplacer.Replace(entry.Message)
|
||||
} else {
|
||||
cmasker := oldnew
|
||||
|
||||
for _, v := range *masks {
|
||||
cmasker = AppendSecretMasker(cmasker, v)
|
||||
for _, v := range secrets {
|
||||
if v != "" {
|
||||
entry.Message = strings.ReplaceAll(entry.Message, v, "***")
|
||||
}
|
||||
}
|
||||
|
||||
entry.Message = strings.NewReplacer(cmasker...).Replace(entry.Message)
|
||||
for _, v := range *masks {
|
||||
if v != "" {
|
||||
entry.Message = strings.ReplaceAll(entry.Message, v, "***")
|
||||
}
|
||||
}
|
||||
|
||||
return entry
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package runner
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestValueMasker(t *testing.T) {
|
||||
table := []struct {
|
||||
name string
|
||||
lines string
|
||||
secrets map[string]string
|
||||
masks []string
|
||||
disallowed []string
|
||||
}{
|
||||
{
|
||||
name: "Multiline Private Key",
|
||||
lines: "cat << EOF > private.key\nPRIVATE_KEY_BEGIN\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\nPRIVATE_KEY_END\nEOF",
|
||||
secrets: map[string]string{
|
||||
"PRIVATE_KEY": "PRIVATE_KEY_BEGIN\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\nPRIVATE_KEY_END",
|
||||
},
|
||||
disallowed: []string{"KEY", "dsdfseffefsefes", "PRIVATE_KEY_END"},
|
||||
},
|
||||
{
|
||||
name: "Multiline Private Key in masks",
|
||||
lines: "cat << EOF > private.key\nPRIVATE_KEY_BEGIN\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\nPRIVATE_KEY_END\nEOF",
|
||||
masks: []string{"PRIVATE_KEY_BEGIN\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\nPRIVATE_KEY_END"},
|
||||
disallowed: []string{"KEY", "dsdfseffefsefes", "PRIVATE_KEY_END"},
|
||||
},
|
||||
}
|
||||
for _, entry := range table {
|
||||
t.Run(entry.name, func(t *testing.T) {
|
||||
ctx := WithMasks(t.Context(), &entry.masks)
|
||||
masker := valueMasker(false, entry.secrets)
|
||||
for line := range strings.SplitSeq(entry.lines, "\n") {
|
||||
lentry := masker(&logrus.Entry{
|
||||
Context: ctx,
|
||||
Message: line,
|
||||
})
|
||||
for _, line := range entry.disallowed {
|
||||
assert.NotContains(t, lentry.Message, line)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,6 @@ import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
@@ -28,9 +27,7 @@ func newLocalReusableWorkflowExecutor(rc *RunContext) common.Executor {
|
||||
workflowDir = strings.TrimPrefix(workflowDir, "./")
|
||||
|
||||
return common.NewPipelineExecutor(
|
||||
// resolve the local workflow against the workspace root, not the process
|
||||
// working directory, so it is found regardless of where the runner is invoked
|
||||
newReusableWorkflowExecutor(rc, filepath.Join(rc.Config.Workdir, workflowDir), fileName),
|
||||
newReusableWorkflowExecutor(rc, workflowDir, fileName),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -287,11 +284,7 @@ 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,9 +20,7 @@ import (
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gitea.com/gitea/runner/act/common"
|
||||
@@ -57,10 +55,6 @@ type RunContext struct {
|
||||
Masks []string
|
||||
cleanUpJobContainer common.Executor
|
||||
caller *caller // job calling this RunContext (reusable workflows)
|
||||
// outputTemplate is this combination's pristine snapshot of the job's output expressions,
|
||||
// captured before execution so each matrix combo interpolates from the originals rather
|
||||
// than from a sibling's already-resolved values written into the shared Job.Outputs.
|
||||
outputTemplate map[string]string
|
||||
}
|
||||
|
||||
func (rc *RunContext) AddMask(mask string) {
|
||||
@@ -136,34 +130,17 @@ func getDockerDaemonSocketMountPath(daemonPath string) string {
|
||||
return daemonPath
|
||||
}
|
||||
|
||||
// containerDaemonSocket returns the configured Docker daemon socket, applying the default
|
||||
// without mutating the shared Config. Parallel jobs in a plan share one *Config, so a job
|
||||
// must never write to it.
|
||||
func (rc *RunContext) containerDaemonSocket() string {
|
||||
if rc.Config.ContainerDaemonSocket == "" {
|
||||
return "/var/run/docker.sock"
|
||||
}
|
||||
return rc.Config.ContainerDaemonSocket
|
||||
}
|
||||
|
||||
// validVolumes returns the volumes allowed on this job's containers: the configured base
|
||||
// plus the volumes the runner mounts automatically. It derives a fresh slice every call and
|
||||
// never mutates the shared Config (see containerDaemonSocket).
|
||||
func (rc *RunContext) validVolumes() []string {
|
||||
name := rc.jobContainerName()
|
||||
volumes := slices.Clone(rc.Config.ValidVolumes)
|
||||
// TODO: add a new configuration to control whether the docker daemon can be mounted
|
||||
return append(volumes, "act-toolcache", name, name+"-env",
|
||||
getDockerDaemonSocketMountPath(rc.containerDaemonSocket()))
|
||||
}
|
||||
|
||||
// Returns the binds and mounts for the container, resolving paths as appopriate
|
||||
func (rc *RunContext) GetBindsAndMounts() ([]string, map[string]string) {
|
||||
name := rc.jobContainerName()
|
||||
|
||||
if rc.Config.ContainerDaemonSocket == "" {
|
||||
rc.Config.ContainerDaemonSocket = "/var/run/docker.sock"
|
||||
}
|
||||
|
||||
binds := []string{}
|
||||
if daemonSocket := rc.containerDaemonSocket(); daemonSocket != "-" {
|
||||
daemonPath := getDockerDaemonSocketMountPath(daemonSocket)
|
||||
if rc.Config.ContainerDaemonSocket != "-" {
|
||||
daemonPath := getDockerDaemonSocketMountPath(rc.Config.ContainerDaemonSocket)
|
||||
binds = append(binds, fmt.Sprintf("%s:%s", daemonPath, "/var/run/docker.sock"))
|
||||
}
|
||||
|
||||
@@ -202,6 +179,14 @@ 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
|
||||
}
|
||||
|
||||
@@ -447,7 +432,7 @@ func (rc *RunContext) startJobContainer() common.Executor {
|
||||
Platform: rc.Config.ContainerArchitecture,
|
||||
Options: rc.options(ctx),
|
||||
AutoRemove: rc.Config.AutoRemove,
|
||||
ValidVolumes: rc.validVolumes(),
|
||||
ValidVolumes: rc.Config.ValidVolumes,
|
||||
AllocatePTY: rc.Config.AllocatePTY,
|
||||
})
|
||||
if rc.JobContainer == nil {
|
||||
@@ -601,29 +586,14 @@ func (rc *RunContext) ActionCacheDir() string {
|
||||
}
|
||||
|
||||
// Interpolate outputs after a job is done
|
||||
// jobMutexes serializes per-job result/output aggregation across the matrix combinations that
|
||||
// share one *model.Job and run in parallel. Keyed by the shared *model.Job (mirrors the
|
||||
// per-directory AcquireCloneLock pattern).
|
||||
var jobMutexes sync.Map // key: *model.Job; value: *sync.Mutex
|
||||
|
||||
func lockJob(job *model.Job) func() {
|
||||
v, _ := jobMutexes.LoadOrStore(job, &sync.Mutex{})
|
||||
mu := v.(*sync.Mutex)
|
||||
mu.Lock()
|
||||
return mu.Unlock
|
||||
}
|
||||
|
||||
func (rc *RunContext) interpolateOutputs() common.Executor {
|
||||
return func(ctx context.Context) error {
|
||||
ee := rc.NewExpressionEvaluator(ctx)
|
||||
job := rc.Run.Job()
|
||||
// Matrix combinations share this Job and its Outputs map. Interpolate from this combo's
|
||||
// pristine snapshot (outputTemplate) and write under the lock, so each combo overwrites
|
||||
// with its own resolved values (last wins, as on GitHub) instead of the first combo's
|
||||
// resolved values freezing the shared template against later combos.
|
||||
defer lockJob(job)()
|
||||
for k, v := range rc.outputTemplate {
|
||||
job.Outputs[k] = ee.Interpolate(ctx, v)
|
||||
for k, v := range rc.Run.Job().Outputs {
|
||||
interpolated := ee.Interpolate(ctx, v)
|
||||
if v != interpolated {
|
||||
rc.Run.Job().Outputs[k] = interpolated
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -690,18 +660,7 @@ func (rc *RunContext) result(result string) {
|
||||
}
|
||||
|
||||
func (rc *RunContext) steps() []*model.Step {
|
||||
// Return per-job copies of the steps. Matrix combinations run in parallel and share the
|
||||
// workflow model, but step execution mutates per-job fields and evaluates the If/Env nodes
|
||||
// in place, so the *model.Step instances must not be shared across jobs (see Step.Clone).
|
||||
shared := rc.Run.Job().Steps
|
||||
steps := make([]*model.Step, len(shared))
|
||||
for i, step := range shared {
|
||||
if step == nil {
|
||||
continue
|
||||
}
|
||||
steps[i] = step.Clone()
|
||||
}
|
||||
return steps
|
||||
return rc.Run.Job().Steps
|
||||
}
|
||||
|
||||
// Executor returns a pipeline executor for all the steps in the job
|
||||
@@ -778,15 +737,12 @@ func (rc *RunContext) runsOnPlatformNames(ctx context.Context) []string {
|
||||
return []string{}
|
||||
}
|
||||
|
||||
// Evaluate a copy: RawRunsOn is shared across parallel matrix jobs, so interpolating it in
|
||||
// place would race and leak one matrix combination's runs-on into the others.
|
||||
rawRunsOn := model.CloneYamlNode(job.RawRunsOn)
|
||||
if err := rc.ExprEval.EvaluateYamlNode(ctx, &rawRunsOn); err != nil {
|
||||
if err := rc.ExprEval.EvaluateYamlNode(ctx, &job.RawRunsOn); err != nil {
|
||||
common.Logger(ctx).Errorf("Error while evaluating runs-on: %v", err)
|
||||
return []string{}
|
||||
}
|
||||
|
||||
return model.RunsOnFromNode(rawRunsOn)
|
||||
return job.RunsOn()
|
||||
}
|
||||
|
||||
func (rc *RunContext) platformImage(ctx context.Context) string {
|
||||
@@ -1209,9 +1165,12 @@ func (rc *RunContext) handleServiceCredentials(ctx context.Context, creds map[st
|
||||
|
||||
// GetServiceBindsAndMounts returns the binds and mounts for the service container, resolving paths as appopriate
|
||||
func (rc *RunContext) GetServiceBindsAndMounts(svcVolumes []string) ([]string, map[string]string) {
|
||||
if rc.Config.ContainerDaemonSocket == "" {
|
||||
rc.Config.ContainerDaemonSocket = "/var/run/docker.sock"
|
||||
}
|
||||
binds := []string{}
|
||||
if daemonSocket := rc.containerDaemonSocket(); daemonSocket != "-" {
|
||||
daemonPath := getDockerDaemonSocketMountPath(daemonSocket)
|
||||
if rc.Config.ContainerDaemonSocket != "-" {
|
||||
daemonPath := getDockerDaemonSocketMountPath(rc.Config.ContainerDaemonSocket)
|
||||
binds = append(binds, fmt.Sprintf("%s:%s", daemonPath, "/var/run/docker.sock"))
|
||||
}
|
||||
|
||||
|
||||
@@ -281,44 +281,6 @@ func TestRunContext_GetBindsAndMounts(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestRunContextValidVolumes(t *testing.T) {
|
||||
rc := &RunContext{
|
||||
Name: "job",
|
||||
Run: &model.Run{Workflow: &model.Workflow{Name: "wf"}},
|
||||
Config: &Config{ValidVolumes: []string{"my-vol", "/host/path"}},
|
||||
}
|
||||
name := rc.jobContainerName()
|
||||
|
||||
got := rc.validVolumes()
|
||||
|
||||
// the configured volumes plus the four the runner mounts automatically
|
||||
assert.Subset(t, got, []string{"my-vol", "/host/path", "act-toolcache", name, name + "-env", "/var/run/docker.sock"})
|
||||
|
||||
// deriving the list must never mutate or grow the shared Config slice: parallel matrix
|
||||
// combinations share one *Config, and the previous in-place append was a data race.
|
||||
assert.Equal(t, []string{"my-vol", "/host/path"}, rc.Config.ValidVolumes)
|
||||
assert.Len(t, rc.validVolumes(), len(got), "repeated calls must be stable, not accumulate")
|
||||
}
|
||||
|
||||
// TestInterpolateOutputsIsPerMatrixCombo guards the matrix-output fix: combinations share one
|
||||
// *model.Job, so each must interpolate from its own pristine snapshot. Otherwise the first
|
||||
// combo's resolved value freezes the shared template and later combos can't resolve their own.
|
||||
func TestInterpolateOutputsIsPerMatrixCombo(t *testing.T) {
|
||||
job := &model.Job{Outputs: map[string]string{"o": "${{ matrix.v }}"}}
|
||||
run := &model.Run{JobID: "j", Workflow: &model.Workflow{Name: "w", Jobs: map[string]*model.Job{"j": job}}}
|
||||
r := &runnerImpl{config: &Config{}}
|
||||
ctx := context.Background()
|
||||
|
||||
rcA := r.newRunContext(ctx, run, map[string]any{"v": "a"})
|
||||
rcB := r.newRunContext(ctx, run, map[string]any{"v": "b"})
|
||||
|
||||
require.NoError(t, rcA.interpolateOutputs()(ctx))
|
||||
require.NoError(t, rcB.interpolateOutputs()(ctx))
|
||||
|
||||
// Last combo wins (matching GitHub) instead of being frozen to combo A's "a".
|
||||
require.Equal(t, "b", job.Outputs["o"])
|
||||
}
|
||||
|
||||
func TestGetGitHubContext(t *testing.T) {
|
||||
log.SetLevel(log.DebugLevel)
|
||||
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"maps"
|
||||
"os"
|
||||
"runtime"
|
||||
"sync"
|
||||
@@ -251,14 +250,7 @@ func (runner *runnerImpl) NewPlanExecutor(plan *model.Plan) common.Executor {
|
||||
return executor(common.WithJobErrorContainer(WithJobLogger(ctx, rc.Run.JobID, jobName, rc.Config, &rc.Masks, matrix)))
|
||||
})
|
||||
}
|
||||
// Run all matrix combinations of this job, then drop its aggregation mutex: the
|
||||
// combos are the only users of it, so once they finish the jobMutexes entry can be
|
||||
// released, keeping the map from growing unbounded over a long-lived runner.
|
||||
stageParallel := common.NewParallelExecutor(maxParallel, stageExecutor...)
|
||||
pipeline = append(pipeline, func(ctx context.Context) error {
|
||||
defer jobMutexes.Delete(job)
|
||||
return stageParallel(ctx)
|
||||
})
|
||||
pipeline = append(pipeline, common.NewParallelExecutor(maxParallel, stageExecutor...))
|
||||
}
|
||||
|
||||
// For pipeline execution:
|
||||
@@ -342,11 +334,6 @@ func (runner *runnerImpl) newRunContext(ctx context.Context, run *model.Run, mat
|
||||
}
|
||||
rc.ExprEval = rc.NewExpressionEvaluator(ctx)
|
||||
rc.Name = rc.ExprEval.Interpolate(ctx, run.String())
|
||||
// Snapshot the job's pristine output expressions now, before any matrix combo runs and
|
||||
// rewrites the shared Job.Outputs (see interpolateOutputs).
|
||||
if job := run.Job(); job != nil {
|
||||
rc.outputTemplate = maps.Clone(job.Outputs)
|
||||
}
|
||||
|
||||
return rc
|
||||
}
|
||||
|
||||
@@ -188,17 +188,14 @@ func (j *TestJobFileInfo) runTest(ctx context.Context, t *testing.T, cfg *Config
|
||||
EventPath: cfg.EventPath,
|
||||
Platforms: j.platforms,
|
||||
ReuseContainers: false,
|
||||
ForceRebuild: true,
|
||||
Env: cfg.Env,
|
||||
Secrets: cfg.Secrets,
|
||||
Inputs: cfg.Inputs,
|
||||
GitHubInstance: "github.com",
|
||||
DefaultActionInstance: cfg.DefaultActionInstance,
|
||||
ContainerArchitecture: cfg.ContainerArchitecture,
|
||||
ContainerMaxLifetime: time.Hour,
|
||||
Matrix: cfg.Matrix,
|
||||
ActionCache: cfg.ActionCache,
|
||||
ValidVolumes: []string{"**"}, // allow workflow-declared volumes (e.g. container-volumes)
|
||||
}
|
||||
|
||||
runner, err := New(runnerConfig)
|
||||
@@ -226,14 +223,18 @@ type TestConfig struct {
|
||||
}
|
||||
|
||||
func TestRunEvent(t *testing.T) {
|
||||
requireDocker(t)
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test")
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
tables := []TestJobFileInfo{
|
||||
// Shells
|
||||
{workdir, "shells/defaults", "push", "", platforms, secrets},
|
||||
{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
|
||||
@@ -245,6 +246,11 @@ func TestRunEvent(t *testing.T) {
|
||||
// Uses
|
||||
{workdir, "uses-composite", "push", "", platforms, secrets},
|
||||
{workdir, "uses-composite-with-error", "push", "Job 'failing-composite-action' failed", platforms, secrets},
|
||||
{workdir, "uses-nested-composite", "push", "", platforms, secrets},
|
||||
{workdir, "remote-action-composite-js-pre-with-defaults", "push", "", platforms, secrets},
|
||||
{workdir, "remote-action-composite-action-ref", "push", "", platforms, secrets},
|
||||
{workdir, "uses-workflow", "push", "", platforms, map[string]string{"secret": "keep_it_private"}},
|
||||
{workdir, "uses-workflow", "pull_request", "", platforms, map[string]string{"secret": "keep_it_private"}},
|
||||
{workdir, "uses-docker-url", "push", "", platforms, secrets},
|
||||
{workdir, "act-composite-env-test", "push", "", platforms, secrets},
|
||||
|
||||
@@ -254,15 +260,21 @@ func TestRunEvent(t *testing.T) {
|
||||
{workdir, "evalmatrixneeds2", "push", "", platforms, secrets},
|
||||
{workdir, "evalmatrix-merge-map", "push", "", platforms, secrets},
|
||||
{workdir, "evalmatrix-merge-array", "push", "", platforms, secrets},
|
||||
{workdir, "issue-1195", "push", "", platforms, secrets},
|
||||
|
||||
{workdir, "basic", "push", "", platforms, secrets},
|
||||
{workdir, "fail", "push", "exit with `FAILURE`: 1", platforms, secrets},
|
||||
{workdir, "runs-on", "push", "", platforms, secrets},
|
||||
{workdir, "checkout", "push", "", platforms, secrets},
|
||||
{workdir, "job-container", "push", "", platforms, secrets},
|
||||
{workdir, "job-container-non-root", "push", "", platforms, secrets},
|
||||
{workdir, "job-container-invalid-credentials", "push", "failed to handle credentials: failed to interpolate container.credentials.password", platforms, secrets},
|
||||
{workdir, "container-hostname", "push", "", platforms, secrets},
|
||||
{workdir, "remote-action-docker", "push", "", platforms, secrets},
|
||||
{workdir, "remote-action-js", "push", "", platforms, secrets},
|
||||
{workdir, "remote-action-js-node-user", "push", "", platforms, secrets}, // Test if this works with non root container
|
||||
{workdir, "matrix", "push", "", platforms, secrets},
|
||||
{workdir, "matrix-include-exclude", "push", "", platforms, secrets},
|
||||
{workdir, "matrix-exitcode", "push", "Job 'test' failed", platforms, secrets},
|
||||
{workdir, "commands", "push", "", platforms, secrets},
|
||||
{workdir, "workdir", "push", "", platforms, secrets},
|
||||
@@ -283,6 +295,7 @@ func TestRunEvent(t *testing.T) {
|
||||
{workdir, "job-status-check", "push", "job 'fail' failed", platforms, secrets},
|
||||
{workdir, "if-expressions", "push", "Job 'mytest' failed", platforms, secrets},
|
||||
{workdir, "actions-environment-and-context-tests", "push", "", platforms, secrets},
|
||||
{workdir, "uses-action-with-pre-and-post-step", "push", "", platforms, secrets},
|
||||
{workdir, "evalenv", "push", "", platforms, secrets},
|
||||
{workdir, "docker-action-custom-path", "push", "", platforms, secrets},
|
||||
{workdir, "GITHUB_ENV-use-in-env-ctx", "push", "", platforms, secrets},
|
||||
@@ -293,6 +306,7 @@ func TestRunEvent(t *testing.T) {
|
||||
{workdir, "workflow_dispatch-scalar", "workflow_dispatch", "", platforms, secrets},
|
||||
{workdir, "workflow_dispatch-scalar-composite-action", "workflow_dispatch", "", platforms, secrets},
|
||||
{workdir, "job-needs-context-contains-result", "push", "", platforms, secrets},
|
||||
{"../model/testdata", "strategy", "push", "", platforms, secrets}, // TODO: move all testdata into pkg so we can validate it with planner and runner
|
||||
{"../model/testdata", "container-volumes", "push", "", platforms, secrets},
|
||||
{workdir, "path-handling", "push", "", platforms, secrets},
|
||||
{workdir, "do-not-leak-step-env-in-composite", "push", "", platforms, secrets},
|
||||
@@ -302,6 +316,7 @@ func TestRunEvent(t *testing.T) {
|
||||
|
||||
// services
|
||||
{workdir, "services", "push", "", platforms, secrets},
|
||||
{workdir, "services-host-network", "push", "", platforms, secrets},
|
||||
{workdir, "services-with-container", "push", "", platforms, secrets},
|
||||
|
||||
// local remote action overrides
|
||||
@@ -310,11 +325,6 @@ func TestRunEvent(t *testing.T) {
|
||||
|
||||
for _, table := range tables {
|
||||
t.Run(table.workflowPath, func(t *testing.T) {
|
||||
if table.workflowPath == "container-volumes" {
|
||||
// host /proc bind mounts are Linux-Docker-only
|
||||
requireLinuxDocker(t)
|
||||
}
|
||||
|
||||
config := &Config{
|
||||
Secrets: table.secrets,
|
||||
}
|
||||
@@ -346,12 +356,9 @@ func TestRunEvent(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRunEventHostEnvironment(t *testing.T) {
|
||||
// Runs steps directly on the host (the "-self-hosted" platform), so it needs the shells
|
||||
// and tools the workflows invoke. No network gate: every action these workflows reference
|
||||
// is a local `./` fixture or the skipped actions/checkout, so the suite runs offline (same
|
||||
// as TestRunEvent). Only the broadly-used interpreters are required up front; the pwsh- and
|
||||
// nix-specific cases gate on their own tool below so a missing pwsh/nix skips just those.
|
||||
requireHostTools(t, "bash", "node")
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test")
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
@@ -367,6 +374,7 @@ func TestRunEventHostEnvironment(t *testing.T) {
|
||||
{workdir, "shells/defaults", "push", "", platforms, secrets},
|
||||
{workdir, "shells/pwsh", "push", "", platforms, secrets},
|
||||
{workdir, "shells/bash", "push", "", platforms, secrets},
|
||||
{workdir, "shells/python", "push", "", platforms, secrets},
|
||||
{workdir, "shells/sh", "push", "", platforms, secrets},
|
||||
|
||||
// Local action
|
||||
@@ -375,6 +383,7 @@ func TestRunEventHostEnvironment(t *testing.T) {
|
||||
// Uses
|
||||
{workdir, "uses-composite", "push", "", platforms, secrets},
|
||||
{workdir, "uses-composite-with-error", "push", "Job 'failing-composite-action' failed", platforms, secrets},
|
||||
{workdir, "uses-nested-composite", "push", "", platforms, secrets},
|
||||
{workdir, "act-composite-env-test", "push", "", platforms, secrets},
|
||||
|
||||
// Eval
|
||||
@@ -383,10 +392,14 @@ func TestRunEventHostEnvironment(t *testing.T) {
|
||||
{workdir, "evalmatrixneeds2", "push", "", platforms, secrets},
|
||||
{workdir, "evalmatrix-merge-map", "push", "", platforms, secrets},
|
||||
{workdir, "evalmatrix-merge-array", "push", "", platforms, secrets},
|
||||
{workdir, "issue-1195", "push", "", platforms, secrets},
|
||||
|
||||
{workdir, "fail", "push", "exit with `FAILURE`: 1", platforms, secrets},
|
||||
{workdir, "runs-on", "push", "", platforms, secrets},
|
||||
{workdir, "checkout", "push", "", platforms, secrets},
|
||||
{workdir, "remote-action-js", "push", "", platforms, secrets},
|
||||
{workdir, "matrix", "push", "", platforms, secrets},
|
||||
{workdir, "matrix-include-exclude", "push", "", platforms, secrets},
|
||||
{workdir, "commands", "push", "", platforms, secrets},
|
||||
{workdir, "defaults-run", "push", "", platforms, secrets},
|
||||
{workdir, "composite-fail-with-output", "push", "", platforms, secrets},
|
||||
@@ -400,6 +413,7 @@ func TestRunEventHostEnvironment(t *testing.T) {
|
||||
{workdir, "steps-context/outcome", "push", "", platforms, secrets},
|
||||
{workdir, "job-status-check", "push", "job 'fail' failed", platforms, secrets},
|
||||
{workdir, "if-expressions", "push", "Job 'mytest' failed", platforms, secrets},
|
||||
{workdir, "uses-action-with-pre-and-post-step", "push", "", platforms, secrets},
|
||||
{workdir, "evalenv", "push", "", platforms, secrets},
|
||||
{workdir, "ensure-post-steps", "push", "Job 'second-post-step-should-fail' failed", platforms, secrets},
|
||||
}...)
|
||||
@@ -432,26 +446,24 @@ func TestRunEventHostEnvironment(t *testing.T) {
|
||||
|
||||
for _, table := range tables {
|
||||
t.Run(table.workflowPath, func(t *testing.T) {
|
||||
switch table.workflowPath {
|
||||
case "shells/pwsh":
|
||||
requireHostTools(t, "pwsh")
|
||||
case "nix-prepend-path":
|
||||
requireHostTools(t, "nix")
|
||||
}
|
||||
table.runTest(ctx, t, &Config{})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDryrunEvent(t *testing.T) {
|
||||
// Dryrun plans without containers or network (shells and local actions only).
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test")
|
||||
}
|
||||
|
||||
ctx := common.WithDryrun(context.Background(), true)
|
||||
|
||||
tables := []TestJobFileInfo{
|
||||
// Shells
|
||||
{workdir, "shells/defaults", "push", "", platforms, secrets},
|
||||
{workdir, "shells/pwsh", "push", "", platforms, secrets},
|
||||
{workdir, "shells/pwsh", "push", "", map[string]string{"ubuntu-latest": "catthehacker/ubuntu:pwsh-latest"}, secrets}, // custom image with pwsh
|
||||
{workdir, "shells/bash", "push", "", platforms, secrets},
|
||||
{workdir, "shells/python", "push", "", map[string]string{"ubuntu-latest": "node:24-bookworm"}, secrets}, // slim doesn't have python
|
||||
{workdir, "shells/sh", "push", "", platforms, secrets},
|
||||
|
||||
// Local action
|
||||
@@ -468,18 +480,10 @@ func TestDryrunEvent(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestReusableWorkflowCaller exercises the reusable-workflow caller path against a local
|
||||
// reusable workflow (typed inputs, secrets as both a map and `inherit`, and reading the called
|
||||
// workflow's outputs via `needs`).
|
||||
func TestReusableWorkflowCaller(t *testing.T) {
|
||||
requireDocker(t)
|
||||
table := TestJobFileInfo{workdir, "uses-workflow", "push", "", platforms, map[string]string{"secret": "keep_it_private"}}
|
||||
table.runTest(context.Background(), t, &Config{Secrets: table.secrets})
|
||||
}
|
||||
|
||||
func TestDockerActionForcePullForceRebuild(t *testing.T) {
|
||||
requireDocker(t)
|
||||
requireNetwork(t) // force-pulls a docker action image
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test")
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
@@ -500,6 +504,22 @@ func TestDockerActionForcePullForceRebuild(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunDifferentArchitecture(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test")
|
||||
}
|
||||
|
||||
tjfi := TestJobFileInfo{
|
||||
workdir: workdir,
|
||||
workflowPath: "basic",
|
||||
eventName: "push",
|
||||
errorMessage: "",
|
||||
platforms: platforms,
|
||||
}
|
||||
|
||||
tjfi.runTest(context.Background(), t, &Config{ContainerArchitecture: "linux/arm64"})
|
||||
}
|
||||
|
||||
type maskJobLoggerFactory struct {
|
||||
Output bytes.Buffer
|
||||
}
|
||||
@@ -520,7 +540,9 @@ func TestMaskValues(t *testing.T) {
|
||||
assert.False(t, strings.Contains(text, "composite secret")) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
}
|
||||
|
||||
requireDocker(t)
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test")
|
||||
}
|
||||
|
||||
log.SetLevel(log.DebugLevel)
|
||||
|
||||
@@ -541,7 +563,9 @@ func TestMaskValues(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRunEventSecrets(t *testing.T) {
|
||||
requireDocker(t)
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test")
|
||||
}
|
||||
workflowPath := "secrets"
|
||||
|
||||
tjfi := TestJobFileInfo{
|
||||
@@ -561,7 +585,9 @@ func TestRunEventSecrets(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRunWithService(t *testing.T) {
|
||||
requireDocker(t)
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test")
|
||||
}
|
||||
|
||||
log.SetLevel(log.DebugLevel)
|
||||
ctx := context.Background()
|
||||
@@ -577,11 +603,10 @@ func TestRunWithService(t *testing.T) {
|
||||
assert.NoError(t, err, workflowPath) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
|
||||
runnerConfig := &Config{
|
||||
Workdir: workdir,
|
||||
EventName: eventName,
|
||||
Platforms: platforms,
|
||||
ReuseContainers: false,
|
||||
ContainerMaxLifetime: time.Hour, // otherwise the job container is `sleep 0` and exits at once
|
||||
Workdir: workdir,
|
||||
EventName: eventName,
|
||||
Platforms: platforms,
|
||||
ReuseContainers: false,
|
||||
}
|
||||
runner, err := New(runnerConfig)
|
||||
assert.NoError(t, err, workflowPath) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
@@ -597,7 +622,9 @@ func TestRunWithService(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRunActionInputs(t *testing.T) {
|
||||
requireDocker(t)
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test")
|
||||
}
|
||||
workflowPath := "input-from-cli"
|
||||
|
||||
tjfi := TestJobFileInfo{
|
||||
@@ -616,7 +643,9 @@ func TestRunActionInputs(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRunEventPullRequest(t *testing.T) {
|
||||
requireDocker(t)
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test")
|
||||
}
|
||||
|
||||
workflowPath := "pull-request"
|
||||
|
||||
@@ -632,7 +661,9 @@ func TestRunEventPullRequest(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRunMatrixWithUserDefinedInclusions(t *testing.T) {
|
||||
requireDocker(t)
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test")
|
||||
}
|
||||
workflowPath := "matrix-with-user-inclusions"
|
||||
|
||||
tjfi := TestJobFileInfo{
|
||||
|
||||
@@ -291,9 +291,7 @@ type remoteAction struct {
|
||||
|
||||
func (ra *remoteAction) CloneURL(u string) string {
|
||||
if ra.URL == "" {
|
||||
// keep an absolute local path as-is (used by tests to resolve actions from a local
|
||||
// repo); only bare host names get the https:// scheme prepended
|
||||
if !strings.HasPrefix(u, "http://") && !strings.HasPrefix(u, "https://") && !filepath.IsAbs(u) {
|
||||
if !strings.HasPrefix(u, "http://") && !strings.HasPrefix(u, "https://") {
|
||||
u = "https://" + u
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -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.validVolumes(),
|
||||
ValidVolumes: rc.Config.ValidVolumes,
|
||||
AllocatePTY: rc.Config.AllocatePTY,
|
||||
})
|
||||
return stepContainer
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
name: local-reusable-workflow
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
string_required:
|
||||
required: true
|
||||
type: string
|
||||
bool_required:
|
||||
required: true
|
||||
type: boolean
|
||||
number_required:
|
||||
required: true
|
||||
type: number
|
||||
secrets:
|
||||
secret:
|
||||
required: true
|
||||
outputs:
|
||||
output:
|
||||
value: ${{ jobs.reusable.outputs.output }}
|
||||
|
||||
jobs:
|
||||
reusable:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
output: ${{ steps.gen.outputs.output }}
|
||||
steps:
|
||||
- name: check inputs and secret arrived
|
||||
run: |
|
||||
[ "${{ inputs.string_required }}" = "string" ]
|
||||
[ "${{ inputs.bool_required }}" = "true" ]
|
||||
[ "${{ inputs.number_required }}" = "1" ]
|
||||
[ "${{ secrets.secret }}" = "keep_it_private" ]
|
||||
- id: gen
|
||||
run: echo "output=${{ inputs.string_required }}" >> $GITHUB_OUTPUT
|
||||
@@ -5,11 +5,10 @@ jobs:
|
||||
env:
|
||||
MYGLOBALENV3: myglobalval3
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- run: |
|
||||
echo MYGLOBALENV1=myglobalval1 > $GITHUB_ENV
|
||||
echo "::set-env name=MYGLOBALENV2::myglobalval2"
|
||||
- uses: ./actions/script
|
||||
- uses: nektos/act-test-actions/script@main
|
||||
with:
|
||||
main: |
|
||||
env
|
||||
|
||||
41
act/runner/testdata/GITHUB_STATE/push.yml
vendored
41
act/runner/testdata/GITHUB_STATE/push.yml
vendored
@@ -1,31 +1,48 @@
|
||||
on: push
|
||||
jobs:
|
||||
# State saved in main (via the $GITHUB_STATE file and the ::save-state command) must surface
|
||||
# as $STATE_* in the action's post step.
|
||||
_:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: ./actions/script
|
||||
- uses: nektos/act-test-actions/script@main
|
||||
with:
|
||||
pre: |
|
||||
env
|
||||
echo mystate0=mystateval > $GITHUB_STATE
|
||||
echo "::save-state name=mystate1::mystateval"
|
||||
main: |
|
||||
env
|
||||
echo mystate2=mystateval > $GITHUB_STATE
|
||||
echo "::save-state name=mystate3::mystateval"
|
||||
post: |
|
||||
env
|
||||
[ "$STATE_mystate0" = "mystateval" ]
|
||||
[ "$STATE_mystate1" = "mystateval" ]
|
||||
[ "$STATE_mystate2" = "mystateval" ]
|
||||
[ "$STATE_mystate3" = "mystateval" ]
|
||||
# State must be isolated per action instance even when two steps use the same action.
|
||||
test-id-collision-bug:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: ./actions/script
|
||||
- uses: nektos/act-test-actions/script@main
|
||||
id: script
|
||||
with:
|
||||
main: echo mystate=val1 > $GITHUB_STATE
|
||||
post: '[ "$STATE_mystate" = "val1" ]'
|
||||
- uses: ./actions/script
|
||||
pre: |
|
||||
env
|
||||
echo mystate0=mystateval > $GITHUB_STATE
|
||||
echo "::save-state name=mystate1::mystateval"
|
||||
main: |
|
||||
env
|
||||
echo mystate2=mystateval > $GITHUB_STATE
|
||||
echo "::save-state name=mystate3::mystateval"
|
||||
post: |
|
||||
env
|
||||
[ "$STATE_mystate0" = "mystateval" ]
|
||||
[ "$STATE_mystate1" = "mystateval" ]
|
||||
[ "$STATE_mystate2" = "mystateval" ]
|
||||
[ "$STATE_mystate3" = "mystateval" ]
|
||||
- uses: nektos/act-test-actions/script@main
|
||||
id: pre-script
|
||||
with:
|
||||
main: echo mystate=val2 > $GITHUB_STATE
|
||||
post: '[ "$STATE_mystate" = "val2" ]'
|
||||
main: |
|
||||
env
|
||||
echo mystate0=mystateerror > $GITHUB_STATE
|
||||
echo "::save-state name=mystate1::mystateerror"
|
||||
@@ -9,3 +9,7 @@ jobs:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: './actions-environment-and-context-tests/js'
|
||||
- uses: './actions-environment-and-context-tests/docker'
|
||||
- uses: 'nektos/act-test-actions/js@main'
|
||||
- uses: 'nektos/act-test-actions/docker@main'
|
||||
- uses: 'nektos/act-test-actions/docker-file@main'
|
||||
- uses: 'nektos/act-test-actions/docker-relative-context/action@main'
|
||||
|
||||
15
act/runner/testdata/actions/script/action.yml
vendored
15
act/runner/testdata/actions/script/action.yml
vendored
@@ -1,15 +0,0 @@
|
||||
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
9
act/runner/testdata/actions/script/index.js
vendored
@@ -1,9 +0,0 @@
|
||||
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'});
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"name": "script",
|
||||
"private": true,
|
||||
"type": "module"
|
||||
}
|
||||
6
act/runner/testdata/actions/script/post.js
vendored
6
act/runner/testdata/actions/script/post.js
vendored
@@ -1,6 +0,0 @@
|
||||
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 node:24-bookworm-slim
|
||||
FROM ubuntu:latest
|
||||
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
Normal file
13
act/runner/testdata/issue-1195/push.yml
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
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,13 +9,24 @@ jobs:
|
||||
steps:
|
||||
- name: My first false step
|
||||
if: "endsWith('Should not', 'o1')"
|
||||
run: exit 1
|
||||
uses: actions/checkout@v2.0.0
|
||||
with:
|
||||
ref: refs/pull/${{github.event.pull_request.number}}/merge
|
||||
fetch-depth: 5
|
||||
- name: My first true step
|
||||
if: ${{endsWith('Hello world', 'ld')}}
|
||||
run: echo "Renst the Octocat"
|
||||
uses: actions/hello-world-javascript-action@main
|
||||
with:
|
||||
who-to-greet: "Renst the Octocat"
|
||||
- name: My second false step
|
||||
if: "endsWith('Should not evaluate', 'o2')"
|
||||
run: exit 1
|
||||
uses: actions/checkout@v2.0.0
|
||||
with:
|
||||
ref: refs/pull/${{github.event.pull_request.number}}/merge
|
||||
fetch-depth: 5
|
||||
- name: My third false step
|
||||
if: ${{endsWith('Should not evaluate', 'o3')}}
|
||||
run: exit 1
|
||||
uses: actions/checkout@v2.0.0
|
||||
with:
|
||||
ref: refs/pull/${{github.event.pull_request.number}}/merge
|
||||
fetch-depth: 5
|
||||
|
||||
22
act/runner/testdata/issue-598/spelling.yml
vendored
22
act/runner/testdata/issue-598/spelling.yml
vendored
@@ -1,21 +1,31 @@
|
||||
name: issue-598
|
||||
on: push
|
||||
|
||||
|
||||
|
||||
jobs:
|
||||
my_first_job:
|
||||
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: My first false step
|
||||
if: "endsWith('Hello world', 'o1')"
|
||||
run: exit 1
|
||||
uses: actions/hello-world-javascript-action@main
|
||||
with:
|
||||
who-to-greet: 'Mona the Octocat'
|
||||
- name: My first true step
|
||||
if: "!endsWith('Hello world', 'od')"
|
||||
run: echo "Renst the Octocat"
|
||||
uses: actions/hello-world-javascript-action@main
|
||||
with:
|
||||
who-to-greet: "Renst the Octocat"
|
||||
- name: My second false step
|
||||
if: "endsWith('Hello world', 'o2')"
|
||||
run: exit 1
|
||||
uses: actions/hello-world-javascript-action@main
|
||||
with:
|
||||
who-to-greet: 'Act the Octocat'
|
||||
- name: My third false step
|
||||
if: "endsWith('Hello world', 'o2')"
|
||||
run: exit 1
|
||||
uses: actions/hello-world-javascript-action@main
|
||||
with:
|
||||
who-to-greet: 'Git the Octocat'
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: node:24-bookworm-slim
|
||||
options: --user 1000
|
||||
image: catthehacker/ubuntu:runner-latest # image with user 'runner:runner' built on tag 'act-latest'
|
||||
steps:
|
||||
- run: echo PASS
|
||||
|
||||
@@ -24,3 +24,4 @@ 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,6 +30,11 @@ 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: https://github.com/nektos/test-override@a
|
||||
- uses: nektos/test-override@a
|
||||
- uses: nektos/test-override@b
|
||||
31
act/runner/testdata/matrix-include-exclude/push.yml
vendored
Normal file
31
act/runner/testdata/matrix-include-exclude/push.yml
vendored
Normal file
@@ -0,0 +1,31 @@
|
||||
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,4 +18,12 @@ jobs:
|
||||
runs:
|
||||
using: composite
|
||||
shell: cp {0} action.yml
|
||||
- uses: ./
|
||||
- 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
|
||||
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: ./path-handling/
|
||||
- uses: nektos/act-test-actions/composite@main
|
||||
with:
|
||||
input: some input
|
||||
|
||||
|
||||
8
act/runner/testdata/remote-action-composite-action-ref/push.yml
vendored
Normal file
8
act/runner/testdata/remote-action-composite-action-ref/push.yml
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
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
|
||||
23
act/runner/testdata/remote-action-composite-js-pre-with-defaults/push.yml
vendored
Normal file
23
act/runner/testdata/remote-action-composite-js-pre-with-defaults/push.yml
vendored
Normal file
@@ -0,0 +1,23 @@
|
||||
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
|
||||
10
act/runner/testdata/remote-action-docker/push.yml
vendored
Normal file
10
act/runner/testdata/remote-action-docker/push.yml
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
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'
|
||||
30
act/runner/testdata/remote-action-js-node-user/push.yml
vendored
Normal file
30
act/runner/testdata/remote-action-js-node-user/push.yml
vendored
Normal file
@@ -0,0 +1,30 @@
|
||||
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
Normal file
12
act/runner/testdata/remote-action-js/push.yml
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
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
Normal file
24
act/runner/testdata/runs-on/push.yml
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
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
|
||||
14
act/runner/testdata/services-host-network/push.yml
vendored
Normal file
14
act/runner/testdata/services-host-network/push.yml
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
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,11 +5,12 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
# https://docs.github.com/en/actions/using-containerized-services/about-service-containers#running-jobs-in-a-container
|
||||
container:
|
||||
image: "node:24-bookworm-slim"
|
||||
image: "ubuntu:latest"
|
||||
services:
|
||||
nginx:
|
||||
image: "nginx:alpine"
|
||||
image: "nginx:latest"
|
||||
ports:
|
||||
- "8080:80"
|
||||
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,9 +6,18 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
postgres:
|
||||
image: nginx:alpine
|
||||
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
|
||||
ports:
|
||||
- 80
|
||||
- 5432:5432
|
||||
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,6 +8,13 @@ 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
Normal file
28
act/runner/testdata/shells/python/push.yml
vendored
Normal file
@@ -0,0 +1,28 @@
|
||||
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())
|
||||
7
act/runner/testdata/uses-action-with-pre-and-post-step/last-action/action.yml
vendored
Normal file
7
act/runner/testdata/uses-action-with-pre-and-post-step/last-action/action.yml
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
name: "last action check"
|
||||
description: "last action check"
|
||||
|
||||
runs:
|
||||
using: "node24"
|
||||
main: main.js
|
||||
post: post.js
|
||||
0
act/runner/testdata/uses-action-with-pre-and-post-step/last-action/main.js
vendored
Normal file
0
act/runner/testdata/uses-action-with-pre-and-post-step/last-action/main.js
vendored
Normal file
17
act/runner/testdata/uses-action-with-pre-and-post-step/last-action/post.js
vendored
Normal file
17
act/runner/testdata/uses-action-with-pre-and-post-step/last-action/post.js
vendored
Normal file
@@ -0,0 +1,17 @@
|
||||
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}'`);
|
||||
}
|
||||
15
act/runner/testdata/uses-action-with-pre-and-post-step/push.yml
vendored
Normal file
15
act/runner/testdata/uses-action-with-pre-and-post-step/push.yml
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
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
|
||||
7
act/runner/testdata/uses-github-full-sha/main.yml
vendored
Normal file
7
act/runner/testdata/uses-github-full-sha/main.yml
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
name: uses-github-root
|
||||
on: push
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/hello-world-docker-action@b136eb8894c5cb1dd5807da824be97ccdf9b5423
|
||||
7
act/runner/testdata/uses-github-path/push.yml
vendored
Normal file
7
act/runner/testdata/uses-github-path/push.yml
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
name: uses-github-path
|
||||
on: push
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: sergioramos/yarn-actions/install@v6
|
||||
7
act/runner/testdata/uses-github-short-sha/main.yml
vendored
Normal file
7
act/runner/testdata/uses-github-short-sha/main.yml
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
name: uses-github-root
|
||||
on: push
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/hello-world-docker-action@b136eb8
|
||||
63
act/runner/testdata/uses-nested-composite/composite_action2/action.yml
vendored
Normal file
63
act/runner/testdata/uses-nested-composite/composite_action2/action.yml
vendored
Normal file
@@ -0,0 +1,63 @@
|
||||
---
|
||||
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
|
||||
15
act/runner/testdata/uses-nested-composite/push.yml
vendored
Normal file
15
act/runner/testdata/uses-nested-composite/push.yml
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
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
|
||||
42
act/runner/testdata/uses-workflow/local-workflow.yml
vendored
Normal file
42
act/runner/testdata/uses-workflow/local-workflow.yml
vendored
Normal file
@@ -0,0 +1,42 @@
|
||||
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,11 +1,8 @@
|
||||
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: ./.github/workflows/local-reusable-workflow.yml
|
||||
uses: nektos/act-test-actions/.github/workflows/reusable-workflow.yml@main
|
||||
with:
|
||||
string_required: string
|
||||
bool_required: ${{ true }}
|
||||
@@ -14,7 +11,7 @@ jobs:
|
||||
secret: keep_it_private
|
||||
|
||||
reusable-workflow-with-inherited-secrets:
|
||||
uses: ./.github/workflows/local-reusable-workflow.yml
|
||||
uses: nektos/act-test-actions/.github/workflows/reusable-workflow.yml@main
|
||||
with:
|
||||
string_required: string
|
||||
bool_required: ${{ true }}
|
||||
@@ -27,5 +24,12 @@ jobs:
|
||||
- reusable-workflow
|
||||
- reusable-workflow-with-inherited-secrets
|
||||
steps:
|
||||
- run: '[[ "${{ needs.reusable-workflow.outputs.output == ''string'' }}" = "true" ]] || exit 1'
|
||||
- run: '[[ "${{ needs.reusable-workflow-with-inherited-secrets.outputs.output == ''string'' }}" = "true" ]] || exit 1'
|
||||
- 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
|
||||
|
||||
2
go.mod
2
go.mod
@@ -37,7 +37,6 @@ require (
|
||||
github.com/timshannon/bolthold v0.0.0-20240314194003-30aac6950928
|
||||
go.etcd.io/bbolt v1.4.3
|
||||
go.yaml.in/yaml/v4 v4.0.0-rc.3
|
||||
golang.org/x/sys v0.44.0
|
||||
golang.org/x/term v0.43.0
|
||||
google.golang.org/protobuf v1.36.11
|
||||
gotest.tools/v3 v3.5.2
|
||||
@@ -107,6 +106,7 @@ require (
|
||||
golang.org/x/crypto v0.50.0 // indirect
|
||||
golang.org/x/net v0.53.0 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/sys v0.44.0 // indirect
|
||||
gopkg.in/warnings.v0 v0.1.2 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
4
go.sum
4
go.sum
@@ -1,5 +1,7 @@
|
||||
code.gitea.io/actions-proto-go v0.4.1 h1:l0EYhjsgpUe/1VABo2eK7zcoNX2W44WOnb0MSLrKfls=
|
||||
code.gitea.io/actions-proto-go v0.4.1/go.mod h1:mn7Wkqz6JbnTOHQpot3yDeHx+O5C9EGhMEE+htvHBas=
|
||||
connectrpc.com/connect v1.19.2 h1:McQ83FGdzL+t60peksi0gXC7MQ/iLKgLduAnThbM0mo=
|
||||
connectrpc.com/connect v1.19.2/go.mod h1:tN20fjdGlewnSFeZxLKb0xwIZ6ozc3OQs2hTXy4du9w=
|
||||
connectrpc.com/connect v1.20.0 h1:6TNDAB+WeNd2uolWNlYczB5E0KNNaVMNUEx8JEUsPmQ=
|
||||
connectrpc.com/connect v1.20.0/go.mod h1:A2ygJrukXwWy32vkCAAHNVguZrqZ+jeZ9rGRnGR4dN4=
|
||||
cyphar.com/go-pathrs v0.2.3 h1:0pH8gep37wB0BgaXrEaN1OtZhUMeS7VvaejSr6i822o=
|
||||
@@ -147,6 +149,8 @@ github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8
|
||||
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
|
||||
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
|
||||
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=
|
||||
|
||||
@@ -60,9 +60,6 @@ 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,7 +39,6 @@ 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.
|
||||
@@ -184,9 +183,6 @@ 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,7 +13,6 @@ 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"
|
||||
@@ -59,9 +58,6 @@ 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
|
||||
@@ -74,13 +70,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 = runner.AppendSecretMasker(oldnew, v)
|
||||
oldnew = append(oldnew, v, "***")
|
||||
}
|
||||
if v := task.Context.Fields["gitea_runtime_token"].GetStringValue(); v != "" {
|
||||
oldnew = runner.AppendSecretMasker(oldnew, v)
|
||||
oldnew = append(oldnew, v, "***")
|
||||
}
|
||||
for _, v := range task.Secrets {
|
||||
oldnew = runner.AppendSecretMasker(oldnew, v)
|
||||
oldnew = append(oldnew, v, "***")
|
||||
}
|
||||
|
||||
rv := &Reporter{
|
||||
@@ -93,7 +89,6 @@ 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{
|
||||
@@ -334,9 +329,6 @@ 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
|
||||
}
|
||||
@@ -439,43 +431,17 @@ func (r *Reporter) Close(lastWords string) error {
|
||||
}
|
||||
r.stateMu.Unlock()
|
||||
|
||||
// Separate budgets so a slow ReportLog can't starve the ReportState that
|
||||
// carries the cancel acknowledgement.
|
||||
// Report the job outcome even when all log upload retry attempts have been exhausted
|
||||
return errors.Join(
|
||||
r.flushFinal(func() error { return r.ReportLog(true) }),
|
||||
r.flushFinal(func() error { return r.ReportState(true) }),
|
||||
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)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
// 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()
|
||||
@@ -488,11 +454,8 @@ func (r *Reporter) ReportLog(noMore bool) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
rpcCtx, rpcCancel := r.rpcCtx()
|
||||
defer rpcCancel()
|
||||
|
||||
start := time.Now()
|
||||
resp, err := r.client.UpdateLog(rpcCtx, connect.NewRequest(&runnerv1.UpdateLogRequest{
|
||||
resp, err := r.client.UpdateLog(r.ctx, connect.NewRequest(&runnerv1.UpdateLogRequest{
|
||||
TaskId: r.state.Id,
|
||||
Index: int64(r.logOffset),
|
||||
Rows: rows,
|
||||
@@ -563,11 +526,8 @@ 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(rpcCtx, connect.NewRequest(&runnerv1.UpdateTaskRequest{
|
||||
resp, err := r.client.UpdateTask(r.ctx, connect.NewRequest(&runnerv1.UpdateTaskRequest{
|
||||
State: state,
|
||||
Outputs: outputs,
|
||||
}))
|
||||
@@ -690,7 +650,7 @@ func (r *Reporter) parseLogRow(entry *log.Entry) *runnerv1.LogRow {
|
||||
|
||||
matches := cmdRegex.FindStringSubmatch(content)
|
||||
if matches != nil {
|
||||
if output := r.handleCommand(content, matches[1], runner.UnescapeCommandData(matches[3])); output != nil {
|
||||
if output := r.handleCommand(content, matches[1], matches[3]); output != nil {
|
||||
content = *output
|
||||
} else {
|
||||
return nil
|
||||
@@ -706,6 +666,6 @@ func (r *Reporter) parseLogRow(entry *log.Entry) *runnerv1.LogRow {
|
||||
}
|
||||
|
||||
func (r *Reporter) addMask(msg string) {
|
||||
r.oldnew = runner.AppendSecretMasker(r.oldnew, msg)
|
||||
r.oldnew = append(r.oldnew, msg, "***")
|
||||
r.logReplacer = strings.NewReplacer(r.oldnew...)
|
||||
}
|
||||
|
||||
@@ -50,19 +50,6 @@ 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{
|
||||
@@ -767,86 +754,3 @@ 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")
|
||||
}
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
#!/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 "$@"
|
||||
Reference in New Issue
Block a user