From 270ea4123275fd3aaa685fc4584ede4aa1de8281 Mon Sep 17 00:00:00 2001 From: silverwind Date: Fri, 29 May 2026 05:23:10 +0000 Subject: [PATCH] fix: matrix-job data races + outputs, leaner offline test suite (#994) Running the full suite under `-race` (dropping `-short`) exposed pre-existing data races in parallel matrix-job execution, fixed by not sharing mutable state across combinations: - `containerDaemonSocket()`/`validVolumes()` derive per-job values instead of mutating shared `Config` - `getWorkflowSecrets` builds a fresh map, `rc.steps()` clones each step, and go-git workdir access is serialized - every write to a shared `Job`'s result/outputs runs under a per-`Job` lock, each combo interpolating outputs from a pristine snapshot (last wins, as on GitHub) ### Test suite - capability gates (docker / network / host-tools / Linux) replace the `-short` skips, and the suite runs offline via local fixtures (the artifact flow uses an in-process loopback server, only the docker-action force-pull needs the network) - drops redundant tests, adds a regression test for https://gitea.com/gitea/runner/issues/981 and a docker-in-docker harness (`make test-dind`) --- This PR was written with the help of Claude Opus 4.7 Reviewed-on: https://gitea.com/gitea/runner/pulls/994 Reviewed-by: Nicolas Co-authored-by: silverwind Co-committed-by: silverwind --- .gitea/workflows/test.yml | 24 +- Makefile | 8 +- act/artifacts/server_test.go | 195 +++++++++------ .../testdata/GHSL-2023-004/artifacts.yml | 39 --- .../upload-and-download/artifacts.yml | 230 ------------------ act/common/executor_parallel_advanced_test.go | 62 ----- act/common/git/git.go | 20 +- act/common/git/git_test.go | 79 +++--- act/container/docker_auth.go | 9 - act/container/docker_images_test.go | 102 ++++---- act/container/docker_run_test.go | 37 ++- act/container/helpers_test.go | 27 ++ act/model/workflow.go | 39 ++- act/model/workflow_test.go | 20 ++ act/runner/action.go | 2 +- act/runner/action_cache_test.go | 149 +++++++++--- act/runner/expression.go | 12 +- act/runner/helpers_test.go | 66 +++++ act/runner/job_executor.go | 35 ++- act/runner/job_executor_test.go | 7 +- act/runner/reusable_workflow.go | 9 +- act/runner/run_context.go | 97 +++++--- act/runner/run_context_test.go | 38 +++ act/runner/runner.go | 15 +- act/runner/runner_test.go | 121 ++++----- act/runner/step_action_remote.go | 4 +- act/runner/step_docker.go | 2 +- .../workflows/local-reusable-workflow.yml | 34 +++ .../GITHUB_ENV-use-in-env-ctx/push.yml | 3 +- act/runner/testdata/GITHUB_STATE/push.yml | 41 +--- .../push.yml | 4 - act/runner/testdata/actions/script/action.yml | 15 ++ act/runner/testdata/actions/script/index.js | 9 + .../testdata/actions/script/package.json | 5 + act/runner/testdata/actions/script/post.js | 6 + .../docker-action-custom-path/push.yml | 2 +- act/runner/testdata/issue-1195/push.yml | 13 - act/runner/testdata/issue-597/spelling.yaml | 19 +- act/runner/testdata/issue-598/spelling.yml | 22 +- .../testdata/job-container-non-root/push.yml | 3 +- .../testdata/local-action-dockerfile/push.yml | 1 - .../action.yml | 5 - .../local-remote-action-overrides/push.yml | 2 +- .../testdata/matrix-include-exclude/push.yml | 31 --- .../push.yml | 10 +- act/runner/testdata/path-handling/push.yml | 2 +- .../push.yml | 8 - .../push.yml | 23 -- .../testdata/remote-action-docker/push.yml | 10 - .../remote-action-js-node-user/push.yml | 30 --- act/runner/testdata/remote-action-js/push.yml | 12 - act/runner/testdata/runs-on/push.yml | 24 -- .../testdata/services-host-network/push.yml | 14 -- .../testdata/services-with-container/push.yml | 7 +- act/runner/testdata/services/push.yaml | 13 +- act/runner/testdata/shells/pwsh/push.yml | 7 - act/runner/testdata/shells/python/push.yml | 28 --- .../last-action/action.yml | 7 - .../last-action/main.js | 0 .../last-action/post.js | 17 -- .../push.yml | 15 -- .../testdata/uses-github-full-sha/main.yml | 7 - act/runner/testdata/uses-github-path/push.yml | 7 - .../testdata/uses-github-short-sha/main.yml | 7 - .../composite_action2/action.yml | 63 ----- .../testdata/uses-nested-composite/push.yml | 15 -- .../testdata/uses-workflow/local-workflow.yml | 42 ---- act/runner/testdata/uses-workflow/push.yml | 18 +- scripts/test-dind.sh | 96 ++++++++ 69 files changed, 969 insertions(+), 1176 deletions(-) delete mode 100644 act/artifacts/testdata/GHSL-2023-004/artifacts.yml delete mode 100644 act/artifacts/testdata/upload-and-download/artifacts.yml create mode 100644 act/container/helpers_test.go create mode 100644 act/runner/helpers_test.go create mode 100644 act/runner/testdata/.github/workflows/local-reusable-workflow.yml create mode 100644 act/runner/testdata/actions/script/action.yml create mode 100644 act/runner/testdata/actions/script/index.js create mode 100644 act/runner/testdata/actions/script/package.json create mode 100644 act/runner/testdata/actions/script/post.js delete mode 100644 act/runner/testdata/issue-1195/push.yml delete mode 100644 act/runner/testdata/matrix-include-exclude/push.yml delete mode 100644 act/runner/testdata/remote-action-composite-action-ref/push.yml delete mode 100644 act/runner/testdata/remote-action-composite-js-pre-with-defaults/push.yml delete mode 100644 act/runner/testdata/remote-action-docker/push.yml delete mode 100644 act/runner/testdata/remote-action-js-node-user/push.yml delete mode 100644 act/runner/testdata/remote-action-js/push.yml delete mode 100644 act/runner/testdata/runs-on/push.yml delete mode 100644 act/runner/testdata/services-host-network/push.yml delete mode 100644 act/runner/testdata/shells/python/push.yml delete mode 100644 act/runner/testdata/uses-action-with-pre-and-post-step/last-action/action.yml delete mode 100644 act/runner/testdata/uses-action-with-pre-and-post-step/last-action/main.js delete mode 100644 act/runner/testdata/uses-action-with-pre-and-post-step/last-action/post.js delete mode 100644 act/runner/testdata/uses-action-with-pre-and-post-step/push.yml delete mode 100644 act/runner/testdata/uses-github-full-sha/main.yml delete mode 100644 act/runner/testdata/uses-github-path/push.yml delete mode 100644 act/runner/testdata/uses-github-short-sha/main.yml delete mode 100644 act/runner/testdata/uses-nested-composite/composite_action2/action.yml delete mode 100644 act/runner/testdata/uses-nested-composite/push.yml delete mode 100644 act/runner/testdata/uses-workflow/local-workflow.yml create mode 100755 scripts/test-dind.sh diff --git a/.gitea/workflows/test.yml b/.gitea/workflows/test.yml index 6820bc80..c990f91e 100644 --- a/.gitea/workflows/test.yml +++ b/.gitea/workflows/test.yml @@ -9,14 +9,36 @@ jobs: lint: name: check and test runs-on: ubuntu-latest + env: + # The runner image ships a stale docker.io login; point docker at an empty config so + # image pulls go straight to anonymous instead of attempting (and failing) that auth + # first. The path must be a literal: the `runner` context is unavailable in job-level + # env, so `${{ runner.temp }}` would resolve to empty and config.Dir() would fall back + # to ~/.docker with the stale credentials. + DOCKER_CONFIG: /tmp/docker-noauth steps: - uses: actions/checkout@v6 - uses: actions/setup-go@v6 with: go-version-file: 'go.mod' + - name: prepare anonymous docker config + run: mkdir -p "$DOCKER_CONFIG" && echo '{}' > "$DOCKER_CONFIG/config.json" + # Pre-pull act/runner's two largest base images so a slow pull can't dominate `make test`; + # the rest (alpine/ubuntu) pull on demand, absorbed by the make-test -timeout. The host + # daemon retains them between runs, so this is usually a fast manifest re-check. + - name: pre-pull test images + run: | + for img in node:24-bookworm-slim nginx:alpine; do + for try in 1 2 3; do docker pull "$img" && break || sleep 5; done + done - name: lint run: make lint - name: build run: make build - name: test - run: make test \ No newline at end of file + 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 diff --git a/Makefile b/Makefile index 693685e1..0c644bd1 100644 --- a/Makefile +++ b/Makefile @@ -140,8 +140,12 @@ tidy-check: tidy fi .PHONY: test -test: fmt-check security-check ## test everything - @$(GO) test -race -short -v -cover -coverprofile coverage.txt ./... && echo "\n==>\033[32m Ok\033[m\n" || exit 1 +test: fmt-check security-check ## test everything (integration tests self-skip without docker/network) + @$(GO) test -race -timeout 20m -v -cover -coverprofile coverage.txt ./... && echo "\n==>\033[32m Ok\033[m\n" || exit 1 + +.PHONY: test-dind +test-dind: ## run the daemon-facing tests against the built dind image (TARGET=dind|dind-rootless) + @./scripts/test-dind.sh $(TARGET) .PHONY: install install: $(GOFILES) ## install the runner binary via `go install` diff --git a/act/artifacts/server_test.go b/act/artifacts/server_test.go index be150a22..76f8bcd5 100644 --- a/act/artifacts/server_test.go +++ b/act/artifacts/server_test.go @@ -5,24 +5,25 @@ package artifacts import ( - "context" + "bytes" + "compress/gzip" "encoding/json" "fmt" + "io" + "maps" "net/http" "net/http/httptest" + "net/url" "os" - "path" "path/filepath" "strings" "testing" "testing/fstest" - - "gitea.com/gitea/runner/act/model" - "gitea.com/gitea/runner/act/runner" + "time" "github.com/julienschmidt/httprouter" - log "github.com/sirupsen/logrus" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) type writableMapFile struct { @@ -234,89 +235,133 @@ func TestDownloadArtifactFile(t *testing.T) { assert.Equal("content", string(data)) } -type TestJobFileInfo struct { - workdir string - workflowPath string - eventName string - errorMessage string - platforms map[string]string - containerArchitecture string -} - -var ( - artifactsPath = path.Join(os.TempDir(), "test-artifacts") - artifactsAddr = "127.0.0.1" - artifactsPort = "12345" -) - +// TestArtifactFlow drives the real Serve() artifact server over a loopback socket, exercising +// the same upload -> finalize -> list -> download protocol the upload-artifact/download-artifact +// actions speak. Running it in-process (rather than from a job container) keeps it network-free +// and reachable everywhere, including when the CI job is itself a container. func TestArtifactFlow(t *testing.T) { - if testing.Short() { - t.Skip("skipping integration test") + artifactPath := t.TempDir() + + // Serve the exact routes Serve() wires up, on a real loopback socket via httptest. httptest + // picks a free port and Close() tears the server down synchronously — avoiding both the + // port-rebind race and Serve()'s detached ListenAndServe goroutine, which logger.Fatal()s + // (process exit) on a bind error and can outlive the test's temp-dir cleanup. + router := httprouter.New() + fsys := readWriteFSImpl{} + uploads(router, artifactPath, fsys) + downloads(router, artifactPath, fsys) + server := httptest.NewServer(router) + defer server.Close() + + baseURL := server.URL + client := server.Client() + client.Timeout = 5 * time.Second + + // request performs one HTTP call and returns the status and body. The default transport adds + // Accept-Encoding: gzip and transparently decompresses, so gzipped downloads come back plain. + request := func(t *testing.T, method, rawURL string, body io.Reader, header http.Header) (int, []byte) { + t.Helper() + req, err := http.NewRequest(method, rawURL, body) + require.NoError(t, err) + maps.Copy(req.Header, header) + resp, err := client.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + data, err := io.ReadAll(resp.Body) + require.NoError(t, err) + return resp.StatusCode, data } - ctx := context.Background() + t.Run("upload-and-download", func(t *testing.T) { + const runID, item, content = "1", "my-artifact/data.txt", "hello artifact\n" - cancel := Serve(ctx, artifactsPath, artifactsAddr, artifactsPort) - defer cancel() + status, data := request(t, http.MethodPost, baseURL+"/_apis/pipelines/workflows/"+runID+"/artifacts", nil, nil) + require.Equal(t, http.StatusOK, status, string(data)) + var prep FileContainerResourceURL + require.NoError(t, json.Unmarshal(data, &prep)) + require.Equal(t, baseURL+"/upload/"+runID, prep.FileContainerResourceURL) - platforms := map[string]string{ - "ubuntu-latest": "node:24-bookworm", // Don't use node:24-bookworm-slim because it doesn't have curl command, which is used in the tests - } + status, data = request(t, http.MethodPut, prep.FileContainerResourceURL+"?itemPath="+url.QueryEscape(item), strings.NewReader(content), nil) + require.Equal(t, http.StatusOK, status, string(data)) + var msg ResponseMessage + require.NoError(t, json.Unmarshal(data, &msg)) + require.Equal(t, "success", msg.Message) - tables := []TestJobFileInfo{ - {"testdata", "upload-and-download", "push", "", platforms, ""}, - {"testdata", "GHSL-2023-004", "push", "", platforms, ""}, - } - log.SetLevel(log.DebugLevel) + status, data = request(t, http.MethodPatch, baseURL+"/_apis/pipelines/workflows/"+runID+"/artifacts", nil, nil) + require.Equal(t, http.StatusOK, status, string(data)) - for _, table := range tables { - runTestJobFile(ctx, t, table) - } -} + status, data = request(t, http.MethodGet, baseURL+"/_apis/pipelines/workflows/"+runID+"/artifacts", nil, nil) + require.Equal(t, http.StatusOK, status, string(data)) + var list NamedFileContainerResourceURLResponse + require.NoError(t, json.Unmarshal(data, &list)) + require.Equal(t, 1, list.Count) + require.Equal(t, "my-artifact", list.Value[0].Name) -func runTestJobFile(ctx context.Context, t *testing.T, tjfi TestJobFileInfo) { - t.Run(tjfi.workflowPath, func(t *testing.T) { - fmt.Printf("::group::%s\n", tjfi.workflowPath) //nolint:forbidigo // pre-existing issue from nektos/act + status, data = request(t, http.MethodGet, list.Value[0].FileContainerResourceURL+"?itemPath=my-artifact", nil, nil) + require.Equal(t, http.StatusOK, status, string(data)) + var items ContainerItemResponse + require.NoError(t, json.Unmarshal(data, &items)) + require.Len(t, items.Value, 1) + require.Equal(t, "file", items.Value[0].ItemType) + require.Equal(t, "my-artifact/data.txt", items.Value[0].Path) - if err := os.RemoveAll(artifactsPath); err != nil { - panic(err) - } + status, data = request(t, http.MethodGet, items.Value[0].ContentLocation, nil, nil) + require.Equal(t, http.StatusOK, status) + require.Equal(t, content, string(data)) - workdir, err := filepath.Abs(tjfi.workdir) - assert.NoError(t, err, workdir) //nolint:testifylint // pre-existing issue from nektos/act - fullWorkflowPath := filepath.Join(workdir, tjfi.workflowPath) - runnerConfig := &runner.Config{ - Workdir: workdir, - BindWorkdir: false, - EventName: tjfi.eventName, - Platforms: tjfi.platforms, - ReuseContainers: false, - ContainerArchitecture: tjfi.containerArchitecture, - GitHubInstance: "github.com", - ArtifactServerPath: artifactsPath, - ArtifactServerAddr: artifactsAddr, - ArtifactServerPort: artifactsPort, - } + stored, err := os.ReadFile(filepath.Join(artifactPath, runID, "my-artifact", "data.txt")) + require.NoError(t, err) + require.Equal(t, content, string(stored)) + }) - runner, err := runner.New(runnerConfig) - assert.NoError(t, err, tjfi.workflowPath) //nolint:testifylint // pre-existing issue from nektos/act + t.Run("gzip-roundtrip", func(t *testing.T) { + const runID, item, content = "2", "logs/app.log", "compressed payload\n" - planner, err := model.NewWorkflowPlanner(fullWorkflowPath, true) - assert.NoError(t, err, fullWorkflowPath) //nolint:testifylint // pre-existing issue from nektos/act + var buf bytes.Buffer + gz := gzip.NewWriter(&buf) + _, err := gz.Write([]byte(content)) + require.NoError(t, err) + require.NoError(t, gz.Close()) - plan, err := planner.PlanEvent(tjfi.eventName) - if err == nil { - err = runner.NewPlanExecutor(plan)(ctx) - if tjfi.errorMessage == "" { - assert.NoError(t, err, fullWorkflowPath) //nolint:testifylint // pre-existing issue from nektos/act - } else { - assert.Error(t, err, tjfi.errorMessage) //nolint:testifylint // pre-existing issue from nektos/act - } - } else { - assert.Nil(t, plan) - } + status, data := request(t, http.MethodPut, baseURL+"/upload/"+runID+"?itemPath="+url.QueryEscape(item), + &buf, http.Header{"Content-Encoding": []string{"gzip"}}) + require.Equal(t, http.StatusOK, status, string(data)) - fmt.Println("::endgroup::") //nolint:forbidigo // pre-existing issue from nektos/act + // stored compressed, with the server's gzip marker suffix + _, err = os.Stat(filepath.Join(artifactPath, runID, "logs", "app.log.gz__")) + require.NoError(t, err) + + status, data = request(t, http.MethodGet, baseURL+"/download/"+runID+"?itemPath=logs", nil, nil) + require.Equal(t, http.StatusOK, status, string(data)) + var items ContainerItemResponse + require.NoError(t, json.Unmarshal(data, &items)) + require.Len(t, items.Value, 1) + require.Equal(t, "logs/app.log", items.Value[0].Path) + + status, data = request(t, http.MethodGet, items.Value[0].ContentLocation, nil, nil) + require.Equal(t, http.StatusOK, status) + require.Equal(t, content, string(data)) + }) + + // GHSL-2023-004: an itemPath that climbs out of the run directory must be neutralised so the + // blob cannot be written outside the artifact root. + t.Run("GHSL-2023-004", func(t *testing.T) { + const runID, content = "3", "contained\n" + + status, data := request(t, http.MethodPut, baseURL+"/upload/"+runID+"?itemPath="+url.QueryEscape("../../escape.txt"), + strings.NewReader(content), nil) + require.Equal(t, http.StatusOK, status, string(data)) + + stored, err := os.ReadFile(filepath.Join(artifactPath, runID, "escape.txt")) + require.NoError(t, err) + require.Equal(t, content, string(stored)) + + _, err = os.Stat(filepath.Join(filepath.Dir(artifactPath), "escape.txt")) + require.True(t, os.IsNotExist(err), "upload escaped the artifact root") + + status, data = request(t, http.MethodGet, baseURL+"/artifact/"+runID+"/escape.txt", nil, nil) + require.Equal(t, http.StatusOK, status) + require.Equal(t, content, string(data)) }) } diff --git a/act/artifacts/testdata/GHSL-2023-004/artifacts.yml b/act/artifacts/testdata/GHSL-2023-004/artifacts.yml deleted file mode 100644 index ec801c35..00000000 --- a/act/artifacts/testdata/GHSL-2023-004/artifacts.yml +++ /dev/null @@ -1,39 +0,0 @@ - -name: "GHSL-2023-0004" -on: push - -jobs: - test-artifacts: - runs-on: ubuntu-latest - steps: - - run: echo "hello world" > test.txt - - name: curl upload - run: curl --silent --show-error --fail ${ACTIONS_RUNTIME_URL}upload/1?itemPath=../../my-artifact/secret.txt --upload-file test.txt - - uses: actions/download-artifact@v2 - with: - name: my-artifact - path: test-artifacts - - name: 'Verify Artifact #1' - run: | - file="test-artifacts/secret.txt" - if [ ! -f $file ] ; then - echo "Expected file does not exist" - exit 1 - fi - if [ "$(cat $file)" != "hello world" ] ; then - echo "File contents of downloaded artifact are incorrect" - exit 1 - fi - - name: Verify download should work by clean extra dots - run: curl --silent --show-error --fail --path-as-is -o out.txt ${ACTIONS_RUNTIME_URL}artifact/1/../../../1/my-artifact/secret.txt - - name: 'Verify download content' - run: | - file="out.txt" - if [ ! -f $file ] ; then - echo "Expected file does not exist" - exit 1 - fi - if [ "$(cat $file)" != "hello world" ] ; then - echo "File contents of downloaded artifact are incorrect" - exit 1 - fi diff --git a/act/artifacts/testdata/upload-and-download/artifacts.yml b/act/artifacts/testdata/upload-and-download/artifacts.yml deleted file mode 100644 index a24c061b..00000000 --- a/act/artifacts/testdata/upload-and-download/artifacts.yml +++ /dev/null @@ -1,230 +0,0 @@ - -name: "Test that artifact uploads and downloads succeed" -on: push - -jobs: - test-artifacts: - runs-on: ubuntu-latest - steps: - - run: mkdir -p path/to/artifact - - run: echo hello > path/to/artifact/world.txt - - uses: actions/upload-artifact@v2 - with: - name: my-artifact - path: path/to/artifact/world.txt - - - run: rm -rf path - - - uses: actions/download-artifact@v2 - with: - name: my-artifact - - name: Display structure of downloaded files - run: ls -la - - # Test end-to-end by uploading two artifacts and then downloading them - - name: Create artifact files - run: | - mkdir -p path/to/dir-1 - mkdir -p path/to/dir-2 - mkdir -p path/to/dir-3 - mkdir -p path/to/dir-5 - mkdir -p path/to/dir-6 - mkdir -p path/to/dir-7 - echo "Lorem ipsum dolor sit amet" > path/to/dir-1/file1.txt - echo "Hello world from file #2" > path/to/dir-2/file2.txt - echo "This is a going to be a test for a large enough file that should get compressed with GZip. The @actions/artifact package uses GZip to upload files. This text should have a compression ratio greater than 100% so it should get uploaded using GZip" > path/to/dir-3/gzip.txt - dd if=/dev/random of=path/to/dir-5/file5.rnd bs=1024 count=1024 - dd if=/dev/random of=path/to/dir-6/file6.rnd bs=1024 count=$((10*1024)) - dd if=/dev/random of=path/to/dir-7/file7.rnd bs=1024 count=$((10*1024)) - - # Upload a single file artifact - - name: 'Upload artifact #1' - uses: actions/upload-artifact@v2 - with: - name: 'Artifact-A' - path: path/to/dir-1/file1.txt - - # Upload using a wildcard pattern, name should default to 'artifact' if not provided - - name: 'Upload artifact #2' - uses: actions/upload-artifact@v2 - with: - path: path/**/dir*/ - - # Upload a directory that contains a file that will be uploaded with GZip - - name: 'Upload artifact #3' - uses: actions/upload-artifact@v2 - with: - name: 'GZip-Artifact' - path: path/to/dir-3/ - - # Upload a directory that contains a file that will be uploaded with GZip - - name: 'Upload artifact #4' - uses: actions/upload-artifact@v2 - with: - name: 'Multi-Path-Artifact' - path: | - path/to/dir-1/* - path/to/dir-[23]/* - !path/to/dir-3/*.txt - - # Upload a mid-size file artifact - - name: 'Upload artifact #5' - uses: actions/upload-artifact@v2 - with: - name: 'Mid-Size-Artifact' - path: path/to/dir-5/file5.rnd - - # Upload a big file artifact - - name: 'Upload artifact #6' - uses: actions/upload-artifact@v2 - with: - name: 'Big-Artifact' - path: path/to/dir-6/file6.rnd - - # Upload a big file artifact twice - - name: 'Upload artifact #7 (First)' - uses: actions/upload-artifact@v2 - with: - name: 'Big-Uploaded-Twice' - path: path/to/dir-7/file7.rnd - - # Upload a big file artifact twice - - name: 'Upload artifact #7 (Second)' - uses: actions/upload-artifact@v2 - with: - name: 'Big-Uploaded-Twice' - path: path/to/dir-7/file7.rnd - - # Verify artifacts. Switch to download-artifact@v2 once it's out of preview - - # Download Artifact #1 and verify the correctness of the content - - name: 'Download artifact #1' - uses: actions/download-artifact@v2 - with: - name: 'Artifact-A' - path: some/new/path - - - name: 'Verify Artifact #1' - run: | - file="some/new/path/file1.txt" - if [ ! -f $file ] ; then - echo "Expected file does not exist" - exit 1 - fi - if [ "$(cat $file)" != "Lorem ipsum dolor sit amet" ] ; then - echo "File contents of downloaded artifact are incorrect" - exit 1 - fi - - # Download Artifact #2 and verify the correctness of the content - - name: 'Download artifact #2' - uses: actions/download-artifact@v2 - with: - name: 'artifact' - path: some/other/path - - - name: 'Verify Artifact #2' - run: | - file1="some/other/path/to/dir-1/file1.txt" - file2="some/other/path/to/dir-2/file2.txt" - if [ ! -f $file1 -o ! -f $file2 ] ; then - echo "Expected files do not exist" - exit 1 - fi - if [ "$(cat $file1)" != "Lorem ipsum dolor sit amet" -o "$(cat $file2)" != "Hello world from file #2" ] ; then - echo "File contents of downloaded artifacts are incorrect" - exit 1 - fi - - # Download Artifact #3 and verify the correctness of the content - - name: 'Download artifact #3' - uses: actions/download-artifact@v2 - with: - name: 'GZip-Artifact' - path: gzip/artifact/path - - # Because a directory was used as input during the upload the parent directories, path/to/dir-3/, should not be included in the uploaded artifact - - name: 'Verify Artifact #3' - run: | - gzipFile="gzip/artifact/path/gzip.txt" - if [ ! -f $gzipFile ] ; then - echo "Expected file do not exist" - exit 1 - fi - if [ "$(cat $gzipFile)" != "This is a going to be a test for a large enough file that should get compressed with GZip. The @actions/artifact package uses GZip to upload files. This text should have a compression ratio greater than 100% so it should get uploaded using GZip" ] ; then - echo "File contents of downloaded artifact is incorrect" - exit 1 - fi - - - name: 'Download artifact #4' - uses: actions/download-artifact@v2 - with: - name: 'Multi-Path-Artifact' - path: multi/artifact - - - name: 'Verify Artifact #4' - run: | - file1="multi/artifact/dir-1/file1.txt" - file2="multi/artifact/dir-2/file2.txt" - if [ ! -f $file1 -o ! -f $file2 ] ; then - echo "Expected files do not exist" - exit 1 - fi - if [ "$(cat $file1)" != "Lorem ipsum dolor sit amet" -o "$(cat $file2)" != "Hello world from file #2" ] ; then - echo "File contents of downloaded artifacts are incorrect" - exit 1 - fi - - - name: 'Download artifact #5' - uses: actions/download-artifact@v2 - with: - name: 'Mid-Size-Artifact' - path: mid-size/artifact/path - - - name: 'Verify Artifact #5' - run: | - file="mid-size/artifact/path/file5.rnd" - if [ ! -f $file ] ; then - echo "Expected file does not exist" - exit 1 - fi - if ! diff $file path/to/dir-5/file5.rnd ; then - echo "File contents of downloaded artifact are incorrect" - exit 1 - fi - - - name: 'Download artifact #6' - uses: actions/download-artifact@v2 - with: - name: 'Big-Artifact' - path: big/artifact/path - - - name: 'Verify Artifact #6' - run: | - file="big/artifact/path/file6.rnd" - if [ ! -f $file ] ; then - echo "Expected file does not exist" - exit 1 - fi - if ! diff $file path/to/dir-6/file6.rnd ; then - echo "File contents of downloaded artifact are incorrect" - exit 1 - fi - - - name: 'Download artifact #7' - uses: actions/download-artifact@v2 - with: - name: 'Big-Uploaded-Twice' - path: big-uploaded-twice/artifact/path - - - name: 'Verify Artifact #7' - run: | - file="big-uploaded-twice/artifact/path/file7.rnd" - if [ ! -f $file ] ; then - echo "Expected file does not exist" - exit 1 - fi - if ! diff $file path/to/dir-7/file7.rnd ; then - echo "File contents of downloaded artifact are incorrect" - exit 1 - fi diff --git a/act/common/executor_parallel_advanced_test.go b/act/common/executor_parallel_advanced_test.go index 5daec0b2..9e69927d 100644 --- a/act/common/executor_parallel_advanced_test.go +++ b/act/common/executor_parallel_advanced_test.go @@ -170,68 +170,6 @@ func TestMaxParallelWithErrors(t *testing.T) { }) } -// TestMaxParallelPerformance tests performance characteristics -func TestMaxParallelPerformance(t *testing.T) { - if testing.Short() { - t.Skip("Skipping performance test in short mode") - } - - t.Run("ParallelFasterThanSequential", func(t *testing.T) { - executors := make([]Executor, 10) - for i := range 10 { - executors[i] = func(ctx context.Context) error { - time.Sleep(50 * time.Millisecond) - return nil - } - } - - ctx := context.Background() - - // Sequential (max-parallel=1) - start := time.Now() - err := NewParallelExecutor(1, executors...)(ctx) - sequentialDuration := time.Since(start) - assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act - - // Parallel (max-parallel=5) - start = time.Now() - err = NewParallelExecutor(5, executors...)(ctx) - parallelDuration := time.Since(start) - assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act - - // Parallel should be significantly faster - assert.Less(t, parallelDuration, sequentialDuration/2, - "Parallel execution should be at least 2x faster") - }) - - t.Run("OptimalWorkerCount", func(t *testing.T) { - executors := make([]Executor, 20) - for i := range 20 { - executors[i] = func(ctx context.Context) error { - time.Sleep(10 * time.Millisecond) - return nil - } - } - - ctx := context.Background() - - // Test with different worker counts - workerCounts := []int{1, 2, 5, 10, 20} - durations := make(map[int]time.Duration) - - for _, count := range workerCounts { - start := time.Now() - err := NewParallelExecutor(count, executors...)(ctx) - durations[count] = time.Since(start) - assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act - } - - // More workers should generally be faster (up to a point) - assert.Less(t, durations[5], durations[1], "5 workers should be faster than 1") - assert.Less(t, durations[10], durations[2], "10 workers should be faster than 2") - }) -} - // TestMaxParallelResourceSharing tests resource sharing scenarios func TestMaxParallelResourceSharing(t *testing.T) { t.Run("SharedResourceWithMutex", func(t *testing.T) { diff --git a/act/common/git/git.go b/act/common/git/git.go index f8d22c3d..16120c70 100644 --- a/act/common/git/git.go +++ b/act/common/git/git.go @@ -66,8 +66,21 @@ func (e *Error) Commit() string { return e.commit } +// goGitMu serializes go-git repository access across the process. go-git is not safe for +// concurrent use of the same repository (even read access decodes packfiles into shared +// state), so parallel jobs inspecting the shared workdir repo race without this. The guarded +// operations are fast local reads; gitea runs one job per process, so the lock is effectively +// uncontended in production. +var goGitMu sync.Mutex + // FindGitRevision get the current git revision func FindGitRevision(ctx context.Context, file string) (shortSha, sha string, err error) { + goGitMu.Lock() + defer goGitMu.Unlock() + return findGitRevision(ctx, file) +} + +func findGitRevision(ctx context.Context, file string) (shortSha, sha string, err error) { logger := common.Logger(ctx) gitDir, err := git.PlainOpenWithOptions( @@ -99,10 +112,13 @@ func FindGitRevision(ctx context.Context, file string) (shortSha, sha string, er // FindGitRef get the current git ref func FindGitRef(ctx context.Context, file string) (string, error) { + goGitMu.Lock() + defer goGitMu.Unlock() + logger := common.Logger(ctx) logger.Debugf("Loading revision from git directory") - _, ref, err := FindGitRevision(ctx, file) + _, ref, err := findGitRevision(ctx, file) if err != nil { return "", err } @@ -174,6 +190,8 @@ func FindGitRef(ctx context.Context, file string) (string, error) { // FindGithubRepo get the repo func FindGithubRepo(ctx context.Context, file, githubInstance, remoteName string) (string, error) { + goGitMu.Lock() + defer goGitMu.Unlock() if remoteName == "" { remoteName = "origin" } diff --git a/act/common/git/git_test.go b/act/common/git/git_test.go index 86ed1af3..1a3398ef 100644 --- a/act/common/git/git_test.go +++ b/act/common/git/git_test.go @@ -16,7 +16,6 @@ import ( "testing" "time" - log "github.com/sirupsen/logrus" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -50,10 +49,6 @@ func TestFindGitSlug(t *testing.T) { } } -func testDir(t *testing.T) string { - return t.TempDir() -} - func cleanGitHooks(dir string) error { hooksDir := filepath.Join(dir, ".git", "hooks") files, err := os.ReadDir(hooksDir) @@ -78,8 +73,7 @@ func cleanGitHooks(dir string) error { func TestFindGitRemoteURL(t *testing.T) { assert := assert.New(t) - basedir := testDir(t) - gitConfig() + basedir := t.TempDir() err := gitCmd("init", basedir) assert.NoError(err) //nolint:testifylint // pre-existing issue from nektos/act err = cleanGitHooks(basedir) @@ -102,8 +96,7 @@ func TestFindGitRemoteURL(t *testing.T) { } func TestGitFindRef(t *testing.T) { - basedir := testDir(t) - gitConfig() + basedir := t.TempDir() for name, tt := range map[string]struct { Prepare func(t *testing.T, dir string) @@ -180,36 +173,55 @@ func TestGitFindRef(t *testing.T) { } func TestGitCloneExecutor(t *testing.T) { + // Build a local bare "remote" so this runs offline and fast. The cases below mirror + // the tag/branch/sha/short-sha ref paths the executor handles, formerly exercised by + // cloning actions/checkout and anchore/scan-action over the network. + remoteDir := t.TempDir() + require.NoError(t, gitCmd("init", "--bare", "--initial-branch=main", remoteDir)) + + workDir := t.TempDir() + require.NoError(t, gitCmd("clone", remoteDir, workDir)) + require.NoError(t, gitCmd("-C", workDir, "checkout", "-b", "main")) + require.NoError(t, gitCmd("-C", workDir, "commit", "--allow-empty", "-m", "initial")) + require.NoError(t, gitCmd("-C", workDir, "tag", "v2")) + require.NoError(t, gitCmd("-C", workDir, "push", "-u", "origin", "main")) + require.NoError(t, gitCmd("-C", workDir, "push", "origin", "v2")) + + // A branch with a dash in the name (mirrors the historical scan-action@act-fails case). + require.NoError(t, gitCmd("-C", workDir, "checkout", "-b", "act-fails")) + require.NoError(t, gitCmd("-C", workDir, "commit", "--allow-empty", "-m", "branch-commit")) + require.NoError(t, gitCmd("-C", workDir, "push", "origin", "act-fails")) + + out, err := exec.Command("git", "-C", workDir, "rev-parse", "main").Output() + require.NoError(t, err) + fullSha := strings.TrimSpace(string(out)) + for name, tt := range map[string]struct { - Err error - URL, Ref string + Err error + Ref string }{ "tag": { Err: nil, - URL: "https://github.com/actions/checkout", Ref: "v2", }, "branch": { Err: nil, - URL: "https://github.com/anchore/scan-action", Ref: "act-fails", }, "sha": { Err: nil, - URL: "https://github.com/actions/checkout", - Ref: "5a4ac9002d0be2fb38bd78e4b4dbde5606d7042f", // v2 + Ref: fullSha, }, "short-sha": { - Err: &Error{ErrShortRef, "5a4ac9002d0be2fb38bd78e4b4dbde5606d7042f"}, - URL: "https://github.com/actions/checkout", - Ref: "5a4ac90", // v2 + Err: &Error{ErrShortRef, fullSha}, + Ref: fullSha[:7], }, } { t.Run(name, func(t *testing.T) { clone := NewGitCloneExecutor(NewGitCloneExecutorInput{ - URL: tt.URL, + URL: remoteDir, Ref: tt.Ref, - Dir: testDir(t), + Dir: t.TempDir(), }) err := clone(context.Background()) @@ -228,8 +240,6 @@ func TestGitCloneExecutorNonFastForwardRef(t *testing.T) { // non-fast-forward between two fetches. Before the fix, the fetch used Force=false, // causing go-git to return ErrForceNeeded and short-circuit the checkout. - gitConfig() - // Create a bare "remote" repo with an initial commit on main and a feature branch. remoteDir := t.TempDir() require.NoError(t, gitCmd("init", "--bare", "--initial-branch=main", remoteDir)) @@ -280,8 +290,6 @@ func TestGitCloneExecutorNonFastForwardRef(t *testing.T) { } func TestGitCloneExecutorOfflineMode(t *testing.T) { - gitConfig() - // Build a local "remote" with a single commit on main. remoteDir := t.TempDir() require.NoError(t, gitCmd("init", "--bare", "--initial-branch=main", remoteDir)) @@ -327,22 +335,21 @@ func TestGitCloneExecutorOfflineMode(t *testing.T) { }) } -func gitConfig() { - if os.Getenv("GITHUB_ACTIONS") == "true" { - var err error - if err = gitCmd("config", "--global", "user.email", "test@test.com"); err != nil { - log.Error(err) - } - if err = gitCmd("config", "--global", "user.name", "Unit Test"); err != nil { - log.Error(err) - } - } -} - func gitCmd(args ...string) error { cmd := exec.Command("git", args...) cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr + // Inject a deterministic identity and ignore the host's global/system config so commits + // succeed regardless of the host having no user.name/user.email (e.g. CI, GITHUB_ACTIONS + // unset) or a global commit.gpgsign, and without mutating the developer's ~/.gitconfig. + cmd.Env = append(os.Environ(), + "GIT_AUTHOR_NAME=Unit Test", + "GIT_AUTHOR_EMAIL=test@test.com", + "GIT_COMMITTER_NAME=Unit Test", + "GIT_COMMITTER_EMAIL=test@test.com", + "GIT_CONFIG_GLOBAL=/dev/null", + "GIT_CONFIG_SYSTEM=/dev/null", + ) err := cmd.Run() if exitError, ok := err.(*exec.ExitError); ok { diff --git a/act/container/docker_auth.go b/act/container/docker_auth.go index 94c8fe71..8f5f2fa8 100644 --- a/act/container/docker_auth.go +++ b/act/container/docker_auth.go @@ -13,7 +13,6 @@ import ( "github.com/distribution/reference" "github.com/docker/cli/cli/config" - "github.com/docker/cli/cli/config/credentials" "github.com/moby/moby/api/types/registry" ) @@ -26,10 +25,6 @@ func LoadDockerAuthConfig(ctx context.Context, image string) (registry.AuthConfi logger.Warnf("Could not load docker config: %v", err) return registry.AuthConfig{}, err } - if !cfg.ContainsAuth() { - cfg.CredentialsStore = credentials.DetectDefaultStore(cfg.CredentialsStore) - } - registryKey := registryAuthConfigKey("docker.io") if image != "" { if registryRef, refErr := reference.ParseNormalizedNamed(image); refErr != nil { @@ -55,10 +50,6 @@ func LoadDockerAuthConfigs(ctx context.Context) map[string]registry.AuthConfig { logger.Warnf("Could not load docker config: %v", err) return nil } - if !cfg.ContainsAuth() { - cfg.CredentialsStore = credentials.DetectDefaultStore(cfg.CredentialsStore) - } - creds, err := cfg.GetAllCredentials() if err != nil { logger.Warnf("Could not get docker auth configs: %v", err) diff --git a/act/container/docker_images_test.go b/act/container/docker_images_test.go index d206bf5e..d30f4019 100644 --- a/act/container/docker_images_test.go +++ b/act/container/docker_images_test.go @@ -6,66 +6,64 @@ package container import ( "context" - "io" + "fmt" + "os" + "os/exec" + "strings" "testing" - "github.com/moby/moby/client" - specs "github.com/opencontainers/image-spec/specs-go/v1" log "github.com/sirupsen/logrus" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func init() { log.SetLevel(log.DebugLevel) } -func TestImageExistsLocally(t *testing.T) { - if testing.Short() { - t.Skip("skipping integration test") - } - ctx := context.Background() - // to help make this test reliable and not flaky, we need to have - // an image that will exist, and onew that won't exist - - // Test if image exists with specific tag - invalidImageTag, err := ImageExistsLocally(ctx, "library/alpine:this-random-tag-will-never-exist", "linux/amd64") - assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act - assert.False(t, invalidImageTag) - - // Test if image exists with specific architecture (image platform) - invalidImagePlatform, err := ImageExistsLocally(ctx, "alpine:latest", "windows/amd64") - assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act - assert.False(t, invalidImagePlatform) - - // pull an image - cli, err := client.New(client.FromEnv) - assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act - defer cli.Close() - - // Chose alpine latest because it's so small - // maybe we should build an image instead so that tests aren't reliable on dockerhub - readerDefault, err := cli.ImagePull(ctx, "node:24-bookworm-slim", client.ImagePullOptions{ - Platforms: []specs.Platform{{OS: "linux", Architecture: "amd64"}}, - }) - assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act - defer readerDefault.Close() - _, err = io.ReadAll(readerDefault) - assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act - - imageDefaultArchExists, err := ImageExistsLocally(ctx, "node:24-bookworm-slim", "linux/amd64") - assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act - assert.True(t, imageDefaultArchExists) - - // Validate if another architecture platform can be pulled - readerArm64, err := cli.ImagePull(ctx, "node:24-bookworm-slim", client.ImagePullOptions{ - Platforms: []specs.Platform{{OS: "linux", Architecture: "arm64"}}, - }) - assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act - defer readerArm64.Close() - _, err = io.ReadAll(readerArm64) - assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act - - imageArm64Exists, err := ImageExistsLocally(ctx, "node:24-bookworm-slim", "linux/arm64") - assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act - assert.True(t, imageArm64Exists) +// buildScratchImage builds a tiny empty image for the given platform locally (FROM scratch, no +// network or emulation since there is nothing to run) and returns its tag, removing it after +// the test. +func buildScratchImage(t *testing.T, platform string) string { + t.Helper() + tag := fmt.Sprintf("act-test-exists-%s:latest", strings.TrimPrefix(platform, "linux/")) + cmd := exec.Command("docker", "build", "--platform", platform, "-t", tag, "-") + cmd.Stdin = strings.NewReader("FROM scratch\nLABEL act-test=1\n") + // Force BuildKit: it records the requested architecture in the image config for a + // FROM-scratch build, whereas the classic builder ignores --platform and tags it with the + // host arch, which would break the per-platform existence assertions below. + cmd.Env = append(os.Environ(), "DOCKER_BUILDKIT=1") + out, err := cmd.CombinedOutput() + require.NoError(t, err, string(out)) + t.Cleanup(func() { _ = exec.Command("docker", "rmi", "-f", tag).Run() }) + return tag +} + +func TestImageExistsLocally(t *testing.T) { + requireDocker(t) + ctx := context.Background() + + // a non-existent image is reported absent + missing, err := ImageExistsLocally(ctx, "library/alpine:this-random-tag-will-never-exist", "linux/amd64") + assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act + assert.False(t, missing) + + // Build tiny images for two architectures locally so per-platform existence can be checked + // offline (formerly pulled node:24-bookworm-slim for amd64 and arm64 over the network). + amd64Ref := buildScratchImage(t, "linux/amd64") + arm64Ref := buildScratchImage(t, "linux/arm64") + + amd64Exists, err := ImageExistsLocally(ctx, amd64Ref, "linux/amd64") + assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act + assert.True(t, amd64Exists) + + // a non-host architecture image is detected for its own architecture + arm64Exists, err := ImageExistsLocally(ctx, arm64Ref, "linux/arm64") + assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act + assert.True(t, arm64Exists) + + // a present image is reported absent for a different platform + wrongPlatform, err := ImageExistsLocally(ctx, amd64Ref, "linux/arm64") + assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act + assert.False(t, wrongPlatform) } diff --git a/act/container/docker_run_test.go b/act/container/docker_run_test.go index 903ad67b..39f406aa 100644 --- a/act/container/docker_run_test.go +++ b/act/container/docker_run_test.go @@ -28,14 +28,10 @@ import ( ) func TestDocker(t *testing.T) { - if testing.Short() { - t.Skip("skipping integration test") - } + requireDocker(t) ctx := context.Background() client, err := GetDockerClient(ctx) - if err != nil { - t.Skipf("skipping integration test: %v", err) - } + require.NoError(t, err) defer client.Close() dockerBuild := NewDockerBuildExecutor(NewDockerBuildExecutorInput{ @@ -302,6 +298,35 @@ func TestDockerCopyTarStreamErrorInMkdir(t *testing.T) { 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{} diff --git a/act/container/helpers_test.go b/act/container/helpers_test.go new file mode 100644 index 00000000..0c660ae5 --- /dev/null +++ b/act/container/helpers_test.go @@ -0,0 +1,27 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package container + +import ( + "context" + "testing" + + mobyclient "github.com/moby/moby/client" +) + +// requireDocker skips the test unless a reachable docker daemon is available. +// GetDockerClient succeeds even without a running daemon (its ping is best-effort), +// so the daemon has to be pinged explicitly here to decide whether to skip. +func requireDocker(t *testing.T) { + t.Helper() + ctx := context.Background() + cli, err := GetDockerClient(ctx) + if err != nil { + t.Skipf("skipping: docker client unavailable: %v", err) + } + defer cli.Close() + if _, err := cli.Ping(ctx, mobyclient.PingOptions{}); err != nil { + t.Skipf("skipping: docker daemon unreachable: %v", err) + } +} diff --git a/act/model/workflow.go b/act/model/workflow.go index 3582843e..8327c800 100644 --- a/act/model/workflow.go +++ b/act/model/workflow.go @@ -325,14 +325,20 @@ func (j *Job) Needs() []string { // RunsOn list for Job func (j *Job) RunsOn() []string { - switch j.RawRunsOn.Kind { + return RunsOnFromNode(j.RawRunsOn) +} + +// RunsOnFromNode parses the runs-on labels from a raw runs-on node, so callers can evaluate a +// copy of the node (avoiding mutation of the shared Job) before reading the labels. +func RunsOnFromNode(rawRunsOn yaml.Node) []string { + switch rawRunsOn.Kind { case yaml.MappingNode: var val struct { Group string Labels yaml.Node } - if !decodeNode(j.RawRunsOn, &val) { + if !decodeNode(rawRunsOn, &val) { return nil } @@ -344,7 +350,7 @@ func (j *Job) RunsOn() []string { return labels default: - return nodeAsStringSlice(j.RawRunsOn) + return nodeAsStringSlice(rawRunsOn) } } @@ -645,6 +651,33 @@ type Step struct { TimeoutMinutes string `yaml:"timeout-minutes"` } +// Clone returns a deep copy safe to mutate independently of s. Job steps are shared across +// parallel matrix runs, which mutate per-job fields (ID, Number, Shell) and evaluate the If/Env +// yaml.Nodes in place, so each job must own its copy. +func (s *Step) Clone() *Step { + clone := *s + clone.If = CloneYamlNode(s.If) + clone.Env = CloneYamlNode(s.Env) + clone.With = maps.Clone(s.With) + return &clone +} + +// CloneYamlNode returns a deep copy of a yaml.Node so callers can evaluate it in place without +// mutating a node shared across parallel jobs. +func CloneYamlNode(n yaml.Node) yaml.Node { + clone := n + if n.Content != nil { + clone.Content = make([]*yaml.Node, len(n.Content)) + for i, child := range n.Content { + if child != nil { + childClone := CloneYamlNode(*child) + clone.Content[i] = &childClone + } + } + } + return clone +} + // String gets the name of step func (s *Step) String() string { if s.Name != "" { diff --git a/act/model/workflow_test.go b/act/model/workflow_test.go index 9b4f173f..f08f8b3b 100644 --- a/act/model/workflow_test.go +++ b/act/model/workflow_test.go @@ -9,9 +9,29 @@ import ( "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "go.yaml.in/yaml/v4" ) +// TestStepCloneIsolatesMutableFields guards the parallel-matrix race fix: combinations share the +// job's *Step, and Clone() must hand each a copy whose If/Env nodes and With map can be mutated +// independently. A shallow copy would share Env.Content's backing array (and the With map) and +// leak writes across combinations. +func TestStepCloneIsolatesMutableFields(t *testing.T) { + var orig Step + require.NoError(t, yaml.Unmarshal([]byte("if: ${{ env.X == 'a' }}\nenv:\n KEY: original\nwith:\n arg: original\n"), &orig)) + require.Len(t, orig.Env.Content, 2) // [key, value] + + clone := orig.Clone() + clone.If.Value = "changed" + clone.Env.Content[1].Value = "changed" + clone.With["arg"] = "changed" + + assert.Equal(t, "${{ env.X == 'a' }}", orig.If.Value, "If must not be shared with the clone") + assert.Equal(t, "original", orig.Env.Content[1].Value, "Env nodes must not be shared with the clone") + assert.Equal(t, "original", orig.With["arg"], "With map must not be shared with the clone") +} + func TestReadWorkflow_ScheduleEvent(t *testing.T) { yaml := ` name: local-action-docker-url diff --git a/act/runner/action.go b/act/runner/action.go index e07def75..1e144462 100644 --- a/act/runner/action.go +++ b/act/runner/action.go @@ -455,7 +455,7 @@ func newStepContainer(ctx context.Context, step step, image string, cmd, entrypo Platform: rc.Config.ContainerArchitecture, Options: rc.Config.ContainerOptions, AutoRemove: rc.Config.AutoRemove, - ValidVolumes: rc.Config.ValidVolumes, + ValidVolumes: rc.validVolumes(), AllocatePTY: rc.Config.AllocatePTY, }) return stepContainer diff --git a/act/runner/action_cache_test.go b/act/runner/action_cache_test.go index 3af46b33..5b185964 100644 --- a/act/runner/action_cache_test.go +++ b/act/runner/action_cache_test.go @@ -8,64 +8,139 @@ import ( "archive/tar" "bytes" "context" + "fmt" "io" + "os" + "os/exec" + "path/filepath" + "strings" "testing" + "time" + + "gitea.com/gitea/runner/act/common" + "gitea.com/gitea/runner/act/model" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) -func TestActionCache(t *testing.T) { - if testing.Short() { - t.Skip("skipping integration test") +func runGit(t *testing.T, dir string, args ...string) { + t.Helper() + if dir != "" { + args = append([]string{"-C", dir}, args...) } + cmd := exec.Command("git", args...) + // Fixed identity and host-config isolation so commits succeed offline regardless of the + // host's git config (mirrors gitCmd in act/common/git). + cmd.Env = append(os.Environ(), + "GIT_AUTHOR_NAME=test", "GIT_AUTHOR_EMAIL=test@example.com", + "GIT_COMMITTER_NAME=test", "GIT_COMMITTER_EMAIL=test@example.com", + "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null", + ) + out, err := cmd.CombinedOutput() + require.NoError(t, err, string(out)) +} + +// TestShortShaActionRejected verifies a `uses` ref that is a shortened commit SHA is rejected +// with a clear error. The action is resolved from a local repo (via DefaultActionInstance) so +// this runs offline. +func TestShortShaActionRejected(t *testing.T) { + // a local "remote" action repo at /actions/hello-world-docker-action + actionRoot := t.TempDir() + repo := filepath.Join(actionRoot, "actions", "hello-world-docker-action") + require.NoError(t, os.MkdirAll(repo, 0o755)) + runGit(t, "", "init", "--initial-branch=main", repo) + require.NoError(t, os.WriteFile(filepath.Join(repo, "action.yml"), + []byte("name: hello\nruns:\n using: node24\n main: index.js\n"), 0o644)) + runGit(t, repo, "add", ".") + runGit(t, repo, "commit", "-m", "initial") + out, err := exec.Command("git", "-C", repo, "rev-parse", "HEAD").Output() + require.NoError(t, err) + shortSha := strings.TrimSpace(string(out))[:7] + + // a workflow that uses the action at the short SHA + wfDir := filepath.Join(t.TempDir(), "wf") + require.NoError(t, os.MkdirAll(wfDir, 0o755)) + wf := fmt.Sprintf("on: push\njobs:\n test:\n runs-on: ubuntu-latest\n steps:\n - uses: actions/hello-world-docker-action@%s\n", shortSha) + require.NoError(t, os.WriteFile(filepath.Join(wfDir, "push.yml"), []byte(wf), 0o644)) + + runner, err := New(&Config{ + Workdir: wfDir, + EventName: "push", + Platforms: map[string]string{"ubuntu-latest": baseImage}, + GitHubInstance: "github.com", + DefaultActionInstance: actionRoot, + ContainerMaxLifetime: time.Hour, + }) + require.NoError(t, err) + planner, err := model.NewWorkflowPlanner(wfDir, true) + require.NoError(t, err) + plan, err := planner.PlanEvent("push") + require.NoError(t, err) + + err = runner.NewPlanExecutor(plan)(common.WithDryrun(context.Background(), true)) + require.Error(t, err) + assert.Contains(t, err.Error(), "shortened version of a commit SHA") +} + +func TestActionCache(t *testing.T) { a := assert.New(t) + ctx := context.Background() + + // Build a local bare repo with a `js` action dir so this runs offline (formerly cloned + // github.com/nektos/act-test-actions over the network). allowAnySHA1InWant lets the + // "Fetch Sha" case fetch a commit hash directly. + remoteDir := t.TempDir() + runGit(t, "", "init", "--bare", "--initial-branch=main", remoteDir) + runGit(t, remoteDir, "config", "uploadpack.allowAnySHA1InWant", "true") + + workDir := t.TempDir() + runGit(t, "", "clone", remoteDir, workDir) + require.NoError(t, os.MkdirAll(filepath.Join(workDir, "js"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(workDir, "js", "action.yml"), + []byte("name: js\nruns:\n using: node24\n main: index.js\n"), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(workDir, "js", "index.js"), + []byte("console.log('hello');\n"), 0o644)) + runGit(t, workDir, "add", ".") + runGit(t, workDir, "commit", "-m", "initial") + runGit(t, workDir, "push", "-u", "origin", "main") + + out, err := exec.Command("git", "-C", workDir, "rev-parse", "main").Output() + require.NoError(t, err) + fullSha := strings.TrimSpace(string(out)) + cache := &GoGitActionCache{ Path: t.TempDir(), } - ctx := context.Background() - cacheDir := "nektos/act-test-actions" - repo := "https://github.com/nektos/act-test-actions" + cacheDir := "local/act-test-actions" refs := []struct { - Name string - CacheDir string - Repo string - Ref string + Name string + Ref string }{ - { - Name: "Fetch Branch Name", - CacheDir: cacheDir, - Repo: repo, - Ref: "main", - }, - { - Name: "Fetch Branch Name Absolutely", - CacheDir: cacheDir, - Repo: repo, - Ref: "refs/heads/main", - }, - { - Name: "Fetch HEAD", - CacheDir: cacheDir, - Repo: repo, - Ref: "HEAD", - }, - { - Name: "Fetch Sha", - CacheDir: cacheDir, - Repo: repo, - Ref: "de984ca37e4df4cb9fd9256435a3b82c4a2662b1", - }, + {Name: "Fetch Branch Name", Ref: "main"}, + {Name: "Fetch Branch Name Absolutely", Ref: "refs/heads/main"}, + {Name: "Fetch HEAD", Ref: "HEAD"}, + {Name: "Fetch Sha", Ref: fullSha}, } for _, c := range refs { t.Run(c.Name, func(t *testing.T) { - sha, err := cache.Fetch(ctx, c.CacheDir, c.Repo, c.Ref, "") + sha, err := cache.Fetch(ctx, cacheDir, remoteDir, c.Ref, "") if !a.NoError(err) || !a.NotEmpty(sha) { //nolint:testifylint // pre-existing issue from nektos/act return } - atar, err := cache.GetTarArchive(ctx, c.CacheDir, sha, "js") - if !a.NoError(err) || !a.NotEmpty(atar) { //nolint:testifylint // pre-existing issue from nektos/act + atar, err := cache.GetTarArchive(ctx, cacheDir, sha, "js") + // NotNil, not NotEmpty: atar is a live io.PipeReader whose producer goroutine is + // writing concurrently; NotEmpty deep-reflects over its internals and races. + if !a.NoError(err) || !a.NotNil(atar) { //nolint:testifylint // pre-existing issue from nektos/act return } + // GetTarArchive streams from a background goroutine walking the shared repo. + // Drain and close so it finishes before the next subtest fetches into the same + // repo; otherwise the lingering walk races with that fetch. + defer func() { + _, _ = io.Copy(io.Discard, atar) + _ = atar.Close() + }() mytar := tar.NewReader(atar) th, err := mytar.Next() if !a.NoError(err) || !a.NotEqual(0, th.Size) { //nolint:testifylint // pre-existing issue from nektos/act diff --git a/act/runner/expression.go b/act/runner/expression.go index a28f1797..ba935daf 100644 --- a/act/runner/expression.go +++ b/act/runner/expression.go @@ -562,15 +562,15 @@ func getWorkflowSecrets(ctx context.Context, rc *RunContext) map[string]string { secrets = rc.caller.runContext.Config.Secrets } - if secrets == nil { - secrets = map[string]string{} - } - + // Interpolate into a new map. secrets may be the shared Config.Secrets (or the job's + // map), which other parallel jobs read concurrently (e.g. log masking), so mutating it + // in place is a data race. + interpolated := make(map[string]string, len(secrets)) for k, v := range secrets { - secrets[k] = rc.caller.runContext.ExprEval.Interpolate(ctx, v) + interpolated[k] = rc.caller.runContext.ExprEval.Interpolate(ctx, v) } - return secrets + return interpolated } return rc.Config.Secrets diff --git a/act/runner/helpers_test.go b/act/runner/helpers_test.go new file mode 100644 index 00000000..b773cb92 --- /dev/null +++ b/act/runner/helpers_test.go @@ -0,0 +1,66 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package runner + +import ( + "context" + "net" + "os/exec" + "runtime" + "testing" + "time" + + "gitea.com/gitea/runner/act/container" + + mobyclient "github.com/moby/moby/client" +) + +// requireLinuxDocker skips on non-Linux hosts. Some integration workflows need Docker features +// that only a Linux daemon provides (host networking, host /proc bind mounts); Docker Desktop +// on macOS/Windows does not, so those tests can only run on Linux. +func requireLinuxDocker(t *testing.T) { + t.Helper() + if runtime.GOOS != "linux" { + t.Skip("skipping: requires a Linux Docker host") + } +} + +// requireDocker skips the test unless a reachable docker daemon is available. +// GetDockerClient succeeds even without a running daemon (its ping is best-effort), +// so the daemon has to be pinged explicitly here to decide whether to skip. +func requireDocker(t *testing.T) { + t.Helper() + ctx := context.Background() + cli, err := container.GetDockerClient(ctx) + if err != nil { + t.Skipf("skipping: docker client unavailable: %v", err) + } + defer cli.Close() + if _, err := cli.Ping(ctx, mobyclient.PingOptions{}); err != nil { + t.Skipf("skipping: docker daemon unreachable: %v", err) + } +} + +// requireNetwork skips the test unless github.com is reachable. A few tests exercise behaviour +// that inherently needs the network (force-pulling an image, resolving a remote short-sha ref); +// gating lets the rest of the suite run offline without these failing. +func requireNetwork(t *testing.T) { + t.Helper() + conn, err := net.DialTimeout("tcp", "github.com:443", 3*time.Second) + if err != nil { + t.Skipf("skipping: network unavailable: %v", err) + } + _ = conn.Close() +} + +// requireHostTools skips the test unless every named executable is on PATH. Used by the +// self-hosted (host environment) suite, which runs steps directly on the host. +func requireHostTools(t *testing.T, tools ...string) { + t.Helper() + for _, tool := range tools { + if _, err := exec.LookPath(tool); err != nil { + t.Skipf("skipping: required host tool %q not found: %v", tool, err) + } + } +} diff --git a/act/runner/job_executor.go b/act/runner/job_executor.go index ed9f7062..d4a3076b 100644 --- a/act/runner/job_executor.go +++ b/act/runner/job_executor.go @@ -183,18 +183,25 @@ func newJobExecutor(info jobInfo, sf stepFactory, rc *RunContext) common.Executo func setJobResult(ctx context.Context, info jobInfo, rc *RunContext, success bool) { logger := common.Logger(ctx) - jobResult := "success" - // we have only one result for a whole matrix build, so we need - // to keep an existing result state if we run a matrix - if len(info.matrix()) > 0 && rc.Run.Job().Result != "" { - jobResult = rc.Run.Job().Result - } + // Matrix combinations share one *model.Job and run in parallel; serialize the + // read-modify-write of the job result so a failing combination is not lost-updated by a + // concurrent succeeding one. + job := rc.Run.Job() + jobResult := func() string { + defer lockJob(job)() + result := "success" + // we have only one result for a whole matrix build, so we need + // to keep an existing result state if we run a matrix + if len(info.matrix()) > 0 && job.Result != "" { + result = job.Result + } + if !success { + result = "failure" + } + info.result(result) + return result + }() - if !success { - jobResult = "failure" - } - - info.result(jobResult) if rc.caller != nil { // set reusable workflow job result rc.caller.setReusedWorkflowJobResult(rc.JobName, jobResult) // For Gitea @@ -220,7 +227,11 @@ func setJobOutputs(ctx context.Context, rc *RunContext) { callerOutputs[k] = ee.Interpolate(ctx, ee.Interpolate(ctx, v.Value)) } - rc.caller.runContext.Run.Job().Outputs = callerOutputs + // Matrix combinations of a reusable-workflow caller share the caller's *model.Job; + // serialize the write so parallel combos don't race on its Outputs field. + callerJob := rc.caller.runContext.Run.Job() + defer lockJob(callerJob)() + callerJob.Outputs = callerOutputs } } diff --git a/act/runner/job_executor_test.go b/act/runner/job_executor_test.go index fe56389a..a89ed0c7 100644 --- a/act/runner/job_executor_test.go +++ b/act/runner/job_executor_test.go @@ -21,18 +21,13 @@ import ( ) func TestJobExecutor(t *testing.T) { - if testing.Short() { - t.Skip("skipping integration test") - } + // Dryrun only checks syntax/planning; all cases resolve locally, so this runs offline. tables := []TestJobFileInfo{ {workdir, "uses-and-run-in-one-step", "push", "Invalid run/uses syntax for job:test step:Test", platforms, secrets}, {workdir, "uses-github-empty", "push", "Expected format {org}/{repo}[/path]@ref", platforms, secrets}, {workdir, "uses-github-noref", "push", "Expected format {org}/{repo}[/path]@ref", platforms, secrets}, {workdir, "uses-github-root", "push", "", platforms, secrets}, - {workdir, "uses-github-path", "push", "", platforms, secrets}, {workdir, "uses-docker-url", "push", "", platforms, secrets}, - {workdir, "uses-github-full-sha", "push", "", platforms, secrets}, - {workdir, "uses-github-short-sha", "push", "Unable to resolve action `actions/hello-world-docker-action@b136eb8`, the provided ref `b136eb8` is the shortened version of a commit SHA, which is not supported. Please use the full commit SHA `b136eb8894c5cb1dd5807da824be97ccdf9b5423` instead", platforms, secrets}, {workdir, "job-nil-step", "push", "invalid Step 0: missing run or uses key", platforms, secrets}, } // These tests are sufficient to only check syntax. diff --git a/act/runner/reusable_workflow.go b/act/runner/reusable_workflow.go index effb74db..e914937b 100644 --- a/act/runner/reusable_workflow.go +++ b/act/runner/reusable_workflow.go @@ -10,6 +10,7 @@ import ( "fmt" "net/url" "path" + "path/filepath" "regexp" "strings" @@ -27,7 +28,9 @@ func newLocalReusableWorkflowExecutor(rc *RunContext) common.Executor { workflowDir = strings.TrimPrefix(workflowDir, "./") return common.NewPipelineExecutor( - newReusableWorkflowExecutor(rc, workflowDir, fileName), + // resolve the local workflow against the workspace root, not the process + // working directory, so it is found regardless of where the runner is invoked + newReusableWorkflowExecutor(rc, filepath.Join(rc.Config.Workdir, workflowDir), fileName), ) } @@ -284,7 +287,11 @@ func setReusedWorkflowCallerResult(rc *RunContext, runner Runner) common.Executo if rc.caller != nil { rc.caller.setReusedWorkflowJobResult(rc.JobName, reusedWorkflowJobResult) } else { + // Serialize this shared Job.Result write against the other matrix combos + // and setJobResult (same lockJob key). + unlock := lockJob(rc.Run.Job()) rc.result(reusedWorkflowJobResult) + unlock() logger.WithField("jobResult", reusedWorkflowJobResult).Infof("Job %s", reusedWorkflowJobResultMessage) } } diff --git a/act/runner/run_context.go b/act/runner/run_context.go index 37aa7e52..a7f98ec9 100644 --- a/act/runner/run_context.go +++ b/act/runner/run_context.go @@ -20,7 +20,9 @@ import ( "path/filepath" "regexp" "runtime" + "slices" "strings" + "sync" "time" "gitea.com/gitea/runner/act/common" @@ -55,6 +57,10 @@ type RunContext struct { Masks []string cleanUpJobContainer common.Executor caller *caller // job calling this RunContext (reusable workflows) + // outputTemplate is this combination's pristine snapshot of the job's output expressions, + // captured before execution so each matrix combo interpolates from the originals rather + // than from a sibling's already-resolved values written into the shared Job.Outputs. + outputTemplate map[string]string } func (rc *RunContext) AddMask(mask string) { @@ -130,17 +136,34 @@ func getDockerDaemonSocketMountPath(daemonPath string) string { return daemonPath } +// containerDaemonSocket returns the configured Docker daemon socket, applying the default +// without mutating the shared Config. Parallel jobs in a plan share one *Config, so a job +// must never write to it. +func (rc *RunContext) containerDaemonSocket() string { + if rc.Config.ContainerDaemonSocket == "" { + return "/var/run/docker.sock" + } + return rc.Config.ContainerDaemonSocket +} + +// validVolumes returns the volumes allowed on this job's containers: the configured base +// plus the volumes the runner mounts automatically. It derives a fresh slice every call and +// never mutates the shared Config (see containerDaemonSocket). +func (rc *RunContext) validVolumes() []string { + name := rc.jobContainerName() + volumes := slices.Clone(rc.Config.ValidVolumes) + // TODO: add a new configuration to control whether the docker daemon can be mounted + return append(volumes, "act-toolcache", name, name+"-env", + getDockerDaemonSocketMountPath(rc.containerDaemonSocket())) +} + // Returns the binds and mounts for the container, resolving paths as appopriate func (rc *RunContext) GetBindsAndMounts() ([]string, map[string]string) { name := rc.jobContainerName() - if rc.Config.ContainerDaemonSocket == "" { - rc.Config.ContainerDaemonSocket = "/var/run/docker.sock" - } - binds := []string{} - if rc.Config.ContainerDaemonSocket != "-" { - daemonPath := getDockerDaemonSocketMountPath(rc.Config.ContainerDaemonSocket) + if daemonSocket := rc.containerDaemonSocket(); daemonSocket != "-" { + daemonPath := getDockerDaemonSocketMountPath(daemonSocket) binds = append(binds, fmt.Sprintf("%s:%s", daemonPath, "/var/run/docker.sock")) } @@ -179,14 +202,6 @@ func (rc *RunContext) GetBindsAndMounts() ([]string, map[string]string) { mounts[name] = ext.ToContainerPath(rc.Config.Workdir) } - // For Gitea - // add some default binds and mounts to ValidVolumes - rc.Config.ValidVolumes = append(rc.Config.ValidVolumes, "act-toolcache") - rc.Config.ValidVolumes = append(rc.Config.ValidVolumes, name) - rc.Config.ValidVolumes = append(rc.Config.ValidVolumes, name+"-env") - // TODO: add a new configuration to control whether the docker daemon can be mounted - rc.Config.ValidVolumes = append(rc.Config.ValidVolumes, getDockerDaemonSocketMountPath(rc.Config.ContainerDaemonSocket)) - return binds, mounts } @@ -432,7 +447,7 @@ func (rc *RunContext) startJobContainer() common.Executor { Platform: rc.Config.ContainerArchitecture, Options: rc.options(ctx), AutoRemove: rc.Config.AutoRemove, - ValidVolumes: rc.Config.ValidVolumes, + ValidVolumes: rc.validVolumes(), AllocatePTY: rc.Config.AllocatePTY, }) if rc.JobContainer == nil { @@ -586,14 +601,29 @@ func (rc *RunContext) ActionCacheDir() string { } // Interpolate outputs after a job is done +// jobMutexes serializes per-job result/output aggregation across the matrix combinations that +// share one *model.Job and run in parallel. Keyed by the shared *model.Job (mirrors the +// per-directory AcquireCloneLock pattern). +var jobMutexes sync.Map // key: *model.Job; value: *sync.Mutex + +func lockJob(job *model.Job) func() { + v, _ := jobMutexes.LoadOrStore(job, &sync.Mutex{}) + mu := v.(*sync.Mutex) + mu.Lock() + return mu.Unlock +} + func (rc *RunContext) interpolateOutputs() common.Executor { return func(ctx context.Context) error { ee := rc.NewExpressionEvaluator(ctx) - for k, v := range rc.Run.Job().Outputs { - interpolated := ee.Interpolate(ctx, v) - if v != interpolated { - rc.Run.Job().Outputs[k] = interpolated - } + job := rc.Run.Job() + // Matrix combinations share this Job and its Outputs map. Interpolate from this combo's + // pristine snapshot (outputTemplate) and write under the lock, so each combo overwrites + // with its own resolved values (last wins, as on GitHub) instead of the first combo's + // resolved values freezing the shared template against later combos. + defer lockJob(job)() + for k, v := range rc.outputTemplate { + job.Outputs[k] = ee.Interpolate(ctx, v) } return nil } @@ -660,7 +690,18 @@ func (rc *RunContext) result(result string) { } func (rc *RunContext) steps() []*model.Step { - return rc.Run.Job().Steps + // Return per-job copies of the steps. Matrix combinations run in parallel and share the + // workflow model, but step execution mutates per-job fields and evaluates the If/Env nodes + // in place, so the *model.Step instances must not be shared across jobs (see Step.Clone). + shared := rc.Run.Job().Steps + steps := make([]*model.Step, len(shared)) + for i, step := range shared { + if step == nil { + continue + } + steps[i] = step.Clone() + } + return steps } // Executor returns a pipeline executor for all the steps in the job @@ -737,12 +778,15 @@ func (rc *RunContext) runsOnPlatformNames(ctx context.Context) []string { return []string{} } - if err := rc.ExprEval.EvaluateYamlNode(ctx, &job.RawRunsOn); err != nil { + // Evaluate a copy: RawRunsOn is shared across parallel matrix jobs, so interpolating it in + // place would race and leak one matrix combination's runs-on into the others. + rawRunsOn := model.CloneYamlNode(job.RawRunsOn) + if err := rc.ExprEval.EvaluateYamlNode(ctx, &rawRunsOn); err != nil { common.Logger(ctx).Errorf("Error while evaluating runs-on: %v", err) return []string{} } - return job.RunsOn() + return model.RunsOnFromNode(rawRunsOn) } func (rc *RunContext) platformImage(ctx context.Context) string { @@ -1165,12 +1209,9 @@ func (rc *RunContext) handleServiceCredentials(ctx context.Context, creds map[st // GetServiceBindsAndMounts returns the binds and mounts for the service container, resolving paths as appopriate func (rc *RunContext) GetServiceBindsAndMounts(svcVolumes []string) ([]string, map[string]string) { - if rc.Config.ContainerDaemonSocket == "" { - rc.Config.ContainerDaemonSocket = "/var/run/docker.sock" - } binds := []string{} - if rc.Config.ContainerDaemonSocket != "-" { - daemonPath := getDockerDaemonSocketMountPath(rc.Config.ContainerDaemonSocket) + if daemonSocket := rc.containerDaemonSocket(); daemonSocket != "-" { + daemonPath := getDockerDaemonSocketMountPath(daemonSocket) binds = append(binds, fmt.Sprintf("%s:%s", daemonPath, "/var/run/docker.sock")) } diff --git a/act/runner/run_context_test.go b/act/runner/run_context_test.go index 22e993c9..56c14bd3 100644 --- a/act/runner/run_context_test.go +++ b/act/runner/run_context_test.go @@ -281,6 +281,44 @@ func TestRunContext_GetBindsAndMounts(t *testing.T) { }) } +func TestRunContextValidVolumes(t *testing.T) { + rc := &RunContext{ + Name: "job", + Run: &model.Run{Workflow: &model.Workflow{Name: "wf"}}, + Config: &Config{ValidVolumes: []string{"my-vol", "/host/path"}}, + } + name := rc.jobContainerName() + + got := rc.validVolumes() + + // the configured volumes plus the four the runner mounts automatically + assert.Subset(t, got, []string{"my-vol", "/host/path", "act-toolcache", name, name + "-env", "/var/run/docker.sock"}) + + // deriving the list must never mutate or grow the shared Config slice: parallel matrix + // combinations share one *Config, and the previous in-place append was a data race. + assert.Equal(t, []string{"my-vol", "/host/path"}, rc.Config.ValidVolumes) + assert.Len(t, rc.validVolumes(), len(got), "repeated calls must be stable, not accumulate") +} + +// TestInterpolateOutputsIsPerMatrixCombo guards the matrix-output fix: combinations share one +// *model.Job, so each must interpolate from its own pristine snapshot. Otherwise the first +// combo's resolved value freezes the shared template and later combos can't resolve their own. +func TestInterpolateOutputsIsPerMatrixCombo(t *testing.T) { + job := &model.Job{Outputs: map[string]string{"o": "${{ matrix.v }}"}} + run := &model.Run{JobID: "j", Workflow: &model.Workflow{Name: "w", Jobs: map[string]*model.Job{"j": job}}} + r := &runnerImpl{config: &Config{}} + ctx := context.Background() + + rcA := r.newRunContext(ctx, run, map[string]any{"v": "a"}) + rcB := r.newRunContext(ctx, run, map[string]any{"v": "b"}) + + require.NoError(t, rcA.interpolateOutputs()(ctx)) + require.NoError(t, rcB.interpolateOutputs()(ctx)) + + // Last combo wins (matching GitHub) instead of being frozen to combo A's "a". + require.Equal(t, "b", job.Outputs["o"]) +} + func TestGetGitHubContext(t *testing.T) { log.SetLevel(log.DebugLevel) diff --git a/act/runner/runner.go b/act/runner/runner.go index 1dda2b18..9f14d601 100644 --- a/act/runner/runner.go +++ b/act/runner/runner.go @@ -8,6 +8,7 @@ import ( "context" "encoding/json" "fmt" + "maps" "os" "runtime" "sync" @@ -250,7 +251,14 @@ func (runner *runnerImpl) NewPlanExecutor(plan *model.Plan) common.Executor { return executor(common.WithJobErrorContainer(WithJobLogger(ctx, rc.Run.JobID, jobName, rc.Config, &rc.Masks, matrix))) }) } - pipeline = append(pipeline, common.NewParallelExecutor(maxParallel, stageExecutor...)) + // Run all matrix combinations of this job, then drop its aggregation mutex: the + // combos are the only users of it, so once they finish the jobMutexes entry can be + // released, keeping the map from growing unbounded over a long-lived runner. + stageParallel := common.NewParallelExecutor(maxParallel, stageExecutor...) + pipeline = append(pipeline, func(ctx context.Context) error { + defer jobMutexes.Delete(job) + return stageParallel(ctx) + }) } // For pipeline execution: @@ -334,6 +342,11 @@ func (runner *runnerImpl) newRunContext(ctx context.Context, run *model.Run, mat } rc.ExprEval = rc.NewExpressionEvaluator(ctx) rc.Name = rc.ExprEval.Interpolate(ctx, run.String()) + // Snapshot the job's pristine output expressions now, before any matrix combo runs and + // rewrites the shared Job.Outputs (see interpolateOutputs). + if job := run.Job(); job != nil { + rc.outputTemplate = maps.Clone(job.Outputs) + } return rc } diff --git a/act/runner/runner_test.go b/act/runner/runner_test.go index 53cac993..92425818 100644 --- a/act/runner/runner_test.go +++ b/act/runner/runner_test.go @@ -188,14 +188,17 @@ func (j *TestJobFileInfo) runTest(ctx context.Context, t *testing.T, cfg *Config EventPath: cfg.EventPath, Platforms: j.platforms, ReuseContainers: false, + ForceRebuild: true, Env: cfg.Env, Secrets: cfg.Secrets, Inputs: cfg.Inputs, GitHubInstance: "github.com", + DefaultActionInstance: cfg.DefaultActionInstance, ContainerArchitecture: cfg.ContainerArchitecture, ContainerMaxLifetime: time.Hour, Matrix: cfg.Matrix, ActionCache: cfg.ActionCache, + ValidVolumes: []string{"**"}, // allow workflow-declared volumes (e.g. container-volumes) } runner, err := New(runnerConfig) @@ -223,18 +226,14 @@ type TestConfig struct { } func TestRunEvent(t *testing.T) { - if testing.Short() { - t.Skip("skipping integration test") - } + requireDocker(t) ctx := context.Background() tables := []TestJobFileInfo{ // Shells {workdir, "shells/defaults", "push", "", platforms, secrets}, - {workdir, "shells/pwsh", "push", "", map[string]string{"ubuntu-latest": "catthehacker/ubuntu:pwsh-latest"}, secrets}, // custom image with pwsh {workdir, "shells/bash", "push", "", platforms, secrets}, - {workdir, "shells/python", "push", "", map[string]string{"ubuntu-latest": "node:24-bookworm"}, secrets}, // slim doesn't have python {workdir, "shells/sh", "push", "", platforms, secrets}, // Local action @@ -246,11 +245,6 @@ func TestRunEvent(t *testing.T) { // Uses {workdir, "uses-composite", "push", "", platforms, secrets}, {workdir, "uses-composite-with-error", "push", "Job 'failing-composite-action' failed", platforms, secrets}, - {workdir, "uses-nested-composite", "push", "", platforms, secrets}, - {workdir, "remote-action-composite-js-pre-with-defaults", "push", "", platforms, secrets}, - {workdir, "remote-action-composite-action-ref", "push", "", platforms, secrets}, - {workdir, "uses-workflow", "push", "", platforms, map[string]string{"secret": "keep_it_private"}}, - {workdir, "uses-workflow", "pull_request", "", platforms, map[string]string{"secret": "keep_it_private"}}, {workdir, "uses-docker-url", "push", "", platforms, secrets}, {workdir, "act-composite-env-test", "push", "", platforms, secrets}, @@ -260,21 +254,15 @@ func TestRunEvent(t *testing.T) { {workdir, "evalmatrixneeds2", "push", "", platforms, secrets}, {workdir, "evalmatrix-merge-map", "push", "", platforms, secrets}, {workdir, "evalmatrix-merge-array", "push", "", platforms, secrets}, - {workdir, "issue-1195", "push", "", platforms, secrets}, {workdir, "basic", "push", "", platforms, secrets}, {workdir, "fail", "push", "exit with `FAILURE`: 1", platforms, secrets}, - {workdir, "runs-on", "push", "", platforms, secrets}, {workdir, "checkout", "push", "", platforms, secrets}, {workdir, "job-container", "push", "", platforms, secrets}, {workdir, "job-container-non-root", "push", "", platforms, secrets}, {workdir, "job-container-invalid-credentials", "push", "failed to handle credentials: failed to interpolate container.credentials.password", platforms, secrets}, {workdir, "container-hostname", "push", "", platforms, secrets}, - {workdir, "remote-action-docker", "push", "", platforms, secrets}, - {workdir, "remote-action-js", "push", "", platforms, secrets}, - {workdir, "remote-action-js-node-user", "push", "", platforms, secrets}, // Test if this works with non root container {workdir, "matrix", "push", "", platforms, secrets}, - {workdir, "matrix-include-exclude", "push", "", platforms, secrets}, {workdir, "matrix-exitcode", "push", "Job 'test' failed", platforms, secrets}, {workdir, "commands", "push", "", platforms, secrets}, {workdir, "workdir", "push", "", platforms, secrets}, @@ -295,7 +283,6 @@ func TestRunEvent(t *testing.T) { {workdir, "job-status-check", "push", "job 'fail' failed", platforms, secrets}, {workdir, "if-expressions", "push", "Job 'mytest' failed", platforms, secrets}, {workdir, "actions-environment-and-context-tests", "push", "", platforms, secrets}, - {workdir, "uses-action-with-pre-and-post-step", "push", "", platforms, secrets}, {workdir, "evalenv", "push", "", platforms, secrets}, {workdir, "docker-action-custom-path", "push", "", platforms, secrets}, {workdir, "GITHUB_ENV-use-in-env-ctx", "push", "", platforms, secrets}, @@ -306,7 +293,6 @@ func TestRunEvent(t *testing.T) { {workdir, "workflow_dispatch-scalar", "workflow_dispatch", "", platforms, secrets}, {workdir, "workflow_dispatch-scalar-composite-action", "workflow_dispatch", "", platforms, secrets}, {workdir, "job-needs-context-contains-result", "push", "", platforms, secrets}, - {"../model/testdata", "strategy", "push", "", platforms, secrets}, // TODO: move all testdata into pkg so we can validate it with planner and runner {"../model/testdata", "container-volumes", "push", "", platforms, secrets}, {workdir, "path-handling", "push", "", platforms, secrets}, {workdir, "do-not-leak-step-env-in-composite", "push", "", platforms, secrets}, @@ -316,7 +302,6 @@ func TestRunEvent(t *testing.T) { // services {workdir, "services", "push", "", platforms, secrets}, - {workdir, "services-host-network", "push", "", platforms, secrets}, {workdir, "services-with-container", "push", "", platforms, secrets}, // local remote action overrides @@ -325,6 +310,11 @@ func TestRunEvent(t *testing.T) { for _, table := range tables { t.Run(table.workflowPath, func(t *testing.T) { + if table.workflowPath == "container-volumes" { + // host /proc bind mounts are Linux-Docker-only + requireLinuxDocker(t) + } + config := &Config{ Secrets: table.secrets, } @@ -356,9 +346,12 @@ func TestRunEvent(t *testing.T) { } func TestRunEventHostEnvironment(t *testing.T) { - if testing.Short() { - t.Skip("skipping integration test") - } + // Runs steps directly on the host (the "-self-hosted" platform), so it needs the shells + // and tools the workflows invoke. No network gate: every action these workflows reference + // is a local `./` fixture or the skipped actions/checkout, so the suite runs offline (same + // as TestRunEvent). Only the broadly-used interpreters are required up front; the pwsh- and + // nix-specific cases gate on their own tool below so a missing pwsh/nix skips just those. + requireHostTools(t, "bash", "node") ctx := context.Background() @@ -374,7 +367,6 @@ func TestRunEventHostEnvironment(t *testing.T) { {workdir, "shells/defaults", "push", "", platforms, secrets}, {workdir, "shells/pwsh", "push", "", platforms, secrets}, {workdir, "shells/bash", "push", "", platforms, secrets}, - {workdir, "shells/python", "push", "", platforms, secrets}, {workdir, "shells/sh", "push", "", platforms, secrets}, // Local action @@ -383,7 +375,6 @@ func TestRunEventHostEnvironment(t *testing.T) { // Uses {workdir, "uses-composite", "push", "", platforms, secrets}, {workdir, "uses-composite-with-error", "push", "Job 'failing-composite-action' failed", platforms, secrets}, - {workdir, "uses-nested-composite", "push", "", platforms, secrets}, {workdir, "act-composite-env-test", "push", "", platforms, secrets}, // Eval @@ -392,14 +383,10 @@ func TestRunEventHostEnvironment(t *testing.T) { {workdir, "evalmatrixneeds2", "push", "", platforms, secrets}, {workdir, "evalmatrix-merge-map", "push", "", platforms, secrets}, {workdir, "evalmatrix-merge-array", "push", "", platforms, secrets}, - {workdir, "issue-1195", "push", "", platforms, secrets}, {workdir, "fail", "push", "exit with `FAILURE`: 1", platforms, secrets}, - {workdir, "runs-on", "push", "", platforms, secrets}, {workdir, "checkout", "push", "", platforms, secrets}, - {workdir, "remote-action-js", "push", "", platforms, secrets}, {workdir, "matrix", "push", "", platforms, secrets}, - {workdir, "matrix-include-exclude", "push", "", platforms, secrets}, {workdir, "commands", "push", "", platforms, secrets}, {workdir, "defaults-run", "push", "", platforms, secrets}, {workdir, "composite-fail-with-output", "push", "", platforms, secrets}, @@ -413,7 +400,6 @@ func TestRunEventHostEnvironment(t *testing.T) { {workdir, "steps-context/outcome", "push", "", platforms, secrets}, {workdir, "job-status-check", "push", "job 'fail' failed", platforms, secrets}, {workdir, "if-expressions", "push", "Job 'mytest' failed", platforms, secrets}, - {workdir, "uses-action-with-pre-and-post-step", "push", "", platforms, secrets}, {workdir, "evalenv", "push", "", platforms, secrets}, {workdir, "ensure-post-steps", "push", "Job 'second-post-step-should-fail' failed", platforms, secrets}, }...) @@ -446,24 +432,26 @@ func TestRunEventHostEnvironment(t *testing.T) { for _, table := range tables { t.Run(table.workflowPath, func(t *testing.T) { + switch table.workflowPath { + case "shells/pwsh": + requireHostTools(t, "pwsh") + case "nix-prepend-path": + requireHostTools(t, "nix") + } table.runTest(ctx, t, &Config{}) }) } } func TestDryrunEvent(t *testing.T) { - if testing.Short() { - t.Skip("skipping integration test") - } - + // Dryrun plans without containers or network (shells and local actions only). ctx := common.WithDryrun(context.Background(), true) tables := []TestJobFileInfo{ // Shells {workdir, "shells/defaults", "push", "", platforms, secrets}, - {workdir, "shells/pwsh", "push", "", map[string]string{"ubuntu-latest": "catthehacker/ubuntu:pwsh-latest"}, secrets}, // custom image with pwsh + {workdir, "shells/pwsh", "push", "", platforms, secrets}, {workdir, "shells/bash", "push", "", platforms, secrets}, - {workdir, "shells/python", "push", "", map[string]string{"ubuntu-latest": "node:24-bookworm"}, secrets}, // slim doesn't have python {workdir, "shells/sh", "push", "", platforms, secrets}, // Local action @@ -480,10 +468,18 @@ func TestDryrunEvent(t *testing.T) { } } +// TestReusableWorkflowCaller exercises the reusable-workflow caller path against a local +// reusable workflow (typed inputs, secrets as both a map and `inherit`, and reading the called +// workflow's outputs via `needs`). +func TestReusableWorkflowCaller(t *testing.T) { + requireDocker(t) + table := TestJobFileInfo{workdir, "uses-workflow", "push", "", platforms, map[string]string{"secret": "keep_it_private"}} + table.runTest(context.Background(), t, &Config{Secrets: table.secrets}) +} + func TestDockerActionForcePullForceRebuild(t *testing.T) { - if testing.Short() { - t.Skip("skipping integration test") - } + requireDocker(t) + requireNetwork(t) // force-pulls a docker action image ctx := context.Background() @@ -504,22 +500,6 @@ func TestDockerActionForcePullForceRebuild(t *testing.T) { } } -func TestRunDifferentArchitecture(t *testing.T) { - if testing.Short() { - t.Skip("skipping integration test") - } - - tjfi := TestJobFileInfo{ - workdir: workdir, - workflowPath: "basic", - eventName: "push", - errorMessage: "", - platforms: platforms, - } - - tjfi.runTest(context.Background(), t, &Config{ContainerArchitecture: "linux/arm64"}) -} - type maskJobLoggerFactory struct { Output bytes.Buffer } @@ -540,9 +520,7 @@ func TestMaskValues(t *testing.T) { assert.False(t, strings.Contains(text, "composite secret")) //nolint:testifylint // pre-existing issue from nektos/act } - if testing.Short() { - t.Skip("skipping integration test") - } + requireDocker(t) log.SetLevel(log.DebugLevel) @@ -563,9 +541,7 @@ func TestMaskValues(t *testing.T) { } func TestRunEventSecrets(t *testing.T) { - if testing.Short() { - t.Skip("skipping integration test") - } + requireDocker(t) workflowPath := "secrets" tjfi := TestJobFileInfo{ @@ -585,9 +561,7 @@ func TestRunEventSecrets(t *testing.T) { } func TestRunWithService(t *testing.T) { - if testing.Short() { - t.Skip("skipping integration test") - } + requireDocker(t) log.SetLevel(log.DebugLevel) ctx := context.Background() @@ -603,10 +577,11 @@ func TestRunWithService(t *testing.T) { assert.NoError(t, err, workflowPath) //nolint:testifylint // pre-existing issue from nektos/act runnerConfig := &Config{ - Workdir: workdir, - EventName: eventName, - Platforms: platforms, - ReuseContainers: false, + Workdir: workdir, + EventName: eventName, + Platforms: platforms, + ReuseContainers: false, + ContainerMaxLifetime: time.Hour, // otherwise the job container is `sleep 0` and exits at once } runner, err := New(runnerConfig) assert.NoError(t, err, workflowPath) //nolint:testifylint // pre-existing issue from nektos/act @@ -622,9 +597,7 @@ func TestRunWithService(t *testing.T) { } func TestRunActionInputs(t *testing.T) { - if testing.Short() { - t.Skip("skipping integration test") - } + requireDocker(t) workflowPath := "input-from-cli" tjfi := TestJobFileInfo{ @@ -643,9 +616,7 @@ func TestRunActionInputs(t *testing.T) { } func TestRunEventPullRequest(t *testing.T) { - if testing.Short() { - t.Skip("skipping integration test") - } + requireDocker(t) workflowPath := "pull-request" @@ -661,9 +632,7 @@ func TestRunEventPullRequest(t *testing.T) { } func TestRunMatrixWithUserDefinedInclusions(t *testing.T) { - if testing.Short() { - t.Skip("skipping integration test") - } + requireDocker(t) workflowPath := "matrix-with-user-inclusions" tjfi := TestJobFileInfo{ diff --git a/act/runner/step_action_remote.go b/act/runner/step_action_remote.go index fdddebad..2df7e808 100644 --- a/act/runner/step_action_remote.go +++ b/act/runner/step_action_remote.go @@ -291,7 +291,9 @@ type remoteAction struct { func (ra *remoteAction) CloneURL(u string) string { if ra.URL == "" { - if !strings.HasPrefix(u, "http://") && !strings.HasPrefix(u, "https://") { + // keep an absolute local path as-is (used by tests to resolve actions from a local + // repo); only bare host names get the https:// scheme prepended + if !strings.HasPrefix(u, "http://") && !strings.HasPrefix(u, "https://") && !filepath.IsAbs(u) { u = "https://" + u } } else { diff --git a/act/runner/step_docker.go b/act/runner/step_docker.go index 252de6ea..28c3bc10 100644 --- a/act/runner/step_docker.go +++ b/act/runner/step_docker.go @@ -138,7 +138,7 @@ func (sd *stepDocker) newStepContainer(ctx context.Context, image string, cmd, e UsernsMode: rc.Config.UsernsMode, Platform: rc.Config.ContainerArchitecture, AutoRemove: rc.Config.AutoRemove, - ValidVolumes: rc.Config.ValidVolumes, + ValidVolumes: rc.validVolumes(), AllocatePTY: rc.Config.AllocatePTY, }) return stepContainer diff --git a/act/runner/testdata/.github/workflows/local-reusable-workflow.yml b/act/runner/testdata/.github/workflows/local-reusable-workflow.yml new file mode 100644 index 00000000..113002fa --- /dev/null +++ b/act/runner/testdata/.github/workflows/local-reusable-workflow.yml @@ -0,0 +1,34 @@ +name: local-reusable-workflow +on: + workflow_call: + inputs: + string_required: + required: true + type: string + bool_required: + required: true + type: boolean + number_required: + required: true + type: number + secrets: + secret: + required: true + outputs: + output: + value: ${{ jobs.reusable.outputs.output }} + +jobs: + reusable: + runs-on: ubuntu-latest + outputs: + output: ${{ steps.gen.outputs.output }} + steps: + - name: check inputs and secret arrived + run: | + [ "${{ inputs.string_required }}" = "string" ] + [ "${{ inputs.bool_required }}" = "true" ] + [ "${{ inputs.number_required }}" = "1" ] + [ "${{ secrets.secret }}" = "keep_it_private" ] + - id: gen + run: echo "output=${{ inputs.string_required }}" >> $GITHUB_OUTPUT diff --git a/act/runner/testdata/GITHUB_ENV-use-in-env-ctx/push.yml b/act/runner/testdata/GITHUB_ENV-use-in-env-ctx/push.yml index c7b75a02..af33415e 100644 --- a/act/runner/testdata/GITHUB_ENV-use-in-env-ctx/push.yml +++ b/act/runner/testdata/GITHUB_ENV-use-in-env-ctx/push.yml @@ -5,10 +5,11 @@ jobs: env: MYGLOBALENV3: myglobalval3 steps: + - uses: actions/checkout@v4 - run: | echo MYGLOBALENV1=myglobalval1 > $GITHUB_ENV echo "::set-env name=MYGLOBALENV2::myglobalval2" - - uses: nektos/act-test-actions/script@main + - uses: ./actions/script with: main: | env diff --git a/act/runner/testdata/GITHUB_STATE/push.yml b/act/runner/testdata/GITHUB_STATE/push.yml index 61afc07c..338e1220 100644 --- a/act/runner/testdata/GITHUB_STATE/push.yml +++ b/act/runner/testdata/GITHUB_STATE/push.yml @@ -1,48 +1,31 @@ on: push jobs: + # State saved in main (via the $GITHUB_STATE file and the ::save-state command) must surface + # as $STATE_* in the action's post step. _: runs-on: ubuntu-latest steps: - - uses: nektos/act-test-actions/script@main + - uses: actions/checkout@v4 + - uses: ./actions/script with: - pre: | - env - echo mystate0=mystateval > $GITHUB_STATE - echo "::save-state name=mystate1::mystateval" main: | - env echo mystate2=mystateval > $GITHUB_STATE echo "::save-state name=mystate3::mystateval" post: | - env - [ "$STATE_mystate0" = "mystateval" ] - [ "$STATE_mystate1" = "mystateval" ] [ "$STATE_mystate2" = "mystateval" ] [ "$STATE_mystate3" = "mystateval" ] + # State must be isolated per action instance even when two steps use the same action. test-id-collision-bug: runs-on: ubuntu-latest steps: - - uses: nektos/act-test-actions/script@main + - uses: actions/checkout@v4 + - uses: ./actions/script id: script with: - pre: | - env - echo mystate0=mystateval > $GITHUB_STATE - echo "::save-state name=mystate1::mystateval" - main: | - env - echo mystate2=mystateval > $GITHUB_STATE - echo "::save-state name=mystate3::mystateval" - post: | - env - [ "$STATE_mystate0" = "mystateval" ] - [ "$STATE_mystate1" = "mystateval" ] - [ "$STATE_mystate2" = "mystateval" ] - [ "$STATE_mystate3" = "mystateval" ] - - uses: nektos/act-test-actions/script@main + main: echo mystate=val1 > $GITHUB_STATE + post: '[ "$STATE_mystate" = "val1" ]' + - uses: ./actions/script id: pre-script with: - main: | - env - echo mystate0=mystateerror > $GITHUB_STATE - echo "::save-state name=mystate1::mystateerror" \ No newline at end of file + main: echo mystate=val2 > $GITHUB_STATE + post: '[ "$STATE_mystate" = "val2" ]' diff --git a/act/runner/testdata/actions-environment-and-context-tests/push.yml b/act/runner/testdata/actions-environment-and-context-tests/push.yml index 1d799d57..8b9919f8 100644 --- a/act/runner/testdata/actions-environment-and-context-tests/push.yml +++ b/act/runner/testdata/actions-environment-and-context-tests/push.yml @@ -9,7 +9,3 @@ jobs: - uses: actions/checkout@v3 - uses: './actions-environment-and-context-tests/js' - uses: './actions-environment-and-context-tests/docker' - - uses: 'nektos/act-test-actions/js@main' - - uses: 'nektos/act-test-actions/docker@main' - - uses: 'nektos/act-test-actions/docker-file@main' - - uses: 'nektos/act-test-actions/docker-relative-context/action@main' diff --git a/act/runner/testdata/actions/script/action.yml b/act/runner/testdata/actions/script/action.yml new file mode 100644 index 00000000..ddf94408 --- /dev/null +++ b/act/runner/testdata/actions/script/action.yml @@ -0,0 +1,15 @@ +name: 'script' +description: 'Run the shell scripts passed as inputs across the pre/main/post lifecycle' +inputs: + main: + description: 'shell script to run in the main step' + required: false + default: '' + post: + description: 'shell script to run in the post step' + required: false + default: '' +runs: + using: 'node24' + main: 'index.js' + post: 'post.js' diff --git a/act/runner/testdata/actions/script/index.js b/act/runner/testdata/actions/script/index.js new file mode 100644 index 00000000..d6fedf98 --- /dev/null +++ b/act/runner/testdata/actions/script/index.js @@ -0,0 +1,9 @@ +import {execFileSync} from 'node:child_process'; + +// Run the `main` input as a bash script; its stdout (workflow commands like +// ::set-output / ::save-state) and $GITHUB_ENV / $GITHUB_STATE writes are +// processed by the runner, exactly like the remote script action this replaces. +const script = process.env.INPUT_MAIN; +if (script) { + execFileSync('bash', ['-eo', 'pipefail', '-c', script], {stdio: 'inherit'}); +} diff --git a/act/runner/testdata/actions/script/package.json b/act/runner/testdata/actions/script/package.json new file mode 100644 index 00000000..8794ff76 --- /dev/null +++ b/act/runner/testdata/actions/script/package.json @@ -0,0 +1,5 @@ +{ + "name": "script", + "private": true, + "type": "module" +} diff --git a/act/runner/testdata/actions/script/post.js b/act/runner/testdata/actions/script/post.js new file mode 100644 index 00000000..e9aa3bd5 --- /dev/null +++ b/act/runner/testdata/actions/script/post.js @@ -0,0 +1,6 @@ +import {execFileSync} from 'node:child_process'; + +const script = process.env.INPUT_POST; +if (script) { + execFileSync('bash', ['-eo', 'pipefail', '-c', script], {stdio: 'inherit'}); +} diff --git a/act/runner/testdata/docker-action-custom-path/push.yml b/act/runner/testdata/docker-action-custom-path/push.yml index 37bbf417..38e48f92 100644 --- a/act/runner/testdata/docker-action-custom-path/push.yml +++ b/act/runner/testdata/docker-action-custom-path/push.yml @@ -4,7 +4,7 @@ jobs: runs-on: ubuntu-latest steps: - run: | - FROM ubuntu:latest + FROM node:24-bookworm-slim ENV PATH="/opt/texlive/texdir/bin/x86_64-linuxmusl:${PATH}" ENV ORG_PATH="${PATH}" ENTRYPOINT [ "bash", "-c", "echo \"PATH=$PATH\" && echo \"ORG_PATH=$ORG_PATH\" && [[ \"$PATH\" = \"$ORG_PATH\" ]]" ] diff --git a/act/runner/testdata/issue-1195/push.yml b/act/runner/testdata/issue-1195/push.yml deleted file mode 100644 index c211ad2c..00000000 --- a/act/runner/testdata/issue-1195/push.yml +++ /dev/null @@ -1,13 +0,0 @@ -on: push - -env: - variable: "${{ github.repository_owner }}" - -jobs: - test: - runs-on: ubuntu-latest - steps: - - name: print env.variable - run: | - echo ${{ env.variable }} - exit ${{ (env.variable == 'nektos') && '0' || '1'}} diff --git a/act/runner/testdata/issue-597/spelling.yaml b/act/runner/testdata/issue-597/spelling.yaml index d594dcbb..db20afa1 100644 --- a/act/runner/testdata/issue-597/spelling.yaml +++ b/act/runner/testdata/issue-597/spelling.yaml @@ -9,24 +9,13 @@ jobs: steps: - name: My first false step if: "endsWith('Should not', 'o1')" - uses: actions/checkout@v2.0.0 - with: - ref: refs/pull/${{github.event.pull_request.number}}/merge - fetch-depth: 5 + run: exit 1 - name: My first true step if: ${{endsWith('Hello world', 'ld')}} - uses: actions/hello-world-javascript-action@main - with: - who-to-greet: "Renst the Octocat" + run: echo "Renst the Octocat" - name: My second false step if: "endsWith('Should not evaluate', 'o2')" - uses: actions/checkout@v2.0.0 - with: - ref: refs/pull/${{github.event.pull_request.number}}/merge - fetch-depth: 5 + run: exit 1 - name: My third false step if: ${{endsWith('Should not evaluate', 'o3')}} - uses: actions/checkout@v2.0.0 - with: - ref: refs/pull/${{github.event.pull_request.number}}/merge - fetch-depth: 5 + run: exit 1 diff --git a/act/runner/testdata/issue-598/spelling.yml b/act/runner/testdata/issue-598/spelling.yml index 65255f97..ea920491 100644 --- a/act/runner/testdata/issue-598/spelling.yml +++ b/act/runner/testdata/issue-598/spelling.yml @@ -1,31 +1,21 @@ name: issue-598 on: push - + jobs: my_first_job: - + runs-on: ubuntu-latest steps: - name: My first false step if: "endsWith('Hello world', 'o1')" - uses: actions/hello-world-javascript-action@main - with: - who-to-greet: 'Mona the Octocat' + run: exit 1 - name: My first true step if: "!endsWith('Hello world', 'od')" - uses: actions/hello-world-javascript-action@main - with: - who-to-greet: "Renst the Octocat" + run: echo "Renst the Octocat" - name: My second false step if: "endsWith('Hello world', 'o2')" - uses: actions/hello-world-javascript-action@main - with: - who-to-greet: 'Act the Octocat' + run: exit 1 - name: My third false step if: "endsWith('Hello world', 'o2')" - uses: actions/hello-world-javascript-action@main - with: - who-to-greet: 'Git the Octocat' - - \ No newline at end of file + run: exit 1 diff --git a/act/runner/testdata/job-container-non-root/push.yml b/act/runner/testdata/job-container-non-root/push.yml index 1fe0e3be..68a9f392 100644 --- a/act/runner/testdata/job-container-non-root/push.yml +++ b/act/runner/testdata/job-container-non-root/push.yml @@ -5,6 +5,7 @@ jobs: test: runs-on: ubuntu-latest container: - image: catthehacker/ubuntu:runner-latest # image with user 'runner:runner' built on tag 'act-latest' + image: node:24-bookworm-slim + options: --user 1000 steps: - run: echo PASS diff --git a/act/runner/testdata/local-action-dockerfile/push.yml b/act/runner/testdata/local-action-dockerfile/push.yml index a4a5b06e..636ea431 100644 --- a/act/runner/testdata/local-action-dockerfile/push.yml +++ b/act/runner/testdata/local-action-dockerfile/push.yml @@ -24,4 +24,3 @@ jobs: args: ${{format('"{0}"', 'Mona is not the Octocat') }} who-to-greet: 'Mona the Octocat' - run: '[[ "${{ env.SOMEVAR }}" == "Mona is not the Octocat" ]]' - - uses: ./localdockerimagetest_ diff --git a/act/runner/testdata/local-action-via-composite-dockerfile/action.yml b/act/runner/testdata/local-action-via-composite-dockerfile/action.yml index 0770815a..f851510a 100644 --- a/act/runner/testdata/local-action-via-composite-dockerfile/action.yml +++ b/act/runner/testdata/local-action-via-composite-dockerfile/action.yml @@ -30,11 +30,6 @@ runs: who-to-greet: ${{inputs.who-to-greet}} - run: '[[ "${{ env.SOMEVAR }}" == "Mona is not the Octocat" ]]' shell: bash - - uses: ./localdockerimagetest_ - # Also test a remote docker action here - - uses: actions/hello-world-docker-action@v2 - with: - who-to-greet: 'Mona the Octocat' # Test if GITHUB_ACTION_PATH is set correctly after all steps - run: stat $GITHUB_ACTION_PATH/push.yml shell: bash diff --git a/act/runner/testdata/local-remote-action-overrides/push.yml b/act/runner/testdata/local-remote-action-overrides/push.yml index 9482438f..76ca2dd9 100644 --- a/act/runner/testdata/local-remote-action-overrides/push.yml +++ b/act/runner/testdata/local-remote-action-overrides/push.yml @@ -5,5 +5,5 @@ jobs: build: runs-on: ubuntu-latest steps: - - uses: nektos/test-override@a + - uses: https://github.com/nektos/test-override@a - uses: nektos/test-override@b \ No newline at end of file diff --git a/act/runner/testdata/matrix-include-exclude/push.yml b/act/runner/testdata/matrix-include-exclude/push.yml deleted file mode 100644 index 2ea16c56..00000000 --- a/act/runner/testdata/matrix-include-exclude/push.yml +++ /dev/null @@ -1,31 +0,0 @@ -name: matrix-include-exclude -on: push - -jobs: - build: - name: PHP ${{ matrix.os }} ${{ matrix.node}} - runs-on: ${{ matrix.os }} - steps: - - run: echo ${NODE_VERSION} | grep ${{ matrix.node }} - env: - NODE_VERSION: ${{ matrix.node }} - strategy: - matrix: - os: [ubuntu-18.04, macos-latest] - node: [4, 6, 8, 10] - exclude: - - os: macos-latest - node: 4 - include: - - os: ubuntu-16.04 - node: 10 - - test: - runs-on: ubuntu-latest - strategy: - matrix: - node: [8.x, 10.x, 12.x, 13.x] - steps: - - run: echo ${NODE_VERSION} | grep ${{ matrix.node }} - env: - NODE_VERSION: ${{ matrix.node }} diff --git a/act/runner/testdata/no-panic-on-invalid-composite-action/push.yml b/act/runner/testdata/no-panic-on-invalid-composite-action/push.yml index 6b9e4ae6..1f8fdfef 100644 --- a/act/runner/testdata/no-panic-on-invalid-composite-action/push.yml +++ b/act/runner/testdata/no-panic-on-invalid-composite-action/push.yml @@ -18,12 +18,4 @@ jobs: runs: using: composite shell: cp {0} action.yml - - uses: ./ - remote-invalid-step: - runs-on: ubuntu-latest - steps: - - uses: nektos/act-test-actions/invalid-composite-action/invalid-step@main - remote-missing-steps: - runs-on: ubuntu-latest - steps: - - uses: nektos/act-test-actions/invalid-composite-action/missing-steps@main \ No newline at end of file + - uses: ./ \ No newline at end of file diff --git a/act/runner/testdata/path-handling/push.yml b/act/runner/testdata/path-handling/push.yml index 812c8b8a..5a1f5f87 100644 --- a/act/runner/testdata/path-handling/push.yml +++ b/act/runner/testdata/path-handling/push.yml @@ -27,7 +27,7 @@ jobs: exit 1 fi - - uses: nektos/act-test-actions/composite@main + - uses: ./path-handling/ with: input: some input diff --git a/act/runner/testdata/remote-action-composite-action-ref/push.yml b/act/runner/testdata/remote-action-composite-action-ref/push.yml deleted file mode 100644 index 65876511..00000000 --- a/act/runner/testdata/remote-action-composite-action-ref/push.yml +++ /dev/null @@ -1,8 +0,0 @@ -name: remote-action-composite-action-ref -on: push - -jobs: - test: - runs-on: ubuntu-latest - steps: - - uses: nektos/act-test-actions/composite-assert-action-ref-action@main diff --git a/act/runner/testdata/remote-action-composite-js-pre-with-defaults/push.yml b/act/runner/testdata/remote-action-composite-js-pre-with-defaults/push.yml deleted file mode 100644 index 90a2987d..00000000 --- a/act/runner/testdata/remote-action-composite-js-pre-with-defaults/push.yml +++ /dev/null @@ -1,23 +0,0 @@ -name: remote-action-composite-js-pre-with-defaults -on: push - -jobs: - test: - runs-on: ubuntu-latest - steps: - - uses: nektos/act-test-actions/composite-js-pre-with-defaults/js@main - with: - in: nix - - uses: nektos/act-test-actions/composite-js-pre-with-defaults@main - with: - in: secretval - - uses: nektos/act-test-actions/composite-js-pre-with-defaults@main - with: - in: secretval - - uses: nektos/act-test-actions/composite-js-pre-with-defaults/js@main - with: - pre: "true" - in: nix - - uses: nektos/act-test-actions/composite-js-pre-with-defaults/js@main - with: - in: nix \ No newline at end of file diff --git a/act/runner/testdata/remote-action-docker/push.yml b/act/runner/testdata/remote-action-docker/push.yml deleted file mode 100644 index a1ba05b5..00000000 --- a/act/runner/testdata/remote-action-docker/push.yml +++ /dev/null @@ -1,10 +0,0 @@ -name: remote-action-docker -on: push - -jobs: - test: - runs-on: ubuntu-latest - steps: - - uses: actions/hello-world-docker-action@v1 - with: - who-to-greet: 'Mona the Octocat' diff --git a/act/runner/testdata/remote-action-js-node-user/push.yml b/act/runner/testdata/remote-action-js-node-user/push.yml deleted file mode 100644 index 69640e1e..00000000 --- a/act/runner/testdata/remote-action-js-node-user/push.yml +++ /dev/null @@ -1,30 +0,0 @@ -name: remote-action-js -on: push - -jobs: - test: - runs-on: ubuntu-latest - container: - image: node:24-bookworm-slim - options: --user node - steps: - - name: check permissions of env files - id: test - run: | - echo "USER: $(id -un) expected: node" - [[ "$(id -un)" = "node" ]] - echo "TEST=Value" >> $GITHUB_OUTPUT - shell: bash - - - name: check if file command worked - if: steps.test.outputs.test != 'Value' - run: | - echo "steps.test.outputs.test=${{ steps.test.outputs.test || 'missing value!' }}" - exit 1 - shell: bash - - - uses: actions/hello-world-javascript-action@v1 - with: - who-to-greet: 'Mona the Octocat' - - - uses: cloudposse/actions/github/slash-command-dispatch@0.14.0 diff --git a/act/runner/testdata/remote-action-js/push.yml b/act/runner/testdata/remote-action-js/push.yml deleted file mode 100644 index c284e6d8..00000000 --- a/act/runner/testdata/remote-action-js/push.yml +++ /dev/null @@ -1,12 +0,0 @@ -name: remote-action-js -on: push - -jobs: - test: - runs-on: ubuntu-latest - steps: - - uses: actions/hello-world-javascript-action@v1 - with: - who-to-greet: 'Mona the Octocat' - - - uses: cloudposse/actions/github/slash-command-dispatch@0.14.0 diff --git a/act/runner/testdata/runs-on/push.yml b/act/runner/testdata/runs-on/push.yml deleted file mode 100644 index f6ecedb0..00000000 --- a/act/runner/testdata/runs-on/push.yml +++ /dev/null @@ -1,24 +0,0 @@ -name: runs-on -on: push - -jobs: - test: - runs-on: ubuntu-latest - steps: - - run: env - - run: echo ${GITHUB_ACTOR} - - run: echo ${GITHUB_ACTOR} | grep nektos/act - - many: - runs-on: [ubuntu-latest] - steps: - - run: env - - run: echo ${GITHUB_ACTOR} - - run: echo ${GITHUB_ACTOR} | grep nektos/act - - selfmany: - runs-on: [self-hosted, ubuntu-latest] - steps: - - run: env - - run: echo ${GITHUB_ACTOR} - - run: echo ${GITHUB_ACTOR} | grep nektos/act diff --git a/act/runner/testdata/services-host-network/push.yml b/act/runner/testdata/services-host-network/push.yml deleted file mode 100644 index 8d0eb294..00000000 --- a/act/runner/testdata/services-host-network/push.yml +++ /dev/null @@ -1,14 +0,0 @@ -name: services-host-network -on: push -jobs: - services-host-network: - runs-on: ubuntu-latest - services: - nginx: - image: "nginx:latest" - ports: - - "8080:80" - steps: - - run: apt-get -qq update && apt-get -yqq install --no-install-recommends curl net-tools - - run: netstat -tlpen - - run: curl -v http://localhost:8080 diff --git a/act/runner/testdata/services-with-container/push.yml b/act/runner/testdata/services-with-container/push.yml index b37e5dcd..b2614c39 100644 --- a/act/runner/testdata/services-with-container/push.yml +++ b/act/runner/testdata/services-with-container/push.yml @@ -5,12 +5,11 @@ jobs: runs-on: ubuntu-latest # https://docs.github.com/en/actions/using-containerized-services/about-service-containers#running-jobs-in-a-container container: - image: "ubuntu:latest" + image: "node:24-bookworm-slim" services: nginx: - image: "nginx:latest" - ports: - - "8080:80" + image: "nginx:alpine" steps: - run: apt-get -qq update && apt-get -yqq install --no-install-recommends curl + # reach the service over the shared job network by its alias, no host port needed - run: curl -v http://nginx:80 diff --git a/act/runner/testdata/services/push.yaml b/act/runner/testdata/services/push.yaml index f6ca7bc4..ef883da6 100644 --- a/act/runner/testdata/services/push.yaml +++ b/act/runner/testdata/services/push.yaml @@ -6,18 +6,9 @@ jobs: runs-on: ubuntu-latest services: postgres: - image: postgres:12 - env: - POSTGRES_USER: runner - POSTGRES_PASSWORD: mysecretdbpass - POSTGRES_DB: mydb - options: >- - --health-cmd pg_isready - --health-interval 10s - --health-timeout 5s - --health-retries 5 + image: nginx:alpine ports: - - 5432:5432 + - 80 steps: - name: Echo the Postgres service ID / Network / Ports run: | diff --git a/act/runner/testdata/shells/pwsh/push.yml b/act/runner/testdata/shells/pwsh/push.yml index 25ce66b4..47385aaf 100644 --- a/act/runner/testdata/shells/pwsh/push.yml +++ b/act/runner/testdata/shells/pwsh/push.yml @@ -8,13 +8,6 @@ jobs: - shell: ${{ env.MY_SHELL }} run: | $PSVersionTable - check-container: - runs-on: ubuntu-latest - container: catthehacker/ubuntu:pwsh-latest - steps: - - shell: ${{ env.MY_SHELL }} - run: | - $PSVersionTable check-job-default: runs-on: ubuntu-latest defaults: diff --git a/act/runner/testdata/shells/python/push.yml b/act/runner/testdata/shells/python/push.yml deleted file mode 100644 index 677b5eca..00000000 --- a/act/runner/testdata/shells/python/push.yml +++ /dev/null @@ -1,28 +0,0 @@ -on: push -env: - MY_SHELL: python -jobs: - check: - runs-on: ubuntu-latest - steps: - - shell: ${{ env.MY_SHELL }} - run: | - import platform - print(platform.python_version()) - check-container: - runs-on: ubuntu-latest - container: node:24-bookworm - steps: - - shell: ${{ env.MY_SHELL }} - run: | - import platform - print(platform.python_version()) - check-job-default: - runs-on: ubuntu-latest - defaults: - run: - shell: ${{ env.MY_SHELL }} - steps: - - run: | - import platform - print(platform.python_version()) diff --git a/act/runner/testdata/uses-action-with-pre-and-post-step/last-action/action.yml b/act/runner/testdata/uses-action-with-pre-and-post-step/last-action/action.yml deleted file mode 100644 index 47e281d2..00000000 --- a/act/runner/testdata/uses-action-with-pre-and-post-step/last-action/action.yml +++ /dev/null @@ -1,7 +0,0 @@ -name: "last action check" -description: "last action check" - -runs: - using: "node24" - main: main.js - post: post.js diff --git a/act/runner/testdata/uses-action-with-pre-and-post-step/last-action/main.js b/act/runner/testdata/uses-action-with-pre-and-post-step/last-action/main.js deleted file mode 100644 index e69de29b..00000000 diff --git a/act/runner/testdata/uses-action-with-pre-and-post-step/last-action/post.js b/act/runner/testdata/uses-action-with-pre-and-post-step/last-action/post.js deleted file mode 100644 index e147e37d..00000000 --- a/act/runner/testdata/uses-action-with-pre-and-post-step/last-action/post.js +++ /dev/null @@ -1,17 +0,0 @@ -const pre = process.env['ACTION_OUTPUT_PRE']; -const main = process.env['ACTION_OUTPUT_MAIN']; -const post = process.env['ACTION_OUTPUT_POST']; - -console.log({pre, main, post}); - -if (pre !== 'pre') { - throw new Error(`Expected 'pre' but got '${pre}'`); -} - -if (main !== 'main') { - throw new Error(`Expected 'main' but got '${main}'`); -} - -if (post !== 'post') { - throw new Error(`Expected 'post' but got '${post}'`); -} diff --git a/act/runner/testdata/uses-action-with-pre-and-post-step/push.yml b/act/runner/testdata/uses-action-with-pre-and-post-step/push.yml deleted file mode 100644 index 0c3b7933..00000000 --- a/act/runner/testdata/uses-action-with-pre-and-post-step/push.yml +++ /dev/null @@ -1,15 +0,0 @@ -name: uses-action-with-pre-and-post-step -on: push - -jobs: - test: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v2 - - uses: ./uses-action-with-pre-and-post-step/last-action - - uses: nektos/act-test-actions/js-with-pre-and-post-step@main - with: - pre: true - post: true - - run: | - cat $GITHUB_ENV diff --git a/act/runner/testdata/uses-github-full-sha/main.yml b/act/runner/testdata/uses-github-full-sha/main.yml deleted file mode 100644 index 2acc4397..00000000 --- a/act/runner/testdata/uses-github-full-sha/main.yml +++ /dev/null @@ -1,7 +0,0 @@ -name: uses-github-root -on: push -jobs: - test: - runs-on: ubuntu-latest - steps: - - uses: actions/hello-world-docker-action@b136eb8894c5cb1dd5807da824be97ccdf9b5423 diff --git a/act/runner/testdata/uses-github-path/push.yml b/act/runner/testdata/uses-github-path/push.yml deleted file mode 100644 index 19374714..00000000 --- a/act/runner/testdata/uses-github-path/push.yml +++ /dev/null @@ -1,7 +0,0 @@ -name: uses-github-path -on: push -jobs: - test: - runs-on: ubuntu-latest - steps: - - uses: sergioramos/yarn-actions/install@v6 diff --git a/act/runner/testdata/uses-github-short-sha/main.yml b/act/runner/testdata/uses-github-short-sha/main.yml deleted file mode 100644 index 7a45fe49..00000000 --- a/act/runner/testdata/uses-github-short-sha/main.yml +++ /dev/null @@ -1,7 +0,0 @@ -name: uses-github-root -on: push -jobs: - test: - runs-on: ubuntu-latest - steps: - - uses: actions/hello-world-docker-action@b136eb8 diff --git a/act/runner/testdata/uses-nested-composite/composite_action2/action.yml b/act/runner/testdata/uses-nested-composite/composite_action2/action.yml deleted file mode 100644 index 8b3bcca0..00000000 --- a/act/runner/testdata/uses-nested-composite/composite_action2/action.yml +++ /dev/null @@ -1,63 +0,0 @@ ---- -name: "Test Composite Action" -description: "Test action uses composite" - -inputs: - test_input_optional: - description: Test - -runs: - using: "composite" - steps: - - uses: actions/setup-node@v6 - with: - node-version: '24' - - run: | - console.log(process.version); - console.log("Hi from node"); - console.log("${{ inputs.test_input_optional }}"); - if("${{ inputs.test_input_optional }}" !== "Test") { - console.log("Invalid input test_input_optional expected \"Test\" as value"); - process.exit(1); - } - if(!process.version.startsWith('v16')) { - console.log("Expected node v16, but got " + process.version); - process.exit(1); - } - shell: node {0} - - uses: ./uses-composite/composite_action - id: composite - with: - test_input_required: 'test_input_required_value' - test_input_optional: 'test_input_optional_value' - test_input_optional_with_default_overriden: 'test_input_optional_with_default_overriden' - test_input_required_with_default: 'test_input_optional_value' - test_input_required_with_default_overriden: 'test_input_required_with_default_overriden' - secret_input: ${{inputs.test_input_optional}} - env: - secret_input: ${{inputs.test_input_optional}} - - run: | - echo "steps.composite.outputs.test_output=${{ steps.composite.outputs.test_output }}" - [[ "${{steps.composite.outputs.test_output == 'test_output_value'}}" = "true" ]] || exit 1 - shell: bash - - run: | - echo "steps.composite.outputs.secret_output=${{ steps.composite.outputs.secret_output }}" - [[ "${{steps.composite.outputs.secret_output == format('{0}/{0}', inputs.test_input_optional)}}" = "true" ]] || exit 1 - shell: bash - # Now test again with default values - - name: ./uses-composite/composite_action with defaults - uses: ./uses-composite/composite_action - id: composite2 - with: - test_input_required: 'test_input_required_value' - test_input_optional_with_default_overriden: 'test_input_optional_with_default_overriden' - test_input_required_with_default_overriden: 'test_input_required_with_default_overriden' - - - run: | - echo "steps.composite2.outputs.test_output=${{ steps.composite2.outputs.test_output }}" - [[ "${{steps.composite2.outputs.test_output == 'test_output_value'}}" = "true" ]] || exit 1 - shell: bash - - run: | - echo "steps.composite.outputs.secret_output=$COMPOSITE_ACTION_ENV_OUTPUT" - [[ "${{env.COMPOSITE_ACTION_ENV_OUTPUT == 'my test value' }}" = "true" ]] || exit 1 - shell: bash diff --git a/act/runner/testdata/uses-nested-composite/push.yml b/act/runner/testdata/uses-nested-composite/push.yml deleted file mode 100644 index b7de1fb8..00000000 --- a/act/runner/testdata/uses-nested-composite/push.yml +++ /dev/null @@ -1,15 +0,0 @@ -name: uses-docker-url -on: push - -jobs: - test: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v2 - - uses: ./uses-nested-composite/composite_action2 - with: - test_input_optional: Test - - run: | - echo "steps.composite.outputs.secret_output=$COMPOSITE_ACTION_ENV_OUTPUT" - [[ "${{env.COMPOSITE_ACTION_ENV_OUTPUT == 'my test value' }}" = "true" ]] || exit 1 - shell: bash \ No newline at end of file diff --git a/act/runner/testdata/uses-workflow/local-workflow.yml b/act/runner/testdata/uses-workflow/local-workflow.yml deleted file mode 100644 index 2e9a08d7..00000000 --- a/act/runner/testdata/uses-workflow/local-workflow.yml +++ /dev/null @@ -1,42 +0,0 @@ -name: local-reusable-workflows -on: pull_request - -jobs: - reusable-workflow: - uses: ./.github/workflows/local-reusable-workflow.yml - with: - string_required: string - bool_required: ${{ true }} - number_required: 1 - secrets: - secret: keep_it_private - - reusable-workflow-with-inherited-secrets: - uses: ./.github/workflows/local-reusable-workflow.yml - with: - string_required: string - bool_required: ${{ true }} - number_required: 1 - secrets: inherit - - reusable-workflow-with-on-string-notation: - uses: ./.github/workflows/local-reusable-workflow-no-inputs-string.yml - - reusable-workflow-with-on-array-notation: - uses: ./.github/workflows/local-reusable-workflow-no-inputs-array.yml - - output-test: - runs-on: ubuntu-latest - needs: - - reusable-workflow - - reusable-workflow-with-inherited-secrets - steps: - - name: output with secrets map - run: | - echo reusable-workflow.output=${{ needs.reusable-workflow.outputs.output }} - [[ "${{ needs.reusable-workflow.outputs.output == 'string' }}" = "true" ]] || exit 1 - - - name: output with inherited secrets - run: | - echo reusable-workflow-with-inherited-secrets.output=${{ needs.reusable-workflow-with-inherited-secrets.outputs.output }} - [[ "${{ needs.reusable-workflow-with-inherited-secrets.outputs.output == 'string' }}" = "true" ]] || exit 1 diff --git a/act/runner/testdata/uses-workflow/push.yml b/act/runner/testdata/uses-workflow/push.yml index ddc37b86..9da95379 100644 --- a/act/runner/testdata/uses-workflow/push.yml +++ b/act/runner/testdata/uses-workflow/push.yml @@ -1,8 +1,11 @@ on: push +# Exercises the reusable-workflow caller path against a local reusable workflow: passing typed +# inputs and secrets (both an explicit map and `inherit`), and reading the called workflow's +# outputs back through `needs`. jobs: reusable-workflow: - uses: nektos/act-test-actions/.github/workflows/reusable-workflow.yml@main + uses: ./.github/workflows/local-reusable-workflow.yml with: string_required: string bool_required: ${{ true }} @@ -11,7 +14,7 @@ jobs: secret: keep_it_private reusable-workflow-with-inherited-secrets: - uses: nektos/act-test-actions/.github/workflows/reusable-workflow.yml@main + uses: ./.github/workflows/local-reusable-workflow.yml with: string_required: string bool_required: ${{ true }} @@ -24,12 +27,5 @@ jobs: - reusable-workflow - reusable-workflow-with-inherited-secrets steps: - - name: output with secrets map - run: | - echo reusable-workflow.output=${{ needs.reusable-workflow.outputs.output }} - [[ "${{ needs.reusable-workflow.outputs.output == 'string' }}" = "true" ]] || exit 1 - - - name: output with inherited secrets - run: | - echo reusable-workflow-with-inherited-secrets.output=${{ needs.reusable-workflow-with-inherited-secrets.outputs.output }} - [[ "${{ needs.reusable-workflow-with-inherited-secrets.outputs.output == 'string' }}" = "true" ]] || exit 1 + - run: '[[ "${{ needs.reusable-workflow.outputs.output == ''string'' }}" = "true" ]] || exit 1' + - run: '[[ "${{ needs.reusable-workflow-with-inherited-secrets.outputs.output == ''string'' }}" = "true" ]] || exit 1' diff --git a/scripts/test-dind.sh b/scripts/test-dind.sh new file mode 100755 index 00000000..63387102 --- /dev/null +++ b/scripts/test-dind.sh @@ -0,0 +1,96 @@ +#!/usr/bin/env bash +# Generic docker-in-docker test harness. +# +# Builds a dind image variant from the repo Dockerfile, starts its docker daemon over a +# local TCP port, and runs a Go test command against that daemon via DOCKER_HOST. This +# validates the actual docker version and behaviour shipped in the dind image, so any +# daemon-level regression surfaces here (e.g. the "docker cp" break in gitea/runner#981). +# It is deliberately generic: point it at any package/test to exercise the dind daemon. +# +# Usage: scripts/test-dind.sh [target] [-- go-test-args...] +# target: dind (default) or dind-rootless +# go-test-args: passed verbatim to `go test`. The default exercises the daemon-facing tests +# that need no registry access (a fresh daemon, e.g. on fork-PR CI, can't +# authenticate pulls): the env-extraction build (FROM scratch) and the #981 +# /var/run symlink copy regression (which reuses a preloaded alpine). +# +# Env: +# DIND_TEST_PORT host port for the daemon (default 32375) +# DIND_TEST_IMAGE skip the build and use this prebuilt image instead +# DIND_TEST_PRELOAD space-separated images to copy from the host daemon into the fresh one +set -euo pipefail + +target="dind" +case "${1:-}" in + dind|dind-rootless) target="$1"; shift ;; +esac +[ "${1:-}" = "--" ] && shift +[ $# -eq 0 ] && set -- -race -run '^TestDocker$|^TestDockerCopyToSymlinkPath$' ./act/container/ + +port="${DIND_TEST_PORT:-32375}" +name="gitea-runner-dind-test-$$" +image="${DIND_TEST_IMAGE:-gitea-runner-${target}:dind-test}" +# The host daemon endpoint, captured before DOCKER_HOST is pointed at the fresh dind daemon. +host_docker="${DOCKER_HOST:-unix:///var/run/docker.sock}" + +cleanup() { docker rm -f "$name" >/dev/null 2>&1 || true; } +trap cleanup EXIT + +if [ -z "${DIND_TEST_IMAGE:-}" ]; then + echo "==> Building ${target} image" + docker build --target "$target" -t "$image" . +fi + +# Override the image entrypoint (s6) and run only dockerd, exposed over insecure TCP. +# We are testing the daemon the image ships, not the runner supervision tree. +# +# How the test process reaches the daemon depends on where it runs: +# - plain host: publish 2375 on loopback and connect to 127.0.0.1. +# - inside a container (CI), the daemon is a sibling container, so its published port is on +# the host, not our loopback; instead attach it to our own network and reach it by name. +self_container="" +if [ -f /.dockerenv ]; then + self_container="$(cat /proc/sys/kernel/hostname 2>/dev/null || cat /etc/hostname)" +fi +self_network="" +if [ -n "$self_container" ]; then + self_network="$(docker inspect -f '{{range $k,$v := .NetworkSettings.Networks}}{{$k}}{{"\n"}}{{end}}' "$self_container" 2>/dev/null | head -1)" +fi + +# The two cases differ only in how the daemon is exposed and addressed; everything else +# (privileged, name, TLS-off entrypoint, image, --host) is shared, so collect just the +# differing run args and the resulting DOCKER_HOST here. +if [ -n "$self_network" ]; then + echo "==> Starting ${target} daemon on network ${self_network} (reached as ${name}:2375)" + run_args=(--network "$self_network") + daemon_host="tcp://${name}:2375" +else + echo "==> Starting ${target} daemon on tcp://127.0.0.1:${port}" + run_args=(-p "127.0.0.1:${port}:2375") + daemon_host="tcp://127.0.0.1:${port}" +fi +# Create the dind container on the host daemon first, then repoint DOCKER_HOST at it: exporting +# DOCKER_HOST before `docker run` would make this `docker run` target the not-yet-existent dind. +docker run -d --privileged --name "$name" "${run_args[@]}" \ + -e DOCKER_TLS_CERTDIR= \ + --entrypoint dockerd-entrypoint.sh \ + "$image" --host=tcp://0.0.0.0:2375 >/dev/null +export DOCKER_HOST="$daemon_host" + +echo "==> Waiting for daemon" +for _ in $(seq 1 60); do + docker version --format 'server docker {{.Server.Version}}' 2>/dev/null && break + sleep 1 +done + +# Seed the fresh daemon with images the host already has (the CI job pulls them in the +# preceding `make test`), so the daemon-facing tests run without registry access. +echo "==> Seeding daemon with cached host images" +for img in ${DIND_TEST_PRELOAD:-alpine:latest}; do + if docker -H "$host_docker" image inspect "$img" >/dev/null 2>&1; then + docker -H "$host_docker" save "$img" | docker load >/dev/null 2>&1 && echo " loaded $img" || true + fi +done + +echo "==> Running tests against dind daemon" +go test "$@"