mirror of
https://gitea.com/gitea/act_runner.git
synced 2026-06-10 11:14:31 +02:00
Compare commits
2 Commits
renovate/d
...
lunny/remo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c5d0457615 | ||
|
|
3815aad750 |
@@ -9,36 +9,14 @@ jobs:
|
|||||||
lint:
|
lint:
|
||||||
name: check and test
|
name: check and test
|
||||||
runs-on: ubuntu-latest
|
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:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v6
|
||||||
- uses: actions/setup-go@v6
|
- uses: actions/setup-go@v6
|
||||||
with:
|
with:
|
||||||
go-version-file: 'go.mod'
|
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
|
- name: lint
|
||||||
run: make lint
|
run: make lint
|
||||||
- name: build
|
- name: build
|
||||||
run: make build
|
run: make build
|
||||||
- name: test
|
- name: test
|
||||||
run: make test
|
run: make test
|
||||||
# Build the dind image and run the daemon-facing tests against the docker version it
|
|
||||||
# ships, catching daemon-level regressions (e.g. gitea/runner#981) before release. Runs
|
|
||||||
# after `make test` so the images it needs are already present on the host daemon.
|
|
||||||
- name: test against dind image
|
|
||||||
run: make test-dind
|
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ RUN make clean && make build
|
|||||||
### DIND VARIANT
|
### DIND VARIANT
|
||||||
#
|
#
|
||||||
#
|
#
|
||||||
FROM docker:29.5.3-dind AS dind
|
FROM docker:29.5.2-dind AS dind
|
||||||
|
|
||||||
ARG VERSION=dev
|
ARG VERSION=dev
|
||||||
|
|
||||||
@@ -37,7 +37,7 @@ ENTRYPOINT ["s6-svscan","/etc/s6"]
|
|||||||
### DIND-ROOTLESS VARIANT
|
### DIND-ROOTLESS VARIANT
|
||||||
#
|
#
|
||||||
#
|
#
|
||||||
FROM docker:29.5.3-dind-rootless AS dind-rootless
|
FROM docker:29.5.2-dind-rootless AS dind-rootless
|
||||||
|
|
||||||
ARG VERSION=dev
|
ARG VERSION=dev
|
||||||
|
|
||||||
|
|||||||
8
Makefile
8
Makefile
@@ -140,12 +140,8 @@ tidy-check: tidy
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
.PHONY: test
|
.PHONY: test
|
||||||
test: fmt-check security-check ## test everything (integration tests self-skip without docker/network)
|
test: fmt-check security-check ## test everything
|
||||||
@$(GO) test -race -timeout 20m -v -cover -coverprofile coverage.txt ./... && echo "\n==>\033[32m Ok\033[m\n" || exit 1
|
@$(GO) test -race -short -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
|
.PHONY: install
|
||||||
install: $(GOFILES) ## install the runner binary via `go install`
|
install: $(GOFILES) ## install the runner binary via `go install`
|
||||||
|
|||||||
38
README.md
38
README.md
@@ -85,44 +85,6 @@ docker run -e GITEA_INSTANCE_URL=https://your_gitea.com -e GITEA_RUNNER_REGISTRA
|
|||||||
|
|
||||||
Mount a volume on `/data` if you want the registration file and optional config to survive container recreation (see [scripts/run.sh](scripts/run.sh)).
|
Mount a volume on `/data` if you want the registration file and optional config to survive container recreation (see [scripts/run.sh](scripts/run.sh)).
|
||||||
|
|
||||||
### Image flavours
|
|
||||||
|
|
||||||
The image is published in three flavours, all built from the single multi-stage [Dockerfile](Dockerfile) in this repository. They differ only in how a Docker daemon is made available to the jobs the runner executes; the `gitea-runner` binary inside them is identical.
|
|
||||||
|
|
||||||
| Tag | Build target | Base image | Docker daemon | Process supervisor | Runs as |
|
|
||||||
| --- | --- | --- | --- | --- | --- |
|
|
||||||
| `latest` (and `<version>`) | `basic` | `alpine` | none — uses an external daemon you provide | [`tini`](https://github.com/krallin/tini) | `root` |
|
|
||||||
| `latest-dind` | `dind` | `docker:dind` | bundled, started inside the container | [`s6`](https://skarnet.org/software/s6/) | `root` (privileged) |
|
|
||||||
| `latest-dind-rootless` | `dind-rootless` | `docker:dind-rootless` | bundled, started rootless inside the container | [`s6`](https://skarnet.org/software/s6/) | `rootless` (UID 1000) |
|
|
||||||
|
|
||||||
#### `latest` — basic
|
|
||||||
|
|
||||||
The default flavour ships only the runner on a minimal Alpine base. It contains **no Docker daemon of its own**: jobs that use `docker://` images need a daemon supplied from outside the container, typically by bind-mounting the host's socket:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker run -e GITEA_INSTANCE_URL=https://your_gitea.com -e GITEA_RUNNER_REGISTRATION_TOKEN=<your_token> \
|
|
||||||
-v /var/run/docker.sock:/var/run/docker.sock --name my_runner gitea/runner:latest
|
|
||||||
```
|
|
||||||
|
|
||||||
`tini` is the entrypoint (it reaps zombie processes), and it just runs [`scripts/run.sh`](scripts/run.sh), which registers the runner on first start and then execs `gitea-runner daemon`. This flavour does not need `--privileged`. The trade-off is that jobs share the host's daemon, so they can see other containers and images on that daemon.
|
|
||||||
|
|
||||||
#### `latest-dind` — Docker-in-Docker
|
|
||||||
|
|
||||||
This flavour is based on the official `docker:dind` image and bundles its own Docker daemon, so it needs no external socket — only the `--privileged` flag that Docker-in-Docker requires:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker run --privileged -e GITEA_INSTANCE_URL=https://your_gitea.com -e GITEA_RUNNER_REGISTRATION_TOKEN=<your_token> \
|
|
||||||
--name my_runner gitea/runner:latest-dind
|
|
||||||
```
|
|
||||||
|
|
||||||
Two processes have to run side by side here (the Docker daemon and the runner), so the entrypoint is the [`s6`](https://skarnet.org/software/s6/) supervision tree under [`scripts/s6`](scripts/s6) instead of `tini`. `s6` starts `dockerd`, and the runner service waits for the daemon to come up (`s6-svwait`) before launching [`run.sh`](scripts/run.sh). Each container has a private daemon isolated from the host's, at the cost of running privileged.
|
|
||||||
|
|
||||||
#### `latest-dind-rootless` — rootless Docker-in-Docker
|
|
||||||
|
|
||||||
Same idea as `dind`, but built on `docker:dind-rootless` so the bundled daemon and the runner run as an unprivileged user (`rootless`, UID 1000) rather than `root`. `DOCKER_HOST` is preset to `unix:///run/user/1000/docker.sock` so the runner talks to the rootless daemon. This reduces the blast radius compared to the privileged `dind` flavour, but rootless Docker carries the usual rootless limitations (networking, cgroups, storage drivers, and some operations that need additional host configuration such as `/etc/subuid` / `/etc/subgid` mappings and unprivileged user-namespace support).
|
|
||||||
|
|
||||||
> **Note on Podman:** these images target the Docker daemon. The bundled `dind`/`dind-rootless` daemons are `dockerd`, not Podman, and the `basic` flavour expects a Docker-compatible socket. Running them under rootless Podman is not a supported configuration, though pointing the `basic` flavour at a Podman socket that emulates the Docker API may work for some workloads.
|
|
||||||
|
|
||||||
### Configuration
|
### Configuration
|
||||||
|
|
||||||
The runner is configured with a YAML file. Generate a starting point (this matches what ships in the tree):
|
The runner is configured with a YAML file. Generate a starting point (this matches what ships in the tree):
|
||||||
|
|||||||
@@ -5,25 +5,24 @@
|
|||||||
package artifacts
|
package artifacts
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"context"
|
||||||
"compress/gzip"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"maps"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"net/url"
|
|
||||||
"os"
|
"os"
|
||||||
|
"path"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"testing/fstest"
|
"testing/fstest"
|
||||||
"time"
|
|
||||||
|
"gitea.com/gitea/runner/act/model"
|
||||||
|
"gitea.com/gitea/runner/act/runner"
|
||||||
|
|
||||||
"github.com/julienschmidt/httprouter"
|
"github.com/julienschmidt/httprouter"
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type writableMapFile struct {
|
type writableMapFile struct {
|
||||||
@@ -235,133 +234,89 @@ func TestDownloadArtifactFile(t *testing.T) {
|
|||||||
assert.Equal("content", string(data))
|
assert.Equal("content", string(data))
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestArtifactFlow drives the real Serve() artifact server over a loopback socket, exercising
|
type TestJobFileInfo struct {
|
||||||
// the same upload -> finalize -> list -> download protocol the upload-artifact/download-artifact
|
workdir string
|
||||||
// actions speak. Running it in-process (rather than from a job container) keeps it network-free
|
workflowPath string
|
||||||
// and reachable everywhere, including when the CI job is itself a container.
|
eventName string
|
||||||
func TestArtifactFlow(t *testing.T) {
|
errorMessage string
|
||||||
artifactPath := t.TempDir()
|
platforms map[string]string
|
||||||
|
containerArchitecture string
|
||||||
// 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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
t.Run("upload-and-download", func(t *testing.T) {
|
var (
|
||||||
const runID, item, content = "1", "my-artifact/data.txt", "hello artifact\n"
|
artifactsPath = path.Join(os.TempDir(), "test-artifacts")
|
||||||
|
artifactsAddr = "127.0.0.1"
|
||||||
|
artifactsPort = "12345"
|
||||||
|
)
|
||||||
|
|
||||||
status, data := request(t, http.MethodPost, baseURL+"/_apis/pipelines/workflows/"+runID+"/artifacts", nil, nil)
|
func TestArtifactFlow(t *testing.T) {
|
||||||
require.Equal(t, http.StatusOK, status, string(data))
|
if testing.Short() {
|
||||||
var prep FileContainerResourceURL
|
t.Skip("skipping integration test")
|
||||||
require.NoError(t, json.Unmarshal(data, &prep))
|
}
|
||||||
require.Equal(t, baseURL+"/upload/"+runID, prep.FileContainerResourceURL)
|
|
||||||
|
|
||||||
status, data = request(t, http.MethodPut, prep.FileContainerResourceURL+"?itemPath="+url.QueryEscape(item), strings.NewReader(content), nil)
|
ctx := context.Background()
|
||||||
require.Equal(t, http.StatusOK, status, string(data))
|
|
||||||
var msg ResponseMessage
|
|
||||||
require.NoError(t, json.Unmarshal(data, &msg))
|
|
||||||
require.Equal(t, "success", msg.Message)
|
|
||||||
|
|
||||||
status, data = request(t, http.MethodPatch, baseURL+"/_apis/pipelines/workflows/"+runID+"/artifacts", nil, nil)
|
cancel := Serve(ctx, artifactsPath, artifactsAddr, artifactsPort)
|
||||||
require.Equal(t, http.StatusOK, status, string(data))
|
defer cancel()
|
||||||
|
|
||||||
status, data = request(t, http.MethodGet, baseURL+"/_apis/pipelines/workflows/"+runID+"/artifacts", nil, nil)
|
platforms := map[string]string{
|
||||||
require.Equal(t, http.StatusOK, status, string(data))
|
"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
|
||||||
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)
|
|
||||||
|
|
||||||
status, data = request(t, http.MethodGet, list.Value[0].FileContainerResourceURL+"?itemPath=my-artifact", nil, nil)
|
tables := []TestJobFileInfo{
|
||||||
require.Equal(t, http.StatusOK, status, string(data))
|
{"testdata", "upload-and-download", "push", "", platforms, ""},
|
||||||
var items ContainerItemResponse
|
{"testdata", "GHSL-2023-004", "push", "", platforms, ""},
|
||||||
require.NoError(t, json.Unmarshal(data, &items))
|
}
|
||||||
require.Len(t, items.Value, 1)
|
log.SetLevel(log.DebugLevel)
|
||||||
require.Equal(t, "file", items.Value[0].ItemType)
|
|
||||||
require.Equal(t, "my-artifact/data.txt", items.Value[0].Path)
|
|
||||||
|
|
||||||
status, data = request(t, http.MethodGet, items.Value[0].ContentLocation, nil, nil)
|
for _, table := range tables {
|
||||||
require.Equal(t, http.StatusOK, status)
|
runTestJobFile(ctx, t, table)
|
||||||
require.Equal(t, content, string(data))
|
}
|
||||||
|
}
|
||||||
|
|
||||||
stored, err := os.ReadFile(filepath.Join(artifactPath, runID, "my-artifact", "data.txt"))
|
func runTestJobFile(ctx context.Context, t *testing.T, tjfi TestJobFileInfo) {
|
||||||
require.NoError(t, err)
|
t.Run(tjfi.workflowPath, func(t *testing.T) {
|
||||||
require.Equal(t, content, string(stored))
|
fmt.Printf("::group::%s\n", tjfi.workflowPath) //nolint:forbidigo // pre-existing issue from nektos/act
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("gzip-roundtrip", func(t *testing.T) {
|
if err := os.RemoveAll(artifactsPath); err != nil {
|
||||||
const runID, item, content = "2", "logs/app.log", "compressed payload\n"
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
var buf bytes.Buffer
|
workdir, err := filepath.Abs(tjfi.workdir)
|
||||||
gz := gzip.NewWriter(&buf)
|
assert.NoError(t, err, workdir) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
_, err := gz.Write([]byte(content))
|
fullWorkflowPath := filepath.Join(workdir, tjfi.workflowPath)
|
||||||
require.NoError(t, err)
|
runnerConfig := &runner.Config{
|
||||||
require.NoError(t, gz.Close())
|
Workdir: workdir,
|
||||||
|
BindWorkdir: false,
|
||||||
|
EventName: tjfi.eventName,
|
||||||
|
Platforms: tjfi.platforms,
|
||||||
|
ReuseContainers: false,
|
||||||
|
ContainerArchitecture: tjfi.containerArchitecture,
|
||||||
|
GitHubInstance: "github.com",
|
||||||
|
ArtifactServerPath: artifactsPath,
|
||||||
|
ArtifactServerAddr: artifactsAddr,
|
||||||
|
ArtifactServerPort: artifactsPort,
|
||||||
|
}
|
||||||
|
|
||||||
status, data := request(t, http.MethodPut, baseURL+"/upload/"+runID+"?itemPath="+url.QueryEscape(item),
|
runner, err := runner.New(runnerConfig)
|
||||||
&buf, http.Header{"Content-Encoding": []string{"gzip"}})
|
assert.NoError(t, err, tjfi.workflowPath) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
require.Equal(t, http.StatusOK, status, string(data))
|
|
||||||
|
|
||||||
// stored compressed, with the server's gzip marker suffix
|
planner, err := model.NewWorkflowPlanner(fullWorkflowPath, true)
|
||||||
_, err = os.Stat(filepath.Join(artifactPath, runID, "logs", "app.log.gz__"))
|
assert.NoError(t, err, fullWorkflowPath) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
status, data = request(t, http.MethodGet, baseURL+"/download/"+runID+"?itemPath=logs", nil, nil)
|
plan, err := planner.PlanEvent(tjfi.eventName)
|
||||||
require.Equal(t, http.StatusOK, status, string(data))
|
if err == nil {
|
||||||
var items ContainerItemResponse
|
err = runner.NewPlanExecutor(plan)(ctx)
|
||||||
require.NoError(t, json.Unmarshal(data, &items))
|
if tjfi.errorMessage == "" {
|
||||||
require.Len(t, items.Value, 1)
|
assert.NoError(t, err, fullWorkflowPath) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
require.Equal(t, "logs/app.log", items.Value[0].Path)
|
} 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.MethodGet, items.Value[0].ContentLocation, nil, nil)
|
fmt.Println("::endgroup::") //nolint:forbidigo // pre-existing issue from nektos/act
|
||||||
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))
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
39
act/artifacts/testdata/GHSL-2023-004/artifacts.yml
vendored
Normal file
39
act/artifacts/testdata/GHSL-2023-004/artifacts.yml
vendored
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
|
||||||
|
name: "GHSL-2023-0004"
|
||||||
|
on: push
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test-artifacts:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- run: echo "hello world" > test.txt
|
||||||
|
- name: curl upload
|
||||||
|
run: curl --silent --show-error --fail ${ACTIONS_RUNTIME_URL}upload/1?itemPath=../../my-artifact/secret.txt --upload-file test.txt
|
||||||
|
- uses: actions/download-artifact@v2
|
||||||
|
with:
|
||||||
|
name: my-artifact
|
||||||
|
path: test-artifacts
|
||||||
|
- name: 'Verify Artifact #1'
|
||||||
|
run: |
|
||||||
|
file="test-artifacts/secret.txt"
|
||||||
|
if [ ! -f $file ] ; then
|
||||||
|
echo "Expected file does not exist"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [ "$(cat $file)" != "hello world" ] ; then
|
||||||
|
echo "File contents of downloaded artifact are incorrect"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
- name: Verify download should work by clean extra dots
|
||||||
|
run: curl --silent --show-error --fail --path-as-is -o out.txt ${ACTIONS_RUNTIME_URL}artifact/1/../../../1/my-artifact/secret.txt
|
||||||
|
- name: 'Verify download content'
|
||||||
|
run: |
|
||||||
|
file="out.txt"
|
||||||
|
if [ ! -f $file ] ; then
|
||||||
|
echo "Expected file does not exist"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [ "$(cat $file)" != "hello world" ] ; then
|
||||||
|
echo "File contents of downloaded artifact are incorrect"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
230
act/artifacts/testdata/upload-and-download/artifacts.yml
vendored
Normal file
230
act/artifacts/testdata/upload-and-download/artifacts.yml
vendored
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
|
||||||
|
name: "Test that artifact uploads and downloads succeed"
|
||||||
|
on: push
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test-artifacts:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- run: mkdir -p path/to/artifact
|
||||||
|
- run: echo hello > path/to/artifact/world.txt
|
||||||
|
- uses: actions/upload-artifact@v2
|
||||||
|
with:
|
||||||
|
name: my-artifact
|
||||||
|
path: path/to/artifact/world.txt
|
||||||
|
|
||||||
|
- run: rm -rf path
|
||||||
|
|
||||||
|
- uses: actions/download-artifact@v2
|
||||||
|
with:
|
||||||
|
name: my-artifact
|
||||||
|
- name: Display structure of downloaded files
|
||||||
|
run: ls -la
|
||||||
|
|
||||||
|
# Test end-to-end by uploading two artifacts and then downloading them
|
||||||
|
- name: Create artifact files
|
||||||
|
run: |
|
||||||
|
mkdir -p path/to/dir-1
|
||||||
|
mkdir -p path/to/dir-2
|
||||||
|
mkdir -p path/to/dir-3
|
||||||
|
mkdir -p path/to/dir-5
|
||||||
|
mkdir -p path/to/dir-6
|
||||||
|
mkdir -p path/to/dir-7
|
||||||
|
echo "Lorem ipsum dolor sit amet" > path/to/dir-1/file1.txt
|
||||||
|
echo "Hello world from file #2" > path/to/dir-2/file2.txt
|
||||||
|
echo "This is a going to be a test for a large enough file that should get compressed with GZip. The @actions/artifact package uses GZip to upload files. This text should have a compression ratio greater than 100% so it should get uploaded using GZip" > path/to/dir-3/gzip.txt
|
||||||
|
dd if=/dev/random of=path/to/dir-5/file5.rnd bs=1024 count=1024
|
||||||
|
dd if=/dev/random of=path/to/dir-6/file6.rnd bs=1024 count=$((10*1024))
|
||||||
|
dd if=/dev/random of=path/to/dir-7/file7.rnd bs=1024 count=$((10*1024))
|
||||||
|
|
||||||
|
# Upload a single file artifact
|
||||||
|
- name: 'Upload artifact #1'
|
||||||
|
uses: actions/upload-artifact@v2
|
||||||
|
with:
|
||||||
|
name: 'Artifact-A'
|
||||||
|
path: path/to/dir-1/file1.txt
|
||||||
|
|
||||||
|
# Upload using a wildcard pattern, name should default to 'artifact' if not provided
|
||||||
|
- name: 'Upload artifact #2'
|
||||||
|
uses: actions/upload-artifact@v2
|
||||||
|
with:
|
||||||
|
path: path/**/dir*/
|
||||||
|
|
||||||
|
# Upload a directory that contains a file that will be uploaded with GZip
|
||||||
|
- name: 'Upload artifact #3'
|
||||||
|
uses: actions/upload-artifact@v2
|
||||||
|
with:
|
||||||
|
name: 'GZip-Artifact'
|
||||||
|
path: path/to/dir-3/
|
||||||
|
|
||||||
|
# Upload a directory that contains a file that will be uploaded with GZip
|
||||||
|
- name: 'Upload artifact #4'
|
||||||
|
uses: actions/upload-artifact@v2
|
||||||
|
with:
|
||||||
|
name: 'Multi-Path-Artifact'
|
||||||
|
path: |
|
||||||
|
path/to/dir-1/*
|
||||||
|
path/to/dir-[23]/*
|
||||||
|
!path/to/dir-3/*.txt
|
||||||
|
|
||||||
|
# Upload a mid-size file artifact
|
||||||
|
- name: 'Upload artifact #5'
|
||||||
|
uses: actions/upload-artifact@v2
|
||||||
|
with:
|
||||||
|
name: 'Mid-Size-Artifact'
|
||||||
|
path: path/to/dir-5/file5.rnd
|
||||||
|
|
||||||
|
# Upload a big file artifact
|
||||||
|
- name: 'Upload artifact #6'
|
||||||
|
uses: actions/upload-artifact@v2
|
||||||
|
with:
|
||||||
|
name: 'Big-Artifact'
|
||||||
|
path: path/to/dir-6/file6.rnd
|
||||||
|
|
||||||
|
# Upload a big file artifact twice
|
||||||
|
- name: 'Upload artifact #7 (First)'
|
||||||
|
uses: actions/upload-artifact@v2
|
||||||
|
with:
|
||||||
|
name: 'Big-Uploaded-Twice'
|
||||||
|
path: path/to/dir-7/file7.rnd
|
||||||
|
|
||||||
|
# Upload a big file artifact twice
|
||||||
|
- name: 'Upload artifact #7 (Second)'
|
||||||
|
uses: actions/upload-artifact@v2
|
||||||
|
with:
|
||||||
|
name: 'Big-Uploaded-Twice'
|
||||||
|
path: path/to/dir-7/file7.rnd
|
||||||
|
|
||||||
|
# Verify artifacts. Switch to download-artifact@v2 once it's out of preview
|
||||||
|
|
||||||
|
# Download Artifact #1 and verify the correctness of the content
|
||||||
|
- name: 'Download artifact #1'
|
||||||
|
uses: actions/download-artifact@v2
|
||||||
|
with:
|
||||||
|
name: 'Artifact-A'
|
||||||
|
path: some/new/path
|
||||||
|
|
||||||
|
- name: 'Verify Artifact #1'
|
||||||
|
run: |
|
||||||
|
file="some/new/path/file1.txt"
|
||||||
|
if [ ! -f $file ] ; then
|
||||||
|
echo "Expected file does not exist"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [ "$(cat $file)" != "Lorem ipsum dolor sit amet" ] ; then
|
||||||
|
echo "File contents of downloaded artifact are incorrect"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Download Artifact #2 and verify the correctness of the content
|
||||||
|
- name: 'Download artifact #2'
|
||||||
|
uses: actions/download-artifact@v2
|
||||||
|
with:
|
||||||
|
name: 'artifact'
|
||||||
|
path: some/other/path
|
||||||
|
|
||||||
|
- name: 'Verify Artifact #2'
|
||||||
|
run: |
|
||||||
|
file1="some/other/path/to/dir-1/file1.txt"
|
||||||
|
file2="some/other/path/to/dir-2/file2.txt"
|
||||||
|
if [ ! -f $file1 -o ! -f $file2 ] ; then
|
||||||
|
echo "Expected files do not exist"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [ "$(cat $file1)" != "Lorem ipsum dolor sit amet" -o "$(cat $file2)" != "Hello world from file #2" ] ; then
|
||||||
|
echo "File contents of downloaded artifacts are incorrect"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Download Artifact #3 and verify the correctness of the content
|
||||||
|
- name: 'Download artifact #3'
|
||||||
|
uses: actions/download-artifact@v2
|
||||||
|
with:
|
||||||
|
name: 'GZip-Artifact'
|
||||||
|
path: gzip/artifact/path
|
||||||
|
|
||||||
|
# Because a directory was used as input during the upload the parent directories, path/to/dir-3/, should not be included in the uploaded artifact
|
||||||
|
- name: 'Verify Artifact #3'
|
||||||
|
run: |
|
||||||
|
gzipFile="gzip/artifact/path/gzip.txt"
|
||||||
|
if [ ! -f $gzipFile ] ; then
|
||||||
|
echo "Expected file do not exist"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [ "$(cat $gzipFile)" != "This is a going to be a test for a large enough file that should get compressed with GZip. The @actions/artifact package uses GZip to upload files. This text should have a compression ratio greater than 100% so it should get uploaded using GZip" ] ; then
|
||||||
|
echo "File contents of downloaded artifact is incorrect"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: 'Download artifact #4'
|
||||||
|
uses: actions/download-artifact@v2
|
||||||
|
with:
|
||||||
|
name: 'Multi-Path-Artifact'
|
||||||
|
path: multi/artifact
|
||||||
|
|
||||||
|
- name: 'Verify Artifact #4'
|
||||||
|
run: |
|
||||||
|
file1="multi/artifact/dir-1/file1.txt"
|
||||||
|
file2="multi/artifact/dir-2/file2.txt"
|
||||||
|
if [ ! -f $file1 -o ! -f $file2 ] ; then
|
||||||
|
echo "Expected files do not exist"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [ "$(cat $file1)" != "Lorem ipsum dolor sit amet" -o "$(cat $file2)" != "Hello world from file #2" ] ; then
|
||||||
|
echo "File contents of downloaded artifacts are incorrect"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: 'Download artifact #5'
|
||||||
|
uses: actions/download-artifact@v2
|
||||||
|
with:
|
||||||
|
name: 'Mid-Size-Artifact'
|
||||||
|
path: mid-size/artifact/path
|
||||||
|
|
||||||
|
- name: 'Verify Artifact #5'
|
||||||
|
run: |
|
||||||
|
file="mid-size/artifact/path/file5.rnd"
|
||||||
|
if [ ! -f $file ] ; then
|
||||||
|
echo "Expected file does not exist"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if ! diff $file path/to/dir-5/file5.rnd ; then
|
||||||
|
echo "File contents of downloaded artifact are incorrect"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: 'Download artifact #6'
|
||||||
|
uses: actions/download-artifact@v2
|
||||||
|
with:
|
||||||
|
name: 'Big-Artifact'
|
||||||
|
path: big/artifact/path
|
||||||
|
|
||||||
|
- name: 'Verify Artifact #6'
|
||||||
|
run: |
|
||||||
|
file="big/artifact/path/file6.rnd"
|
||||||
|
if [ ! -f $file ] ; then
|
||||||
|
echo "Expected file does not exist"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if ! diff $file path/to/dir-6/file6.rnd ; then
|
||||||
|
echo "File contents of downloaded artifact are incorrect"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: 'Download artifact #7'
|
||||||
|
uses: actions/download-artifact@v2
|
||||||
|
with:
|
||||||
|
name: 'Big-Uploaded-Twice'
|
||||||
|
path: big-uploaded-twice/artifact/path
|
||||||
|
|
||||||
|
- name: 'Verify Artifact #7'
|
||||||
|
run: |
|
||||||
|
file="big-uploaded-twice/artifact/path/file7.rnd"
|
||||||
|
if [ ! -f $file ] ; then
|
||||||
|
echo "Expected file does not exist"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if ! diff $file path/to/dir-7/file7.rnd ; then
|
||||||
|
echo "File contents of downloaded artifact are incorrect"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
@@ -170,6 +170,68 @@ func TestMaxParallelWithErrors(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestMaxParallelPerformance tests performance characteristics
|
||||||
|
func TestMaxParallelPerformance(t *testing.T) {
|
||||||
|
if testing.Short() {
|
||||||
|
t.Skip("Skipping performance test in short mode")
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("ParallelFasterThanSequential", func(t *testing.T) {
|
||||||
|
executors := make([]Executor, 10)
|
||||||
|
for i := range 10 {
|
||||||
|
executors[i] = func(ctx context.Context) error {
|
||||||
|
time.Sleep(50 * time.Millisecond)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Sequential (max-parallel=1)
|
||||||
|
start := time.Now()
|
||||||
|
err := NewParallelExecutor(1, executors...)(ctx)
|
||||||
|
sequentialDuration := time.Since(start)
|
||||||
|
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
|
||||||
|
// Parallel (max-parallel=5)
|
||||||
|
start = time.Now()
|
||||||
|
err = NewParallelExecutor(5, executors...)(ctx)
|
||||||
|
parallelDuration := time.Since(start)
|
||||||
|
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
|
||||||
|
// Parallel should be significantly faster
|
||||||
|
assert.Less(t, parallelDuration, sequentialDuration/2,
|
||||||
|
"Parallel execution should be at least 2x faster")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("OptimalWorkerCount", func(t *testing.T) {
|
||||||
|
executors := make([]Executor, 20)
|
||||||
|
for i := range 20 {
|
||||||
|
executors[i] = func(ctx context.Context) error {
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Test with different worker counts
|
||||||
|
workerCounts := []int{1, 2, 5, 10, 20}
|
||||||
|
durations := make(map[int]time.Duration)
|
||||||
|
|
||||||
|
for _, count := range workerCounts {
|
||||||
|
start := time.Now()
|
||||||
|
err := NewParallelExecutor(count, executors...)(ctx)
|
||||||
|
durations[count] = time.Since(start)
|
||||||
|
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
}
|
||||||
|
|
||||||
|
// More workers should generally be faster (up to a point)
|
||||||
|
assert.Less(t, durations[5], durations[1], "5 workers should be faster than 1")
|
||||||
|
assert.Less(t, durations[10], durations[2], "10 workers should be faster than 2")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// TestMaxParallelResourceSharing tests resource sharing scenarios
|
// TestMaxParallelResourceSharing tests resource sharing scenarios
|
||||||
func TestMaxParallelResourceSharing(t *testing.T) {
|
func TestMaxParallelResourceSharing(t *testing.T) {
|
||||||
t.Run("SharedResourceWithMutex", func(t *testing.T) {
|
t.Run("SharedResourceWithMutex", func(t *testing.T) {
|
||||||
|
|||||||
@@ -66,21 +66,8 @@ func (e *Error) Commit() string {
|
|||||||
return e.commit
|
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
|
// FindGitRevision get the current git revision
|
||||||
func FindGitRevision(ctx context.Context, file string) (shortSha, sha string, err error) {
|
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)
|
logger := common.Logger(ctx)
|
||||||
|
|
||||||
gitDir, err := git.PlainOpenWithOptions(
|
gitDir, err := git.PlainOpenWithOptions(
|
||||||
@@ -112,13 +99,10 @@ func findGitRevision(ctx context.Context, file string) (shortSha, sha string, er
|
|||||||
|
|
||||||
// FindGitRef get the current git ref
|
// FindGitRef get the current git ref
|
||||||
func FindGitRef(ctx context.Context, file string) (string, error) {
|
func FindGitRef(ctx context.Context, file string) (string, error) {
|
||||||
goGitMu.Lock()
|
|
||||||
defer goGitMu.Unlock()
|
|
||||||
|
|
||||||
logger := common.Logger(ctx)
|
logger := common.Logger(ctx)
|
||||||
|
|
||||||
logger.Debugf("Loading revision from git directory")
|
logger.Debugf("Loading revision from git directory")
|
||||||
_, ref, err := findGitRevision(ctx, file)
|
_, ref, err := FindGitRevision(ctx, file)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
@@ -190,8 +174,6 @@ func FindGitRef(ctx context.Context, file string) (string, error) {
|
|||||||
|
|
||||||
// FindGithubRepo get the repo
|
// FindGithubRepo get the repo
|
||||||
func FindGithubRepo(ctx context.Context, file, githubInstance, remoteName string) (string, error) {
|
func FindGithubRepo(ctx context.Context, file, githubInstance, remoteName string) (string, error) {
|
||||||
goGitMu.Lock()
|
|
||||||
defer goGitMu.Unlock()
|
|
||||||
if remoteName == "" {
|
if remoteName == "" {
|
||||||
remoteName = "origin"
|
remoteName = "origin"
|
||||||
}
|
}
|
||||||
@@ -265,25 +247,10 @@ type NewGitCloneExecutorInput struct {
|
|||||||
func CloneIfRequired(ctx context.Context, refName plumbing.ReferenceName, input NewGitCloneExecutorInput, logger log.FieldLogger) (*git.Repository, bool, error) {
|
func CloneIfRequired(ctx context.Context, refName plumbing.ReferenceName, input NewGitCloneExecutorInput, logger log.FieldLogger) (*git.Repository, bool, error) {
|
||||||
r, err := git.PlainOpen(input.Dir)
|
r, err := git.PlainOpen(input.Dir)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
// Verify the cached clone still points to the resolved URL before reusing it.
|
|
||||||
remote, err := r.Remote("origin")
|
|
||||||
if err == nil && len(remote.Config().URLs) > 0 && remote.Config().URLs[0] == input.URL {
|
|
||||||
// Reuse existing clone
|
// Reuse existing clone
|
||||||
return r, true, nil
|
return r, true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
logger.Debugf("Removing cached clone at %s because origin cannot be read: %v", input.Dir, err)
|
|
||||||
} else if len(remote.Config().URLs) == 0 {
|
|
||||||
logger.Debugf("Removing cached clone at %s because origin has no URL", input.Dir)
|
|
||||||
} else {
|
|
||||||
logger.Debugf("Removing cached clone at %s because origin URL changed from %s to %s", input.Dir, remote.Config().URLs[0], input.URL)
|
|
||||||
}
|
|
||||||
if err := os.RemoveAll(input.Dir); err != nil {
|
|
||||||
return nil, false, fmt.Errorf("remove cached clone %s: %w", input.Dir, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var progressWriter io.Writer
|
var progressWriter io.Writer
|
||||||
if isatty.IsTerminal(os.Stdout.Fd()) || isatty.IsCygwinTerminal(os.Stdout.Fd()) {
|
if isatty.IsTerminal(os.Stdout.Fd()) || isatty.IsCygwinTerminal(os.Stdout.Fd()) {
|
||||||
if entry, ok := logger.(*log.Entry); ok {
|
if entry, ok := logger.(*log.Entry); ok {
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
@@ -49,6 +50,10 @@ func TestFindGitSlug(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testDir(t *testing.T) string {
|
||||||
|
return t.TempDir()
|
||||||
|
}
|
||||||
|
|
||||||
func cleanGitHooks(dir string) error {
|
func cleanGitHooks(dir string) error {
|
||||||
hooksDir := filepath.Join(dir, ".git", "hooks")
|
hooksDir := filepath.Join(dir, ".git", "hooks")
|
||||||
files, err := os.ReadDir(hooksDir)
|
files, err := os.ReadDir(hooksDir)
|
||||||
@@ -73,7 +78,8 @@ func cleanGitHooks(dir string) error {
|
|||||||
func TestFindGitRemoteURL(t *testing.T) {
|
func TestFindGitRemoteURL(t *testing.T) {
|
||||||
assert := assert.New(t)
|
assert := assert.New(t)
|
||||||
|
|
||||||
basedir := t.TempDir()
|
basedir := testDir(t)
|
||||||
|
gitConfig()
|
||||||
err := gitCmd("init", basedir)
|
err := gitCmd("init", basedir)
|
||||||
assert.NoError(err) //nolint:testifylint // pre-existing issue from nektos/act
|
assert.NoError(err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
err = cleanGitHooks(basedir)
|
err = cleanGitHooks(basedir)
|
||||||
@@ -96,7 +102,8 @@ func TestFindGitRemoteURL(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestGitFindRef(t *testing.T) {
|
func TestGitFindRef(t *testing.T) {
|
||||||
basedir := t.TempDir()
|
basedir := testDir(t)
|
||||||
|
gitConfig()
|
||||||
|
|
||||||
for name, tt := range map[string]struct {
|
for name, tt := range map[string]struct {
|
||||||
Prepare func(t *testing.T, dir string)
|
Prepare func(t *testing.T, dir string)
|
||||||
@@ -173,55 +180,36 @@ func TestGitFindRef(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestGitCloneExecutor(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 {
|
for name, tt := range map[string]struct {
|
||||||
Err error
|
Err error
|
||||||
Ref string
|
URL, Ref string
|
||||||
}{
|
}{
|
||||||
"tag": {
|
"tag": {
|
||||||
Err: nil,
|
Err: nil,
|
||||||
|
URL: "https://github.com/actions/checkout",
|
||||||
Ref: "v2",
|
Ref: "v2",
|
||||||
},
|
},
|
||||||
"branch": {
|
"branch": {
|
||||||
Err: nil,
|
Err: nil,
|
||||||
|
URL: "https://github.com/anchore/scan-action",
|
||||||
Ref: "act-fails",
|
Ref: "act-fails",
|
||||||
},
|
},
|
||||||
"sha": {
|
"sha": {
|
||||||
Err: nil,
|
Err: nil,
|
||||||
Ref: fullSha,
|
URL: "https://github.com/actions/checkout",
|
||||||
|
Ref: "5a4ac9002d0be2fb38bd78e4b4dbde5606d7042f", // v2
|
||||||
},
|
},
|
||||||
"short-sha": {
|
"short-sha": {
|
||||||
Err: &Error{ErrShortRef, fullSha},
|
Err: &Error{ErrShortRef, "5a4ac9002d0be2fb38bd78e4b4dbde5606d7042f"},
|
||||||
Ref: fullSha[:7],
|
URL: "https://github.com/actions/checkout",
|
||||||
|
Ref: "5a4ac90", // v2
|
||||||
},
|
},
|
||||||
} {
|
} {
|
||||||
t.Run(name, func(t *testing.T) {
|
t.Run(name, func(t *testing.T) {
|
||||||
clone := NewGitCloneExecutor(NewGitCloneExecutorInput{
|
clone := NewGitCloneExecutor(NewGitCloneExecutorInput{
|
||||||
URL: remoteDir,
|
URL: tt.URL,
|
||||||
Ref: tt.Ref,
|
Ref: tt.Ref,
|
||||||
Dir: t.TempDir(),
|
Dir: testDir(t),
|
||||||
})
|
})
|
||||||
|
|
||||||
err := clone(context.Background())
|
err := clone(context.Background())
|
||||||
@@ -235,56 +223,13 @@ func TestGitCloneExecutor(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGitCloneExecutorReclonesWhenOriginURLChanges(t *testing.T) {
|
|
||||||
createRemote := func(message string) string {
|
|
||||||
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", message))
|
|
||||||
require.NoError(t, gitCmd("-C", workDir, "push", "-u", "origin", "main"))
|
|
||||||
|
|
||||||
return remoteDir
|
|
||||||
}
|
|
||||||
|
|
||||||
oldRemoteDir := createRemote("old-action")
|
|
||||||
newRemoteDir := createRemote("new-action")
|
|
||||||
cacheDir := t.TempDir()
|
|
||||||
|
|
||||||
require.NoError(t, NewGitCloneExecutor(NewGitCloneExecutorInput{
|
|
||||||
URL: oldRemoteDir,
|
|
||||||
Ref: "main",
|
|
||||||
Dir: cacheDir,
|
|
||||||
})(t.Context()))
|
|
||||||
|
|
||||||
markerPath := filepath.Join(cacheDir, "stale-marker")
|
|
||||||
require.NoError(t, os.WriteFile(markerPath, []byte("stale"), 0o644))
|
|
||||||
|
|
||||||
require.NoError(t, NewGitCloneExecutor(NewGitCloneExecutorInput{
|
|
||||||
URL: newRemoteDir,
|
|
||||||
Ref: "main",
|
|
||||||
Dir: cacheDir,
|
|
||||||
})(t.Context()))
|
|
||||||
|
|
||||||
originURL, err := findGitRemoteURL(t.Context(), cacheDir, "origin")
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, newRemoteDir, originURL)
|
|
||||||
|
|
||||||
out, err := exec.Command("git", "-C", cacheDir, "log", "--oneline", "-1", "--format=%s").Output()
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, "new-action", strings.TrimSpace(string(out)))
|
|
||||||
|
|
||||||
_, err = os.Stat(markerPath)
|
|
||||||
require.True(t, os.IsNotExist(err), "stale cached directory should be removed before recloning")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGitCloneExecutorNonFastForwardRef(t *testing.T) {
|
func TestGitCloneExecutorNonFastForwardRef(t *testing.T) {
|
||||||
// Simulate the scenario where a remote ref (e.g. a GitHub PR head ref) changes
|
// Simulate the scenario where a remote ref (e.g. a GitHub PR head ref) changes
|
||||||
// non-fast-forward between two fetches. Before the fix, the fetch used Force=false,
|
// 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.
|
// 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.
|
// Create a bare "remote" repo with an initial commit on main and a feature branch.
|
||||||
remoteDir := t.TempDir()
|
remoteDir := t.TempDir()
|
||||||
require.NoError(t, gitCmd("init", "--bare", "--initial-branch=main", remoteDir))
|
require.NoError(t, gitCmd("init", "--bare", "--initial-branch=main", remoteDir))
|
||||||
@@ -335,6 +280,8 @@ func TestGitCloneExecutorNonFastForwardRef(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestGitCloneExecutorOfflineMode(t *testing.T) {
|
func TestGitCloneExecutorOfflineMode(t *testing.T) {
|
||||||
|
gitConfig()
|
||||||
|
|
||||||
// Build a local "remote" with a single commit on main.
|
// Build a local "remote" with a single commit on main.
|
||||||
remoteDir := t.TempDir()
|
remoteDir := t.TempDir()
|
||||||
require.NoError(t, gitCmd("init", "--bare", "--initial-branch=main", remoteDir))
|
require.NoError(t, gitCmd("init", "--bare", "--initial-branch=main", remoteDir))
|
||||||
@@ -380,21 +327,22 @@ func TestGitCloneExecutorOfflineMode(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func gitConfig() {
|
||||||
|
if os.Getenv("GITHUB_ACTIONS") == "true" {
|
||||||
|
var err error
|
||||||
|
if err = gitCmd("config", "--global", "user.email", "test@test.com"); err != nil {
|
||||||
|
log.Error(err)
|
||||||
|
}
|
||||||
|
if err = gitCmd("config", "--global", "user.name", "Unit Test"); err != nil {
|
||||||
|
log.Error(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func gitCmd(args ...string) error {
|
func gitCmd(args ...string) error {
|
||||||
cmd := exec.Command("git", args...)
|
cmd := exec.Command("git", args...)
|
||||||
cmd.Stdout = os.Stdout
|
cmd.Stdout = os.Stdout
|
||||||
cmd.Stderr = os.Stderr
|
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()
|
err := cmd.Run()
|
||||||
if exitError, ok := err.(*exec.ExitError); ok {
|
if exitError, ok := err.(*exec.ExitError); ok {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
|
|
||||||
"github.com/distribution/reference"
|
"github.com/distribution/reference"
|
||||||
"github.com/docker/cli/cli/config"
|
"github.com/docker/cli/cli/config"
|
||||||
|
"github.com/docker/cli/cli/config/credentials"
|
||||||
"github.com/moby/moby/api/types/registry"
|
"github.com/moby/moby/api/types/registry"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -25,6 +26,10 @@ func LoadDockerAuthConfig(ctx context.Context, image string) (registry.AuthConfi
|
|||||||
logger.Warnf("Could not load docker config: %v", err)
|
logger.Warnf("Could not load docker config: %v", err)
|
||||||
return registry.AuthConfig{}, err
|
return registry.AuthConfig{}, err
|
||||||
}
|
}
|
||||||
|
if !cfg.ContainsAuth() {
|
||||||
|
cfg.CredentialsStore = credentials.DetectDefaultStore(cfg.CredentialsStore)
|
||||||
|
}
|
||||||
|
|
||||||
registryKey := registryAuthConfigKey("docker.io")
|
registryKey := registryAuthConfigKey("docker.io")
|
||||||
if image != "" {
|
if image != "" {
|
||||||
if registryRef, refErr := reference.ParseNormalizedNamed(image); refErr != nil {
|
if registryRef, refErr := reference.ParseNormalizedNamed(image); refErr != nil {
|
||||||
@@ -50,6 +55,10 @@ func LoadDockerAuthConfigs(ctx context.Context) map[string]registry.AuthConfig {
|
|||||||
logger.Warnf("Could not load docker config: %v", err)
|
logger.Warnf("Could not load docker config: %v", err)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
if !cfg.ContainsAuth() {
|
||||||
|
cfg.CredentialsStore = credentials.DetectDefaultStore(cfg.CredentialsStore)
|
||||||
|
}
|
||||||
|
|
||||||
creds, err := cfg.GetAllCredentials()
|
creds, err := cfg.GetAllCredentials()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Warnf("Could not get docker auth configs: %v", err)
|
logger.Warnf("Could not get docker auth configs: %v", err)
|
||||||
|
|||||||
@@ -6,64 +6,66 @@ package container
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"io"
|
||||||
"os"
|
|
||||||
"os/exec"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/moby/moby/client"
|
||||||
|
specs "github.com/opencontainers/image-spec/specs-go/v1"
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
log.SetLevel(log.DebugLevel)
|
log.SetLevel(log.DebugLevel)
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildScratchImage builds a tiny empty image for the given platform locally (FROM scratch, no
|
|
||||||
// network or emulation since there is nothing to run) and returns its tag, removing it after
|
|
||||||
// the test.
|
|
||||||
func buildScratchImage(t *testing.T, platform string) string {
|
|
||||||
t.Helper()
|
|
||||||
tag := fmt.Sprintf("act-test-exists-%s:latest", strings.TrimPrefix(platform, "linux/"))
|
|
||||||
cmd := exec.Command("docker", "build", "--platform", platform, "-t", tag, "-")
|
|
||||||
cmd.Stdin = strings.NewReader("FROM scratch\nLABEL act-test=1\n")
|
|
||||||
// Force BuildKit: it records the requested architecture in the image config for a
|
|
||||||
// FROM-scratch build, whereas the classic builder ignores --platform and tags it with the
|
|
||||||
// host arch, which would break the per-platform existence assertions below.
|
|
||||||
cmd.Env = append(os.Environ(), "DOCKER_BUILDKIT=1")
|
|
||||||
out, err := cmd.CombinedOutput()
|
|
||||||
require.NoError(t, err, string(out))
|
|
||||||
t.Cleanup(func() { _ = exec.Command("docker", "rmi", "-f", tag).Run() })
|
|
||||||
return tag
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestImageExistsLocally(t *testing.T) {
|
func TestImageExistsLocally(t *testing.T) {
|
||||||
requireDocker(t)
|
if testing.Short() {
|
||||||
ctx := context.Background()
|
t.Skip("skipping integration test")
|
||||||
|
}
|
||||||
// a non-existent image is reported absent
|
ctx := context.Background()
|
||||||
missing, err := ImageExistsLocally(ctx, "library/alpine:this-random-tag-will-never-exist", "linux/amd64")
|
// to help make this test reliable and not flaky, we need to have
|
||||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
// an image that will exist, and onew that won't exist
|
||||||
assert.False(t, missing)
|
|
||||||
|
// Test if image exists with specific tag
|
||||||
// Build tiny images for two architectures locally so per-platform existence can be checked
|
invalidImageTag, err := ImageExistsLocally(ctx, "library/alpine:this-random-tag-will-never-exist", "linux/amd64")
|
||||||
// offline (formerly pulled node:24-bookworm-slim for amd64 and arm64 over the network).
|
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
amd64Ref := buildScratchImage(t, "linux/amd64")
|
assert.False(t, invalidImageTag)
|
||||||
arm64Ref := buildScratchImage(t, "linux/arm64")
|
|
||||||
|
// Test if image exists with specific architecture (image platform)
|
||||||
amd64Exists, err := ImageExistsLocally(ctx, amd64Ref, "linux/amd64")
|
invalidImagePlatform, err := ImageExistsLocally(ctx, "alpine:latest", "windows/amd64")
|
||||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
assert.True(t, amd64Exists)
|
assert.False(t, invalidImagePlatform)
|
||||||
|
|
||||||
// a non-host architecture image is detected for its own architecture
|
// pull an image
|
||||||
arm64Exists, err := ImageExistsLocally(ctx, arm64Ref, "linux/arm64")
|
cli, err := client.New(client.FromEnv)
|
||||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
assert.True(t, arm64Exists)
|
defer cli.Close()
|
||||||
|
|
||||||
// a present image is reported absent for a different platform
|
// Chose alpine latest because it's so small
|
||||||
wrongPlatform, err := ImageExistsLocally(ctx, amd64Ref, "linux/arm64")
|
// maybe we should build an image instead so that tests aren't reliable on dockerhub
|
||||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
readerDefault, err := cli.ImagePull(ctx, "node:24-bookworm-slim", client.ImagePullOptions{
|
||||||
assert.False(t, wrongPlatform)
|
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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,12 +8,24 @@ package container
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
|
|
||||||
"github.com/moby/moby/client"
|
"github.com/moby/moby/client"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
dockerNetworkRemoveRetryInterval = 200 * time.Millisecond
|
||||||
|
dockerNetworkRemoveTimeout = 10 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
type dockerNetworkClient interface {
|
||||||
|
NetworkList(ctx context.Context, options client.NetworkListOptions) (client.NetworkListResult, error)
|
||||||
|
NetworkInspect(ctx context.Context, networkID string, options client.NetworkInspectOptions) (client.NetworkInspectResult, error)
|
||||||
|
NetworkRemove(ctx context.Context, networkID string, options client.NetworkRemoveOptions) (client.NetworkRemoveResult, error)
|
||||||
|
}
|
||||||
|
|
||||||
func NewDockerNetworkCreateExecutor(name string) common.Executor {
|
func NewDockerNetworkCreateExecutor(name string) common.Executor {
|
||||||
return func(ctx context.Context) error {
|
return func(ctx context.Context) error {
|
||||||
cli, err := GetDockerClient(ctx)
|
cli, err := GetDockerClient(ctx)
|
||||||
@@ -56,31 +68,64 @@ func NewDockerNetworkRemoveExecutor(name string) common.Executor {
|
|||||||
}
|
}
|
||||||
defer cli.Close()
|
defer cli.Close()
|
||||||
|
|
||||||
// Make sure that all network of the specified name are removed
|
return removeDockerNetworks(ctx, cli, name)
|
||||||
// cli.NetworkRemove refuses to remove a network if there are duplicates
|
}
|
||||||
networks, err := cli.NetworkList(ctx, client.NetworkListOptions{})
|
}
|
||||||
|
|
||||||
|
func removeDockerNetworks(ctx context.Context, cli dockerNetworkClient, name string) error {
|
||||||
|
cleanupCtx, cancel := context.WithTimeout(ctx, dockerNetworkRemoveTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
for {
|
||||||
|
pendingRemoval, err := removeDockerNetworksOnce(cleanupCtx, cli, name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if !pendingRemoval {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-cleanupCtx.Done():
|
||||||
|
common.Logger(ctx).Warnf("Timed out waiting for Docker network %v endpoints to detach; leaving network behind", name)
|
||||||
|
return nil
|
||||||
|
case <-time.After(dockerNetworkRemoveRetryInterval):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func removeDockerNetworksOnce(ctx context.Context, cli dockerNetworkClient, name string) (bool, error) {
|
||||||
|
// Make sure that all network of the specified name are removed.
|
||||||
|
// cli.NetworkRemove refuses to remove a network if there are duplicates.
|
||||||
|
networks, err := cli.NetworkList(ctx, client.NetworkListOptions{})
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
// For Gitea, reduce log noise
|
// For Gitea, reduce log noise
|
||||||
// common.Logger(ctx).Debugf("%v", networks)
|
// common.Logger(ctx).Debugf("%v", networks)
|
||||||
|
|
||||||
|
pendingRemoval := false
|
||||||
for _, n := range networks.Items {
|
for _, n := range networks.Items {
|
||||||
if n.Name == name {
|
if n.Name != name {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
result, err := cli.NetworkInspect(ctx, n.ID, client.NetworkInspectOptions{})
|
result, err := cli.NetworkInspect(ctx, n.ID, client.NetworkInspectOptions{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(result.Network.Containers) != 0 {
|
||||||
|
pendingRemoval = true
|
||||||
|
common.Logger(ctx).Debugf("Waiting to remove network %v because it still has active endpoints", name)
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(result.Network.Containers) == 0 {
|
|
||||||
if _, err = cli.NetworkRemove(ctx, n.ID, client.NetworkRemoveOptions{}); err != nil {
|
if _, err = cli.NetworkRemove(ctx, n.ID, client.NetworkRemoveOptions{}); err != nil {
|
||||||
common.Logger(ctx).Debugf("%v", err)
|
pendingRemoval = true
|
||||||
}
|
common.Logger(ctx).Debugf("Retrying Docker network removal for %v: %v", name, err)
|
||||||
} else {
|
|
||||||
common.Logger(ctx).Debugf("Refusing to remove network %v because it still has active endpoints", name)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return err
|
return pendingRemoval, nil
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
115
act/container/docker_network_test.go
Normal file
115
act/container/docker_network_test.go
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// Copyright 2026 The nektos/act Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
//go:build !(WITHOUT_DOCKER || !(linux || darwin || windows || netbsd))
|
||||||
|
|
||||||
|
package container
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
containernetwork "github.com/moby/moby/api/types/network"
|
||||||
|
"github.com/moby/moby/client"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
type fakeDockerNetworkClient struct {
|
||||||
|
listResult client.NetworkListResult
|
||||||
|
inspectByID map[string][]client.NetworkInspectResult
|
||||||
|
inspectCalls map[string]int
|
||||||
|
removeCalls []string
|
||||||
|
removeErrs map[string][]error
|
||||||
|
removeIdx map[string]int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeDockerNetworkClient) NetworkList(context.Context, client.NetworkListOptions) (client.NetworkListResult, error) {
|
||||||
|
return f.listResult, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeDockerNetworkClient) NetworkInspect(_ context.Context, networkID string, _ client.NetworkInspectOptions) (client.NetworkInspectResult, error) {
|
||||||
|
idx := f.inspectCalls[networkID]
|
||||||
|
f.inspectCalls[networkID] = idx + 1
|
||||||
|
results := f.inspectByID[networkID]
|
||||||
|
if len(results) == 0 {
|
||||||
|
return client.NetworkInspectResult{}, nil
|
||||||
|
}
|
||||||
|
if idx >= len(results) {
|
||||||
|
return results[len(results)-1], nil
|
||||||
|
}
|
||||||
|
return results[idx], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeDockerNetworkClient) NetworkRemove(_ context.Context, networkID string, _ client.NetworkRemoveOptions) (client.NetworkRemoveResult, error) {
|
||||||
|
f.removeCalls = append(f.removeCalls, networkID)
|
||||||
|
idx := f.removeIdx[networkID]
|
||||||
|
f.removeIdx[networkID] = idx + 1
|
||||||
|
if errs := f.removeErrs[networkID]; idx < len(errs) {
|
||||||
|
return client.NetworkRemoveResult{}, errs[idx]
|
||||||
|
}
|
||||||
|
return client.NetworkRemoveResult{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRemoveDockerNetworksRetriesUntilEndpointsDetach(t *testing.T) {
|
||||||
|
originalInterval := dockerNetworkRemoveRetryInterval
|
||||||
|
originalTimeout := dockerNetworkRemoveTimeout
|
||||||
|
dockerNetworkRemoveRetryInterval = time.Millisecond
|
||||||
|
dockerNetworkRemoveTimeout = 50 * time.Millisecond
|
||||||
|
t.Cleanup(func() {
|
||||||
|
dockerNetworkRemoveRetryInterval = originalInterval
|
||||||
|
dockerNetworkRemoveTimeout = originalTimeout
|
||||||
|
})
|
||||||
|
|
||||||
|
cli := &fakeDockerNetworkClient{
|
||||||
|
listResult: client.NetworkListResult{
|
||||||
|
Items: []containernetwork.Summary{{Network: containernetwork.Network{ID: "n1", Name: "test"}}},
|
||||||
|
},
|
||||||
|
inspectByID: map[string][]client.NetworkInspectResult{
|
||||||
|
"n1": {
|
||||||
|
{Network: containernetwork.Inspect{Containers: map[string]containernetwork.EndpointResource{"c1": {}}}},
|
||||||
|
{Network: containernetwork.Inspect{Containers: map[string]containernetwork.EndpointResource{}}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
inspectCalls: map[string]int{},
|
||||||
|
removeErrs: map[string][]error{},
|
||||||
|
removeIdx: map[string]int{},
|
||||||
|
}
|
||||||
|
|
||||||
|
err := removeDockerNetworks(context.Background(), cli, "test")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, []string{"n1"}, cli.removeCalls)
|
||||||
|
assert.GreaterOrEqual(t, cli.inspectCalls["n1"], 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRemoveDockerNetworksStopsRetryingAfterTimeout(t *testing.T) {
|
||||||
|
originalInterval := dockerNetworkRemoveRetryInterval
|
||||||
|
originalTimeout := dockerNetworkRemoveTimeout
|
||||||
|
dockerNetworkRemoveRetryInterval = time.Millisecond
|
||||||
|
dockerNetworkRemoveTimeout = 5 * time.Millisecond
|
||||||
|
t.Cleanup(func() {
|
||||||
|
dockerNetworkRemoveRetryInterval = originalInterval
|
||||||
|
dockerNetworkRemoveTimeout = originalTimeout
|
||||||
|
})
|
||||||
|
|
||||||
|
cli := &fakeDockerNetworkClient{
|
||||||
|
listResult: client.NetworkListResult{
|
||||||
|
Items: []containernetwork.Summary{{Network: containernetwork.Network{ID: "n1", Name: "test"}}},
|
||||||
|
},
|
||||||
|
inspectByID: map[string][]client.NetworkInspectResult{
|
||||||
|
"n1": {
|
||||||
|
{Network: containernetwork.Inspect{Containers: map[string]containernetwork.EndpointResource{"c1": {}}}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
inspectCalls: map[string]int{},
|
||||||
|
removeErrs: map[string][]error{},
|
||||||
|
removeIdx: map[string]int{},
|
||||||
|
}
|
||||||
|
|
||||||
|
err := removeDockerNetworks(context.Background(), cli, "test")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Empty(t, cli.removeCalls)
|
||||||
|
assert.Positive(t, cli.inspectCalls["n1"])
|
||||||
|
}
|
||||||
@@ -40,9 +40,6 @@ func TestCleanImage(t *testing.T) {
|
|||||||
func TestGetImagePullOptions(t *testing.T) {
|
func TestGetImagePullOptions(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
orig := config.Dir()
|
|
||||||
t.Cleanup(func() { config.SetDir(orig) })
|
|
||||||
|
|
||||||
config.SetDir("/non-existent/docker")
|
config.SetDir("/non-existent/docker")
|
||||||
|
|
||||||
options, err := getImagePullOptions(ctx, NewDockerPullExecutorInput{})
|
options, err := getImagePullOptions(ctx, NewDockerPullExecutorInput{})
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ import (
|
|||||||
|
|
||||||
"dario.cat/mergo"
|
"dario.cat/mergo"
|
||||||
"github.com/Masterminds/semver"
|
"github.com/Masterminds/semver"
|
||||||
cerrdefs "github.com/containerd/errdefs"
|
|
||||||
"github.com/docker/cli/cli/compose/loader"
|
"github.com/docker/cli/cli/compose/loader"
|
||||||
"github.com/docker/cli/cli/connhelper"
|
"github.com/docker/cli/cli/connhelper"
|
||||||
"github.com/go-git/go-billy/v5/helper/polyfill"
|
"github.com/go-git/go-billy/v5/helper/polyfill"
|
||||||
@@ -153,8 +152,6 @@ func (cr *containerReference) Copy(destPath string, files ...*FileEntry) common.
|
|||||||
func (cr *containerReference) CopyDir(destPath, srcPath string, useGitIgnore bool) common.Executor {
|
func (cr *containerReference) CopyDir(destPath, srcPath string, useGitIgnore bool) common.Executor {
|
||||||
return common.NewPipelineExecutor(
|
return common.NewPipelineExecutor(
|
||||||
common.NewInfoExecutor("docker cp src=%s dst=%s", srcPath, destPath),
|
common.NewInfoExecutor("docker cp src=%s dst=%s", srcPath, destPath),
|
||||||
cr.connect(),
|
|
||||||
cr.find(),
|
|
||||||
cr.copyDir(destPath, srcPath, useGitIgnore),
|
cr.copyDir(destPath, srcPath, useGitIgnore),
|
||||||
func(ctx context.Context) error {
|
func(ctx context.Context) error {
|
||||||
// If this fails, then folders have wrong permissions on non root container
|
// If this fails, then folders have wrong permissions on non root container
|
||||||
@@ -170,16 +167,6 @@ func (cr *containerReference) GetContainerArchive(ctx context.Context, srcPath s
|
|||||||
if common.Dryrun(ctx) {
|
if common.Dryrun(ctx) {
|
||||||
return nil, errors.New("DRYRUN is not supported in GetContainerArchive")
|
return nil, errors.New("DRYRUN is not supported in GetContainerArchive")
|
||||||
}
|
}
|
||||||
// Direct entry point (no pipeline) — revalidate cr.id ourselves.
|
|
||||||
if err := cr.connect()(ctx); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if err := cr.find()(ctx); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if cr.id == "" {
|
|
||||||
return nil, cr.missingContainerError("get archive %s", srcPath)
|
|
||||||
}
|
|
||||||
result, err := cr.cli.CopyFromContainer(ctx, cr.id, client.CopyFromContainerOptions{SourcePath: srcPath})
|
result, err := cr.cli.CopyFromContainer(ctx, cr.id, client.CopyFromContainerOptions{SourcePath: srcPath})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -327,23 +314,11 @@ func (cr *containerReference) Close() common.Executor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// missingContainerError is the shared "container X does not exist" error
|
|
||||||
// used by ops that need a live cr.id.
|
|
||||||
func (cr *containerReference) missingContainerError(format string, args ...any) error {
|
|
||||||
return fmt.Errorf("container %q does not exist; cannot "+format, append([]any{cr.input.Name}, args...)...)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (cr *containerReference) find() common.Executor {
|
func (cr *containerReference) find() common.Executor {
|
||||||
return func(ctx context.Context) error {
|
return func(ctx context.Context) error {
|
||||||
if cr.id != "" {
|
if cr.id != "" {
|
||||||
// Validate cached id; clear only on definitive NotFound so a
|
|
||||||
// transient daemon error doesn't abort cleanup pipelines.
|
|
||||||
_, err := cr.cli.ContainerInspect(ctx, cr.id, client.ContainerInspectOptions{})
|
|
||||||
if !cerrdefs.IsNotFound(err) {
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
cr.id = ""
|
|
||||||
}
|
|
||||||
containers, err := cr.cli.ContainerList(ctx, client.ContainerListOptions{
|
containers, err := cr.cli.ContainerList(ctx, client.ContainerListOptions{
|
||||||
All: true,
|
All: true,
|
||||||
})
|
})
|
||||||
@@ -360,6 +335,7 @@ func (cr *containerReference) find() common.Executor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cr.id = ""
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -616,9 +592,6 @@ func (cr *containerReference) extractFromImageEnv(env *map[string]string) common
|
|||||||
|
|
||||||
func (cr *containerReference) exec(cmd []string, env map[string]string, user, workdir string) common.Executor {
|
func (cr *containerReference) exec(cmd []string, env map[string]string, user, workdir string) common.Executor {
|
||||||
return func(ctx context.Context) error {
|
return func(ctx context.Context) error {
|
||||||
if cr.id == "" {
|
|
||||||
return cr.missingContainerError("exec %v", cmd)
|
|
||||||
}
|
|
||||||
logger := common.Logger(ctx)
|
logger := common.Logger(ctx)
|
||||||
// Fix slashes when running on Windows
|
// Fix slashes when running on Windows
|
||||||
if runtime.GOOS == "windows" {
|
if runtime.GOOS == "windows" {
|
||||||
@@ -773,9 +746,6 @@ func (cr *containerReference) waitForCommand(ctx context.Context, isTerminal boo
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (cr *containerReference) CopyTarStream(ctx context.Context, destPath string, tarStream io.Reader) error {
|
func (cr *containerReference) CopyTarStream(ctx context.Context, destPath string, tarStream io.Reader) error {
|
||||||
if cr.id == "" {
|
|
||||||
return cr.missingContainerError("copy to %s", destPath)
|
|
||||||
}
|
|
||||||
// Mkdir
|
// Mkdir
|
||||||
buf := &bytes.Buffer{}
|
buf := &bytes.Buffer{}
|
||||||
tw := tar.NewWriter(buf)
|
tw := tar.NewWriter(buf)
|
||||||
@@ -809,9 +779,6 @@ func (cr *containerReference) CopyTarStream(ctx context.Context, destPath string
|
|||||||
|
|
||||||
func (cr *containerReference) copyDir(dstPath, srcPath string, useGitIgnore bool) common.Executor {
|
func (cr *containerReference) copyDir(dstPath, srcPath string, useGitIgnore bool) common.Executor {
|
||||||
return func(ctx context.Context) error {
|
return func(ctx context.Context) error {
|
||||||
if cr.id == "" {
|
|
||||||
return cr.missingContainerError("copy directory to %s", dstPath)
|
|
||||||
}
|
|
||||||
logger := common.Logger(ctx)
|
logger := common.Logger(ctx)
|
||||||
tarFile, err := os.CreateTemp("", "act")
|
tarFile, err := os.CreateTemp("", "act")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -886,9 +853,6 @@ func (cr *containerReference) copyDir(dstPath, srcPath string, useGitIgnore bool
|
|||||||
|
|
||||||
func (cr *containerReference) copyContent(dstPath string, files ...*FileEntry) common.Executor {
|
func (cr *containerReference) copyContent(dstPath string, files ...*FileEntry) common.Executor {
|
||||||
return func(ctx context.Context) error {
|
return func(ctx context.Context) error {
|
||||||
if cr.id == "" {
|
|
||||||
return cr.missingContainerError("copy to %s", dstPath)
|
|
||||||
}
|
|
||||||
logger := common.Logger(ctx)
|
logger := common.Logger(ctx)
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
tw := tar.NewWriter(&buf)
|
tw := tar.NewWriter(&buf)
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ import (
|
|||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
|
|
||||||
cerrdefs "github.com/containerd/errdefs"
|
|
||||||
"github.com/moby/moby/api/types/container"
|
"github.com/moby/moby/api/types/container"
|
||||||
mobyclient "github.com/moby/moby/client"
|
mobyclient "github.com/moby/moby/client"
|
||||||
"github.com/sirupsen/logrus/hooks/test"
|
"github.com/sirupsen/logrus/hooks/test"
|
||||||
@@ -29,10 +28,14 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func TestDocker(t *testing.T) {
|
func TestDocker(t *testing.T) {
|
||||||
requireDocker(t)
|
if testing.Short() {
|
||||||
|
t.Skip("skipping integration test")
|
||||||
|
}
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
client, err := GetDockerClient(ctx)
|
client, err := GetDockerClient(ctx)
|
||||||
require.NoError(t, err)
|
if err != nil {
|
||||||
|
t.Skipf("skipping integration test: %v", err)
|
||||||
|
}
|
||||||
defer client.Close()
|
defer client.Close()
|
||||||
|
|
||||||
dockerBuild := NewDockerBuildExecutor(NewDockerBuildExecutorInput{
|
dockerBuild := NewDockerBuildExecutor(NewDockerBuildExecutorInput{
|
||||||
@@ -99,16 +102,6 @@ func (m *mockDockerClient) CopyToContainer(ctx context.Context, id string, optio
|
|||||||
return args.Get(0).(mobyclient.CopyToContainerResult), args.Error(1)
|
return args.Get(0).(mobyclient.CopyToContainerResult), args.Error(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *mockDockerClient) ContainerInspect(ctx context.Context, id string, opts mobyclient.ContainerInspectOptions) (mobyclient.ContainerInspectResult, error) {
|
|
||||||
args := m.Called(ctx, id, opts)
|
|
||||||
return args.Get(0).(mobyclient.ContainerInspectResult), args.Error(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *mockDockerClient) ContainerList(ctx context.Context, opts mobyclient.ContainerListOptions) (mobyclient.ContainerListResult, error) {
|
|
||||||
args := m.Called(ctx, opts)
|
|
||||||
return args.Get(0).(mobyclient.ContainerListResult), args.Error(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
type endlessReader struct {
|
type endlessReader struct {
|
||||||
io.Reader
|
io.Reader
|
||||||
}
|
}
|
||||||
@@ -309,134 +302,6 @@ func TestDockerCopyTarStreamErrorInMkdir(t *testing.T) {
|
|||||||
client.AssertExpectations(t)
|
client.AssertExpectations(t)
|
||||||
}
|
}
|
||||||
|
|
||||||
// find() must drop a stale cached id so later Copy/Exec don't hit the
|
|
||||||
// daemon with a torn-down container.
|
|
||||||
func TestFindRevalidatesStaleID(t *testing.T) {
|
|
||||||
ctx := context.Background()
|
|
||||||
notFound := cerrdefs.ErrNotFound.WithMessage("No such container")
|
|
||||||
boom := errors.New("daemon unreachable")
|
|
||||||
newCR := func(id string) (*containerReference, *mockDockerClient) {
|
|
||||||
client := &mockDockerClient{}
|
|
||||||
return &containerReference{id: id, cli: client, input: &NewContainerInput{Name: "job-1"}}, client
|
|
||||||
}
|
|
||||||
listOpts := mobyclient.ContainerListOptions{All: true}
|
|
||||||
inspectOpts := mobyclient.ContainerInspectOptions{}
|
|
||||||
|
|
||||||
t.Run("stale id cleared, name lookup empty", func(t *testing.T) {
|
|
||||||
cr, client := newCR("stale")
|
|
||||||
client.On("ContainerInspect", ctx, "stale", inspectOpts).Return(mobyclient.ContainerInspectResult{}, notFound)
|
|
||||||
client.On("ContainerList", ctx, listOpts).Return(mobyclient.ContainerListResult{}, nil)
|
|
||||||
require.NoError(t, cr.find()(ctx))
|
|
||||||
assert.Empty(t, cr.id)
|
|
||||||
client.AssertExpectations(t)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("stale id cleared, name lookup repopulates", func(t *testing.T) {
|
|
||||||
cr, client := newCR("stale")
|
|
||||||
client.On("ContainerInspect", ctx, "stale", inspectOpts).Return(mobyclient.ContainerInspectResult{}, notFound)
|
|
||||||
client.On("ContainerList", ctx, listOpts).Return(mobyclient.ContainerListResult{Items: []container.Summary{
|
|
||||||
{ID: "other", Names: []string{"/somebody-else"}},
|
|
||||||
{ID: "fresh", Names: []string{"/job-1"}},
|
|
||||||
}}, nil)
|
|
||||||
require.NoError(t, cr.find()(ctx))
|
|
||||||
assert.Equal(t, "fresh", cr.id)
|
|
||||||
client.AssertExpectations(t)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("live id kept", func(t *testing.T) {
|
|
||||||
cr, client := newCR("live")
|
|
||||||
client.On("ContainerInspect", ctx, "live", inspectOpts).Return(mobyclient.ContainerInspectResult{}, nil)
|
|
||||||
require.NoError(t, cr.find()(ctx))
|
|
||||||
assert.Equal(t, "live", cr.id)
|
|
||||||
client.AssertExpectations(t)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("transient inspect error trusts cache", func(t *testing.T) {
|
|
||||||
cr, client := newCR("live")
|
|
||||||
client.On("ContainerInspect", ctx, "live", inspectOpts).Return(mobyclient.ContainerInspectResult{}, boom)
|
|
||||||
require.NoError(t, cr.find()(ctx))
|
|
||||||
assert.Equal(t, "live", cr.id)
|
|
||||||
client.AssertExpectations(t)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("list error propagates", func(t *testing.T) {
|
|
||||||
cr, client := newCR("")
|
|
||||||
client.On("ContainerList", ctx, listOpts).Return(mobyclient.ContainerListResult{}, boom)
|
|
||||||
require.ErrorIs(t, cr.find()(ctx), boom)
|
|
||||||
client.AssertExpectations(t)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Every daemon entry point fails fast with a clear, container-named
|
|
||||||
// error when no live cr.id is known.
|
|
||||||
func TestRejectsMissingContainer(t *testing.T) {
|
|
||||||
ctx := context.Background()
|
|
||||||
client := &mockDockerClient{}
|
|
||||||
client.On("ContainerList", ctx, mobyclient.ContainerListOptions{All: true}).Return(mobyclient.ContainerListResult{}, nil)
|
|
||||||
cr := &containerReference{cli: client, input: &NewContainerInput{Name: "job-1"}}
|
|
||||||
check := func(op string, err error) {
|
|
||||||
t.Helper()
|
|
||||||
require.Error(t, err, op)
|
|
||||||
assert.Contains(t, err.Error(), `container "job-1" does not exist`, op)
|
|
||||||
}
|
|
||||||
check("copyContent", cr.copyContent("/var/run/act", &FileEntry{Name: "x", Mode: 0o644})(ctx))
|
|
||||||
check("copyDir", cr.copyDir("/var/run/act", "/src", false)(ctx))
|
|
||||||
check("CopyTarStream", cr.CopyTarStream(ctx, "/var/run/act", &bytes.Buffer{}))
|
|
||||||
check("exec", cr.exec([]string{"echo"}, nil, "", "")(ctx))
|
|
||||||
_, err := cr.GetContainerArchive(ctx, "/var/run/act/x")
|
|
||||||
check("GetContainerArchive", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// End-to-end: a stale cr.id is cleared, repopulated from name lookup,
|
|
||||||
// and the Copy completes against the fresh id.
|
|
||||||
func TestPublicCopyPipelineHandlesStaleID(t *testing.T) {
|
|
||||||
ctx := context.Background()
|
|
||||||
client := &mockDockerClient{}
|
|
||||||
client.On("ContainerInspect", ctx, "stale", mobyclient.ContainerInspectOptions{}).
|
|
||||||
Return(mobyclient.ContainerInspectResult{}, cerrdefs.ErrNotFound.WithMessage("gone"))
|
|
||||||
client.On("ContainerList", ctx, mobyclient.ContainerListOptions{All: true}).
|
|
||||||
Return(mobyclient.ContainerListResult{Items: []container.Summary{
|
|
||||||
{ID: "fresh", Names: []string{"/job-1"}},
|
|
||||||
}}, nil)
|
|
||||||
client.On("CopyToContainer", ctx, "fresh", mock.MatchedBy(func(opts mobyclient.CopyToContainerOptions) bool {
|
|
||||||
return opts.DestinationPath == "/var/run/act"
|
|
||||||
})).Return(mobyclient.CopyToContainerResult{}, nil)
|
|
||||||
|
|
||||||
cr := &containerReference{id: "stale", cli: client, input: &NewContainerInput{Name: "job-1"}}
|
|
||||||
require.NoError(t, cr.Copy("/var/run/act", &FileEntry{Name: "x", Mode: 0o644})(ctx))
|
|
||||||
assert.Equal(t, "fresh", cr.id)
|
|
||||||
client.AssertExpectations(t)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestDockerCopyToSymlinkPath is a regression test for gitea/runner#981. Most base images
|
|
||||||
// symlink /var/run to /run, so copying into /var/run/act traverses that symlink. The broken
|
|
||||||
// docker 29.5.1 daemon fails the extraction with "mkdirat var/run: file exists" (fixed in
|
|
||||||
// 29.5.2). Running against the daemon shipped in the dind image, this catches a bad bump.
|
|
||||||
func TestDockerCopyToSymlinkPath(t *testing.T) {
|
|
||||||
requireDocker(t)
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
rc := NewContainer(&NewContainerInput{
|
|
||||||
Image: "alpine:latest",
|
|
||||||
Entrypoint: []string{"sleep", "30"},
|
|
||||||
Name: "act-test-symlink-" + time.Now().Format("20060102150405.000000"),
|
|
||||||
AutoRemove: true,
|
|
||||||
})
|
|
||||||
require.NoError(t, rc.Pull(false)(ctx))
|
|
||||||
require.NoError(t, rc.Create(nil, nil)(ctx))
|
|
||||||
require.NoError(t, rc.Start(false)(ctx))
|
|
||||||
t.Cleanup(func() {
|
|
||||||
_ = rc.Remove()(ctx)
|
|
||||||
_ = rc.Close()(ctx)
|
|
||||||
})
|
|
||||||
|
|
||||||
// CopyTarStream first creates the destination directory by extracting a tar at "/",
|
|
||||||
// which makes the daemon mkdir var, then var/run (the symlink), then act — the exact
|
|
||||||
// step that fails on the broken daemon.
|
|
||||||
err := rc.CopyTarStream(ctx, "/var/run/act", &bytes.Buffer{})
|
|
||||||
require.NoError(t, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Type assert containerReference implements ExecutionsEnvironment
|
// Type assert containerReference implements ExecutionsEnvironment
|
||||||
var _ ExecutionsEnvironment = &containerReference{}
|
var _ ExecutionsEnvironment = &containerReference{}
|
||||||
|
|
||||||
|
|||||||
@@ -18,19 +18,9 @@ func init() {
|
|||||||
|
|
||||||
var originalCommonSocketLocations = CommonSocketLocations
|
var originalCommonSocketLocations = CommonSocketLocations
|
||||||
|
|
||||||
func isolateSocketEnv(t *testing.T) {
|
|
||||||
t.Helper()
|
|
||||||
t.Cleanup(func() { CommonSocketLocations = originalCommonSocketLocations })
|
|
||||||
if host, ok := os.LookupEnv("DOCKER_HOST"); ok {
|
|
||||||
t.Setenv("DOCKER_HOST", host)
|
|
||||||
} else {
|
|
||||||
t.Cleanup(func() { os.Unsetenv("DOCKER_HOST") })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGetSocketAndHostWithSocket(t *testing.T) {
|
func TestGetSocketAndHostWithSocket(t *testing.T) {
|
||||||
// Arrange
|
// Arrange
|
||||||
isolateSocketEnv(t)
|
CommonSocketLocations = originalCommonSocketLocations
|
||||||
dockerHost := "unix:///my/docker/host.sock"
|
dockerHost := "unix:///my/docker/host.sock"
|
||||||
socketURI := "/path/to/my.socket"
|
socketURI := "/path/to/my.socket"
|
||||||
t.Setenv("DOCKER_HOST", dockerHost)
|
t.Setenv("DOCKER_HOST", dockerHost)
|
||||||
@@ -58,9 +48,9 @@ func TestGetSocketAndHostNoSocket(t *testing.T) {
|
|||||||
|
|
||||||
func TestGetSocketAndHostOnlySocket(t *testing.T) {
|
func TestGetSocketAndHostOnlySocket(t *testing.T) {
|
||||||
// Arrange
|
// Arrange
|
||||||
isolateSocketEnv(t)
|
|
||||||
socketURI := "/path/to/my.socket"
|
socketURI := "/path/to/my.socket"
|
||||||
os.Unsetenv("DOCKER_HOST")
|
os.Unsetenv("DOCKER_HOST")
|
||||||
|
CommonSocketLocations = originalCommonSocketLocations
|
||||||
defaultSocket, defaultSocketFound := socketLocation()
|
defaultSocket, defaultSocketFound := socketLocation()
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
@@ -75,7 +65,7 @@ func TestGetSocketAndHostOnlySocket(t *testing.T) {
|
|||||||
|
|
||||||
func TestGetSocketAndHostDontMount(t *testing.T) {
|
func TestGetSocketAndHostDontMount(t *testing.T) {
|
||||||
// Arrange
|
// Arrange
|
||||||
isolateSocketEnv(t)
|
CommonSocketLocations = originalCommonSocketLocations
|
||||||
dockerHost := "unix:///my/docker/host.sock"
|
dockerHost := "unix:///my/docker/host.sock"
|
||||||
t.Setenv("DOCKER_HOST", dockerHost)
|
t.Setenv("DOCKER_HOST", dockerHost)
|
||||||
|
|
||||||
@@ -89,7 +79,7 @@ func TestGetSocketAndHostDontMount(t *testing.T) {
|
|||||||
|
|
||||||
func TestGetSocketAndHostNoHostNoSocket(t *testing.T) {
|
func TestGetSocketAndHostNoHostNoSocket(t *testing.T) {
|
||||||
// Arrange
|
// Arrange
|
||||||
isolateSocketEnv(t)
|
CommonSocketLocations = originalCommonSocketLocations
|
||||||
os.Unsetenv("DOCKER_HOST")
|
os.Unsetenv("DOCKER_HOST")
|
||||||
defaultSocket, found := socketLocation()
|
defaultSocket, found := socketLocation()
|
||||||
|
|
||||||
@@ -107,7 +97,6 @@ func TestGetSocketAndHostNoHostNoSocket(t *testing.T) {
|
|||||||
// > This happens if neither DOCKER_HOST nor --container-daemon-socket has a value, but socketLocation() returns a URI
|
// > This happens if neither DOCKER_HOST nor --container-daemon-socket has a value, but socketLocation() returns a URI
|
||||||
func TestGetSocketAndHostNoHostNoSocketDefaultLocation(t *testing.T) {
|
func TestGetSocketAndHostNoHostNoSocketDefaultLocation(t *testing.T) {
|
||||||
// Arrange
|
// Arrange
|
||||||
isolateSocketEnv(t)
|
|
||||||
mySocketFile, tmpErr := os.CreateTemp(t.TempDir(), "act-*.sock")
|
mySocketFile, tmpErr := os.CreateTemp(t.TempDir(), "act-*.sock")
|
||||||
mySocket := mySocketFile.Name()
|
mySocket := mySocketFile.Name()
|
||||||
unixSocket := "unix://" + mySocket
|
unixSocket := "unix://" + mySocket
|
||||||
@@ -130,7 +119,6 @@ func TestGetSocketAndHostNoHostNoSocketDefaultLocation(t *testing.T) {
|
|||||||
|
|
||||||
func TestGetSocketAndHostNoHostInvalidSocket(t *testing.T) {
|
func TestGetSocketAndHostNoHostInvalidSocket(t *testing.T) {
|
||||||
// Arrange
|
// Arrange
|
||||||
isolateSocketEnv(t)
|
|
||||||
os.Unsetenv("DOCKER_HOST")
|
os.Unsetenv("DOCKER_HOST")
|
||||||
mySocket := "/my/socket/path.sock"
|
mySocket := "/my/socket/path.sock"
|
||||||
CommonSocketLocations = []string{"/unusual", "/socket", "/location"}
|
CommonSocketLocations = []string{"/unusual", "/socket", "/location"}
|
||||||
@@ -148,7 +136,6 @@ func TestGetSocketAndHostNoHostInvalidSocket(t *testing.T) {
|
|||||||
|
|
||||||
func TestGetSocketAndHostOnlySocketValidButUnusualLocation(t *testing.T) {
|
func TestGetSocketAndHostOnlySocketValidButUnusualLocation(t *testing.T) {
|
||||||
// Arrange
|
// Arrange
|
||||||
isolateSocketEnv(t)
|
|
||||||
socketURI := "unix:///path/to/my.socket"
|
socketURI := "unix:///path/to/my.socket"
|
||||||
CommonSocketLocations = []string{"/unusual", "/location"}
|
CommonSocketLocations = []string{"/unusual", "/location"}
|
||||||
os.Unsetenv("DOCKER_HOST")
|
os.Unsetenv("DOCKER_HOST")
|
||||||
|
|||||||
@@ -1,27 +0,0 @@
|
|||||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
|
||||||
// SPDX-License-Identifier: MIT
|
|
||||||
|
|
||||||
package container
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
mobyclient "github.com/moby/moby/client"
|
|
||||||
)
|
|
||||||
|
|
||||||
// requireDocker skips the test unless a reachable docker daemon is available.
|
|
||||||
// GetDockerClient succeeds even without a running daemon (its ping is best-effort),
|
|
||||||
// so the daemon has to be pinged explicitly here to decide whether to skip.
|
|
||||||
func requireDocker(t *testing.T) {
|
|
||||||
t.Helper()
|
|
||||||
ctx := context.Background()
|
|
||||||
cli, err := GetDockerClient(ctx)
|
|
||||||
if err != nil {
|
|
||||||
t.Skipf("skipping: docker client unavailable: %v", err)
|
|
||||||
}
|
|
||||||
defer cli.Close()
|
|
||||||
if _, err := cli.Ping(ctx, mobyclient.PingOptions{}); err != nil {
|
|
||||||
t.Skipf("skipping: docker daemon unreachable: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -16,7 +16,9 @@ import (
|
|||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -42,6 +44,9 @@ type HostEnvironment struct {
|
|||||||
CleanUp func()
|
CleanUp func()
|
||||||
StdOut io.Writer
|
StdOut io.Writer
|
||||||
AllocatePTY bool // allocate a pseudo-TTY for each step's process
|
AllocatePTY bool // allocate a pseudo-TTY for each step's process
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
runningPIDs map[int]struct{}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *HostEnvironment) Create(_, _ []string) common.Executor {
|
func (e *HostEnvironment) Create(_, _ []string) common.Executor {
|
||||||
@@ -322,30 +327,6 @@ func (e *HostEnvironment) exec(ctx context.Context, command []string, cmdline st
|
|||||||
cmd.Stderr = e.StdOut
|
cmd.Stderr = e.StdOut
|
||||||
cmd.Dir = wd
|
cmd.Dir = wd
|
||||||
cmd.SysProcAttr = getSysProcAttr(cmdline, false)
|
cmd.SysProcAttr = getSysProcAttr(cmdline, false)
|
||||||
|
|
||||||
// On Windows a step often launches a process tree (a shell that starts a
|
|
||||||
// child which spawns further GUI or background processes). The default
|
|
||||||
// context cancellation only kills the direct child, leaving the rest of the
|
|
||||||
// tree running; and because the orphans inherit cmd's stdout/stderr pipe,
|
|
||||||
// cmd.Wait() would block forever, hanging the runner. Kill the whole tree
|
|
||||||
// via a Job Object on cancellation, and bound the wait so a leftover pipe
|
|
||||||
// writer can never hang Wait indefinitely.
|
|
||||||
var killer atomic.Pointer[processKiller]
|
|
||||||
if runtime.GOOS == "windows" {
|
|
||||||
cmd.Cancel = func() error {
|
|
||||||
if k := killer.Load(); k != nil {
|
|
||||||
return k.Kill()
|
|
||||||
}
|
|
||||||
if cmd.Process != nil {
|
|
||||||
return cmd.Process.Kill()
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
// Once the step process has exited, give its I/O pipes at most this long
|
|
||||||
// to drain before Wait force-closes them and returns (Go's WaitDelay).
|
|
||||||
cmd.WaitDelay = 10 * time.Second
|
|
||||||
}
|
|
||||||
|
|
||||||
var ppty *os.File
|
var ppty *os.File
|
||||||
var tty *os.File
|
var tty *os.File
|
||||||
defer func() {
|
defer func() {
|
||||||
@@ -372,20 +353,23 @@ func (e *HostEnvironment) exec(ctx context.Context, command []string, cmdline st
|
|||||||
go copyPtyOutput(writer, ppty, finishLog)
|
go copyPtyOutput(writer, ppty, finishLog)
|
||||||
go writeKeepAlive(ppty)
|
go writeKeepAlive(ppty)
|
||||||
}
|
}
|
||||||
|
// Split Start/Wait so the PID can be registered before the process can exit;
|
||||||
|
// cmd.Run() would block until exit, by which time the PID may have been reused.
|
||||||
if err := cmd.Start(); err != nil {
|
if err := cmd.Start(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if runtime.GOOS == "windows" {
|
if cmd.Process != nil {
|
||||||
// Assign the started process to a Job Object so cmd.Cancel can kill the
|
e.mu.Lock()
|
||||||
// whole descendant tree. Children spawned afterwards are auto-included.
|
if e.runningPIDs == nil {
|
||||||
// On failure (e.g. nested-job restrictions) we fall back to the default
|
e.runningPIDs = map[int]struct{}{}
|
||||||
// single-process kill; WaitDelay + end-of-job cleanup still apply.
|
|
||||||
if k, kerr := newProcessKiller(cmd.Process); kerr != nil {
|
|
||||||
common.Logger(ctx).Warnf("process tree kill setup failed, falling back to single-process kill: %v", kerr)
|
|
||||||
} else {
|
|
||||||
killer.Store(k)
|
|
||||||
defer k.Close()
|
|
||||||
}
|
}
|
||||||
|
e.runningPIDs[cmd.Process.Pid] = struct{}{}
|
||||||
|
e.mu.Unlock()
|
||||||
|
defer func(pid int) {
|
||||||
|
e.mu.Lock()
|
||||||
|
delete(e.runningPIDs, pid)
|
||||||
|
e.mu.Unlock()
|
||||||
|
}(cmd.Process.Pid)
|
||||||
}
|
}
|
||||||
err = cmd.Wait()
|
err = cmd.Wait()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -456,80 +440,30 @@ func removePathWithRetry(ctx context.Context, path string) error {
|
|||||||
return lastErr
|
return lastErr
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildWindowsWorkspaceKillScript builds a PowerShell command that `taskkill
|
|
||||||
// /T /F`s every process tree whose ExecutablePath or CommandLine references one
|
|
||||||
// of the given absolute workspace dirs, releasing file handles for cleanup.
|
|
||||||
//
|
|
||||||
// Win32_Process is used because it exposes both ExecutablePath and CommandLine
|
|
||||||
// (Get-Process doesn't, wmic is deprecated). Both match the dir+separator
|
|
||||||
// prefix, so a sibling dir sharing a name prefix (job1 vs job10) is spared.
|
|
||||||
// Ordinal String methods, not -like, so path metacharacters ([ ] ? *) stay
|
|
||||||
// literal.
|
|
||||||
//
|
|
||||||
// Pure function so the quote-escaping can be unit-tested without PowerShell.
|
|
||||||
func buildWindowsWorkspaceKillScript(dirs []string) string {
|
|
||||||
quoted := make([]string, len(dirs))
|
|
||||||
for i, d := range dirs {
|
|
||||||
// Single-quoted PowerShell literal; escape ' by doubling it.
|
|
||||||
quoted[i] = "'" + strings.ReplaceAll(d, "'", "''") + "'"
|
|
||||||
}
|
|
||||||
|
|
||||||
return `$paths = @(` + strings.Join(quoted, ",") + `)
|
|
||||||
$selfPid = $PID
|
|
||||||
Get-CimInstance Win32_Process -ErrorAction SilentlyContinue | Where-Object {
|
|
||||||
if ($_.ProcessId -eq $selfPid) { return $false }
|
|
||||||
foreach ($p in $paths) {
|
|
||||||
$prefix = $p + '\'
|
|
||||||
if ($_.ExecutablePath -and $_.ExecutablePath.StartsWith($prefix, [System.StringComparison]::OrdinalIgnoreCase)) { return $true }
|
|
||||||
if ($_.CommandLine -and $_.CommandLine.IndexOf($prefix, [System.StringComparison]::OrdinalIgnoreCase) -ge 0) { return $true }
|
|
||||||
}
|
|
||||||
return $false
|
|
||||||
} | ForEach-Object {
|
|
||||||
& taskkill.exe /PID $_.ProcessId /T /F 2>$null | Out-Null
|
|
||||||
}
|
|
||||||
`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *HostEnvironment) terminateRunningProcesses(ctx context.Context) {
|
func (e *HostEnvironment) terminateRunningProcesses(ctx context.Context) {
|
||||||
if runtime.GOOS != "windows" {
|
if runtime.GOOS != "windows" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
e.mu.Lock()
|
||||||
|
pids := make([]int, 0, len(e.runningPIDs))
|
||||||
|
for pid := range e.runningPIDs {
|
||||||
|
pids = append(pids, pid)
|
||||||
|
}
|
||||||
|
e.mu.Unlock()
|
||||||
|
|
||||||
// Detached: exec.CommandContext won't start on a cancelled ctx, and a
|
if len(pids) == 0 {
|
||||||
// server cancel has already cancelled the parent ctx.
|
|
||||||
killCtx, killCancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
||||||
defer killCancel()
|
|
||||||
|
|
||||||
logger := common.Logger(ctx)
|
|
||||||
|
|
||||||
// Workspace dirs we own. Any process running from or referencing one is a
|
|
||||||
// leftover job process. ToolCache is shared across jobs; Workdir only when
|
|
||||||
// we own it (else it's a caller-provided checkout, e.g. act local mode).
|
|
||||||
owned := []string{e.Path, e.TmpDir}
|
|
||||||
if e.CleanWorkdir {
|
|
||||||
owned = append(owned, e.Workdir)
|
|
||||||
}
|
|
||||||
dirs := make([]string, 0, len(owned))
|
|
||||||
for _, d := range owned {
|
|
||||||
if d == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
abs, err := filepath.Abs(d)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
dirs = append(dirs, abs)
|
|
||||||
}
|
|
||||||
if len(dirs) == 0 {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
script := buildWindowsWorkspaceKillScript(dirs)
|
logger := common.Logger(ctx)
|
||||||
|
for _, pid := range pids {
|
||||||
cmd := exec.CommandContext(killCtx, "powershell.exe", "-NoProfile", "-NonInteractive", "-Command", script)
|
// Best-effort: forcibly terminate process tree to release file handles
|
||||||
|
// so that workspace cleanup can succeed on Windows.
|
||||||
|
cmd := exec.CommandContext(ctx, "taskkill", "/PID", strconv.Itoa(pid), "/T", "/F")
|
||||||
out, err := cmd.CombinedOutput()
|
out, err := cmd.CombinedOutput()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Debugf("workspace process-tree kill via PowerShell failed: %v output=%s", err, strings.TrimSpace(string(out)))
|
logger.Debugf("taskkill failed for pid=%d: %v output=%s", pid, err, strings.TrimSpace(string(out)))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -543,20 +477,14 @@ func (e *HostEnvironment) Remove() common.Executor {
|
|||||||
if e.CleanUp != nil {
|
if e.CleanUp != nil {
|
||||||
e.CleanUp()
|
e.CleanUp()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Detach: a cancelled ctx would skip removePathWithRetry's retries,
|
|
||||||
// which absorb Windows file-handle release lag after the kill above.
|
|
||||||
rmCtx, rmCancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
||||||
defer rmCancel()
|
|
||||||
|
|
||||||
logger := common.Logger(ctx)
|
logger := common.Logger(ctx)
|
||||||
var errs []error
|
var errs []error
|
||||||
if err := removePathWithRetry(rmCtx, e.Path); err != nil {
|
if err := removePathWithRetry(ctx, e.Path); err != nil {
|
||||||
logger.Warnf("failed to remove host misc state %s: %v", e.Path, err)
|
logger.Warnf("failed to remove host misc state %s: %v", e.Path, err)
|
||||||
errs = append(errs, err)
|
errs = append(errs, err)
|
||||||
}
|
}
|
||||||
if e.CleanWorkdir {
|
if e.CleanWorkdir {
|
||||||
if err := removePathWithRetry(rmCtx, e.Workdir); err != nil {
|
if err := removePathWithRetry(ctx, e.Workdir); err != nil {
|
||||||
logger.Warnf("failed to remove host workspace %s: %v", e.Workdir, err)
|
logger.Warnf("failed to remove host workspace %s: %v", e.Workdir, err)
|
||||||
errs = append(errs, err)
|
errs = append(errs, err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -187,64 +187,3 @@ func TestHostEnvironmentRemoveCleansWorkdirWhenOwned(t *testing.T) {
|
|||||||
_, err := os.Stat(workdir)
|
_, err := os.Stat(workdir)
|
||||||
assert.ErrorIs(t, err, os.ErrNotExist)
|
assert.ErrorIs(t, err, os.ErrNotExist)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestBuildWindowsWorkspaceKillScript(t *testing.T) {
|
|
||||||
t.Run("single dir", func(t *testing.T) {
|
|
||||||
s := buildWindowsWorkspaceKillScript([]string{`C:\workspace\job1`})
|
|
||||||
assert.Contains(t, s, `$paths = @('C:\workspace\job1')`)
|
|
||||||
// Self-PID guard is essential — without it the script could taskkill
|
|
||||||
// the PowerShell process running it.
|
|
||||||
assert.Contains(t, s, "$selfPid = $PID")
|
|
||||||
assert.Contains(t, s, "$_.ProcessId -eq $selfPid")
|
|
||||||
// Must match both ExecutablePath (binaries from the workspace) and
|
|
||||||
// CommandLine (system binaries invoked with workspace paths in args),
|
|
||||||
// both bounded by dir+separator so a name-prefix sibling is spared.
|
|
||||||
assert.Contains(t, s, `$prefix = $p + '\'`)
|
|
||||||
assert.Contains(t, s, "$_.ExecutablePath.StartsWith($prefix")
|
|
||||||
assert.Contains(t, s, "$_.CommandLine.IndexOf($prefix")
|
|
||||||
// Each matched PID must be tree-killed, not just stopped.
|
|
||||||
assert.Contains(t, s, "taskkill.exe /PID $_.ProcessId /T /F")
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("multiple dirs comma-separated", func(t *testing.T) {
|
|
||||||
s := buildWindowsWorkspaceKillScript([]string{
|
|
||||||
`C:\work\path`,
|
|
||||||
`C:\work\workdir`,
|
|
||||||
`C:\Users\runner\AppData\Local\Temp\job-42`,
|
|
||||||
})
|
|
||||||
assert.Contains(t, s, `'C:\work\path'`)
|
|
||||||
assert.Contains(t, s, `'C:\work\workdir'`)
|
|
||||||
assert.Contains(t, s, `'C:\Users\runner\AppData\Local\Temp\job-42'`)
|
|
||||||
// Commas between entries — no trailing comma, no leading comma.
|
|
||||||
assert.Contains(t, s, `'C:\work\path','C:\work\workdir',`)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("path with single quote is escaped", func(t *testing.T) {
|
|
||||||
// In PowerShell single-quoted strings the only special char is the
|
|
||||||
// quote itself, escaped by doubling. A workspace path that ever
|
|
||||||
// contained `'` would inject a command into the script otherwise.
|
|
||||||
s := buildWindowsWorkspaceKillScript([]string{`C:\work\it's\path`})
|
|
||||||
assert.Contains(t, s, `'C:\work\it''s\path'`)
|
|
||||||
// And it must NOT appear unescaped — otherwise the quote would
|
|
||||||
// terminate the literal early.
|
|
||||||
assert.NotContains(t, s, `'C:\work\it's\path'`)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("path with wildcard metacharacters is matched literally", func(t *testing.T) {
|
|
||||||
// A path containing [ ] ? * must be embedded verbatim and matched with
|
|
||||||
// ordinal String methods, not -like, otherwise the metacharacters would
|
|
||||||
// be interpreted as wildcards and the leftover process could escape.
|
|
||||||
s := buildWindowsWorkspaceKillScript([]string{`C:\work\[job]?1`})
|
|
||||||
assert.Contains(t, s, `'C:\work\[job]?1'`)
|
|
||||||
assert.NotContains(t, s, "-like")
|
|
||||||
assert.Contains(t, s, "StartsWith")
|
|
||||||
assert.Contains(t, s, "IndexOf")
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("empty dir list still produces a valid script", func(t *testing.T) {
|
|
||||||
s := buildWindowsWorkspaceKillScript(nil)
|
|
||||||
// Empty array literal — script runs, matches nothing, is a no-op.
|
|
||||||
assert.Contains(t, s, "$paths = @()")
|
|
||||||
assert.Contains(t, s, "Get-CimInstance Win32_Process")
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,19 +0,0 @@
|
|||||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
|
||||||
// SPDX-License-Identifier: MIT
|
|
||||||
|
|
||||||
//go:build !windows
|
|
||||||
|
|
||||||
package container
|
|
||||||
|
|
||||||
import "os"
|
|
||||||
|
|
||||||
// processKiller is a no-op on non-Windows platforms. The Job Object based
|
|
||||||
// tree-kill is only wired in on Windows (see exec()); elsewhere the default
|
|
||||||
// exec.CommandContext cancellation and Setpgid handling apply.
|
|
||||||
type processKiller struct{}
|
|
||||||
|
|
||||||
func newProcessKiller(_ *os.Process) (*processKiller, error) { return &processKiller{}, nil }
|
|
||||||
|
|
||||||
func (k *processKiller) Kill() error { return nil }
|
|
||||||
|
|
||||||
func (k *processKiller) Close() error { return nil }
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
|
||||||
// SPDX-License-Identifier: MIT
|
|
||||||
|
|
||||||
package container
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
|
|
||||||
"golang.org/x/sys/windows"
|
|
||||||
)
|
|
||||||
|
|
||||||
// processKiller terminates a step process together with its entire descendant
|
|
||||||
// tree via a Windows Job Object.
|
|
||||||
//
|
|
||||||
// Background: a step often launches a process tree (a shell that starts a
|
|
||||||
// child which in turn spawns further GUI or background processes). The default
|
|
||||||
// exec.CommandContext cancellation only kills the direct child, so cancelling a
|
|
||||||
// job left the rest of the tree running. Because those orphans inherited the
|
|
||||||
// step's stdout/stderr pipe, cmd.Wait() also blocked forever and the runner hung.
|
|
||||||
//
|
|
||||||
// Assigning the step process to a Job Object lets us kill the whole tree
|
|
||||||
// atomically on cancellation (TerminateJobObject), which also closes the
|
|
||||||
// inherited pipe handles so cmd.Wait() can return.
|
|
||||||
type processKiller struct {
|
|
||||||
job windows.Handle
|
|
||||||
}
|
|
||||||
|
|
||||||
// newProcessKiller creates a Job Object and assigns p (an already-started
|
|
||||||
// process) to it. Children spawned by p afterwards are automatically part of
|
|
||||||
// the job. The job does NOT use JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, so closing
|
|
||||||
// the handle on normal completion does not kill legitimate background
|
|
||||||
// processes; the tree is only torn down by an explicit Kill (cancellation).
|
|
||||||
func newProcessKiller(p *os.Process) (*processKiller, error) {
|
|
||||||
job, err := windows.CreateJobObject(nil, nil)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
h, err := windows.OpenProcess(windows.PROCESS_SET_QUOTA|windows.PROCESS_TERMINATE, false, uint32(p.Pid))
|
|
||||||
if err != nil {
|
|
||||||
windows.CloseHandle(job)
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer windows.CloseHandle(h)
|
|
||||||
|
|
||||||
if err := windows.AssignProcessToJobObject(job, h); err != nil {
|
|
||||||
windows.CloseHandle(job)
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return &processKiller{job: job}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Kill terminates every process currently assigned to the job (the step process
|
|
||||||
// and all of its descendants).
|
|
||||||
func (k *processKiller) Kill() error {
|
|
||||||
if k == nil || k.job == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return windows.TerminateJobObject(k.job, 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close releases the job handle. It does not terminate the processes.
|
|
||||||
func (k *processKiller) Close() error {
|
|
||||||
if k == nil || k.job == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
h := k.job
|
|
||||||
k.job = 0
|
|
||||||
return windows.CloseHandle(h)
|
|
||||||
}
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
|
||||||
// SPDX-License-Identifier: MIT
|
|
||||||
|
|
||||||
package container
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"os/exec"
|
|
||||||
"path/filepath"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
"golang.org/x/sys/windows"
|
|
||||||
)
|
|
||||||
|
|
||||||
// processAlive reports whether pid refers to a still-running process.
|
|
||||||
func processAlive(pid int) bool {
|
|
||||||
h, err := windows.OpenProcess(windows.PROCESS_QUERY_LIMITED_INFORMATION, false, uint32(pid))
|
|
||||||
if err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
defer windows.CloseHandle(h)
|
|
||||||
var code uint32
|
|
||||||
if err := windows.GetExitCodeProcess(h, &code); err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
const stillActive = 259 // STILL_ACTIVE
|
|
||||||
return code == stillActive
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestProcessKillerKillsTree verifies that a process assigned to the Job Object
|
|
||||||
// is terminated together with a child it spawns afterwards. This mirrors a step
|
|
||||||
// that launches a child which spawns further processes, where cancelling the
|
|
||||||
// job must take down the whole tree, not just the direct child.
|
|
||||||
func TestProcessKillerKillsTree(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
pidFile := filepath.Join(dir, "child.pid")
|
|
||||||
|
|
||||||
// Parent powershell spawns a detached, long-lived child powershell (writing
|
|
||||||
// its PID to a file) and then sleeps. The child is launched AFTER the parent
|
|
||||||
// has been assigned to the job, so it must be captured by the job too.
|
|
||||||
script := fmt.Sprintf(
|
|
||||||
`$c = Start-Process powershell -PassThru -ArgumentList '-NoProfile','-Command','Start-Sleep -Seconds 600'; `+
|
|
||||||
`Set-Content -LiteralPath %q -Value $c.Id; Start-Sleep -Seconds 600`, pidFile)
|
|
||||||
cmd := exec.Command("powershell.exe", "-NoProfile", "-Command", script)
|
|
||||||
require.NoError(t, cmd.Start())
|
|
||||||
t.Cleanup(func() { _ = cmd.Process.Kill() })
|
|
||||||
|
|
||||||
killer, err := newProcessKiller(cmd.Process)
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer killer.Close()
|
|
||||||
|
|
||||||
// Wait for the child PID to be reported.
|
|
||||||
var childPID int
|
|
||||||
require.Eventually(t, func() bool {
|
|
||||||
b, e := os.ReadFile(pidFile)
|
|
||||||
if e != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
s := strings.TrimSpace(string(b))
|
|
||||||
if s == "" {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
childPID, _ = strconv.Atoi(s)
|
|
||||||
return childPID > 0 && processAlive(childPID)
|
|
||||||
}, 20*time.Second, 200*time.Millisecond, "child process should start")
|
|
||||||
|
|
||||||
// Killing the job must terminate both the parent and the detached child.
|
|
||||||
require.NoError(t, killer.Kill())
|
|
||||||
|
|
||||||
require.Eventually(t, func() bool {
|
|
||||||
return !processAlive(cmd.Process.Pid) && !processAlive(childPID)
|
|
||||||
}, 20*time.Second, 200*time.Millisecond, "parent and child should both be terminated")
|
|
||||||
}
|
|
||||||
@@ -325,20 +325,14 @@ func (j *Job) Needs() []string {
|
|||||||
|
|
||||||
// RunsOn list for Job
|
// RunsOn list for Job
|
||||||
func (j *Job) RunsOn() []string {
|
func (j *Job) RunsOn() []string {
|
||||||
return RunsOnFromNode(j.RawRunsOn)
|
switch j.RawRunsOn.Kind {
|
||||||
}
|
|
||||||
|
|
||||||
// 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:
|
case yaml.MappingNode:
|
||||||
var val struct {
|
var val struct {
|
||||||
Group string
|
Group string
|
||||||
Labels yaml.Node
|
Labels yaml.Node
|
||||||
}
|
}
|
||||||
|
|
||||||
if !decodeNode(rawRunsOn, &val) {
|
if !decodeNode(j.RawRunsOn, &val) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -350,7 +344,7 @@ func RunsOnFromNode(rawRunsOn yaml.Node) []string {
|
|||||||
|
|
||||||
return labels
|
return labels
|
||||||
default:
|
default:
|
||||||
return nodeAsStringSlice(rawRunsOn)
|
return nodeAsStringSlice(j.RawRunsOn)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -651,33 +645,6 @@ type Step struct {
|
|||||||
TimeoutMinutes string `yaml:"timeout-minutes"`
|
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
|
// String gets the name of step
|
||||||
func (s *Step) String() string {
|
func (s *Step) String() string {
|
||||||
if s.Name != "" {
|
if s.Name != "" {
|
||||||
|
|||||||
@@ -9,29 +9,9 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
"go.yaml.in/yaml/v4"
|
"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) {
|
func TestReadWorkflow_ScheduleEvent(t *testing.T) {
|
||||||
yaml := `
|
yaml := `
|
||||||
name: local-action-docker-url
|
name: local-action-docker-url
|
||||||
|
|||||||
@@ -436,11 +436,13 @@ func newStepContainer(ctx context.Context, step step, image string, cmd, entrypo
|
|||||||
if rc.IsHostEnv(ctx) {
|
if rc.IsHostEnv(ctx) {
|
||||||
networkMode = "default"
|
networkMode = "default"
|
||||||
}
|
}
|
||||||
stepContainer := ContainerNewContainer(&container.NewContainerInput{
|
stepContainer := container.NewContainer(&container.NewContainerInput{
|
||||||
Cmd: cmd,
|
Cmd: cmd,
|
||||||
Entrypoint: entrypoint,
|
Entrypoint: entrypoint,
|
||||||
WorkingDir: rc.JobContainer.ToContainerPath(rc.Config.Workdir),
|
WorkingDir: rc.JobContainer.ToContainerPath(rc.Config.Workdir),
|
||||||
Image: image,
|
Image: image,
|
||||||
|
Username: rc.Config.Secrets["DOCKER_USERNAME"],
|
||||||
|
Password: rc.Config.Secrets["DOCKER_PASSWORD"],
|
||||||
Name: createContainerName(rc.jobContainerName(), "STEP-"+stepModel.ID),
|
Name: createContainerName(rc.jobContainerName(), "STEP-"+stepModel.ID),
|
||||||
Env: envList,
|
Env: envList,
|
||||||
Mounts: mounts,
|
Mounts: mounts,
|
||||||
@@ -453,7 +455,7 @@ func newStepContainer(ctx context.Context, step step, image string, cmd, entrypo
|
|||||||
Platform: rc.Config.ContainerArchitecture,
|
Platform: rc.Config.ContainerArchitecture,
|
||||||
Options: rc.Config.ContainerOptions,
|
Options: rc.Config.ContainerOptions,
|
||||||
AutoRemove: rc.Config.AutoRemove,
|
AutoRemove: rc.Config.AutoRemove,
|
||||||
ValidVolumes: rc.validVolumes(),
|
ValidVolumes: rc.Config.ValidVolumes,
|
||||||
AllocatePTY: rc.Config.AllocatePTY,
|
AllocatePTY: rc.Config.AllocatePTY,
|
||||||
})
|
})
|
||||||
return stepContainer
|
return stepContainer
|
||||||
|
|||||||
@@ -8,139 +8,64 @@ import (
|
|||||||
"archive/tar"
|
"archive/tar"
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
|
||||||
"io"
|
"io"
|
||||||
"os"
|
|
||||||
"os/exec"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
|
||||||
"gitea.com/gitea/runner/act/model"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func runGit(t *testing.T, dir string, args ...string) {
|
|
||||||
t.Helper()
|
|
||||||
if dir != "" {
|
|
||||||
args = append([]string{"-C", dir}, args...)
|
|
||||||
}
|
|
||||||
cmd := exec.Command("git", args...)
|
|
||||||
// Fixed identity and host-config isolation so commits succeed offline regardless of the
|
|
||||||
// host's git config (mirrors gitCmd in act/common/git).
|
|
||||||
cmd.Env = append(os.Environ(),
|
|
||||||
"GIT_AUTHOR_NAME=test", "GIT_AUTHOR_EMAIL=test@example.com",
|
|
||||||
"GIT_COMMITTER_NAME=test", "GIT_COMMITTER_EMAIL=test@example.com",
|
|
||||||
"GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null",
|
|
||||||
)
|
|
||||||
out, err := cmd.CombinedOutput()
|
|
||||||
require.NoError(t, err, string(out))
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestShortShaActionRejected verifies a `uses` ref that is a shortened commit SHA is rejected
|
|
||||||
// with a clear error. The action is resolved from a local repo (via DefaultActionInstance) so
|
|
||||||
// this runs offline.
|
|
||||||
func TestShortShaActionRejected(t *testing.T) {
|
|
||||||
// a local "remote" action repo at <root>/actions/hello-world-docker-action
|
|
||||||
actionRoot := t.TempDir()
|
|
||||||
repo := filepath.Join(actionRoot, "actions", "hello-world-docker-action")
|
|
||||||
require.NoError(t, os.MkdirAll(repo, 0o755))
|
|
||||||
runGit(t, "", "init", "--initial-branch=main", repo)
|
|
||||||
require.NoError(t, os.WriteFile(filepath.Join(repo, "action.yml"),
|
|
||||||
[]byte("name: hello\nruns:\n using: node24\n main: index.js\n"), 0o644))
|
|
||||||
runGit(t, repo, "add", ".")
|
|
||||||
runGit(t, repo, "commit", "-m", "initial")
|
|
||||||
out, err := exec.Command("git", "-C", repo, "rev-parse", "HEAD").Output()
|
|
||||||
require.NoError(t, err)
|
|
||||||
shortSha := strings.TrimSpace(string(out))[:7]
|
|
||||||
|
|
||||||
// a workflow that uses the action at the short SHA
|
|
||||||
wfDir := filepath.Join(t.TempDir(), "wf")
|
|
||||||
require.NoError(t, os.MkdirAll(wfDir, 0o755))
|
|
||||||
wf := fmt.Sprintf("on: push\njobs:\n test:\n runs-on: ubuntu-latest\n steps:\n - uses: actions/hello-world-docker-action@%s\n", shortSha)
|
|
||||||
require.NoError(t, os.WriteFile(filepath.Join(wfDir, "push.yml"), []byte(wf), 0o644))
|
|
||||||
|
|
||||||
runner, err := New(&Config{
|
|
||||||
Workdir: wfDir,
|
|
||||||
EventName: "push",
|
|
||||||
Platforms: map[string]string{"ubuntu-latest": baseImage},
|
|
||||||
GitHubInstance: "github.com",
|
|
||||||
DefaultActionInstance: actionRoot,
|
|
||||||
ContainerMaxLifetime: time.Hour,
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
planner, err := model.NewWorkflowPlanner(wfDir, true)
|
|
||||||
require.NoError(t, err)
|
|
||||||
plan, err := planner.PlanEvent("push")
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
err = runner.NewPlanExecutor(plan)(common.WithDryrun(context.Background(), true))
|
|
||||||
require.Error(t, err)
|
|
||||||
assert.Contains(t, err.Error(), "shortened version of a commit SHA")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestActionCache(t *testing.T) {
|
func TestActionCache(t *testing.T) {
|
||||||
|
if testing.Short() {
|
||||||
|
t.Skip("skipping integration test")
|
||||||
|
}
|
||||||
a := assert.New(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{
|
cache := &GoGitActionCache{
|
||||||
Path: t.TempDir(),
|
Path: t.TempDir(),
|
||||||
}
|
}
|
||||||
cacheDir := "local/act-test-actions"
|
ctx := context.Background()
|
||||||
|
cacheDir := "nektos/act-test-actions"
|
||||||
|
repo := "https://github.com/nektos/act-test-actions"
|
||||||
refs := []struct {
|
refs := []struct {
|
||||||
Name string
|
Name string
|
||||||
|
CacheDir string
|
||||||
|
Repo string
|
||||||
Ref string
|
Ref string
|
||||||
}{
|
}{
|
||||||
{Name: "Fetch Branch Name", Ref: "main"},
|
{
|
||||||
{Name: "Fetch Branch Name Absolutely", Ref: "refs/heads/main"},
|
Name: "Fetch Branch Name",
|
||||||
{Name: "Fetch HEAD", Ref: "HEAD"},
|
CacheDir: cacheDir,
|
||||||
{Name: "Fetch Sha", Ref: fullSha},
|
Repo: repo,
|
||||||
|
Ref: "main",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "Fetch Branch Name Absolutely",
|
||||||
|
CacheDir: cacheDir,
|
||||||
|
Repo: repo,
|
||||||
|
Ref: "refs/heads/main",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "Fetch HEAD",
|
||||||
|
CacheDir: cacheDir,
|
||||||
|
Repo: repo,
|
||||||
|
Ref: "HEAD",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "Fetch Sha",
|
||||||
|
CacheDir: cacheDir,
|
||||||
|
Repo: repo,
|
||||||
|
Ref: "de984ca37e4df4cb9fd9256435a3b82c4a2662b1",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
for _, c := range refs {
|
for _, c := range refs {
|
||||||
t.Run(c.Name, func(t *testing.T) {
|
t.Run(c.Name, func(t *testing.T) {
|
||||||
sha, err := cache.Fetch(ctx, cacheDir, remoteDir, c.Ref, "")
|
sha, err := cache.Fetch(ctx, c.CacheDir, c.Repo, c.Ref, "")
|
||||||
if !a.NoError(err) || !a.NotEmpty(sha) { //nolint:testifylint // pre-existing issue from nektos/act
|
if !a.NoError(err) || !a.NotEmpty(sha) { //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
atar, err := cache.GetTarArchive(ctx, cacheDir, sha, "js")
|
atar, err := cache.GetTarArchive(ctx, c.CacheDir, sha, "js")
|
||||||
// NotNil, not NotEmpty: atar is a live io.PipeReader whose producer goroutine is
|
if !a.NoError(err) || !a.NotEmpty(atar) { //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
// 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
|
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)
|
mytar := tar.NewReader(atar)
|
||||||
th, err := mytar.Next()
|
th, err := mytar.Next()
|
||||||
if !a.NoError(err) || !a.NotEqual(0, th.Size) { //nolint:testifylint // pre-existing issue from nektos/act
|
if !a.NoError(err) || !a.NotEqual(0, th.Size) { //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
|||||||
@@ -258,54 +258,6 @@ func TestActionRunner(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestNewStepContainerDoesNotUseDockerSecrets(t *testing.T) {
|
|
||||||
cm := &containerMock{}
|
|
||||||
|
|
||||||
var captured *container.NewContainerInput
|
|
||||||
origContainerNewContainer := ContainerNewContainer
|
|
||||||
ContainerNewContainer = func(input *container.NewContainerInput) container.ExecutionsEnvironment {
|
|
||||||
captured = input
|
|
||||||
return cm
|
|
||||||
}
|
|
||||||
defer func() {
|
|
||||||
ContainerNewContainer = origContainerNewContainer
|
|
||||||
}()
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
rc := &RunContext{
|
|
||||||
Name: "job",
|
|
||||||
Config: &Config{
|
|
||||||
Secrets: map[string]string{
|
|
||||||
"DOCKER_USERNAME": "docker-user",
|
|
||||||
"DOCKER_PASSWORD": "docker-password",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
Run: &model.Run{
|
|
||||||
JobID: "job",
|
|
||||||
Workflow: &model.Workflow{
|
|
||||||
Name: "test",
|
|
||||||
Jobs: map[string]*model.Job{
|
|
||||||
"job": {},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
JobContainer: cm,
|
|
||||||
StepResults: map[string]*model.StepResult{},
|
|
||||||
}
|
|
||||||
env := map[string]string{}
|
|
||||||
step := &stepMock{}
|
|
||||||
step.On("getRunContext").Return(rc)
|
|
||||||
step.On("getStepModel").Return(&model.Step{ID: "action"})
|
|
||||||
step.On("getEnv").Return(&env)
|
|
||||||
|
|
||||||
_ = newStepContainer(ctx, step, "registry.example.com/action:tag", nil, nil)
|
|
||||||
|
|
||||||
// DOCKER_USERNAME/DOCKER_PASSWORD should not be injected as pull credentials for docker action containers.
|
|
||||||
assert.Empty(t, captured.Username)
|
|
||||||
assert.Empty(t, captured.Password)
|
|
||||||
step.AssertExpectations(t)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMaybeCopyToActionDirHoldsCloneLock(t *testing.T) {
|
func TestMaybeCopyToActionDirHoldsCloneLock(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ func (rc *RunContext) commandHandler(ctx context.Context) common.LineHandler {
|
|||||||
logger.Infof("%s", line)
|
logger.Infof("%s", line)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
arg = UnescapeCommandData(arg)
|
arg = unescapeCommandData(arg)
|
||||||
kvPairs = unescapeKvPairs(kvPairs)
|
kvPairs = unescapeKvPairs(kvPairs)
|
||||||
switch command {
|
switch command {
|
||||||
case "set-env":
|
case "set-env":
|
||||||
@@ -151,7 +151,7 @@ func parseKeyValuePairs(kvPairs, separator string) map[string]string {
|
|||||||
return rtn
|
return rtn
|
||||||
}
|
}
|
||||||
|
|
||||||
func UnescapeCommandData(arg string) string {
|
func unescapeCommandData(arg string) string {
|
||||||
escapeMap := map[string]string{
|
escapeMap := map[string]string{
|
||||||
"%25": "%",
|
"%25": "%",
|
||||||
"%0D": "\r",
|
"%0D": "\r",
|
||||||
|
|||||||
@@ -562,15 +562,15 @@ func getWorkflowSecrets(ctx context.Context, rc *RunContext) map[string]string {
|
|||||||
secrets = rc.caller.runContext.Config.Secrets
|
secrets = rc.caller.runContext.Config.Secrets
|
||||||
}
|
}
|
||||||
|
|
||||||
// Interpolate into a new map. secrets may be the shared Config.Secrets (or the job's
|
if secrets == nil {
|
||||||
// map), which other parallel jobs read concurrently (e.g. log masking), so mutating it
|
secrets = map[string]string{}
|
||||||
// in place is a data race.
|
|
||||||
interpolated := make(map[string]string, len(secrets))
|
|
||||||
for k, v := range secrets {
|
|
||||||
interpolated[k] = rc.caller.runContext.ExprEval.Interpolate(ctx, v)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return interpolated
|
for k, v := range secrets {
|
||||||
|
secrets[k] = rc.caller.runContext.ExprEval.Interpolate(ctx, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
return secrets
|
||||||
}
|
}
|
||||||
|
|
||||||
return rc.Config.Secrets
|
return rc.Config.Secrets
|
||||||
|
|||||||
@@ -1,66 +0,0 @@
|
|||||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
|
||||||
// SPDX-License-Identifier: MIT
|
|
||||||
|
|
||||||
package runner
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"net"
|
|
||||||
"os/exec"
|
|
||||||
"runtime"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/container"
|
|
||||||
|
|
||||||
mobyclient "github.com/moby/moby/client"
|
|
||||||
)
|
|
||||||
|
|
||||||
// requireLinuxDocker skips on non-Linux hosts. Some integration workflows need Docker features
|
|
||||||
// that only a Linux daemon provides (host networking, host /proc bind mounts); Docker Desktop
|
|
||||||
// on macOS/Windows does not, so those tests can only run on Linux.
|
|
||||||
func requireLinuxDocker(t *testing.T) {
|
|
||||||
t.Helper()
|
|
||||||
if runtime.GOOS != "linux" {
|
|
||||||
t.Skip("skipping: requires a Linux Docker host")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// requireDocker skips the test unless a reachable docker daemon is available.
|
|
||||||
// GetDockerClient succeeds even without a running daemon (its ping is best-effort),
|
|
||||||
// so the daemon has to be pinged explicitly here to decide whether to skip.
|
|
||||||
func requireDocker(t *testing.T) {
|
|
||||||
t.Helper()
|
|
||||||
ctx := context.Background()
|
|
||||||
cli, err := container.GetDockerClient(ctx)
|
|
||||||
if err != nil {
|
|
||||||
t.Skipf("skipping: docker client unavailable: %v", err)
|
|
||||||
}
|
|
||||||
defer cli.Close()
|
|
||||||
if _, err := cli.Ping(ctx, mobyclient.PingOptions{}); err != nil {
|
|
||||||
t.Skipf("skipping: docker daemon unreachable: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// requireNetwork skips the test unless github.com is reachable. A few tests exercise behaviour
|
|
||||||
// that inherently needs the network (force-pulling an image, resolving a remote short-sha ref);
|
|
||||||
// gating lets the rest of the suite run offline without these failing.
|
|
||||||
func requireNetwork(t *testing.T) {
|
|
||||||
t.Helper()
|
|
||||||
conn, err := net.DialTimeout("tcp", "github.com:443", 3*time.Second)
|
|
||||||
if err != nil {
|
|
||||||
t.Skipf("skipping: network unavailable: %v", err)
|
|
||||||
}
|
|
||||||
_ = conn.Close()
|
|
||||||
}
|
|
||||||
|
|
||||||
// requireHostTools skips the test unless every named executable is on PATH. Used by the
|
|
||||||
// self-hosted (host environment) suite, which runs steps directly on the host.
|
|
||||||
func requireHostTools(t *testing.T, tools ...string) {
|
|
||||||
t.Helper()
|
|
||||||
for _, tool := range tools {
|
|
||||||
if _, err := exec.LookPath(tool); err != nil {
|
|
||||||
t.Skipf("skipping: required host tool %q not found: %v", tool, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -5,46 +5,15 @@
|
|||||||
package runner
|
package runner
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"archive/tar"
|
|
||||||
"bytes"
|
|
||||||
"context"
|
"context"
|
||||||
"encoding/base64"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"path"
|
|
||||||
"slices"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
|
||||||
"time"
|
"time"
|
||||||
"unicode"
|
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
"gitea.com/gitea/runner/act/container"
|
|
||||||
"gitea.com/gitea/runner/act/model"
|
"gitea.com/gitea/runner/act/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
const maxJobSummaryBytes = 1024 * 1024
|
|
||||||
|
|
||||||
// jobSummaryTruncationMarker is appended to a summary that exceeded the size limit
|
|
||||||
// so the rendered output makes the truncation visible instead of silently cutting off.
|
|
||||||
const jobSummaryTruncationMarker = "\n\n---\n\n*Job summary truncated: it exceeded the maximum allowed size.*\n"
|
|
||||||
|
|
||||||
var (
|
|
||||||
jobSummaryUploadRetryDelay = time.Second
|
|
||||||
// jobSummaryUploadRequestTimeout bounds a single step upload request. It is kept
|
|
||||||
// below jobSummaryUploadPhaseTimeout so one slow or unreachable request times out
|
|
||||||
// and lets the remaining steps still upload within the phase budget, instead of a
|
|
||||||
// single stuck request consuming the whole phase.
|
|
||||||
jobSummaryUploadRequestTimeout = 5 * time.Second
|
|
||||||
// jobSummaryUploadPhaseTimeout bounds the total time spent uploading all step
|
|
||||||
// summaries. The uploads run inside the job cleanup budget that is also used to
|
|
||||||
// stop and remove the container, so a slow or unreachable endpoint must not be
|
|
||||||
// allowed to consume it; this keeps the remaining budget available for teardown.
|
|
||||||
jobSummaryUploadPhaseTimeout = 15 * time.Second
|
|
||||||
)
|
|
||||||
|
|
||||||
type jobInfo interface {
|
type jobInfo interface {
|
||||||
matrix() map[string]any
|
matrix() map[string]any
|
||||||
steps() []*model.Step
|
steps() []*model.Step
|
||||||
@@ -66,6 +35,7 @@ func newJobExecutor(info jobInfo, sf stepFactory, rc *RunContext) common.Executo
|
|||||||
steps := make([]common.Executor, 0)
|
steps := make([]common.Executor, 0)
|
||||||
preSteps := make([]common.Executor, 0)
|
preSteps := make([]common.Executor, 0)
|
||||||
var postExecutor common.Executor
|
var postExecutor common.Executor
|
||||||
|
var startErr error
|
||||||
|
|
||||||
steps = append(steps, func(ctx context.Context) error {
|
steps = append(steps, func(ctx context.Context) error {
|
||||||
logger := common.Logger(ctx)
|
logger := common.Logger(ctx)
|
||||||
@@ -111,10 +81,8 @@ func newJobExecutor(info jobInfo, sf stepFactory, rc *RunContext) common.Executo
|
|||||||
return common.NewErrorExecutor(err)
|
return common.NewErrorExecutor(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
stepIdx := stepModel.Number
|
|
||||||
preExec := step.pre()
|
preExec := step.pre()
|
||||||
preSteps = append(preSteps, useStepLogger(rc, stepModel, stepStagePre, func(ctx context.Context) error {
|
preSteps = append(preSteps, useStepLogger(rc, stepModel, stepStagePre, func(ctx context.Context) error {
|
||||||
rc.CurrentStepIndex = stepIdx
|
|
||||||
preErr := preExec(ctx)
|
preErr := preExec(ctx)
|
||||||
if preErr != nil {
|
if preErr != nil {
|
||||||
reportStepError(ctx, preErr)
|
reportStepError(ctx, preErr)
|
||||||
@@ -126,7 +94,6 @@ func newJobExecutor(info jobInfo, sf stepFactory, rc *RunContext) common.Executo
|
|||||||
|
|
||||||
stepExec := step.main()
|
stepExec := step.main()
|
||||||
steps = append(steps, useStepLogger(rc, stepModel, stepStageMain, func(ctx context.Context) error {
|
steps = append(steps, useStepLogger(rc, stepModel, stepStageMain, func(ctx context.Context) error {
|
||||||
rc.CurrentStepIndex = stepIdx
|
|
||||||
err := stepExec(ctx)
|
err := stepExec(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
reportStepError(ctx, err)
|
reportStepError(ctx, err)
|
||||||
@@ -138,7 +105,6 @@ func newJobExecutor(info jobInfo, sf stepFactory, rc *RunContext) common.Executo
|
|||||||
|
|
||||||
postFn := step.post()
|
postFn := step.post()
|
||||||
postExec := useStepLogger(rc, stepModel, stepStagePost, func(ctx context.Context) error {
|
postExec := useStepLogger(rc, stepModel, stepStagePost, func(ctx context.Context) error {
|
||||||
rc.CurrentStepIndex = stepIdx
|
|
||||||
err := postFn(ctx)
|
err := postFn(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
reportStepError(ctx, err)
|
reportStepError(ctx, err)
|
||||||
@@ -164,7 +130,6 @@ func newJobExecutor(info jobInfo, sf stepFactory, rc *RunContext) common.Executo
|
|||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
logger := common.Logger(ctx)
|
logger := common.Logger(ctx)
|
||||||
tryUploadJobSummary(ctx, rc)
|
|
||||||
// For Gitea
|
// For Gitea
|
||||||
// We don't need to call `stopServiceContainers` here since it will be called by following `info.stopContainer`
|
// We don't need to call `stopServiceContainers` here since it will be called by following `info.stopContainer`
|
||||||
// logger.Infof("Cleaning up services for job %s", rc.JobName)
|
// logger.Infof("Cleaning up services for job %s", rc.JobName)
|
||||||
@@ -201,7 +166,12 @@ func newJobExecutor(info jobInfo, sf stepFactory, rc *RunContext) common.Executo
|
|||||||
pipeline = append(pipeline, preSteps...)
|
pipeline = append(pipeline, preSteps...)
|
||||||
pipeline = append(pipeline, steps...)
|
pipeline = append(pipeline, steps...)
|
||||||
|
|
||||||
return common.NewPipelineExecutor(info.startContainer(), common.NewPipelineExecutor(pipeline...).
|
startContainer := func(ctx context.Context) error {
|
||||||
|
startErr = info.startContainer()(ctx)
|
||||||
|
return startErr
|
||||||
|
}
|
||||||
|
|
||||||
|
return common.NewPipelineExecutor(startContainer, common.NewPipelineExecutor(pipeline...).
|
||||||
Finally(func(ctx context.Context) error {
|
Finally(func(ctx context.Context) error {
|
||||||
var cancel context.CancelFunc
|
var cancel context.CancelFunc
|
||||||
if ctx.Err() == context.Canceled {
|
if ctx.Err() == context.Canceled {
|
||||||
@@ -212,32 +182,40 @@ func newJobExecutor(info jobInfo, sf stepFactory, rc *RunContext) common.Executo
|
|||||||
}
|
}
|
||||||
return postExecutor(ctx)
|
return postExecutor(ctx)
|
||||||
}).
|
}).
|
||||||
Finally(info.interpolateOutputs()).
|
Finally(info.interpolateOutputs())).
|
||||||
Finally(info.closeContainer()))
|
Finally(func(ctx context.Context) error {
|
||||||
|
if startErr == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanupCtx, cancel := context.WithTimeout(common.WithLogger(context.Background(), common.Logger(ctx)), time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
logger := common.Logger(cleanupCtx)
|
||||||
|
logger.Infof("Cleaning up container for failed startup of job %s", rc.JobName)
|
||||||
|
if err := info.stopContainer()(cleanupCtx); err != nil {
|
||||||
|
logger.Errorf("Error while cleaning up failed job startup: %v", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}).
|
||||||
|
Finally(info.closeContainer())
|
||||||
}
|
}
|
||||||
|
|
||||||
func setJobResult(ctx context.Context, info jobInfo, rc *RunContext, success bool) {
|
func setJobResult(ctx context.Context, info jobInfo, rc *RunContext, success bool) {
|
||||||
logger := common.Logger(ctx)
|
logger := common.Logger(ctx)
|
||||||
|
|
||||||
// Matrix combinations share one *model.Job and run in parallel; serialize the
|
jobResult := "success"
|
||||||
// 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
|
// we have only one result for a whole matrix build, so we need
|
||||||
// to keep an existing result state if we run a matrix
|
// to keep an existing result state if we run a matrix
|
||||||
if len(info.matrix()) > 0 && job.Result != "" {
|
if len(info.matrix()) > 0 && rc.Run.Job().Result != "" {
|
||||||
result = job.Result
|
jobResult = rc.Run.Job().Result
|
||||||
}
|
}
|
||||||
if !success {
|
|
||||||
result = "failure"
|
|
||||||
}
|
|
||||||
info.result(result)
|
|
||||||
return result
|
|
||||||
}()
|
|
||||||
|
|
||||||
|
if !success {
|
||||||
|
jobResult = "failure"
|
||||||
|
}
|
||||||
|
|
||||||
|
info.result(jobResult)
|
||||||
if rc.caller != nil {
|
if rc.caller != nil {
|
||||||
// set reusable workflow job result
|
// set reusable workflow job result
|
||||||
rc.caller.setReusedWorkflowJobResult(rc.JobName, jobResult) // For Gitea
|
rc.caller.setReusedWorkflowJobResult(rc.JobName, jobResult) // For Gitea
|
||||||
@@ -263,188 +241,10 @@ func setJobOutputs(ctx context.Context, rc *RunContext) {
|
|||||||
callerOutputs[k] = ee.Interpolate(ctx, ee.Interpolate(ctx, v.Value))
|
callerOutputs[k] = ee.Interpolate(ctx, ee.Interpolate(ctx, v.Value))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Matrix combinations of a reusable-workflow caller share the caller's *model.Job;
|
rc.caller.runContext.Run.Job().Outputs = callerOutputs
|
||||||
// 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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func tryUploadJobSummary(ctx context.Context, rc *RunContext) {
|
|
||||||
if rc == nil || rc.JobContainer == nil || rc.Config == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// Bound the whole upload phase so a slow or unreachable endpoint cannot consume
|
|
||||||
// the job cleanup budget reserved for stopping and removing the container.
|
|
||||||
ctx, cancel := context.WithTimeout(ctx, jobSummaryUploadPhaseTimeout)
|
|
||||||
defer cancel()
|
|
||||||
env := rc.GetEnv()
|
|
||||||
caps := strings.TrimSpace(env["GITEA_ACTIONS_CAPABILITIES"])
|
|
||||||
if !hasJobSummaryCapability(caps) {
|
|
||||||
// Server did not advertise support. Do not attempt upload.
|
|
||||||
return
|
|
||||||
}
|
|
||||||
runtimeURL := strings.TrimSpace(env["ACTIONS_RUNTIME_URL"])
|
|
||||||
runtimeToken := strings.TrimSpace(env["ACTIONS_RUNTIME_TOKEN"])
|
|
||||||
runID := strings.TrimSpace(env["GITEA_RUN_ID"])
|
|
||||||
if runtimeURL == "" || runtimeToken == "" || runID == "" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if rc.Run == nil || rc.Run.Job() == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// The numeric ActionRunJob ID is not exposed in the proto Task message or task context,
|
|
||||||
// but the server signs it into the ACTIONS_RUNTIME_TOKEN JWT claims. We decode the
|
|
||||||
// unverified claims to retrieve it; the server re-verifies the token on the request.
|
|
||||||
jobID := extractJobIDFromRuntimeToken(runtimeToken)
|
|
||||||
if jobID <= 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
base := strings.TrimRight(runtimeURL, "/") + "/_apis/pipelines/workflows/" + runID +
|
|
||||||
"/jobs/" + strconv.FormatInt(jobID, 10) + "/steps/"
|
|
||||||
actPath := rc.JobContainer.GetActPath()
|
|
||||||
// Reuse a single client across all step uploads so connections can be pooled.
|
|
||||||
client := &http.Client{Timeout: jobSummaryUploadRequestTimeout}
|
|
||||||
for i := range rc.Run.Job().Steps {
|
|
||||||
summaryPath := path.Join(actPath, "workflow", "step-summary-"+strconv.Itoa(i)+".md")
|
|
||||||
body, ok := readSingleFileFromContainerArchive(ctx, rc.JobContainer, summaryPath, maxJobSummaryBytes)
|
|
||||||
if !ok || len(body) == 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
uploadJobSummary(ctx, client, base+strconv.Itoa(i)+"/summary", runtimeToken, body)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// extractJobIDFromRuntimeToken returns the JobID claim from an ACTIONS_RUNTIME_TOKEN JWT
|
|
||||||
// without verifying its signature. Returns 0 if the token is unparseable or has no JobID.
|
|
||||||
func extractJobIDFromRuntimeToken(token string) int64 {
|
|
||||||
parts := strings.Split(token, ".")
|
|
||||||
if len(parts) != 3 {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
|
|
||||||
if err != nil {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
var claims struct {
|
|
||||||
JobID int64 `json:"JobID"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(payload, &claims); err != nil {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
return claims.JobID
|
|
||||||
}
|
|
||||||
|
|
||||||
func hasJobSummaryCapability(caps string) bool {
|
|
||||||
return slices.Contains(strings.FieldsFunc(caps, func(r rune) bool {
|
|
||||||
return r == ',' || unicode.IsSpace(r)
|
|
||||||
}), "job-summary")
|
|
||||||
}
|
|
||||||
|
|
||||||
func uploadJobSummary(ctx context.Context, client *http.Client, url, runtimeToken string, body []byte) {
|
|
||||||
logger := common.Logger(ctx)
|
|
||||||
|
|
||||||
var lastStatus int
|
|
||||||
var lastErr error
|
|
||||||
for attempt := 0; attempt < 2; attempt++ {
|
|
||||||
status, err := putJobSummary(ctx, client, url, runtimeToken, body)
|
|
||||||
if err == nil && status/100 == 2 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
lastStatus = status
|
|
||||||
lastErr = err
|
|
||||||
if attempt == 1 || !isTransientJobSummaryUploadFailure(status, err) {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
timer := time.NewTimer(jobSummaryUploadRetryDelay)
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
timer.Stop()
|
|
||||||
lastErr = ctx.Err()
|
|
||||||
attempt = 1
|
|
||||||
case <-timer.C:
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Best-effort only; do not fail job, but log because capability was advertised.
|
|
||||||
if lastErr != nil {
|
|
||||||
logger.WithError(lastErr).Warn("job summary upload failed")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
logger.Warnf("job summary upload failed: status=%d", lastStatus)
|
|
||||||
}
|
|
||||||
|
|
||||||
func putJobSummary(ctx context.Context, client *http.Client, url, runtimeToken string, body []byte) (int, error) {
|
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodPut, url, bytes.NewReader(body))
|
|
||||||
if err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
req.Header.Set("Authorization", "Bearer "+runtimeToken)
|
|
||||||
req.Header.Set("Content-Type", "text/markdown; charset=utf-8")
|
|
||||||
|
|
||||||
resp, err := client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
_, _ = io.Copy(io.Discard, resp.Body)
|
|
||||||
return resp.StatusCode, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func isTransientJobSummaryUploadFailure(status int, err error) bool {
|
|
||||||
return err != nil || status == http.StatusRequestTimeout || status == http.StatusTooManyRequests || status/100 == 5
|
|
||||||
}
|
|
||||||
|
|
||||||
func readSingleFileFromContainerArchive(ctx context.Context, env container.ExecutionsEnvironment, p string, maxBytes int64) ([]byte, bool) {
|
|
||||||
rc, err := env.GetContainerArchive(ctx, p)
|
|
||||||
if err != nil {
|
|
||||||
return nil, false
|
|
||||||
}
|
|
||||||
defer rc.Close()
|
|
||||||
|
|
||||||
tr := tar.NewReader(rc)
|
|
||||||
for {
|
|
||||||
header, err := tr.Next()
|
|
||||||
if err == io.EOF {
|
|
||||||
return nil, false
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return nil, false
|
|
||||||
}
|
|
||||||
if header.Typeflag != tar.TypeReg {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if !archiveEntryMatchesPath(header.Name, p) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
// Summaries larger than the limit are truncated rather than dropped, so the
|
|
||||||
// user still gets the leading content (mirroring how GitHub caps oversized
|
|
||||||
// step summaries instead of discarding them). Read one extra byte so an
|
|
||||||
// over-limit file is detected from the actual stream rather than trusting
|
|
||||||
// header.Size, then cap the returned content at maxBytes.
|
|
||||||
b, err := io.ReadAll(io.LimitReader(tr, maxBytes+1))
|
|
||||||
if err != nil {
|
|
||||||
return nil, false
|
|
||||||
}
|
|
||||||
if int64(len(b)) > maxBytes {
|
|
||||||
// Reserve room for the marker so the marked-up result still fits in maxBytes.
|
|
||||||
marker := []byte(jobSummaryTruncationMarker)
|
|
||||||
keep := max(maxBytes-int64(len(marker)), 0)
|
|
||||||
b = append(b[:keep], marker...)
|
|
||||||
common.Logger(ctx).Warnf("job summary truncated: path=%s max=%d", p, maxBytes)
|
|
||||||
}
|
|
||||||
return b, true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func archiveEntryMatchesPath(entryName, requestedPath string) bool {
|
|
||||||
entryName = path.Clean(strings.TrimPrefix(entryName, "/"))
|
|
||||||
requestedPath = path.Clean(strings.TrimPrefix(requestedPath, "/"))
|
|
||||||
return entryName == requestedPath || entryName == path.Base(requestedPath)
|
|
||||||
}
|
|
||||||
|
|
||||||
func useStepLogger(rc *RunContext, stepModel *model.Step, stage stepStage, executor common.Executor) common.Executor {
|
func useStepLogger(rc *RunContext, stepModel *model.Step, stage stepStage, executor common.Executor) common.Executor {
|
||||||
return func(ctx context.Context) error {
|
return func(ctx context.Context) error {
|
||||||
ctx = withStepLogger(ctx, stepModel.Number, stepModel.ID, rc.ExprEval.Interpolate(ctx, stepModel.String()), stage.String())
|
ctx = withStepLogger(ctx, stepModel.Number, stepModel.ID, rc.ExprEval.Interpolate(ctx, stepModel.String()), stage.String())
|
||||||
|
|||||||
@@ -5,39 +5,35 @@
|
|||||||
package runner
|
package runner
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"archive/tar"
|
|
||||||
"bytes"
|
|
||||||
"context"
|
"context"
|
||||||
"encoding/base64"
|
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"slices"
|
"slices"
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
"gitea.com/gitea/runner/act/container"
|
"gitea.com/gitea/runner/act/container"
|
||||||
"gitea.com/gitea/runner/act/model"
|
"gitea.com/gitea/runner/act/model"
|
||||||
|
|
||||||
logrustest "github.com/sirupsen/logrus/hooks/test"
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/mock"
|
"github.com/stretchr/testify/mock"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestJobExecutor(t *testing.T) {
|
func TestJobExecutor(t *testing.T) {
|
||||||
// Dryrun only checks syntax/planning; all cases resolve locally, so this runs offline.
|
if testing.Short() {
|
||||||
|
t.Skip("skipping integration test")
|
||||||
|
}
|
||||||
tables := []TestJobFileInfo{
|
tables := []TestJobFileInfo{
|
||||||
{workdir, "uses-and-run-in-one-step", "push", "Invalid run/uses syntax for job:test step:Test", platforms, secrets},
|
{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-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-noref", "push", "Expected format {org}/{repo}[/path]@ref", platforms, secrets},
|
||||||
{workdir, "uses-github-root", "push", "", 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-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},
|
{workdir, "job-nil-step", "push", "invalid Step 0: missing run or uses key", platforms, secrets},
|
||||||
}
|
}
|
||||||
// These tests are sufficient to only check syntax.
|
// These tests are sufficient to only check syntax.
|
||||||
@@ -347,330 +343,63 @@ func TestNewJobExecutor(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestHasJobSummaryCapability(t *testing.T) {
|
func TestNewJobExecutorCleansUpAfterStartContainerFailure(t *testing.T) {
|
||||||
assert.True(t, hasJobSummaryCapability("cache,job-summary artifacts"))
|
ctx := common.WithJobErrorContainer(context.Background())
|
||||||
assert.True(t, hasJobSummaryCapability("cache,\njob-summary\tartifacts"))
|
jim := &jobInfoMock{}
|
||||||
assert.False(t, hasJobSummaryCapability("not-job-summary,job-summary-v2"))
|
sfm := &stepFactoryMock{}
|
||||||
}
|
rc := &RunContext{
|
||||||
|
JobName: "test",
|
||||||
// fakeRuntimeToken builds a JWT-shaped string whose middle (claims) segment encodes
|
JobContainer: &jobContainerMock{},
|
||||||
// the given JobID. The header and signature segments are filler — the runner does not
|
|
||||||
// verify the signature; the server does.
|
|
||||||
func fakeRuntimeToken(jobID int64) string {
|
|
||||||
header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"HS256","typ":"JWT"}`))
|
|
||||||
claims := base64.RawURLEncoding.EncodeToString(fmt.Appendf(nil, `{"JobID":%d}`, jobID))
|
|
||||||
sig := base64.RawURLEncoding.EncodeToString([]byte("sig"))
|
|
||||||
return header + "." + claims + "." + sig
|
|
||||||
}
|
|
||||||
|
|
||||||
func newJobSummaryRC(env map[string]string, jobContainer container.ExecutionsEnvironment, stepCount int) *RunContext {
|
|
||||||
steps := make([]*model.Step, stepCount)
|
|
||||||
for i := range steps {
|
|
||||||
steps[i] = &model.Step{ID: strconv.Itoa(i)}
|
|
||||||
}
|
|
||||||
return &RunContext{
|
|
||||||
Config: &Config{},
|
|
||||||
JobContainer: jobContainer,
|
|
||||||
Env: env,
|
|
||||||
Run: &model.Run{
|
Run: &model.Run{
|
||||||
JobID: "test",
|
JobID: "test",
|
||||||
Workflow: &model.Workflow{
|
Workflow: &model.Workflow{
|
||||||
Jobs: map[string]*model.Job{
|
Jobs: map[string]*model.Job{
|
||||||
"test": {Steps: steps},
|
"test": {},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
Config: &Config{},
|
||||||
}
|
}
|
||||||
}
|
rc.ExprEval = rc.NewExpressionEvaluator(ctx)
|
||||||
|
|
||||||
func TestTryUploadJobSummaryRetriesTransientFailure(t *testing.T) {
|
executorOrder := make([]string, 0)
|
||||||
oldDelay := jobSummaryUploadRetryDelay
|
startErr := errors.New("failed to start container")
|
||||||
jobSummaryUploadRetryDelay = 0
|
stepModel := &model.Step{ID: "1"}
|
||||||
defer func() {
|
sm := &stepMock{}
|
||||||
jobSummaryUploadRetryDelay = oldDelay
|
|
||||||
}()
|
jim.On("steps").Return([]*model.Step{stepModel})
|
||||||
|
jim.On("startContainer").Return(func(ctx context.Context) error {
|
||||||
runtimeToken := fakeRuntimeToken(34)
|
executorOrder = append(executorOrder, "startContainer")
|
||||||
|
return startErr
|
||||||
requests := 0
|
})
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
jim.On("stopContainer").Return(func(ctx context.Context) error {
|
||||||
requests++
|
executorOrder = append(executorOrder, "stopContainer")
|
||||||
assert.Equal(t, http.MethodPut, r.Method)
|
return nil
|
||||||
assert.Equal(t, "/_apis/pipelines/workflows/12/jobs/34/steps/0/summary", r.URL.Path)
|
})
|
||||||
assert.Equal(t, "Bearer "+runtimeToken, r.Header.Get("Authorization"))
|
jim.On("closeContainer").Return(func(ctx context.Context) error {
|
||||||
assert.Equal(t, "text/markdown; charset=utf-8", r.Header.Get("Content-Type"))
|
executorOrder = append(executorOrder, "closeContainer")
|
||||||
body, err := io.ReadAll(r.Body)
|
return nil
|
||||||
assert.NoError(t, err)
|
})
|
||||||
assert.Equal(t, []byte("# summary"), body)
|
jim.On("interpolateOutputs").Return(func(ctx context.Context) error {
|
||||||
if requests == 1 {
|
return nil
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
})
|
||||||
return
|
sfm.On("newStep", stepModel, rc).Return(sm, nil)
|
||||||
}
|
sm.On("pre").Return(func(ctx context.Context) error {
|
||||||
w.WriteHeader(http.StatusNoContent)
|
return nil
|
||||||
}))
|
})
|
||||||
defer server.Close()
|
sm.On("main").Return(func(ctx context.Context) error {
|
||||||
|
return nil
|
||||||
ctx := context.Background()
|
})
|
||||||
cm := &containerMock{}
|
sm.On("post").Return(func(ctx context.Context) error {
|
||||||
cm.On("GetContainerArchive", mock.Anything, "/var/run/act/workflow/step-summary-0.md").Return(
|
return nil
|
||||||
io.NopCloser(bytes.NewReader(tarArchive(t, tarEntry{name: "step-summary-0.md", body: "# summary"}))),
|
})
|
||||||
nil,
|
|
||||||
).Once()
|
executor := newJobExecutor(jim, sfm, rc)
|
||||||
|
err := executor(ctx)
|
||||||
rc := newJobSummaryRC(map[string]string{
|
require.ErrorIs(t, err, startErr)
|
||||||
"GITEA_ACTIONS_CAPABILITIES": "cache, job-summary",
|
assert.Equal(t, []string{"startContainer", "stopContainer", "closeContainer"}, executorOrder)
|
||||||
"ACTIONS_RUNTIME_URL": server.URL,
|
|
||||||
"ACTIONS_RUNTIME_TOKEN": runtimeToken,
|
jim.AssertExpectations(t)
|
||||||
"GITEA_RUN_ID": "12",
|
sfm.AssertExpectations(t)
|
||||||
}, cm, 1)
|
sm.AssertExpectations(t)
|
||||||
|
|
||||||
tryUploadJobSummary(ctx, rc)
|
|
||||||
|
|
||||||
assert.Equal(t, 2, requests)
|
|
||||||
cm.AssertExpectations(t)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestTryUploadJobSummaryStopsAtPhaseTimeout(t *testing.T) {
|
|
||||||
oldPhase := jobSummaryUploadPhaseTimeout
|
|
||||||
jobSummaryUploadPhaseTimeout = 100 * time.Millisecond
|
|
||||||
defer func() {
|
|
||||||
jobSummaryUploadPhaseTimeout = oldPhase
|
|
||||||
}()
|
|
||||||
|
|
||||||
runtimeToken := fakeRuntimeToken(34)
|
|
||||||
|
|
||||||
// The server blocks until either the request context is cancelled (the behaviour
|
|
||||||
// under test: the phase timeout aborts the in-flight upload) or the test tears it
|
|
||||||
// down. Without the phase timeout the upload would hang until the 30s client
|
|
||||||
// timeout instead of releasing the cleanup budget. The release channel guarantees
|
|
||||||
// the handler always returns so server.Close() cannot itself hang.
|
|
||||||
release := make(chan struct{})
|
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
select {
|
|
||||||
case <-r.Context().Done():
|
|
||||||
case <-release:
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
defer server.Close()
|
|
||||||
defer close(release)
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
cm := &containerMock{}
|
|
||||||
cm.On("GetContainerArchive", mock.Anything, "/var/run/act/workflow/step-summary-0.md").Return(
|
|
||||||
io.NopCloser(bytes.NewReader(tarArchive(t, tarEntry{name: "step-summary-0.md", body: "# summary"}))),
|
|
||||||
nil,
|
|
||||||
).Once()
|
|
||||||
|
|
||||||
rc := newJobSummaryRC(map[string]string{
|
|
||||||
"GITEA_ACTIONS_CAPABILITIES": "job-summary",
|
|
||||||
"ACTIONS_RUNTIME_URL": server.URL,
|
|
||||||
"ACTIONS_RUNTIME_TOKEN": runtimeToken,
|
|
||||||
"GITEA_RUN_ID": "12",
|
|
||||||
}, cm, 1)
|
|
||||||
|
|
||||||
done := make(chan struct{})
|
|
||||||
go func() {
|
|
||||||
defer close(done)
|
|
||||||
tryUploadJobSummary(ctx, rc)
|
|
||||||
}()
|
|
||||||
|
|
||||||
select {
|
|
||||||
case <-done:
|
|
||||||
case <-time.After(5 * time.Second):
|
|
||||||
t.Fatal("tryUploadJobSummary did not honour the phase timeout")
|
|
||||||
}
|
|
||||||
cm.AssertExpectations(t)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestTryUploadJobSummaryUploadsEachStepIndependently(t *testing.T) {
|
|
||||||
runtimeToken := fakeRuntimeToken(34)
|
|
||||||
|
|
||||||
type upload struct {
|
|
||||||
path string
|
|
||||||
body string
|
|
||||||
}
|
|
||||||
var got []upload
|
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
body, err := io.ReadAll(r.Body)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
got = append(got, upload{r.URL.Path, string(body)})
|
|
||||||
w.WriteHeader(http.StatusNoContent)
|
|
||||||
}))
|
|
||||||
defer server.Close()
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
cm := &containerMock{}
|
|
||||||
// Three steps: 0 has content, 1 has empty content (skipped), 2 has content.
|
|
||||||
cm.On("GetContainerArchive", mock.Anything, "/var/run/act/workflow/step-summary-0.md").Return(
|
|
||||||
io.NopCloser(bytes.NewReader(tarArchive(t, tarEntry{name: "step-summary-0.md", body: "first"}))),
|
|
||||||
nil,
|
|
||||||
).Once()
|
|
||||||
cm.On("GetContainerArchive", mock.Anything, "/var/run/act/workflow/step-summary-1.md").Return(
|
|
||||||
io.NopCloser(bytes.NewReader(tarArchive(t, tarEntry{name: "step-summary-1.md", body: ""}))),
|
|
||||||
nil,
|
|
||||||
).Once()
|
|
||||||
cm.On("GetContainerArchive", mock.Anything, "/var/run/act/workflow/step-summary-2.md").Return(
|
|
||||||
io.NopCloser(bytes.NewReader(tarArchive(t, tarEntry{name: "step-summary-2.md", body: "third"}))),
|
|
||||||
nil,
|
|
||||||
).Once()
|
|
||||||
|
|
||||||
rc := newJobSummaryRC(map[string]string{
|
|
||||||
"GITEA_ACTIONS_CAPABILITIES": "job-summary",
|
|
||||||
"ACTIONS_RUNTIME_URL": server.URL,
|
|
||||||
"ACTIONS_RUNTIME_TOKEN": runtimeToken,
|
|
||||||
"GITEA_RUN_ID": "12",
|
|
||||||
}, cm, 3)
|
|
||||||
|
|
||||||
tryUploadJobSummary(ctx, rc)
|
|
||||||
|
|
||||||
assert.Equal(t, []upload{
|
|
||||||
{"/_apis/pipelines/workflows/12/jobs/34/steps/0/summary", "first"},
|
|
||||||
{"/_apis/pipelines/workflows/12/jobs/34/steps/2/summary", "third"},
|
|
||||||
}, got)
|
|
||||||
cm.AssertExpectations(t)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestTryUploadJobSummaryRequiresExactCapability(t *testing.T) {
|
|
||||||
requests := 0
|
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
requests++
|
|
||||||
w.WriteHeader(http.StatusNoContent)
|
|
||||||
}))
|
|
||||||
defer server.Close()
|
|
||||||
|
|
||||||
rc := newJobSummaryRC(map[string]string{
|
|
||||||
"GITEA_ACTIONS_CAPABILITIES": "not-job-summary,job-summary-v2",
|
|
||||||
"ACTIONS_RUNTIME_URL": server.URL,
|
|
||||||
"ACTIONS_RUNTIME_TOKEN": fakeRuntimeToken(34),
|
|
||||||
"GITEA_RUN_ID": "12",
|
|
||||||
}, &containerMock{}, 1)
|
|
||||||
|
|
||||||
tryUploadJobSummary(context.Background(), rc)
|
|
||||||
|
|
||||||
assert.Equal(t, 0, requests)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestTryUploadJobSummarySkipsWhenJobIDMissingFromToken(t *testing.T) {
|
|
||||||
requests := 0
|
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
requests++
|
|
||||||
w.WriteHeader(http.StatusNoContent)
|
|
||||||
}))
|
|
||||||
defer server.Close()
|
|
||||||
|
|
||||||
rc := newJobSummaryRC(map[string]string{
|
|
||||||
"GITEA_ACTIONS_CAPABILITIES": "job-summary",
|
|
||||||
"ACTIONS_RUNTIME_URL": server.URL,
|
|
||||||
"ACTIONS_RUNTIME_TOKEN": "not-a-jwt",
|
|
||||||
"GITEA_RUN_ID": "12",
|
|
||||||
}, &containerMock{}, 1)
|
|
||||||
|
|
||||||
tryUploadJobSummary(context.Background(), rc)
|
|
||||||
|
|
||||||
assert.Equal(t, 0, requests)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestExtractJobIDFromRuntimeToken(t *testing.T) {
|
|
||||||
assert.Equal(t, int64(42), extractJobIDFromRuntimeToken(fakeRuntimeToken(42)))
|
|
||||||
assert.Equal(t, int64(0), extractJobIDFromRuntimeToken("not-a-jwt"))
|
|
||||||
assert.Equal(t, int64(0), extractJobIDFromRuntimeToken("a.b.c"))
|
|
||||||
assert.Equal(t, int64(0), extractJobIDFromRuntimeToken(""))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestReadSingleFileFromContainerArchiveFindsMatchingRegularFile(t *testing.T) {
|
|
||||||
ctx := context.Background()
|
|
||||||
cm := &containerMock{}
|
|
||||||
cm.On("GetContainerArchive", ctx, "/var/run/act/workflow/SUMMARY.md").Return(
|
|
||||||
io.NopCloser(bytes.NewReader(tarArchive(t,
|
|
||||||
tarEntry{name: "workflow", typeflag: tar.TypeDir},
|
|
||||||
tarEntry{name: "other.md", body: "wrong"},
|
|
||||||
tarEntry{name: "SUMMARY.md", body: "right"},
|
|
||||||
))),
|
|
||||||
nil,
|
|
||||||
).Once()
|
|
||||||
|
|
||||||
body, ok := readSingleFileFromContainerArchive(ctx, cm, "/var/run/act/workflow/SUMMARY.md", 1024)
|
|
||||||
|
|
||||||
assert.True(t, ok)
|
|
||||||
assert.Equal(t, []byte("right"), body)
|
|
||||||
cm.AssertExpectations(t)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestReadSingleFileFromContainerArchiveTruncatesWhenTooLarge(t *testing.T) {
|
|
||||||
logger, hook := logrustest.NewNullLogger()
|
|
||||||
ctx := common.WithLogger(context.Background(), logger)
|
|
||||||
cm := &containerMock{}
|
|
||||||
content := strings.Repeat("a", 300)
|
|
||||||
cm.On("GetContainerArchive", ctx, "/var/run/act/workflow/SUMMARY.md").Return(
|
|
||||||
io.NopCloser(bytes.NewReader(tarArchive(t, tarEntry{name: "SUMMARY.md", body: content}))),
|
|
||||||
nil,
|
|
||||||
).Once()
|
|
||||||
|
|
||||||
const maxBytes = 200
|
|
||||||
body, ok := readSingleFileFromContainerArchive(ctx, cm, "/var/run/act/workflow/SUMMARY.md", maxBytes)
|
|
||||||
|
|
||||||
// Oversized summaries are truncated to the limit (reserving room for the marker)
|
|
||||||
// rather than dropped entirely, and the truncation marker is appended.
|
|
||||||
assert.True(t, ok)
|
|
||||||
assert.LessOrEqual(t, len(body), maxBytes)
|
|
||||||
keep := maxBytes - len(jobSummaryTruncationMarker)
|
|
||||||
assert.Equal(t, []byte(content[:keep]+jobSummaryTruncationMarker), body)
|
|
||||||
if assert.Len(t, hook.Entries, 1) {
|
|
||||||
assert.Contains(t, hook.Entries[0].Message, "job summary truncated")
|
|
||||||
}
|
|
||||||
cm.AssertExpectations(t)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestReadSingleFileFromContainerArchiveKeepsExactLimitWithoutWarning(t *testing.T) {
|
|
||||||
logger, hook := logrustest.NewNullLogger()
|
|
||||||
ctx := common.WithLogger(context.Background(), logger)
|
|
||||||
cm := &containerMock{}
|
|
||||||
cm.On("GetContainerArchive", ctx, "/var/run/act/workflow/SUMMARY.md").Return(
|
|
||||||
io.NopCloser(bytes.NewReader(tarArchive(t, tarEntry{name: "SUMMARY.md", body: "abc"}))),
|
|
||||||
nil,
|
|
||||||
).Once()
|
|
||||||
|
|
||||||
body, ok := readSingleFileFromContainerArchive(ctx, cm, "/var/run/act/workflow/SUMMARY.md", 3)
|
|
||||||
|
|
||||||
// A summary that is exactly at the limit is kept whole and not flagged as truncated.
|
|
||||||
assert.True(t, ok)
|
|
||||||
assert.Equal(t, []byte("abc"), body)
|
|
||||||
assert.Empty(t, hook.Entries)
|
|
||||||
cm.AssertExpectations(t)
|
|
||||||
}
|
|
||||||
|
|
||||||
type tarEntry struct {
|
|
||||||
name string
|
|
||||||
body string
|
|
||||||
typeflag byte
|
|
||||||
}
|
|
||||||
|
|
||||||
func tarArchive(t *testing.T, entries ...tarEntry) []byte {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
buf := &bytes.Buffer{}
|
|
||||||
tw := tar.NewWriter(buf)
|
|
||||||
for _, entry := range entries {
|
|
||||||
typeflag := entry.typeflag
|
|
||||||
if typeflag == 0 {
|
|
||||||
typeflag = tar.TypeReg
|
|
||||||
}
|
|
||||||
header := &tar.Header{
|
|
||||||
Name: entry.name,
|
|
||||||
Typeflag: typeflag,
|
|
||||||
Mode: 0o644,
|
|
||||||
Size: int64(len(entry.body)),
|
|
||||||
}
|
|
||||||
if typeflag == tar.TypeDir {
|
|
||||||
header.Mode = 0o755
|
|
||||||
header.Size = 0
|
|
||||||
}
|
|
||||||
require.NoError(t, tw.WriteHeader(header))
|
|
||||||
if typeflag == tar.TypeReg {
|
|
||||||
_, err := tw.Write([]byte(entry.body))
|
|
||||||
require.NoError(t, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
require.NoError(t, tw.Close())
|
|
||||||
return buf.Bytes()
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"slices"
|
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
@@ -167,29 +166,9 @@ func withStepLogger(ctx context.Context, stepNumber int, stepID, stepName, stage
|
|||||||
|
|
||||||
type entryProcessor func(entry *logrus.Entry) *logrus.Entry
|
type entryProcessor func(entry *logrus.Entry) *logrus.Entry
|
||||||
|
|
||||||
func AppendSecretMasker(oldnew []string, v string) []string {
|
|
||||||
ret := oldnew
|
|
||||||
|
|
||||||
for l := range strings.SplitSeq(v, "\n") {
|
|
||||||
tm := strings.TrimSpace(l)
|
|
||||||
// formatted JSON secrets could otherwise mask {,[,],} everywhere
|
|
||||||
if len(tm) > 1 {
|
|
||||||
ret = append(ret, tm, "***")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return ret
|
|
||||||
}
|
|
||||||
|
|
||||||
// valueMasker applies secrets and ::add-mask:: patterns to every log entry, including
|
// valueMasker applies secrets and ::add-mask:: patterns to every log entry, including
|
||||||
// raw_output (command/stream) lines; there is no bypass by field.
|
// raw_output (command/stream) lines; there is no bypass by field.
|
||||||
func valueMasker(insecureSecrets bool, secrets map[string]string) entryProcessor {
|
func valueMasker(insecureSecrets bool, secrets map[string]string) entryProcessor {
|
||||||
var oldnew []string
|
|
||||||
for _, v := range secrets {
|
|
||||||
oldnew = AppendSecretMasker(oldnew, v)
|
|
||||||
}
|
|
||||||
oldnew = slices.Clip(oldnew)
|
|
||||||
defReplacer := strings.NewReplacer(oldnew...)
|
|
||||||
return func(entry *logrus.Entry) *logrus.Entry {
|
return func(entry *logrus.Entry) *logrus.Entry {
|
||||||
if insecureSecrets {
|
if insecureSecrets {
|
||||||
return entry
|
return entry
|
||||||
@@ -197,16 +176,16 @@ func valueMasker(insecureSecrets bool, secrets map[string]string) entryProcessor
|
|||||||
|
|
||||||
masks := Masks(entry.Context)
|
masks := Masks(entry.Context)
|
||||||
|
|
||||||
if len(*masks) == 0 {
|
for _, v := range secrets {
|
||||||
entry.Message = defReplacer.Replace(entry.Message)
|
if v != "" {
|
||||||
} else {
|
entry.Message = strings.ReplaceAll(entry.Message, v, "***")
|
||||||
cmasker := oldnew
|
}
|
||||||
|
|
||||||
for _, v := range *masks {
|
|
||||||
cmasker = AppendSecretMasker(cmasker, v)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
entry.Message = strings.NewReplacer(cmasker...).Replace(entry.Message)
|
for _, v := range *masks {
|
||||||
|
if v != "" {
|
||||||
|
entry.Message = strings.ReplaceAll(entry.Message, v, "***")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return entry
|
return entry
|
||||||
|
|||||||
@@ -1,52 +0,0 @@
|
|||||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
|
||||||
// SPDX-License-Identifier: MIT
|
|
||||||
|
|
||||||
package runner
|
|
||||||
|
|
||||||
import (
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/sirupsen/logrus"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestValueMasker(t *testing.T) {
|
|
||||||
table := []struct {
|
|
||||||
name string
|
|
||||||
lines string
|
|
||||||
secrets map[string]string
|
|
||||||
masks []string
|
|
||||||
disallowed []string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "Multiline Private Key",
|
|
||||||
lines: "cat << EOF > private.key\nPRIVATE_KEY_BEGIN\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\nPRIVATE_KEY_END\nEOF",
|
|
||||||
secrets: map[string]string{
|
|
||||||
"PRIVATE_KEY": "PRIVATE_KEY_BEGIN\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\nPRIVATE_KEY_END",
|
|
||||||
},
|
|
||||||
disallowed: []string{"KEY", "dsdfseffefsefes", "PRIVATE_KEY_END"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Multiline Private Key in masks",
|
|
||||||
lines: "cat << EOF > private.key\nPRIVATE_KEY_BEGIN\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\nPRIVATE_KEY_END\nEOF",
|
|
||||||
masks: []string{"PRIVATE_KEY_BEGIN\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\nPRIVATE_KEY_END"},
|
|
||||||
disallowed: []string{"KEY", "dsdfseffefsefes", "PRIVATE_KEY_END"},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
for _, entry := range table {
|
|
||||||
t.Run(entry.name, func(t *testing.T) {
|
|
||||||
ctx := WithMasks(t.Context(), &entry.masks)
|
|
||||||
masker := valueMasker(false, entry.secrets)
|
|
||||||
for line := range strings.SplitSeq(entry.lines, "\n") {
|
|
||||||
lentry := masker(&logrus.Entry{
|
|
||||||
Context: ctx,
|
|
||||||
Message: line,
|
|
||||||
})
|
|
||||||
for _, line := range entry.disallowed {
|
|
||||||
assert.NotContains(t, lentry.Message, line)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -10,7 +10,6 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net/url"
|
"net/url"
|
||||||
"path"
|
"path"
|
||||||
"path/filepath"
|
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -28,9 +27,7 @@ func newLocalReusableWorkflowExecutor(rc *RunContext) common.Executor {
|
|||||||
workflowDir = strings.TrimPrefix(workflowDir, "./")
|
workflowDir = strings.TrimPrefix(workflowDir, "./")
|
||||||
|
|
||||||
return common.NewPipelineExecutor(
|
return common.NewPipelineExecutor(
|
||||||
// resolve the local workflow against the workspace root, not the process
|
newReusableWorkflowExecutor(rc, workflowDir, fileName),
|
||||||
// working directory, so it is found regardless of where the runner is invoked
|
|
||||||
newReusableWorkflowExecutor(rc, filepath.Join(rc.Config.Workdir, workflowDir), fileName),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -287,11 +284,7 @@ func setReusedWorkflowCallerResult(rc *RunContext, runner Runner) common.Executo
|
|||||||
if rc.caller != nil {
|
if rc.caller != nil {
|
||||||
rc.caller.setReusedWorkflowJobResult(rc.JobName, reusedWorkflowJobResult)
|
rc.caller.setReusedWorkflowJobResult(rc.JobName, reusedWorkflowJobResult)
|
||||||
} else {
|
} 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)
|
rc.result(reusedWorkflowJobResult)
|
||||||
unlock()
|
|
||||||
logger.WithField("jobResult", reusedWorkflowJobResult).Infof("Job %s", reusedWorkflowJobResultMessage)
|
logger.WithField("jobResult", reusedWorkflowJobResult).Infof("Job %s", reusedWorkflowJobResultMessage)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,9 +20,7 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
"runtime"
|
"runtime"
|
||||||
"slices"
|
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
@@ -45,10 +43,6 @@ type RunContext struct {
|
|||||||
GlobalEnv map[string]string // to pass env changes of GITHUB_ENV and set-env correctly, due to dirty Env field
|
GlobalEnv map[string]string // to pass env changes of GITHUB_ENV and set-env correctly, due to dirty Env field
|
||||||
ExtraPath []string
|
ExtraPath []string
|
||||||
CurrentStep string
|
CurrentStep string
|
||||||
// CurrentStepIndex is the index of the top-level job step currently executing
|
|
||||||
// (model.Step.Number). Composite sub-steps inherit the outer step's index by
|
|
||||||
// walking the Parent chain; see topLevelRunContext.
|
|
||||||
CurrentStepIndex int
|
|
||||||
StepResults map[string]*model.StepResult
|
StepResults map[string]*model.StepResult
|
||||||
IntraActionState map[string]map[string]string
|
IntraActionState map[string]map[string]string
|
||||||
ExprEval ExpressionEvaluator
|
ExprEval ExpressionEvaluator
|
||||||
@@ -61,18 +55,6 @@ type RunContext struct {
|
|||||||
Masks []string
|
Masks []string
|
||||||
cleanUpJobContainer common.Executor
|
cleanUpJobContainer common.Executor
|
||||||
caller *caller // job calling this RunContext (reusable workflows)
|
caller *caller // job calling this RunContext (reusable workflows)
|
||||||
// summaryFileInitialized tracks which per-step summary files (workflow/step-summary-N.md)
|
|
||||||
// have already been created on the JobContainer. The runner sets up file-command files
|
|
||||||
// via JobContainer.Copy at the start of every phase, which truncates them — fine for
|
|
||||||
// GITHUB_ENV/OUTPUT/STATE/PATH (consumed per phase) but wrong for GITHUB_STEP_SUMMARY,
|
|
||||||
// which has accumulating semantics. We initialize each step's summary file exactly once
|
|
||||||
// so writes from later phases and from composite sub-steps append to the same file.
|
|
||||||
// Only populated on the top-level RunContext; child RCs walk Parent via topLevelRunContext.
|
|
||||||
summaryFileInitialized map[int]bool
|
|
||||||
// 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) {
|
func (rc *RunContext) AddMask(mask string) {
|
||||||
@@ -148,34 +130,17 @@ func getDockerDaemonSocketMountPath(daemonPath string) string {
|
|||||||
return daemonPath
|
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
|
// Returns the binds and mounts for the container, resolving paths as appopriate
|
||||||
func (rc *RunContext) GetBindsAndMounts() ([]string, map[string]string) {
|
func (rc *RunContext) GetBindsAndMounts() ([]string, map[string]string) {
|
||||||
name := rc.jobContainerName()
|
name := rc.jobContainerName()
|
||||||
|
|
||||||
|
if rc.Config.ContainerDaemonSocket == "" {
|
||||||
|
rc.Config.ContainerDaemonSocket = "/var/run/docker.sock"
|
||||||
|
}
|
||||||
|
|
||||||
binds := []string{}
|
binds := []string{}
|
||||||
if daemonSocket := rc.containerDaemonSocket(); daemonSocket != "-" {
|
if rc.Config.ContainerDaemonSocket != "-" {
|
||||||
daemonPath := getDockerDaemonSocketMountPath(daemonSocket)
|
daemonPath := getDockerDaemonSocketMountPath(rc.Config.ContainerDaemonSocket)
|
||||||
binds = append(binds, fmt.Sprintf("%s:%s", daemonPath, "/var/run/docker.sock"))
|
binds = append(binds, fmt.Sprintf("%s:%s", daemonPath, "/var/run/docker.sock"))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -214,6 +179,14 @@ func (rc *RunContext) GetBindsAndMounts() ([]string, map[string]string) {
|
|||||||
mounts[name] = ext.ToContainerPath(rc.Config.Workdir)
|
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
|
return binds, mounts
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -459,7 +432,7 @@ func (rc *RunContext) startJobContainer() common.Executor {
|
|||||||
Platform: rc.Config.ContainerArchitecture,
|
Platform: rc.Config.ContainerArchitecture,
|
||||||
Options: rc.options(ctx),
|
Options: rc.options(ctx),
|
||||||
AutoRemove: rc.Config.AutoRemove,
|
AutoRemove: rc.Config.AutoRemove,
|
||||||
ValidVolumes: rc.validVolumes(),
|
ValidVolumes: rc.Config.ValidVolumes,
|
||||||
AllocatePTY: rc.Config.AllocatePTY,
|
AllocatePTY: rc.Config.AllocatePTY,
|
||||||
})
|
})
|
||||||
if rc.JobContainer == nil {
|
if rc.JobContainer == nil {
|
||||||
@@ -613,29 +586,14 @@ func (rc *RunContext) ActionCacheDir() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Interpolate outputs after a job is done
|
// 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 {
|
func (rc *RunContext) interpolateOutputs() common.Executor {
|
||||||
return func(ctx context.Context) error {
|
return func(ctx context.Context) error {
|
||||||
ee := rc.NewExpressionEvaluator(ctx)
|
ee := rc.NewExpressionEvaluator(ctx)
|
||||||
job := rc.Run.Job()
|
for k, v := range rc.Run.Job().Outputs {
|
||||||
// Matrix combinations share this Job and its Outputs map. Interpolate from this combo's
|
interpolated := ee.Interpolate(ctx, v)
|
||||||
// pristine snapshot (outputTemplate) and write under the lock, so each combo overwrites
|
if v != interpolated {
|
||||||
// with its own resolved values (last wins, as on GitHub) instead of the first combo's
|
rc.Run.Job().Outputs[k] = interpolated
|
||||||
// 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
|
return nil
|
||||||
}
|
}
|
||||||
@@ -702,29 +660,7 @@ func (rc *RunContext) result(result string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (rc *RunContext) steps() []*model.Step {
|
func (rc *RunContext) steps() []*model.Step {
|
||||||
// Return per-job copies of the steps. Matrix combinations run in parallel and share the
|
return rc.Run.Job().Steps
|
||||||
// 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
|
|
||||||
}
|
|
||||||
|
|
||||||
// topLevelRunContext walks the Parent chain to the outermost RunContext. Composite
|
|
||||||
// actions create child RunContexts whose sub-steps need to share the outer job step's
|
|
||||||
// summary file path so that nested writes accumulate under the right step_index.
|
|
||||||
func (rc *RunContext) topLevelRunContext() *RunContext {
|
|
||||||
top := rc
|
|
||||||
for top.Parent != nil {
|
|
||||||
top = top.Parent
|
|
||||||
}
|
|
||||||
return top
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Executor returns a pipeline executor for all the steps in the job
|
// Executor returns a pipeline executor for all the steps in the job
|
||||||
@@ -801,15 +737,12 @@ func (rc *RunContext) runsOnPlatformNames(ctx context.Context) []string {
|
|||||||
return []string{}
|
return []string{}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Evaluate a copy: RawRunsOn is shared across parallel matrix jobs, so interpolating it in
|
if err := rc.ExprEval.EvaluateYamlNode(ctx, &job.RawRunsOn); err != nil {
|
||||||
// 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)
|
common.Logger(ctx).Errorf("Error while evaluating runs-on: %v", err)
|
||||||
return []string{}
|
return []string{}
|
||||||
}
|
}
|
||||||
|
|
||||||
return model.RunsOnFromNode(rawRunsOn)
|
return job.RunsOn()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (rc *RunContext) platformImage(ctx context.Context) string {
|
func (rc *RunContext) platformImage(ctx context.Context) string {
|
||||||
@@ -1175,18 +1108,21 @@ func setActionRuntimeVars(rc *RunContext, env map[string]string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (rc *RunContext) handleCredentials(ctx context.Context) (string, string, error) {
|
func (rc *RunContext) handleCredentials(ctx context.Context) (string, string, error) {
|
||||||
|
// TODO: remove below 2 lines when we can release act with breaking changes
|
||||||
|
username := rc.Config.Secrets["DOCKER_USERNAME"]
|
||||||
|
password := rc.Config.Secrets["DOCKER_PASSWORD"]
|
||||||
|
|
||||||
container := rc.Run.Job().Container()
|
container := rc.Run.Job().Container()
|
||||||
if container == nil || container.Credentials == nil {
|
if container == nil || container.Credentials == nil {
|
||||||
return "", "", nil
|
return username, password, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(container.Credentials) != 2 {
|
if container.Credentials != nil && len(container.Credentials) != 2 {
|
||||||
err := errors.New("invalid property count for key 'credentials:'")
|
err := errors.New("invalid property count for key 'credentials:'")
|
||||||
return "", "", err
|
return "", "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
ee := rc.NewExpressionEvaluator(ctx)
|
ee := rc.NewExpressionEvaluator(ctx)
|
||||||
var username, password string
|
|
||||||
if username = ee.Interpolate(ctx, container.Credentials["username"]); username == "" {
|
if username = ee.Interpolate(ctx, container.Credentials["username"]); username == "" {
|
||||||
err := errors.New("failed to interpolate container.credentials.username")
|
err := errors.New("failed to interpolate container.credentials.username")
|
||||||
return "", "", err
|
return "", "", err
|
||||||
@@ -1229,9 +1165,12 @@ func (rc *RunContext) handleServiceCredentials(ctx context.Context, creds map[st
|
|||||||
|
|
||||||
// GetServiceBindsAndMounts returns the binds and mounts for the service container, resolving paths as appopriate
|
// GetServiceBindsAndMounts returns the binds and mounts for the service container, resolving paths as appopriate
|
||||||
func (rc *RunContext) GetServiceBindsAndMounts(svcVolumes []string) ([]string, map[string]string) {
|
func (rc *RunContext) GetServiceBindsAndMounts(svcVolumes []string) ([]string, map[string]string) {
|
||||||
|
if rc.Config.ContainerDaemonSocket == "" {
|
||||||
|
rc.Config.ContainerDaemonSocket = "/var/run/docker.sock"
|
||||||
|
}
|
||||||
binds := []string{}
|
binds := []string{}
|
||||||
if daemonSocket := rc.containerDaemonSocket(); daemonSocket != "-" {
|
if rc.Config.ContainerDaemonSocket != "-" {
|
||||||
daemonPath := getDockerDaemonSocketMountPath(daemonSocket)
|
daemonPath := getDockerDaemonSocketMountPath(rc.Config.ContainerDaemonSocket)
|
||||||
binds = append(binds, fmt.Sprintf("%s:%s", daemonPath, "/var/run/docker.sock"))
|
binds = append(binds, fmt.Sprintf("%s:%s", daemonPath, "/var/run/docker.sock"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -170,38 +170,6 @@ func TestRunContext_EvalBool(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRunContextHandleCredentialsDoesNotUseDockerSecrets(t *testing.T) {
|
|
||||||
workflow, err := model.ReadWorkflow(strings.NewReader(`
|
|
||||||
name: test
|
|
||||||
on: push
|
|
||||||
jobs:
|
|
||||||
job:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps: []
|
|
||||||
`))
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
rc := &RunContext{
|
|
||||||
Config: &Config{
|
|
||||||
Secrets: map[string]string{
|
|
||||||
"DOCKER_USERNAME": "docker-user",
|
|
||||||
"DOCKER_PASSWORD": "docker-password",
|
|
||||||
},
|
|
||||||
Env: map[string]string{},
|
|
||||||
},
|
|
||||||
Run: &model.Run{
|
|
||||||
JobID: "job",
|
|
||||||
Workflow: workflow,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
// DOCKER_USERNAME/DOCKER_PASSWORD secrets should not be used as implicit job container pull credentials.
|
|
||||||
username, password, err := rc.handleCredentials(t.Context())
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Empty(t, username)
|
|
||||||
assert.Empty(t, password)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRunContext_GetBindsAndMounts(t *testing.T) {
|
func TestRunContext_GetBindsAndMounts(t *testing.T) {
|
||||||
rctemplate := &RunContext{
|
rctemplate := &RunContext{
|
||||||
Name: "TestRCName",
|
Name: "TestRCName",
|
||||||
@@ -313,44 +281,6 @@ func TestRunContext_GetBindsAndMounts(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRunContextValidVolumes(t *testing.T) {
|
|
||||||
rc := &RunContext{
|
|
||||||
Name: "job",
|
|
||||||
Run: &model.Run{Workflow: &model.Workflow{Name: "wf"}},
|
|
||||||
Config: &Config{ValidVolumes: []string{"my-vol", "/host/path"}},
|
|
||||||
}
|
|
||||||
name := rc.jobContainerName()
|
|
||||||
|
|
||||||
got := rc.validVolumes()
|
|
||||||
|
|
||||||
// the configured volumes plus the four the runner mounts automatically
|
|
||||||
assert.Subset(t, got, []string{"my-vol", "/host/path", "act-toolcache", name, name + "-env", "/var/run/docker.sock"})
|
|
||||||
|
|
||||||
// deriving the list must never mutate or grow the shared Config slice: parallel matrix
|
|
||||||
// combinations share one *Config, and the previous in-place append was a data race.
|
|
||||||
assert.Equal(t, []string{"my-vol", "/host/path"}, rc.Config.ValidVolumes)
|
|
||||||
assert.Len(t, rc.validVolumes(), len(got), "repeated calls must be stable, not accumulate")
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestInterpolateOutputsIsPerMatrixCombo guards the matrix-output fix: combinations share one
|
|
||||||
// *model.Job, so each must interpolate from its own pristine snapshot. Otherwise the first
|
|
||||||
// combo's resolved value freezes the shared template and later combos can't resolve their own.
|
|
||||||
func TestInterpolateOutputsIsPerMatrixCombo(t *testing.T) {
|
|
||||||
job := &model.Job{Outputs: map[string]string{"o": "${{ matrix.v }}"}}
|
|
||||||
run := &model.Run{JobID: "j", Workflow: &model.Workflow{Name: "w", Jobs: map[string]*model.Job{"j": job}}}
|
|
||||||
r := &runnerImpl{config: &Config{}}
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
rcA := r.newRunContext(ctx, run, map[string]any{"v": "a"})
|
|
||||||
rcB := r.newRunContext(ctx, run, map[string]any{"v": "b"})
|
|
||||||
|
|
||||||
require.NoError(t, rcA.interpolateOutputs()(ctx))
|
|
||||||
require.NoError(t, rcB.interpolateOutputs()(ctx))
|
|
||||||
|
|
||||||
// Last combo wins (matching GitHub) instead of being frozen to combo A's "a".
|
|
||||||
require.Equal(t, "b", job.Outputs["o"])
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGetGitHubContext(t *testing.T) {
|
func TestGetGitHubContext(t *testing.T) {
|
||||||
log.SetLevel(log.DebugLevel)
|
log.SetLevel(log.DebugLevel)
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"maps"
|
|
||||||
"os"
|
"os"
|
||||||
"runtime"
|
"runtime"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -251,14 +250,7 @@ func (runner *runnerImpl) NewPlanExecutor(plan *model.Plan) common.Executor {
|
|||||||
return executor(common.WithJobErrorContainer(WithJobLogger(ctx, rc.Run.JobID, jobName, rc.Config, &rc.Masks, matrix)))
|
return executor(common.WithJobErrorContainer(WithJobLogger(ctx, rc.Run.JobID, jobName, rc.Config, &rc.Masks, matrix)))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
// Run all matrix combinations of this job, then drop its aggregation mutex: the
|
pipeline = append(pipeline, common.NewParallelExecutor(maxParallel, stageExecutor...))
|
||||||
// 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:
|
// For pipeline execution:
|
||||||
@@ -342,11 +334,6 @@ func (runner *runnerImpl) newRunContext(ctx context.Context, run *model.Run, mat
|
|||||||
}
|
}
|
||||||
rc.ExprEval = rc.NewExpressionEvaluator(ctx)
|
rc.ExprEval = rc.NewExpressionEvaluator(ctx)
|
||||||
rc.Name = rc.ExprEval.Interpolate(ctx, run.String())
|
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
|
return rc
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -188,17 +188,14 @@ func (j *TestJobFileInfo) runTest(ctx context.Context, t *testing.T, cfg *Config
|
|||||||
EventPath: cfg.EventPath,
|
EventPath: cfg.EventPath,
|
||||||
Platforms: j.platforms,
|
Platforms: j.platforms,
|
||||||
ReuseContainers: false,
|
ReuseContainers: false,
|
||||||
ForceRebuild: true,
|
|
||||||
Env: cfg.Env,
|
Env: cfg.Env,
|
||||||
Secrets: cfg.Secrets,
|
Secrets: cfg.Secrets,
|
||||||
Inputs: cfg.Inputs,
|
Inputs: cfg.Inputs,
|
||||||
GitHubInstance: "github.com",
|
GitHubInstance: "github.com",
|
||||||
DefaultActionInstance: cfg.DefaultActionInstance,
|
|
||||||
ContainerArchitecture: cfg.ContainerArchitecture,
|
ContainerArchitecture: cfg.ContainerArchitecture,
|
||||||
ContainerMaxLifetime: time.Hour,
|
ContainerMaxLifetime: time.Hour,
|
||||||
Matrix: cfg.Matrix,
|
Matrix: cfg.Matrix,
|
||||||
ActionCache: cfg.ActionCache,
|
ActionCache: cfg.ActionCache,
|
||||||
ValidVolumes: []string{"**"}, // allow workflow-declared volumes (e.g. container-volumes)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
runner, err := New(runnerConfig)
|
runner, err := New(runnerConfig)
|
||||||
@@ -226,14 +223,18 @@ type TestConfig struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestRunEvent(t *testing.T) {
|
func TestRunEvent(t *testing.T) {
|
||||||
requireDocker(t)
|
if testing.Short() {
|
||||||
|
t.Skip("skipping integration test")
|
||||||
|
}
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
tables := []TestJobFileInfo{
|
tables := []TestJobFileInfo{
|
||||||
// Shells
|
// Shells
|
||||||
{workdir, "shells/defaults", "push", "", platforms, secrets},
|
{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/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},
|
{workdir, "shells/sh", "push", "", platforms, secrets},
|
||||||
|
|
||||||
// Local action
|
// Local action
|
||||||
@@ -245,6 +246,11 @@ func TestRunEvent(t *testing.T) {
|
|||||||
// Uses
|
// Uses
|
||||||
{workdir, "uses-composite", "push", "", platforms, secrets},
|
{workdir, "uses-composite", "push", "", platforms, secrets},
|
||||||
{workdir, "uses-composite-with-error", "push", "Job 'failing-composite-action' failed", 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, "uses-docker-url", "push", "", platforms, secrets},
|
||||||
{workdir, "act-composite-env-test", "push", "", platforms, secrets},
|
{workdir, "act-composite-env-test", "push", "", platforms, secrets},
|
||||||
|
|
||||||
@@ -254,15 +260,21 @@ func TestRunEvent(t *testing.T) {
|
|||||||
{workdir, "evalmatrixneeds2", "push", "", platforms, secrets},
|
{workdir, "evalmatrixneeds2", "push", "", platforms, secrets},
|
||||||
{workdir, "evalmatrix-merge-map", "push", "", platforms, secrets},
|
{workdir, "evalmatrix-merge-map", "push", "", platforms, secrets},
|
||||||
{workdir, "evalmatrix-merge-array", "push", "", platforms, secrets},
|
{workdir, "evalmatrix-merge-array", "push", "", platforms, secrets},
|
||||||
|
{workdir, "issue-1195", "push", "", platforms, secrets},
|
||||||
|
|
||||||
{workdir, "basic", "push", "", platforms, secrets},
|
{workdir, "basic", "push", "", platforms, secrets},
|
||||||
{workdir, "fail", "push", "exit with `FAILURE`: 1", platforms, secrets},
|
{workdir, "fail", "push", "exit with `FAILURE`: 1", platforms, secrets},
|
||||||
|
{workdir, "runs-on", "push", "", platforms, secrets},
|
||||||
{workdir, "checkout", "push", "", platforms, secrets},
|
{workdir, "checkout", "push", "", platforms, secrets},
|
||||||
{workdir, "job-container", "push", "", platforms, secrets},
|
{workdir, "job-container", "push", "", platforms, secrets},
|
||||||
{workdir, "job-container-non-root", "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, "job-container-invalid-credentials", "push", "failed to handle credentials: failed to interpolate container.credentials.password", platforms, secrets},
|
||||||
{workdir, "container-hostname", "push", "", 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", "push", "", platforms, secrets},
|
||||||
|
{workdir, "matrix-include-exclude", "push", "", platforms, secrets},
|
||||||
{workdir, "matrix-exitcode", "push", "Job 'test' failed", platforms, secrets},
|
{workdir, "matrix-exitcode", "push", "Job 'test' failed", platforms, secrets},
|
||||||
{workdir, "commands", "push", "", platforms, secrets},
|
{workdir, "commands", "push", "", platforms, secrets},
|
||||||
{workdir, "workdir", "push", "", platforms, secrets},
|
{workdir, "workdir", "push", "", platforms, secrets},
|
||||||
@@ -283,6 +295,7 @@ func TestRunEvent(t *testing.T) {
|
|||||||
{workdir, "job-status-check", "push", "job 'fail' failed", platforms, secrets},
|
{workdir, "job-status-check", "push", "job 'fail' failed", platforms, secrets},
|
||||||
{workdir, "if-expressions", "push", "Job 'mytest' failed", platforms, secrets},
|
{workdir, "if-expressions", "push", "Job 'mytest' failed", platforms, secrets},
|
||||||
{workdir, "actions-environment-and-context-tests", "push", "", 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, "evalenv", "push", "", platforms, secrets},
|
||||||
{workdir, "docker-action-custom-path", "push", "", platforms, secrets},
|
{workdir, "docker-action-custom-path", "push", "", platforms, secrets},
|
||||||
{workdir, "GITHUB_ENV-use-in-env-ctx", "push", "", platforms, secrets},
|
{workdir, "GITHUB_ENV-use-in-env-ctx", "push", "", platforms, secrets},
|
||||||
@@ -293,6 +306,7 @@ func TestRunEvent(t *testing.T) {
|
|||||||
{workdir, "workflow_dispatch-scalar", "workflow_dispatch", "", platforms, secrets},
|
{workdir, "workflow_dispatch-scalar", "workflow_dispatch", "", platforms, secrets},
|
||||||
{workdir, "workflow_dispatch-scalar-composite-action", "workflow_dispatch", "", platforms, secrets},
|
{workdir, "workflow_dispatch-scalar-composite-action", "workflow_dispatch", "", platforms, secrets},
|
||||||
{workdir, "job-needs-context-contains-result", "push", "", 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},
|
{"../model/testdata", "container-volumes", "push", "", platforms, secrets},
|
||||||
{workdir, "path-handling", "push", "", platforms, secrets},
|
{workdir, "path-handling", "push", "", platforms, secrets},
|
||||||
{workdir, "do-not-leak-step-env-in-composite", "push", "", platforms, secrets},
|
{workdir, "do-not-leak-step-env-in-composite", "push", "", platforms, secrets},
|
||||||
@@ -302,6 +316,7 @@ func TestRunEvent(t *testing.T) {
|
|||||||
|
|
||||||
// services
|
// services
|
||||||
{workdir, "services", "push", "", platforms, secrets},
|
{workdir, "services", "push", "", platforms, secrets},
|
||||||
|
{workdir, "services-host-network", "push", "", platforms, secrets},
|
||||||
{workdir, "services-with-container", "push", "", platforms, secrets},
|
{workdir, "services-with-container", "push", "", platforms, secrets},
|
||||||
|
|
||||||
// local remote action overrides
|
// local remote action overrides
|
||||||
@@ -310,11 +325,6 @@ func TestRunEvent(t *testing.T) {
|
|||||||
|
|
||||||
for _, table := range tables {
|
for _, table := range tables {
|
||||||
t.Run(table.workflowPath, func(t *testing.T) {
|
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{
|
config := &Config{
|
||||||
Secrets: table.secrets,
|
Secrets: table.secrets,
|
||||||
}
|
}
|
||||||
@@ -346,12 +356,9 @@ func TestRunEvent(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestRunEventHostEnvironment(t *testing.T) {
|
func TestRunEventHostEnvironment(t *testing.T) {
|
||||||
// Runs steps directly on the host (the "-self-hosted" platform), so it needs the shells
|
if testing.Short() {
|
||||||
// and tools the workflows invoke. No network gate: every action these workflows reference
|
t.Skip("skipping integration test")
|
||||||
// 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()
|
ctx := context.Background()
|
||||||
|
|
||||||
@@ -367,6 +374,7 @@ func TestRunEventHostEnvironment(t *testing.T) {
|
|||||||
{workdir, "shells/defaults", "push", "", platforms, secrets},
|
{workdir, "shells/defaults", "push", "", platforms, secrets},
|
||||||
{workdir, "shells/pwsh", "push", "", platforms, secrets},
|
{workdir, "shells/pwsh", "push", "", platforms, secrets},
|
||||||
{workdir, "shells/bash", "push", "", platforms, secrets},
|
{workdir, "shells/bash", "push", "", platforms, secrets},
|
||||||
|
{workdir, "shells/python", "push", "", platforms, secrets},
|
||||||
{workdir, "shells/sh", "push", "", platforms, secrets},
|
{workdir, "shells/sh", "push", "", platforms, secrets},
|
||||||
|
|
||||||
// Local action
|
// Local action
|
||||||
@@ -375,6 +383,7 @@ func TestRunEventHostEnvironment(t *testing.T) {
|
|||||||
// Uses
|
// Uses
|
||||||
{workdir, "uses-composite", "push", "", platforms, secrets},
|
{workdir, "uses-composite", "push", "", platforms, secrets},
|
||||||
{workdir, "uses-composite-with-error", "push", "Job 'failing-composite-action' failed", 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},
|
{workdir, "act-composite-env-test", "push", "", platforms, secrets},
|
||||||
|
|
||||||
// Eval
|
// Eval
|
||||||
@@ -383,10 +392,14 @@ func TestRunEventHostEnvironment(t *testing.T) {
|
|||||||
{workdir, "evalmatrixneeds2", "push", "", platforms, secrets},
|
{workdir, "evalmatrixneeds2", "push", "", platforms, secrets},
|
||||||
{workdir, "evalmatrix-merge-map", "push", "", platforms, secrets},
|
{workdir, "evalmatrix-merge-map", "push", "", platforms, secrets},
|
||||||
{workdir, "evalmatrix-merge-array", "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, "fail", "push", "exit with `FAILURE`: 1", platforms, secrets},
|
||||||
|
{workdir, "runs-on", "push", "", platforms, secrets},
|
||||||
{workdir, "checkout", "push", "", platforms, secrets},
|
{workdir, "checkout", "push", "", platforms, secrets},
|
||||||
|
{workdir, "remote-action-js", "push", "", platforms, secrets},
|
||||||
{workdir, "matrix", "push", "", platforms, secrets},
|
{workdir, "matrix", "push", "", platforms, secrets},
|
||||||
|
{workdir, "matrix-include-exclude", "push", "", platforms, secrets},
|
||||||
{workdir, "commands", "push", "", platforms, secrets},
|
{workdir, "commands", "push", "", platforms, secrets},
|
||||||
{workdir, "defaults-run", "push", "", platforms, secrets},
|
{workdir, "defaults-run", "push", "", platforms, secrets},
|
||||||
{workdir, "composite-fail-with-output", "push", "", platforms, secrets},
|
{workdir, "composite-fail-with-output", "push", "", platforms, secrets},
|
||||||
@@ -400,6 +413,7 @@ func TestRunEventHostEnvironment(t *testing.T) {
|
|||||||
{workdir, "steps-context/outcome", "push", "", platforms, secrets},
|
{workdir, "steps-context/outcome", "push", "", platforms, secrets},
|
||||||
{workdir, "job-status-check", "push", "job 'fail' failed", platforms, secrets},
|
{workdir, "job-status-check", "push", "job 'fail' failed", platforms, secrets},
|
||||||
{workdir, "if-expressions", "push", "Job 'mytest' 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, "evalenv", "push", "", platforms, secrets},
|
||||||
{workdir, "ensure-post-steps", "push", "Job 'second-post-step-should-fail' failed", platforms, secrets},
|
{workdir, "ensure-post-steps", "push", "Job 'second-post-step-should-fail' failed", platforms, secrets},
|
||||||
}...)
|
}...)
|
||||||
@@ -432,26 +446,24 @@ func TestRunEventHostEnvironment(t *testing.T) {
|
|||||||
|
|
||||||
for _, table := range tables {
|
for _, table := range tables {
|
||||||
t.Run(table.workflowPath, func(t *testing.T) {
|
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{})
|
table.runTest(ctx, t, &Config{})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDryrunEvent(t *testing.T) {
|
func TestDryrunEvent(t *testing.T) {
|
||||||
// Dryrun plans without containers or network (shells and local actions only).
|
if testing.Short() {
|
||||||
|
t.Skip("skipping integration test")
|
||||||
|
}
|
||||||
|
|
||||||
ctx := common.WithDryrun(context.Background(), true)
|
ctx := common.WithDryrun(context.Background(), true)
|
||||||
|
|
||||||
tables := []TestJobFileInfo{
|
tables := []TestJobFileInfo{
|
||||||
// Shells
|
// Shells
|
||||||
{workdir, "shells/defaults", "push", "", platforms, secrets},
|
{workdir, "shells/defaults", "push", "", platforms, secrets},
|
||||||
{workdir, "shells/pwsh", "push", "", platforms, secrets},
|
{workdir, "shells/pwsh", "push", "", map[string]string{"ubuntu-latest": "catthehacker/ubuntu:pwsh-latest"}, secrets}, // custom image with pwsh
|
||||||
{workdir, "shells/bash", "push", "", platforms, secrets},
|
{workdir, "shells/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},
|
{workdir, "shells/sh", "push", "", platforms, secrets},
|
||||||
|
|
||||||
// Local action
|
// Local action
|
||||||
@@ -468,18 +480,10 @@ func TestDryrunEvent(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestReusableWorkflowCaller exercises the reusable-workflow caller path against a local
|
|
||||||
// reusable workflow (typed inputs, secrets as both a map and `inherit`, and reading the called
|
|
||||||
// workflow's outputs via `needs`).
|
|
||||||
func TestReusableWorkflowCaller(t *testing.T) {
|
|
||||||
requireDocker(t)
|
|
||||||
table := TestJobFileInfo{workdir, "uses-workflow", "push", "", platforms, map[string]string{"secret": "keep_it_private"}}
|
|
||||||
table.runTest(context.Background(), t, &Config{Secrets: table.secrets})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDockerActionForcePullForceRebuild(t *testing.T) {
|
func TestDockerActionForcePullForceRebuild(t *testing.T) {
|
||||||
requireDocker(t)
|
if testing.Short() {
|
||||||
requireNetwork(t) // force-pulls a docker action image
|
t.Skip("skipping integration test")
|
||||||
|
}
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
@@ -500,6 +504,22 @@ func TestDockerActionForcePullForceRebuild(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRunDifferentArchitecture(t *testing.T) {
|
||||||
|
if testing.Short() {
|
||||||
|
t.Skip("skipping integration test")
|
||||||
|
}
|
||||||
|
|
||||||
|
tjfi := TestJobFileInfo{
|
||||||
|
workdir: workdir,
|
||||||
|
workflowPath: "basic",
|
||||||
|
eventName: "push",
|
||||||
|
errorMessage: "",
|
||||||
|
platforms: platforms,
|
||||||
|
}
|
||||||
|
|
||||||
|
tjfi.runTest(context.Background(), t, &Config{ContainerArchitecture: "linux/arm64"})
|
||||||
|
}
|
||||||
|
|
||||||
type maskJobLoggerFactory struct {
|
type maskJobLoggerFactory struct {
|
||||||
Output bytes.Buffer
|
Output bytes.Buffer
|
||||||
}
|
}
|
||||||
@@ -520,7 +540,9 @@ func TestMaskValues(t *testing.T) {
|
|||||||
assert.False(t, strings.Contains(text, "composite secret")) //nolint:testifylint // pre-existing issue from nektos/act
|
assert.False(t, strings.Contains(text, "composite secret")) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
}
|
}
|
||||||
|
|
||||||
requireDocker(t)
|
if testing.Short() {
|
||||||
|
t.Skip("skipping integration test")
|
||||||
|
}
|
||||||
|
|
||||||
log.SetLevel(log.DebugLevel)
|
log.SetLevel(log.DebugLevel)
|
||||||
|
|
||||||
@@ -541,7 +563,9 @@ func TestMaskValues(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestRunEventSecrets(t *testing.T) {
|
func TestRunEventSecrets(t *testing.T) {
|
||||||
requireDocker(t)
|
if testing.Short() {
|
||||||
|
t.Skip("skipping integration test")
|
||||||
|
}
|
||||||
workflowPath := "secrets"
|
workflowPath := "secrets"
|
||||||
|
|
||||||
tjfi := TestJobFileInfo{
|
tjfi := TestJobFileInfo{
|
||||||
@@ -561,7 +585,9 @@ func TestRunEventSecrets(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestRunWithService(t *testing.T) {
|
func TestRunWithService(t *testing.T) {
|
||||||
requireDocker(t)
|
if testing.Short() {
|
||||||
|
t.Skip("skipping integration test")
|
||||||
|
}
|
||||||
|
|
||||||
log.SetLevel(log.DebugLevel)
|
log.SetLevel(log.DebugLevel)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
@@ -581,7 +607,6 @@ func TestRunWithService(t *testing.T) {
|
|||||||
EventName: eventName,
|
EventName: eventName,
|
||||||
Platforms: platforms,
|
Platforms: platforms,
|
||||||
ReuseContainers: false,
|
ReuseContainers: false,
|
||||||
ContainerMaxLifetime: time.Hour, // otherwise the job container is `sleep 0` and exits at once
|
|
||||||
}
|
}
|
||||||
runner, err := New(runnerConfig)
|
runner, err := New(runnerConfig)
|
||||||
assert.NoError(t, err, workflowPath) //nolint:testifylint // pre-existing issue from nektos/act
|
assert.NoError(t, err, workflowPath) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
@@ -597,7 +622,9 @@ func TestRunWithService(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestRunActionInputs(t *testing.T) {
|
func TestRunActionInputs(t *testing.T) {
|
||||||
requireDocker(t)
|
if testing.Short() {
|
||||||
|
t.Skip("skipping integration test")
|
||||||
|
}
|
||||||
workflowPath := "input-from-cli"
|
workflowPath := "input-from-cli"
|
||||||
|
|
||||||
tjfi := TestJobFileInfo{
|
tjfi := TestJobFileInfo{
|
||||||
@@ -616,7 +643,9 @@ func TestRunActionInputs(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestRunEventPullRequest(t *testing.T) {
|
func TestRunEventPullRequest(t *testing.T) {
|
||||||
requireDocker(t)
|
if testing.Short() {
|
||||||
|
t.Skip("skipping integration test")
|
||||||
|
}
|
||||||
|
|
||||||
workflowPath := "pull-request"
|
workflowPath := "pull-request"
|
||||||
|
|
||||||
@@ -632,7 +661,9 @@ func TestRunEventPullRequest(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestRunMatrixWithUserDefinedInclusions(t *testing.T) {
|
func TestRunMatrixWithUserDefinedInclusions(t *testing.T) {
|
||||||
requireDocker(t)
|
if testing.Short() {
|
||||||
|
t.Skip("skipping integration test")
|
||||||
|
}
|
||||||
workflowPath := "matrix-with-user-inclusions"
|
workflowPath := "matrix-with-user-inclusions"
|
||||||
|
|
||||||
tjfi := TestJobFileInfo{
|
tjfi := TestJobFileInfo{
|
||||||
|
|||||||
@@ -124,12 +124,7 @@ func runStepExecutor(step step, stage stepStage, executor common.Executor) commo
|
|||||||
envFileCommand := path.Join("workflow", "envs.txt")
|
envFileCommand := path.Join("workflow", "envs.txt")
|
||||||
(*step.getEnv())["GITHUB_ENV"] = path.Join(actPath, envFileCommand)
|
(*step.getEnv())["GITHUB_ENV"] = path.Join(actPath, envFileCommand)
|
||||||
|
|
||||||
// Per-step summary file. Composite sub-steps share the outer job step's index
|
summaryFileCommand := path.Join("workflow", "SUMMARY.md")
|
||||||
// via the Parent chain so all writes from within a composite action accumulate
|
|
||||||
// in the same file and upload under the outer step_index.
|
|
||||||
topRC := rc.topLevelRunContext()
|
|
||||||
stepSummaryIndex := topRC.CurrentStepIndex
|
|
||||||
summaryFileCommand := path.Join("workflow", "step-summary-"+strconv.Itoa(stepSummaryIndex)+".md")
|
|
||||||
(*step.getEnv())["GITHUB_STEP_SUMMARY"] = path.Join(actPath, summaryFileCommand)
|
(*step.getEnv())["GITHUB_STEP_SUMMARY"] = path.Join(actPath, summaryFileCommand)
|
||||||
|
|
||||||
{
|
{
|
||||||
@@ -141,23 +136,22 @@ func runStepExecutor(step step, stage stepStage, executor common.Executor) commo
|
|||||||
(*step.getEnv())["GITEA_STEP_SUMMARY"] = (*step.getEnv())["GITHUB_STEP_SUMMARY"]
|
(*step.getEnv())["GITEA_STEP_SUMMARY"] = (*step.getEnv())["GITHUB_STEP_SUMMARY"]
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reset the per-phase file-command files. GITHUB_STEP_SUMMARY is intentionally
|
_ = rc.JobContainer.Copy(actPath, &container.FileEntry{
|
||||||
// excluded here and initialized below at most once per step so writes from later
|
Name: outputFileCommand,
|
||||||
// phases and from composite sub-steps accumulate instead of being truncated.
|
Mode: 0o666,
|
||||||
files := []*container.FileEntry{
|
}, &container.FileEntry{
|
||||||
{Name: outputFileCommand, Mode: 0o666},
|
Name: stateFileCommand,
|
||||||
{Name: stateFileCommand, Mode: 0o666},
|
Mode: 0o666,
|
||||||
{Name: pathFileCommand, Mode: 0o666},
|
}, &container.FileEntry{
|
||||||
{Name: envFileCommand, Mode: 0o666},
|
Name: pathFileCommand,
|
||||||
}
|
Mode: 0o666,
|
||||||
if topRC.summaryFileInitialized == nil {
|
}, &container.FileEntry{
|
||||||
topRC.summaryFileInitialized = map[int]bool{}
|
Name: envFileCommand,
|
||||||
}
|
Mode: 0o666,
|
||||||
if !topRC.summaryFileInitialized[stepSummaryIndex] {
|
}, &container.FileEntry{
|
||||||
files = append(files, &container.FileEntry{Name: summaryFileCommand, Mode: 0o666})
|
Name: summaryFileCommand,
|
||||||
topRC.summaryFileInitialized[stepSummaryIndex] = true
|
Mode: 0o666,
|
||||||
}
|
})(ctx)
|
||||||
_ = rc.JobContainer.Copy(actPath, files...)(ctx)
|
|
||||||
|
|
||||||
timeoutctx, cancelTimeOut := evaluateStepTimeout(ctx, rc.ExprEval, stepModel)
|
timeoutctx, cancelTimeOut := evaluateStepTimeout(ctx, rc.ExprEval, stepModel)
|
||||||
defer cancelTimeOut()
|
defer cancelTimeOut()
|
||||||
|
|||||||
@@ -291,9 +291,7 @@ type remoteAction struct {
|
|||||||
|
|
||||||
func (ra *remoteAction) CloneURL(u string) string {
|
func (ra *remoteAction) CloneURL(u string) string {
|
||||||
if ra.URL == "" {
|
if ra.URL == "" {
|
||||||
// keep an absolute local path as-is (used by tests to resolve actions from a local
|
if !strings.HasPrefix(u, "http://") && !strings.HasPrefix(u, "https://") {
|
||||||
// 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
|
u = "https://" + u
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -125,6 +125,8 @@ func (sd *stepDocker) newStepContainer(ctx context.Context, image string, cmd, e
|
|||||||
Entrypoint: entrypoint,
|
Entrypoint: entrypoint,
|
||||||
WorkingDir: rc.JobContainer.ToContainerPath(rc.Config.Workdir),
|
WorkingDir: rc.JobContainer.ToContainerPath(rc.Config.Workdir),
|
||||||
Image: image,
|
Image: image,
|
||||||
|
Username: rc.Config.Secrets["DOCKER_USERNAME"],
|
||||||
|
Password: rc.Config.Secrets["DOCKER_PASSWORD"],
|
||||||
Name: createContainerName(rc.jobContainerName(), "STEP-"+step.ID),
|
Name: createContainerName(rc.jobContainerName(), "STEP-"+step.ID),
|
||||||
Env: envList,
|
Env: envList,
|
||||||
Mounts: mounts,
|
Mounts: mounts,
|
||||||
@@ -136,7 +138,7 @@ func (sd *stepDocker) newStepContainer(ctx context.Context, image string, cmd, e
|
|||||||
UsernsMode: rc.Config.UsernsMode,
|
UsernsMode: rc.Config.UsernsMode,
|
||||||
Platform: rc.Config.ContainerArchitecture,
|
Platform: rc.Config.ContainerArchitecture,
|
||||||
AutoRemove: rc.Config.AutoRemove,
|
AutoRemove: rc.Config.AutoRemove,
|
||||||
ValidVolumes: rc.validVolumes(),
|
ValidVolumes: rc.Config.ValidVolumes,
|
||||||
AllocatePTY: rc.Config.AllocatePTY,
|
AllocatePTY: rc.Config.AllocatePTY,
|
||||||
})
|
})
|
||||||
return stepContainer
|
return stepContainer
|
||||||
|
|||||||
@@ -38,12 +38,7 @@ func TestStepDockerMain(t *testing.T) {
|
|||||||
sd := &stepDocker{
|
sd := &stepDocker{
|
||||||
RunContext: &RunContext{
|
RunContext: &RunContext{
|
||||||
StepResults: map[string]*model.StepResult{},
|
StepResults: map[string]*model.StepResult{},
|
||||||
Config: &Config{
|
Config: &Config{},
|
||||||
Secrets: map[string]string{
|
|
||||||
"DOCKER_USERNAME": "docker-user",
|
|
||||||
"DOCKER_PASSWORD": "docker-password",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
Run: &model.Run{
|
Run: &model.Run{
|
||||||
JobID: "1",
|
JobID: "1",
|
||||||
Workflow: &model.Workflow{
|
Workflow: &model.Workflow{
|
||||||
@@ -111,10 +106,6 @@ func TestStepDockerMain(t *testing.T) {
|
|||||||
|
|
||||||
assert.Equal(t, "node:14", input.Image)
|
assert.Equal(t, "node:14", input.Image)
|
||||||
|
|
||||||
// DOCKER_USERNAME/DOCKER_PASSWORD secrets should not be used as implicit pull credentials for docker:// action containers.
|
|
||||||
assert.Empty(t, input.Username)
|
|
||||||
assert.Empty(t, input.Password)
|
|
||||||
|
|
||||||
cm.AssertExpectations(t)
|
cm.AssertExpectations(t)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,34 +0,0 @@
|
|||||||
name: local-reusable-workflow
|
|
||||||
on:
|
|
||||||
workflow_call:
|
|
||||||
inputs:
|
|
||||||
string_required:
|
|
||||||
required: true
|
|
||||||
type: string
|
|
||||||
bool_required:
|
|
||||||
required: true
|
|
||||||
type: boolean
|
|
||||||
number_required:
|
|
||||||
required: true
|
|
||||||
type: number
|
|
||||||
secrets:
|
|
||||||
secret:
|
|
||||||
required: true
|
|
||||||
outputs:
|
|
||||||
output:
|
|
||||||
value: ${{ jobs.reusable.outputs.output }}
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
reusable:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
outputs:
|
|
||||||
output: ${{ steps.gen.outputs.output }}
|
|
||||||
steps:
|
|
||||||
- name: check inputs and secret arrived
|
|
||||||
run: |
|
|
||||||
[ "${{ inputs.string_required }}" = "string" ]
|
|
||||||
[ "${{ inputs.bool_required }}" = "true" ]
|
|
||||||
[ "${{ inputs.number_required }}" = "1" ]
|
|
||||||
[ "${{ secrets.secret }}" = "keep_it_private" ]
|
|
||||||
- id: gen
|
|
||||||
run: echo "output=${{ inputs.string_required }}" >> $GITHUB_OUTPUT
|
|
||||||
@@ -5,11 +5,10 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
MYGLOBALENV3: myglobalval3
|
MYGLOBALENV3: myglobalval3
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- run: |
|
- run: |
|
||||||
echo MYGLOBALENV1=myglobalval1 > $GITHUB_ENV
|
echo MYGLOBALENV1=myglobalval1 > $GITHUB_ENV
|
||||||
echo "::set-env name=MYGLOBALENV2::myglobalval2"
|
echo "::set-env name=MYGLOBALENV2::myglobalval2"
|
||||||
- uses: ./actions/script
|
- uses: nektos/act-test-actions/script@main
|
||||||
with:
|
with:
|
||||||
main: |
|
main: |
|
||||||
env
|
env
|
||||||
|
|||||||
41
act/runner/testdata/GITHUB_STATE/push.yml
vendored
41
act/runner/testdata/GITHUB_STATE/push.yml
vendored
@@ -1,31 +1,48 @@
|
|||||||
on: push
|
on: push
|
||||||
jobs:
|
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
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: nektos/act-test-actions/script@main
|
||||||
- uses: ./actions/script
|
|
||||||
with:
|
with:
|
||||||
|
pre: |
|
||||||
|
env
|
||||||
|
echo mystate0=mystateval > $GITHUB_STATE
|
||||||
|
echo "::save-state name=mystate1::mystateval"
|
||||||
main: |
|
main: |
|
||||||
|
env
|
||||||
echo mystate2=mystateval > $GITHUB_STATE
|
echo mystate2=mystateval > $GITHUB_STATE
|
||||||
echo "::save-state name=mystate3::mystateval"
|
echo "::save-state name=mystate3::mystateval"
|
||||||
post: |
|
post: |
|
||||||
|
env
|
||||||
|
[ "$STATE_mystate0" = "mystateval" ]
|
||||||
|
[ "$STATE_mystate1" = "mystateval" ]
|
||||||
[ "$STATE_mystate2" = "mystateval" ]
|
[ "$STATE_mystate2" = "mystateval" ]
|
||||||
[ "$STATE_mystate3" = "mystateval" ]
|
[ "$STATE_mystate3" = "mystateval" ]
|
||||||
# State must be isolated per action instance even when two steps use the same action.
|
|
||||||
test-id-collision-bug:
|
test-id-collision-bug:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: nektos/act-test-actions/script@main
|
||||||
- uses: ./actions/script
|
|
||||||
id: script
|
id: script
|
||||||
with:
|
with:
|
||||||
main: echo mystate=val1 > $GITHUB_STATE
|
pre: |
|
||||||
post: '[ "$STATE_mystate" = "val1" ]'
|
env
|
||||||
- uses: ./actions/script
|
echo mystate0=mystateval > $GITHUB_STATE
|
||||||
|
echo "::save-state name=mystate1::mystateval"
|
||||||
|
main: |
|
||||||
|
env
|
||||||
|
echo mystate2=mystateval > $GITHUB_STATE
|
||||||
|
echo "::save-state name=mystate3::mystateval"
|
||||||
|
post: |
|
||||||
|
env
|
||||||
|
[ "$STATE_mystate0" = "mystateval" ]
|
||||||
|
[ "$STATE_mystate1" = "mystateval" ]
|
||||||
|
[ "$STATE_mystate2" = "mystateval" ]
|
||||||
|
[ "$STATE_mystate3" = "mystateval" ]
|
||||||
|
- uses: nektos/act-test-actions/script@main
|
||||||
id: pre-script
|
id: pre-script
|
||||||
with:
|
with:
|
||||||
main: echo mystate=val2 > $GITHUB_STATE
|
main: |
|
||||||
post: '[ "$STATE_mystate" = "val2" ]'
|
env
|
||||||
|
echo mystate0=mystateerror > $GITHUB_STATE
|
||||||
|
echo "::save-state name=mystate1::mystateerror"
|
||||||
@@ -9,3 +9,7 @@ jobs:
|
|||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
- uses: './actions-environment-and-context-tests/js'
|
- uses: './actions-environment-and-context-tests/js'
|
||||||
- uses: './actions-environment-and-context-tests/docker'
|
- uses: './actions-environment-and-context-tests/docker'
|
||||||
|
- uses: 'nektos/act-test-actions/js@main'
|
||||||
|
- uses: 'nektos/act-test-actions/docker@main'
|
||||||
|
- uses: 'nektos/act-test-actions/docker-file@main'
|
||||||
|
- uses: 'nektos/act-test-actions/docker-relative-context/action@main'
|
||||||
|
|||||||
15
act/runner/testdata/actions/script/action.yml
vendored
15
act/runner/testdata/actions/script/action.yml
vendored
@@ -1,15 +0,0 @@
|
|||||||
name: 'script'
|
|
||||||
description: 'Run the shell scripts passed as inputs across the pre/main/post lifecycle'
|
|
||||||
inputs:
|
|
||||||
main:
|
|
||||||
description: 'shell script to run in the main step'
|
|
||||||
required: false
|
|
||||||
default: ''
|
|
||||||
post:
|
|
||||||
description: 'shell script to run in the post step'
|
|
||||||
required: false
|
|
||||||
default: ''
|
|
||||||
runs:
|
|
||||||
using: 'node24'
|
|
||||||
main: 'index.js'
|
|
||||||
post: 'post.js'
|
|
||||||
9
act/runner/testdata/actions/script/index.js
vendored
9
act/runner/testdata/actions/script/index.js
vendored
@@ -1,9 +0,0 @@
|
|||||||
import {execFileSync} from 'node:child_process';
|
|
||||||
|
|
||||||
// Run the `main` input as a bash script; its stdout (workflow commands like
|
|
||||||
// ::set-output / ::save-state) and $GITHUB_ENV / $GITHUB_STATE writes are
|
|
||||||
// processed by the runner, exactly like the remote script action this replaces.
|
|
||||||
const script = process.env.INPUT_MAIN;
|
|
||||||
if (script) {
|
|
||||||
execFileSync('bash', ['-eo', 'pipefail', '-c', script], {stdio: 'inherit'});
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "script",
|
|
||||||
"private": true,
|
|
||||||
"type": "module"
|
|
||||||
}
|
|
||||||
6
act/runner/testdata/actions/script/post.js
vendored
6
act/runner/testdata/actions/script/post.js
vendored
@@ -1,6 +0,0 @@
|
|||||||
import {execFileSync} from 'node:child_process';
|
|
||||||
|
|
||||||
const script = process.env.INPUT_POST;
|
|
||||||
if (script) {
|
|
||||||
execFileSync('bash', ['-eo', 'pipefail', '-c', script], {stdio: 'inherit'});
|
|
||||||
}
|
|
||||||
@@ -4,7 +4,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- run: |
|
- run: |
|
||||||
FROM node:24-bookworm-slim
|
FROM ubuntu:latest
|
||||||
ENV PATH="/opt/texlive/texdir/bin/x86_64-linuxmusl:${PATH}"
|
ENV PATH="/opt/texlive/texdir/bin/x86_64-linuxmusl:${PATH}"
|
||||||
ENV ORG_PATH="${PATH}"
|
ENV ORG_PATH="${PATH}"
|
||||||
ENTRYPOINT [ "bash", "-c", "echo \"PATH=$PATH\" && echo \"ORG_PATH=$ORG_PATH\" && [[ \"$PATH\" = \"$ORG_PATH\" ]]" ]
|
ENTRYPOINT [ "bash", "-c", "echo \"PATH=$PATH\" && echo \"ORG_PATH=$ORG_PATH\" && [[ \"$PATH\" = \"$ORG_PATH\" ]]" ]
|
||||||
|
|||||||
13
act/runner/testdata/issue-1195/push.yml
vendored
Normal file
13
act/runner/testdata/issue-1195/push.yml
vendored
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
on: push
|
||||||
|
|
||||||
|
env:
|
||||||
|
variable: "${{ github.repository_owner }}"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: print env.variable
|
||||||
|
run: |
|
||||||
|
echo ${{ env.variable }}
|
||||||
|
exit ${{ (env.variable == 'nektos') && '0' || '1'}}
|
||||||
19
act/runner/testdata/issue-597/spelling.yaml
vendored
19
act/runner/testdata/issue-597/spelling.yaml
vendored
@@ -9,13 +9,24 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: My first false step
|
- name: My first false step
|
||||||
if: "endsWith('Should not', 'o1')"
|
if: "endsWith('Should not', 'o1')"
|
||||||
run: exit 1
|
uses: actions/checkout@v2.0.0
|
||||||
|
with:
|
||||||
|
ref: refs/pull/${{github.event.pull_request.number}}/merge
|
||||||
|
fetch-depth: 5
|
||||||
- name: My first true step
|
- name: My first true step
|
||||||
if: ${{endsWith('Hello world', 'ld')}}
|
if: ${{endsWith('Hello world', 'ld')}}
|
||||||
run: echo "Renst the Octocat"
|
uses: actions/hello-world-javascript-action@main
|
||||||
|
with:
|
||||||
|
who-to-greet: "Renst the Octocat"
|
||||||
- name: My second false step
|
- name: My second false step
|
||||||
if: "endsWith('Should not evaluate', 'o2')"
|
if: "endsWith('Should not evaluate', 'o2')"
|
||||||
run: exit 1
|
uses: actions/checkout@v2.0.0
|
||||||
|
with:
|
||||||
|
ref: refs/pull/${{github.event.pull_request.number}}/merge
|
||||||
|
fetch-depth: 5
|
||||||
- name: My third false step
|
- name: My third false step
|
||||||
if: ${{endsWith('Should not evaluate', 'o3')}}
|
if: ${{endsWith('Should not evaluate', 'o3')}}
|
||||||
run: exit 1
|
uses: actions/checkout@v2.0.0
|
||||||
|
with:
|
||||||
|
ref: refs/pull/${{github.event.pull_request.number}}/merge
|
||||||
|
fetch-depth: 5
|
||||||
|
|||||||
18
act/runner/testdata/issue-598/spelling.yml
vendored
18
act/runner/testdata/issue-598/spelling.yml
vendored
@@ -9,13 +9,23 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: My first false step
|
- name: My first false step
|
||||||
if: "endsWith('Hello world', 'o1')"
|
if: "endsWith('Hello world', 'o1')"
|
||||||
run: exit 1
|
uses: actions/hello-world-javascript-action@main
|
||||||
|
with:
|
||||||
|
who-to-greet: 'Mona the Octocat'
|
||||||
- name: My first true step
|
- name: My first true step
|
||||||
if: "!endsWith('Hello world', 'od')"
|
if: "!endsWith('Hello world', 'od')"
|
||||||
run: echo "Renst the Octocat"
|
uses: actions/hello-world-javascript-action@main
|
||||||
|
with:
|
||||||
|
who-to-greet: "Renst the Octocat"
|
||||||
- name: My second false step
|
- name: My second false step
|
||||||
if: "endsWith('Hello world', 'o2')"
|
if: "endsWith('Hello world', 'o2')"
|
||||||
run: exit 1
|
uses: actions/hello-world-javascript-action@main
|
||||||
|
with:
|
||||||
|
who-to-greet: 'Act the Octocat'
|
||||||
- name: My third false step
|
- name: My third false step
|
||||||
if: "endsWith('Hello world', 'o2')"
|
if: "endsWith('Hello world', 'o2')"
|
||||||
run: exit 1
|
uses: actions/hello-world-javascript-action@main
|
||||||
|
with:
|
||||||
|
who-to-greet: 'Git the Octocat'
|
||||||
|
|
||||||
|
|
||||||
@@ -5,7 +5,6 @@ jobs:
|
|||||||
test:
|
test:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
container:
|
container:
|
||||||
image: node:24-bookworm-slim
|
image: catthehacker/ubuntu:runner-latest # image with user 'runner:runner' built on tag 'act-latest'
|
||||||
options: --user 1000
|
|
||||||
steps:
|
steps:
|
||||||
- run: echo PASS
|
- run: echo PASS
|
||||||
|
|||||||
@@ -24,3 +24,4 @@ jobs:
|
|||||||
args: ${{format('"{0}"', 'Mona is not the Octocat') }}
|
args: ${{format('"{0}"', 'Mona is not the Octocat') }}
|
||||||
who-to-greet: 'Mona the Octocat'
|
who-to-greet: 'Mona the Octocat'
|
||||||
- run: '[[ "${{ env.SOMEVAR }}" == "Mona is not the Octocat" ]]'
|
- run: '[[ "${{ env.SOMEVAR }}" == "Mona is not the Octocat" ]]'
|
||||||
|
- uses: ./localdockerimagetest_
|
||||||
|
|||||||
@@ -30,6 +30,11 @@ runs:
|
|||||||
who-to-greet: ${{inputs.who-to-greet}}
|
who-to-greet: ${{inputs.who-to-greet}}
|
||||||
- run: '[[ "${{ env.SOMEVAR }}" == "Mona is not the Octocat" ]]'
|
- run: '[[ "${{ env.SOMEVAR }}" == "Mona is not the Octocat" ]]'
|
||||||
shell: bash
|
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
|
# Test if GITHUB_ACTION_PATH is set correctly after all steps
|
||||||
- run: stat $GITHUB_ACTION_PATH/push.yml
|
- run: stat $GITHUB_ACTION_PATH/push.yml
|
||||||
shell: bash
|
shell: bash
|
||||||
|
|||||||
@@ -5,5 +5,5 @@ jobs:
|
|||||||
build:
|
build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: https://github.com/nektos/test-override@a
|
- uses: nektos/test-override@a
|
||||||
- uses: nektos/test-override@b
|
- uses: nektos/test-override@b
|
||||||
31
act/runner/testdata/matrix-include-exclude/push.yml
vendored
Normal file
31
act/runner/testdata/matrix-include-exclude/push.yml
vendored
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
name: matrix-include-exclude
|
||||||
|
on: push
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
name: PHP ${{ matrix.os }} ${{ matrix.node}}
|
||||||
|
runs-on: ${{ matrix.os }}
|
||||||
|
steps:
|
||||||
|
- run: echo ${NODE_VERSION} | grep ${{ matrix.node }}
|
||||||
|
env:
|
||||||
|
NODE_VERSION: ${{ matrix.node }}
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
os: [ubuntu-18.04, macos-latest]
|
||||||
|
node: [4, 6, 8, 10]
|
||||||
|
exclude:
|
||||||
|
- os: macos-latest
|
||||||
|
node: 4
|
||||||
|
include:
|
||||||
|
- os: ubuntu-16.04
|
||||||
|
node: 10
|
||||||
|
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
node: [8.x, 10.x, 12.x, 13.x]
|
||||||
|
steps:
|
||||||
|
- run: echo ${NODE_VERSION} | grep ${{ matrix.node }}
|
||||||
|
env:
|
||||||
|
NODE_VERSION: ${{ matrix.node }}
|
||||||
@@ -19,3 +19,11 @@ jobs:
|
|||||||
using: composite
|
using: composite
|
||||||
shell: cp {0} action.yml
|
shell: cp {0} action.yml
|
||||||
- uses: ./
|
- uses: ./
|
||||||
|
remote-invalid-step:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: nektos/act-test-actions/invalid-composite-action/invalid-step@main
|
||||||
|
remote-missing-steps:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: nektos/act-test-actions/invalid-composite-action/missing-steps@main
|
||||||
2
act/runner/testdata/path-handling/push.yml
vendored
2
act/runner/testdata/path-handling/push.yml
vendored
@@ -27,7 +27,7 @@ jobs:
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- uses: ./path-handling/
|
- uses: nektos/act-test-actions/composite@main
|
||||||
with:
|
with:
|
||||||
input: some input
|
input: some input
|
||||||
|
|
||||||
|
|||||||
8
act/runner/testdata/remote-action-composite-action-ref/push.yml
vendored
Normal file
8
act/runner/testdata/remote-action-composite-action-ref/push.yml
vendored
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
name: remote-action-composite-action-ref
|
||||||
|
on: push
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: nektos/act-test-actions/composite-assert-action-ref-action@main
|
||||||
23
act/runner/testdata/remote-action-composite-js-pre-with-defaults/push.yml
vendored
Normal file
23
act/runner/testdata/remote-action-composite-js-pre-with-defaults/push.yml
vendored
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
name: remote-action-composite-js-pre-with-defaults
|
||||||
|
on: push
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: nektos/act-test-actions/composite-js-pre-with-defaults/js@main
|
||||||
|
with:
|
||||||
|
in: nix
|
||||||
|
- uses: nektos/act-test-actions/composite-js-pre-with-defaults@main
|
||||||
|
with:
|
||||||
|
in: secretval
|
||||||
|
- uses: nektos/act-test-actions/composite-js-pre-with-defaults@main
|
||||||
|
with:
|
||||||
|
in: secretval
|
||||||
|
- uses: nektos/act-test-actions/composite-js-pre-with-defaults/js@main
|
||||||
|
with:
|
||||||
|
pre: "true"
|
||||||
|
in: nix
|
||||||
|
- uses: nektos/act-test-actions/composite-js-pre-with-defaults/js@main
|
||||||
|
with:
|
||||||
|
in: nix
|
||||||
10
act/runner/testdata/remote-action-docker/push.yml
vendored
Normal file
10
act/runner/testdata/remote-action-docker/push.yml
vendored
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
name: remote-action-docker
|
||||||
|
on: push
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/hello-world-docker-action@v1
|
||||||
|
with:
|
||||||
|
who-to-greet: 'Mona the Octocat'
|
||||||
30
act/runner/testdata/remote-action-js-node-user/push.yml
vendored
Normal file
30
act/runner/testdata/remote-action-js-node-user/push.yml
vendored
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
name: remote-action-js
|
||||||
|
on: push
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
container:
|
||||||
|
image: node:24-bookworm-slim
|
||||||
|
options: --user node
|
||||||
|
steps:
|
||||||
|
- name: check permissions of env files
|
||||||
|
id: test
|
||||||
|
run: |
|
||||||
|
echo "USER: $(id -un) expected: node"
|
||||||
|
[[ "$(id -un)" = "node" ]]
|
||||||
|
echo "TEST=Value" >> $GITHUB_OUTPUT
|
||||||
|
shell: bash
|
||||||
|
|
||||||
|
- name: check if file command worked
|
||||||
|
if: steps.test.outputs.test != 'Value'
|
||||||
|
run: |
|
||||||
|
echo "steps.test.outputs.test=${{ steps.test.outputs.test || 'missing value!' }}"
|
||||||
|
exit 1
|
||||||
|
shell: bash
|
||||||
|
|
||||||
|
- uses: actions/hello-world-javascript-action@v1
|
||||||
|
with:
|
||||||
|
who-to-greet: 'Mona the Octocat'
|
||||||
|
|
||||||
|
- uses: cloudposse/actions/github/slash-command-dispatch@0.14.0
|
||||||
12
act/runner/testdata/remote-action-js/push.yml
vendored
Normal file
12
act/runner/testdata/remote-action-js/push.yml
vendored
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
name: remote-action-js
|
||||||
|
on: push
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/hello-world-javascript-action@v1
|
||||||
|
with:
|
||||||
|
who-to-greet: 'Mona the Octocat'
|
||||||
|
|
||||||
|
- uses: cloudposse/actions/github/slash-command-dispatch@0.14.0
|
||||||
24
act/runner/testdata/runs-on/push.yml
vendored
Normal file
24
act/runner/testdata/runs-on/push.yml
vendored
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
name: runs-on
|
||||||
|
on: push
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- run: env
|
||||||
|
- run: echo ${GITHUB_ACTOR}
|
||||||
|
- run: echo ${GITHUB_ACTOR} | grep nektos/act
|
||||||
|
|
||||||
|
many:
|
||||||
|
runs-on: [ubuntu-latest]
|
||||||
|
steps:
|
||||||
|
- run: env
|
||||||
|
- run: echo ${GITHUB_ACTOR}
|
||||||
|
- run: echo ${GITHUB_ACTOR} | grep nektos/act
|
||||||
|
|
||||||
|
selfmany:
|
||||||
|
runs-on: [self-hosted, ubuntu-latest]
|
||||||
|
steps:
|
||||||
|
- run: env
|
||||||
|
- run: echo ${GITHUB_ACTOR}
|
||||||
|
- run: echo ${GITHUB_ACTOR} | grep nektos/act
|
||||||
14
act/runner/testdata/services-host-network/push.yml
vendored
Normal file
14
act/runner/testdata/services-host-network/push.yml
vendored
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
name: services-host-network
|
||||||
|
on: push
|
||||||
|
jobs:
|
||||||
|
services-host-network:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
services:
|
||||||
|
nginx:
|
||||||
|
image: "nginx:latest"
|
||||||
|
ports:
|
||||||
|
- "8080:80"
|
||||||
|
steps:
|
||||||
|
- run: apt-get -qq update && apt-get -yqq install --no-install-recommends curl net-tools
|
||||||
|
- run: netstat -tlpen
|
||||||
|
- run: curl -v http://localhost:8080
|
||||||
@@ -5,11 +5,12 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
# https://docs.github.com/en/actions/using-containerized-services/about-service-containers#running-jobs-in-a-container
|
# https://docs.github.com/en/actions/using-containerized-services/about-service-containers#running-jobs-in-a-container
|
||||||
container:
|
container:
|
||||||
image: "node:24-bookworm-slim"
|
image: "ubuntu:latest"
|
||||||
services:
|
services:
|
||||||
nginx:
|
nginx:
|
||||||
image: "nginx:alpine"
|
image: "nginx:latest"
|
||||||
|
ports:
|
||||||
|
- "8080:80"
|
||||||
steps:
|
steps:
|
||||||
- run: apt-get -qq update && apt-get -yqq install --no-install-recommends curl
|
- 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
|
- run: curl -v http://nginx:80
|
||||||
|
|||||||
13
act/runner/testdata/services/push.yaml
vendored
13
act/runner/testdata/services/push.yaml
vendored
@@ -6,9 +6,18 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
services:
|
services:
|
||||||
postgres:
|
postgres:
|
||||||
image: nginx:alpine
|
image: postgres:12
|
||||||
|
env:
|
||||||
|
POSTGRES_USER: runner
|
||||||
|
POSTGRES_PASSWORD: mysecretdbpass
|
||||||
|
POSTGRES_DB: mydb
|
||||||
|
options: >-
|
||||||
|
--health-cmd pg_isready
|
||||||
|
--health-interval 10s
|
||||||
|
--health-timeout 5s
|
||||||
|
--health-retries 5
|
||||||
ports:
|
ports:
|
||||||
- 80
|
- 5432:5432
|
||||||
steps:
|
steps:
|
||||||
- name: Echo the Postgres service ID / Network / Ports
|
- name: Echo the Postgres service ID / Network / Ports
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
7
act/runner/testdata/shells/pwsh/push.yml
vendored
7
act/runner/testdata/shells/pwsh/push.yml
vendored
@@ -8,6 +8,13 @@ jobs:
|
|||||||
- shell: ${{ env.MY_SHELL }}
|
- shell: ${{ env.MY_SHELL }}
|
||||||
run: |
|
run: |
|
||||||
$PSVersionTable
|
$PSVersionTable
|
||||||
|
check-container:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
container: catthehacker/ubuntu:pwsh-latest
|
||||||
|
steps:
|
||||||
|
- shell: ${{ env.MY_SHELL }}
|
||||||
|
run: |
|
||||||
|
$PSVersionTable
|
||||||
check-job-default:
|
check-job-default:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
defaults:
|
defaults:
|
||||||
|
|||||||
28
act/runner/testdata/shells/python/push.yml
vendored
Normal file
28
act/runner/testdata/shells/python/push.yml
vendored
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
on: push
|
||||||
|
env:
|
||||||
|
MY_SHELL: python
|
||||||
|
jobs:
|
||||||
|
check:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- shell: ${{ env.MY_SHELL }}
|
||||||
|
run: |
|
||||||
|
import platform
|
||||||
|
print(platform.python_version())
|
||||||
|
check-container:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
container: node:24-bookworm
|
||||||
|
steps:
|
||||||
|
- shell: ${{ env.MY_SHELL }}
|
||||||
|
run: |
|
||||||
|
import platform
|
||||||
|
print(platform.python_version())
|
||||||
|
check-job-default:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
shell: ${{ env.MY_SHELL }}
|
||||||
|
steps:
|
||||||
|
- run: |
|
||||||
|
import platform
|
||||||
|
print(platform.python_version())
|
||||||
7
act/runner/testdata/uses-action-with-pre-and-post-step/last-action/action.yml
vendored
Normal file
7
act/runner/testdata/uses-action-with-pre-and-post-step/last-action/action.yml
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
name: "last action check"
|
||||||
|
description: "last action check"
|
||||||
|
|
||||||
|
runs:
|
||||||
|
using: "node24"
|
||||||
|
main: main.js
|
||||||
|
post: post.js
|
||||||
0
act/runner/testdata/uses-action-with-pre-and-post-step/last-action/main.js
vendored
Normal file
0
act/runner/testdata/uses-action-with-pre-and-post-step/last-action/main.js
vendored
Normal file
17
act/runner/testdata/uses-action-with-pre-and-post-step/last-action/post.js
vendored
Normal file
17
act/runner/testdata/uses-action-with-pre-and-post-step/last-action/post.js
vendored
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
const pre = process.env['ACTION_OUTPUT_PRE'];
|
||||||
|
const main = process.env['ACTION_OUTPUT_MAIN'];
|
||||||
|
const post = process.env['ACTION_OUTPUT_POST'];
|
||||||
|
|
||||||
|
console.log({pre, main, post});
|
||||||
|
|
||||||
|
if (pre !== 'pre') {
|
||||||
|
throw new Error(`Expected 'pre' but got '${pre}'`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (main !== 'main') {
|
||||||
|
throw new Error(`Expected 'main' but got '${main}'`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (post !== 'post') {
|
||||||
|
throw new Error(`Expected 'post' but got '${post}'`);
|
||||||
|
}
|
||||||
15
act/runner/testdata/uses-action-with-pre-and-post-step/push.yml
vendored
Normal file
15
act/runner/testdata/uses-action-with-pre-and-post-step/push.yml
vendored
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
name: uses-action-with-pre-and-post-step
|
||||||
|
on: push
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v2
|
||||||
|
- uses: ./uses-action-with-pre-and-post-step/last-action
|
||||||
|
- uses: nektos/act-test-actions/js-with-pre-and-post-step@main
|
||||||
|
with:
|
||||||
|
pre: true
|
||||||
|
post: true
|
||||||
|
- run: |
|
||||||
|
cat $GITHUB_ENV
|
||||||
7
act/runner/testdata/uses-github-full-sha/main.yml
vendored
Normal file
7
act/runner/testdata/uses-github-full-sha/main.yml
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
name: uses-github-root
|
||||||
|
on: push
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/hello-world-docker-action@b136eb8894c5cb1dd5807da824be97ccdf9b5423
|
||||||
7
act/runner/testdata/uses-github-path/push.yml
vendored
Normal file
7
act/runner/testdata/uses-github-path/push.yml
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
name: uses-github-path
|
||||||
|
on: push
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: sergioramos/yarn-actions/install@v6
|
||||||
7
act/runner/testdata/uses-github-short-sha/main.yml
vendored
Normal file
7
act/runner/testdata/uses-github-short-sha/main.yml
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
name: uses-github-root
|
||||||
|
on: push
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/hello-world-docker-action@b136eb8
|
||||||
63
act/runner/testdata/uses-nested-composite/composite_action2/action.yml
vendored
Normal file
63
act/runner/testdata/uses-nested-composite/composite_action2/action.yml
vendored
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
---
|
||||||
|
name: "Test Composite Action"
|
||||||
|
description: "Test action uses composite"
|
||||||
|
|
||||||
|
inputs:
|
||||||
|
test_input_optional:
|
||||||
|
description: Test
|
||||||
|
|
||||||
|
runs:
|
||||||
|
using: "composite"
|
||||||
|
steps:
|
||||||
|
- uses: actions/setup-node@v6
|
||||||
|
with:
|
||||||
|
node-version: '24'
|
||||||
|
- run: |
|
||||||
|
console.log(process.version);
|
||||||
|
console.log("Hi from node");
|
||||||
|
console.log("${{ inputs.test_input_optional }}");
|
||||||
|
if("${{ inputs.test_input_optional }}" !== "Test") {
|
||||||
|
console.log("Invalid input test_input_optional expected \"Test\" as value");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
if(!process.version.startsWith('v16')) {
|
||||||
|
console.log("Expected node v16, but got " + process.version);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
shell: node {0}
|
||||||
|
- uses: ./uses-composite/composite_action
|
||||||
|
id: composite
|
||||||
|
with:
|
||||||
|
test_input_required: 'test_input_required_value'
|
||||||
|
test_input_optional: 'test_input_optional_value'
|
||||||
|
test_input_optional_with_default_overriden: 'test_input_optional_with_default_overriden'
|
||||||
|
test_input_required_with_default: 'test_input_optional_value'
|
||||||
|
test_input_required_with_default_overriden: 'test_input_required_with_default_overriden'
|
||||||
|
secret_input: ${{inputs.test_input_optional}}
|
||||||
|
env:
|
||||||
|
secret_input: ${{inputs.test_input_optional}}
|
||||||
|
- run: |
|
||||||
|
echo "steps.composite.outputs.test_output=${{ steps.composite.outputs.test_output }}"
|
||||||
|
[[ "${{steps.composite.outputs.test_output == 'test_output_value'}}" = "true" ]] || exit 1
|
||||||
|
shell: bash
|
||||||
|
- run: |
|
||||||
|
echo "steps.composite.outputs.secret_output=${{ steps.composite.outputs.secret_output }}"
|
||||||
|
[[ "${{steps.composite.outputs.secret_output == format('{0}/{0}', inputs.test_input_optional)}}" = "true" ]] || exit 1
|
||||||
|
shell: bash
|
||||||
|
# Now test again with default values
|
||||||
|
- name: ./uses-composite/composite_action with defaults
|
||||||
|
uses: ./uses-composite/composite_action
|
||||||
|
id: composite2
|
||||||
|
with:
|
||||||
|
test_input_required: 'test_input_required_value'
|
||||||
|
test_input_optional_with_default_overriden: 'test_input_optional_with_default_overriden'
|
||||||
|
test_input_required_with_default_overriden: 'test_input_required_with_default_overriden'
|
||||||
|
|
||||||
|
- run: |
|
||||||
|
echo "steps.composite2.outputs.test_output=${{ steps.composite2.outputs.test_output }}"
|
||||||
|
[[ "${{steps.composite2.outputs.test_output == 'test_output_value'}}" = "true" ]] || exit 1
|
||||||
|
shell: bash
|
||||||
|
- run: |
|
||||||
|
echo "steps.composite.outputs.secret_output=$COMPOSITE_ACTION_ENV_OUTPUT"
|
||||||
|
[[ "${{env.COMPOSITE_ACTION_ENV_OUTPUT == 'my test value' }}" = "true" ]] || exit 1
|
||||||
|
shell: bash
|
||||||
15
act/runner/testdata/uses-nested-composite/push.yml
vendored
Normal file
15
act/runner/testdata/uses-nested-composite/push.yml
vendored
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
name: uses-docker-url
|
||||||
|
on: push
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v2
|
||||||
|
- uses: ./uses-nested-composite/composite_action2
|
||||||
|
with:
|
||||||
|
test_input_optional: Test
|
||||||
|
- run: |
|
||||||
|
echo "steps.composite.outputs.secret_output=$COMPOSITE_ACTION_ENV_OUTPUT"
|
||||||
|
[[ "${{env.COMPOSITE_ACTION_ENV_OUTPUT == 'my test value' }}" = "true" ]] || exit 1
|
||||||
|
shell: bash
|
||||||
42
act/runner/testdata/uses-workflow/local-workflow.yml
vendored
Normal file
42
act/runner/testdata/uses-workflow/local-workflow.yml
vendored
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
name: local-reusable-workflows
|
||||||
|
on: pull_request
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
reusable-workflow:
|
||||||
|
uses: ./.github/workflows/local-reusable-workflow.yml
|
||||||
|
with:
|
||||||
|
string_required: string
|
||||||
|
bool_required: ${{ true }}
|
||||||
|
number_required: 1
|
||||||
|
secrets:
|
||||||
|
secret: keep_it_private
|
||||||
|
|
||||||
|
reusable-workflow-with-inherited-secrets:
|
||||||
|
uses: ./.github/workflows/local-reusable-workflow.yml
|
||||||
|
with:
|
||||||
|
string_required: string
|
||||||
|
bool_required: ${{ true }}
|
||||||
|
number_required: 1
|
||||||
|
secrets: inherit
|
||||||
|
|
||||||
|
reusable-workflow-with-on-string-notation:
|
||||||
|
uses: ./.github/workflows/local-reusable-workflow-no-inputs-string.yml
|
||||||
|
|
||||||
|
reusable-workflow-with-on-array-notation:
|
||||||
|
uses: ./.github/workflows/local-reusable-workflow-no-inputs-array.yml
|
||||||
|
|
||||||
|
output-test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs:
|
||||||
|
- reusable-workflow
|
||||||
|
- reusable-workflow-with-inherited-secrets
|
||||||
|
steps:
|
||||||
|
- name: output with secrets map
|
||||||
|
run: |
|
||||||
|
echo reusable-workflow.output=${{ needs.reusable-workflow.outputs.output }}
|
||||||
|
[[ "${{ needs.reusable-workflow.outputs.output == 'string' }}" = "true" ]] || exit 1
|
||||||
|
|
||||||
|
- name: output with inherited secrets
|
||||||
|
run: |
|
||||||
|
echo reusable-workflow-with-inherited-secrets.output=${{ needs.reusable-workflow-with-inherited-secrets.outputs.output }}
|
||||||
|
[[ "${{ needs.reusable-workflow-with-inherited-secrets.outputs.output == 'string' }}" = "true" ]] || exit 1
|
||||||
18
act/runner/testdata/uses-workflow/push.yml
vendored
18
act/runner/testdata/uses-workflow/push.yml
vendored
@@ -1,11 +1,8 @@
|
|||||||
on: push
|
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:
|
jobs:
|
||||||
reusable-workflow:
|
reusable-workflow:
|
||||||
uses: ./.github/workflows/local-reusable-workflow.yml
|
uses: nektos/act-test-actions/.github/workflows/reusable-workflow.yml@main
|
||||||
with:
|
with:
|
||||||
string_required: string
|
string_required: string
|
||||||
bool_required: ${{ true }}
|
bool_required: ${{ true }}
|
||||||
@@ -14,7 +11,7 @@ jobs:
|
|||||||
secret: keep_it_private
|
secret: keep_it_private
|
||||||
|
|
||||||
reusable-workflow-with-inherited-secrets:
|
reusable-workflow-with-inherited-secrets:
|
||||||
uses: ./.github/workflows/local-reusable-workflow.yml
|
uses: nektos/act-test-actions/.github/workflows/reusable-workflow.yml@main
|
||||||
with:
|
with:
|
||||||
string_required: string
|
string_required: string
|
||||||
bool_required: ${{ true }}
|
bool_required: ${{ true }}
|
||||||
@@ -27,5 +24,12 @@ jobs:
|
|||||||
- reusable-workflow
|
- reusable-workflow
|
||||||
- reusable-workflow-with-inherited-secrets
|
- reusable-workflow-with-inherited-secrets
|
||||||
steps:
|
steps:
|
||||||
- run: '[[ "${{ needs.reusable-workflow.outputs.output == ''string'' }}" = "true" ]] || exit 1'
|
- name: output with secrets map
|
||||||
- run: '[[ "${{ needs.reusable-workflow-with-inherited-secrets.outputs.output == ''string'' }}" = "true" ]] || exit 1'
|
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
|
||||||
|
|||||||
8
go.mod
8
go.mod
@@ -3,15 +3,15 @@ module gitea.com/gitea/runner
|
|||||||
go 1.26.0
|
go 1.26.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
code.gitea.io/actions-proto-go v0.4.1
|
||||||
connectrpc.com/connect v1.20.0
|
connectrpc.com/connect v1.20.0
|
||||||
dario.cat/mergo v1.0.2
|
dario.cat/mergo v1.0.2
|
||||||
gitea.dev/actions-proto-go v0.5.0
|
|
||||||
github.com/Masterminds/semver v1.5.0
|
github.com/Masterminds/semver v1.5.0
|
||||||
github.com/avast/retry-go/v5 v5.0.0
|
github.com/avast/retry-go/v5 v5.0.0
|
||||||
github.com/containerd/errdefs v1.0.0
|
github.com/containerd/errdefs v1.0.0
|
||||||
github.com/creack/pty v1.1.24
|
github.com/creack/pty v1.1.24
|
||||||
github.com/distribution/reference v0.6.0
|
github.com/distribution/reference v0.6.0
|
||||||
github.com/docker/cli v29.5.3+incompatible
|
github.com/docker/cli v29.5.2+incompatible
|
||||||
github.com/docker/go-connections v0.7.0
|
github.com/docker/go-connections v0.7.0
|
||||||
github.com/go-git/go-billy/v5 v5.9.0
|
github.com/go-git/go-billy/v5 v5.9.0
|
||||||
github.com/go-git/go-git/v5 v5.19.1
|
github.com/go-git/go-git/v5 v5.19.1
|
||||||
@@ -26,7 +26,7 @@ require (
|
|||||||
github.com/moby/moby/client v0.4.1
|
github.com/moby/moby/client v0.4.1
|
||||||
github.com/moby/patternmatcher v0.6.1
|
github.com/moby/patternmatcher v0.6.1
|
||||||
github.com/opencontainers/image-spec v1.1.1
|
github.com/opencontainers/image-spec v1.1.1
|
||||||
github.com/opencontainers/selinux v1.15.1
|
github.com/opencontainers/selinux v1.15.0
|
||||||
github.com/pkg/errors v0.9.1
|
github.com/pkg/errors v0.9.1
|
||||||
github.com/prometheus/client_golang v1.23.2
|
github.com/prometheus/client_golang v1.23.2
|
||||||
github.com/rhysd/actionlint v1.7.12
|
github.com/rhysd/actionlint v1.7.12
|
||||||
@@ -37,7 +37,6 @@ require (
|
|||||||
github.com/timshannon/bolthold v0.0.0-20240314194003-30aac6950928
|
github.com/timshannon/bolthold v0.0.0-20240314194003-30aac6950928
|
||||||
go.etcd.io/bbolt v1.4.3
|
go.etcd.io/bbolt v1.4.3
|
||||||
go.yaml.in/yaml/v4 v4.0.0-rc.3
|
go.yaml.in/yaml/v4 v4.0.0-rc.3
|
||||||
golang.org/x/sys v0.46.0
|
|
||||||
golang.org/x/term v0.43.0
|
golang.org/x/term v0.43.0
|
||||||
google.golang.org/protobuf v1.36.11
|
google.golang.org/protobuf v1.36.11
|
||||||
gotest.tools/v3 v3.5.2
|
gotest.tools/v3 v3.5.2
|
||||||
@@ -107,6 +106,7 @@ require (
|
|||||||
golang.org/x/crypto v0.50.0 // indirect
|
golang.org/x/crypto v0.50.0 // indirect
|
||||||
golang.org/x/net v0.53.0 // indirect
|
golang.org/x/net v0.53.0 // indirect
|
||||||
golang.org/x/sync v0.20.0 // indirect
|
golang.org/x/sync v0.20.0 // indirect
|
||||||
|
golang.org/x/sys v0.44.0 // indirect
|
||||||
gopkg.in/warnings.v0 v0.1.2 // indirect
|
gopkg.in/warnings.v0 v0.1.2 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
16
go.sum
16
go.sum
@@ -1,11 +1,13 @@
|
|||||||
|
code.gitea.io/actions-proto-go v0.4.1 h1:l0EYhjsgpUe/1VABo2eK7zcoNX2W44WOnb0MSLrKfls=
|
||||||
|
code.gitea.io/actions-proto-go v0.4.1/go.mod h1:mn7Wkqz6JbnTOHQpot3yDeHx+O5C9EGhMEE+htvHBas=
|
||||||
|
connectrpc.com/connect v1.19.2 h1:McQ83FGdzL+t60peksi0gXC7MQ/iLKgLduAnThbM0mo=
|
||||||
|
connectrpc.com/connect v1.19.2/go.mod h1:tN20fjdGlewnSFeZxLKb0xwIZ6ozc3OQs2hTXy4du9w=
|
||||||
connectrpc.com/connect v1.20.0 h1:6TNDAB+WeNd2uolWNlYczB5E0KNNaVMNUEx8JEUsPmQ=
|
connectrpc.com/connect v1.20.0 h1:6TNDAB+WeNd2uolWNlYczB5E0KNNaVMNUEx8JEUsPmQ=
|
||||||
connectrpc.com/connect v1.20.0/go.mod h1:A2ygJrukXwWy32vkCAAHNVguZrqZ+jeZ9rGRnGR4dN4=
|
connectrpc.com/connect v1.20.0/go.mod h1:A2ygJrukXwWy32vkCAAHNVguZrqZ+jeZ9rGRnGR4dN4=
|
||||||
cyphar.com/go-pathrs v0.2.3 h1:0pH8gep37wB0BgaXrEaN1OtZhUMeS7VvaejSr6i822o=
|
cyphar.com/go-pathrs v0.2.3 h1:0pH8gep37wB0BgaXrEaN1OtZhUMeS7VvaejSr6i822o=
|
||||||
cyphar.com/go-pathrs v0.2.3/go.mod h1:y8f1EMG7r+hCuFf/rXsKqMJrJAUoADZGNh5/vZPKcGc=
|
cyphar.com/go-pathrs v0.2.3/go.mod h1:y8f1EMG7r+hCuFf/rXsKqMJrJAUoADZGNh5/vZPKcGc=
|
||||||
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
|
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
|
||||||
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
|
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
|
||||||
gitea.dev/actions-proto-go v0.5.0 h1:Fc3DI4Fm3B3JBRXFUjegql+usoNAjjAw1cxMansfA2I=
|
|
||||||
gitea.dev/actions-proto-go v0.5.0/go.mod h1:p4RX+D9oqiEEzzkPMXscw2CmaGuYFPWFc6xIOmDNDqs=
|
|
||||||
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk=
|
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk=
|
||||||
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8=
|
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8=
|
||||||
github.com/Masterminds/semver v1.5.0 h1:H65muMkzWKEuNDnfl9d70GUjFniHKHRbFPGBuZ3QEww=
|
github.com/Masterminds/semver v1.5.0 h1:H65muMkzWKEuNDnfl9d70GUjFniHKHRbFPGBuZ3QEww=
|
||||||
@@ -49,8 +51,6 @@ github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5Qvfr
|
|||||||
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
|
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
|
||||||
github.com/docker/cli v29.5.2+incompatible h1:ubykJ1Y8LmNRGJ2BuMQ0kHOt/RO1YzGNswqWMJgivuQ=
|
github.com/docker/cli v29.5.2+incompatible h1:ubykJ1Y8LmNRGJ2BuMQ0kHOt/RO1YzGNswqWMJgivuQ=
|
||||||
github.com/docker/cli v29.5.2+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
|
github.com/docker/cli v29.5.2+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
|
||||||
github.com/docker/cli v29.5.3+incompatible h1:nbEFfz774vBwQ5KRYv7c/AghjReqnGISvrRhzjV0evs=
|
|
||||||
github.com/docker/cli v29.5.3+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
|
|
||||||
github.com/docker/docker-credential-helpers v0.9.6 h1:cT2PbRPSlnMmNTfT2TDMXRyQ1KMWHG7xoTLBcn1ZNv0=
|
github.com/docker/docker-credential-helpers v0.9.6 h1:cT2PbRPSlnMmNTfT2TDMXRyQ1KMWHG7xoTLBcn1ZNv0=
|
||||||
github.com/docker/docker-credential-helpers v0.9.6/go.mod h1:v1S+hepowrQXITkEfw6o4+BMbGot02wiKpzWhGUZK6c=
|
github.com/docker/docker-credential-helpers v0.9.6/go.mod h1:v1S+hepowrQXITkEfw6o4+BMbGot02wiKpzWhGUZK6c=
|
||||||
github.com/docker/go-connections v0.7.0 h1:6SsRfJddP22WMrCkj19x9WKjEDTB+ahsdiGYf0mN39c=
|
github.com/docker/go-connections v0.7.0 h1:6SsRfJddP22WMrCkj19x9WKjEDTB+ahsdiGYf0mN39c=
|
||||||
@@ -149,10 +149,10 @@ github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8
|
|||||||
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
|
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
|
||||||
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
|
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
|
||||||
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
|
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
|
||||||
|
github.com/opencontainers/selinux v1.14.1 h1:a7XlXV/nN/l5zFP1FWZYoExpClu1QOPMfWUV2CZ8kEQ=
|
||||||
|
github.com/opencontainers/selinux v1.14.1/go.mod h1:LenyElirjUHszfxrjuFqC85HIeXZKumHcKMQtnaDlQQ=
|
||||||
github.com/opencontainers/selinux v1.15.0 h1:4Gs40e/R2FvM8PC1HPaPncLLaDor8Y2WDfk5gjU9o5M=
|
github.com/opencontainers/selinux v1.15.0 h1:4Gs40e/R2FvM8PC1HPaPncLLaDor8Y2WDfk5gjU9o5M=
|
||||||
github.com/opencontainers/selinux v1.15.0/go.mod h1:LenyElirjUHszfxrjuFqC85HIeXZKumHcKMQtnaDlQQ=
|
github.com/opencontainers/selinux v1.15.0/go.mod h1:LenyElirjUHszfxrjuFqC85HIeXZKumHcKMQtnaDlQQ=
|
||||||
github.com/opencontainers/selinux v1.15.1 h1:ERxeh5caJvCzNAKdI8WQbJmB1LDTn4BuaAg8wihLBpA=
|
|
||||||
github.com/opencontainers/selinux v1.15.1/go.mod h1:LenyElirjUHszfxrjuFqC85HIeXZKumHcKMQtnaDlQQ=
|
|
||||||
github.com/pjbgf/sha1cd v0.6.0 h1:3WJ8Wz8gvDz29quX1OcEmkAlUg9diU4GxJHqs0/XiwU=
|
github.com/pjbgf/sha1cd v0.6.0 h1:3WJ8Wz8gvDz29quX1OcEmkAlUg9diU4GxJHqs0/XiwU=
|
||||||
github.com/pjbgf/sha1cd v0.6.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM=
|
github.com/pjbgf/sha1cd v0.6.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM=
|
||||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||||
@@ -256,10 +256,6 @@ golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|||||||
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
|
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
|
||||||
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
|
|
||||||
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
|
||||||
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
|
|
||||||
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
|
||||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
|
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
|
||||||
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
|
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
|
||||||
|
|||||||
@@ -148,7 +148,6 @@ func runDaemon(ctx context.Context, daemArgs *daemonArgs, configFile *string) fu
|
|||||||
log.Infof("runner: %s, with version: %s, with labels: %v, declare successfully",
|
log.Infof("runner: %s, with version: %s, with labels: %v, declare successfully",
|
||||||
resp.Msg.Runner.Name, resp.Msg.Runner.Version, resp.Msg.Runner.Labels)
|
resp.Msg.Runner.Name, resp.Msg.Runner.Version, resp.Msg.Runner.Labels)
|
||||||
}
|
}
|
||||||
runner.SetCapabilitiesFromDeclare(resp)
|
|
||||||
|
|
||||||
if cfg.Metrics.Enabled {
|
if cfg.Metrics.Enabled {
|
||||||
metrics.Init()
|
metrics.Init()
|
||||||
|
|||||||
@@ -19,9 +19,9 @@ import (
|
|||||||
"gitea.com/gitea/runner/internal/pkg/labels"
|
"gitea.com/gitea/runner/internal/pkg/labels"
|
||||||
"gitea.com/gitea/runner/internal/pkg/ver"
|
"gitea.com/gitea/runner/internal/pkg/ver"
|
||||||
|
|
||||||
|
pingv1 "code.gitea.io/actions-proto-go/ping/v1"
|
||||||
|
runnerv1 "code.gitea.io/actions-proto-go/runner/v1"
|
||||||
"connectrpc.com/connect"
|
"connectrpc.com/connect"
|
||||||
pingv1 "gitea.dev/actions-proto-go/ping/v1"
|
|
||||||
runnerv1 "gitea.dev/actions-proto-go/runner/v1"
|
|
||||||
"github.com/mattn/go-isatty"
|
"github.com/mattn/go-isatty"
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
|||||||
@@ -16,8 +16,8 @@ import (
|
|||||||
"gitea.com/gitea/runner/internal/pkg/config"
|
"gitea.com/gitea/runner/internal/pkg/config"
|
||||||
"gitea.com/gitea/runner/internal/pkg/metrics"
|
"gitea.com/gitea/runner/internal/pkg/metrics"
|
||||||
|
|
||||||
|
runnerv1 "code.gitea.io/actions-proto-go/runner/v1"
|
||||||
"connectrpc.com/connect"
|
"connectrpc.com/connect"
|
||||||
runnerv1 "gitea.dev/actions-proto-go/runner/v1"
|
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ import (
|
|||||||
"gitea.com/gitea/runner/internal/pkg/client/mocks"
|
"gitea.com/gitea/runner/internal/pkg/client/mocks"
|
||||||
"gitea.com/gitea/runner/internal/pkg/config"
|
"gitea.com/gitea/runner/internal/pkg/config"
|
||||||
|
|
||||||
|
runnerv1 "code.gitea.io/actions-proto-go/runner/v1"
|
||||||
connect_go "connectrpc.com/connect"
|
connect_go "connectrpc.com/connect"
|
||||||
runnerv1 "gitea.dev/actions-proto-go/runner/v1"
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/mock"
|
"github.com/stretchr/testify/mock"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
|||||||
@@ -31,8 +31,8 @@ import (
|
|||||||
"gitea.com/gitea/runner/internal/pkg/report"
|
"gitea.com/gitea/runner/internal/pkg/report"
|
||||||
"gitea.com/gitea/runner/internal/pkg/ver"
|
"gitea.com/gitea/runner/internal/pkg/ver"
|
||||||
|
|
||||||
|
runnerv1 "code.gitea.io/actions-proto-go/runner/v1"
|
||||||
"connectrpc.com/connect"
|
"connectrpc.com/connect"
|
||||||
runnerv1 "gitea.dev/actions-proto-go/runner/v1"
|
|
||||||
"github.com/moby/moby/api/types/container"
|
"github.com/moby/moby/api/types/container"
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
)
|
)
|
||||||
@@ -47,7 +47,6 @@ type Runner struct {
|
|||||||
labels labels.Labels
|
labels labels.Labels
|
||||||
envs map[string]string
|
envs map[string]string
|
||||||
cacheHandler *artifactcache.Handler
|
cacheHandler *artifactcache.Handler
|
||||||
capabilities string
|
|
||||||
|
|
||||||
runningTasks sync.Map
|
runningTasks sync.Map
|
||||||
runningCount atomic.Int64
|
runningCount atomic.Int64
|
||||||
@@ -186,14 +185,6 @@ func (r *Runner) cleanupStaleTaskDirs(ctx context.Context, workdirRoot string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Runner) SetCapabilitiesFromDeclare(resp *connect.Response[runnerv1.DeclareResponse]) {
|
|
||||||
if resp == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// Capability negotiation is done via response headers to avoid a hard proto bump.
|
|
||||||
r.capabilities = strings.TrimSpace(resp.Header().Get("X-Gitea-Actions-Capabilities"))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *Runner) Run(ctx context.Context, task *runnerv1.Task) error {
|
func (r *Runner) Run(ctx context.Context, task *runnerv1.Task) error {
|
||||||
if _, ok := r.runningTasks.Load(task.Id); ok {
|
if _, ok := r.runningTasks.Load(task.Id); ok {
|
||||||
return fmt.Errorf("task %d is already running", task.Id)
|
return fmt.Errorf("task %d is already running", task.Id)
|
||||||
@@ -228,10 +219,9 @@ func (r *Runner) Run(ctx context.Context, task *runnerv1.Task) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (r *Runner) cloneEnvs() map[string]string {
|
func (r *Runner) cloneEnvs() map[string]string {
|
||||||
// Reserve space for the per-task keys injected by run():
|
// +3 reserves space for the per-task keys injected by run():
|
||||||
// ACTIONS_ID_TOKEN_REQUEST_URL, ACTIONS_ID_TOKEN_REQUEST_TOKEN, ACTIONS_RUNTIME_TOKEN,
|
// ACTIONS_ID_TOKEN_REQUEST_URL, ACTIONS_ID_TOKEN_REQUEST_TOKEN, ACTIONS_RUNTIME_TOKEN.
|
||||||
// GITEA_ACTIONS_CAPABILITIES, GITEA_RUN_ID.
|
envs := make(map[string]string, len(r.envs)+3)
|
||||||
envs := make(map[string]string, len(r.envs)+5)
|
|
||||||
maps.Copy(envs, r.envs)
|
maps.Copy(envs, r.envs)
|
||||||
return envs
|
return envs
|
||||||
}
|
}
|
||||||
@@ -271,13 +261,6 @@ func (r *Runner) run(ctx context.Context, task *runnerv1.Task, reporter *report.
|
|||||||
taskContext := task.Context.Fields
|
taskContext := task.Context.Fields
|
||||||
envs := r.cloneEnvs()
|
envs := r.cloneEnvs()
|
||||||
|
|
||||||
if r.capabilities != "" {
|
|
||||||
envs["GITEA_ACTIONS_CAPABILITIES"] = r.capabilities
|
|
||||||
}
|
|
||||||
if v := taskContext["run_id"].GetStringValue(); v != "" {
|
|
||||||
envs["GITEA_RUN_ID"] = v
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Infof("task %v repo is %v %v %v", task.Id, taskContext["repository"].GetStringValue(),
|
log.Infof("task %v repo is %v %v %v", task.Id, taskContext["repository"].GetStringValue(),
|
||||||
r.getDefaultActionsURL(task),
|
r.getDefaultActionsURL(task),
|
||||||
r.client.Address())
|
r.client.Address())
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import (
|
|||||||
|
|
||||||
"gitea.com/gitea/runner/act/model"
|
"gitea.com/gitea/runner/act/model"
|
||||||
|
|
||||||
runnerv1 "gitea.dev/actions-proto-go/runner/v1"
|
runnerv1 "code.gitea.io/actions-proto-go/runner/v1"
|
||||||
"go.yaml.in/yaml/v4"
|
"go.yaml.in/yaml/v4"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import (
|
|||||||
|
|
||||||
"gitea.com/gitea/runner/act/model"
|
"gitea.com/gitea/runner/act/model"
|
||||||
|
|
||||||
runnerv1 "gitea.dev/actions-proto-go/runner/v1"
|
runnerv1 "code.gitea.io/actions-proto-go/runner/v1"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"go.yaml.in/yaml/v4"
|
"go.yaml.in/yaml/v4"
|
||||||
"gotest.tools/v3/assert"
|
"gotest.tools/v3/assert"
|
||||||
|
|||||||
@@ -4,8 +4,8 @@
|
|||||||
package client
|
package client
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"gitea.dev/actions-proto-go/ping/v1/pingv1connect"
|
"code.gitea.io/actions-proto-go/ping/v1/pingv1connect"
|
||||||
"gitea.dev/actions-proto-go/runner/v1/runnerv1connect"
|
"code.gitea.io/actions-proto-go/runner/v1/runnerv1connect"
|
||||||
)
|
)
|
||||||
|
|
||||||
// A Client manages communication with the runner.
|
// A Client manages communication with the runner.
|
||||||
|
|||||||
@@ -10,9 +10,9 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"code.gitea.io/actions-proto-go/ping/v1/pingv1connect"
|
||||||
|
"code.gitea.io/actions-proto-go/runner/v1/runnerv1connect"
|
||||||
"connectrpc.com/connect"
|
"connectrpc.com/connect"
|
||||||
"gitea.dev/actions-proto-go/ping/v1/pingv1connect"
|
|
||||||
"gitea.dev/actions-proto-go/runner/v1/runnerv1connect"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func getHTTPClient(endpoint string, insecure bool) *http.Client {
|
func getHTTPClient(endpoint string, insecure bool) *http.Client {
|
||||||
|
|||||||
@@ -9,9 +9,9 @@ import (
|
|||||||
|
|
||||||
mock "github.com/stretchr/testify/mock"
|
mock "github.com/stretchr/testify/mock"
|
||||||
|
|
||||||
pingv1 "gitea.dev/actions-proto-go/ping/v1"
|
pingv1 "code.gitea.io/actions-proto-go/ping/v1"
|
||||||
|
|
||||||
runnerv1 "gitea.dev/actions-proto-go/runner/v1"
|
runnerv1 "code.gitea.io/actions-proto-go/runner/v1"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Client is an autogenerated mock type for the Client type
|
// Client is an autogenerated mock type for the Client type
|
||||||
|
|||||||
@@ -60,9 +60,6 @@ runner:
|
|||||||
# The interval for reporting task state (step status, timing) to the Gitea instance.
|
# The interval for reporting task state (step status, timing) to the Gitea instance.
|
||||||
# State is also reported immediately on step transitions (start/stop).
|
# State is also reported immediately on step transitions (start/stop).
|
||||||
state_report_interval: 5s
|
state_report_interval: 5s
|
||||||
# Per-attempt deadline for flushing the final logs and task state when a job
|
|
||||||
# finishes, on a detached context so a server cancel can't block the acknowledgement.
|
|
||||||
report_close_timeout: 10s
|
|
||||||
# The github_mirror of a runner is used to specify the mirror address of the github that pulls the action repository.
|
# The github_mirror of a runner is used to specify the mirror address of the github that pulls the action repository.
|
||||||
# It works when something like `uses: actions/checkout@v4` is used and DEFAULT_ACTIONS_URL is set to github,
|
# It works when something like `uses: actions/checkout@v4` is used and DEFAULT_ACTIONS_URL is set to github,
|
||||||
# and github_mirror is not empty. In this case,
|
# and github_mirror is not empty. In this case,
|
||||||
|
|||||||
@@ -39,7 +39,6 @@ type Runner struct {
|
|||||||
LogReportMaxLatency time.Duration `yaml:"log_report_max_latency"` // LogReportMaxLatency specifies the max time a log row can wait before being sent.
|
LogReportMaxLatency time.Duration `yaml:"log_report_max_latency"` // LogReportMaxLatency specifies the max time a log row can wait before being sent.
|
||||||
LogReportBatchSize int `yaml:"log_report_batch_size"` // LogReportBatchSize triggers immediate log flush when buffer reaches this size.
|
LogReportBatchSize int `yaml:"log_report_batch_size"` // LogReportBatchSize triggers immediate log flush when buffer reaches this size.
|
||||||
StateReportInterval time.Duration `yaml:"state_report_interval"` // StateReportInterval specifies the interval for state reporting.
|
StateReportInterval time.Duration `yaml:"state_report_interval"` // StateReportInterval specifies the interval for state reporting.
|
||||||
ReportCloseTimeout time.Duration `yaml:"report_close_timeout"` // ReportCloseTimeout caps each RPC attempt when flushing the final logs and task state at job completion, on a detached context so a server cancel can't block the acknowledgement.
|
|
||||||
Labels []string `yaml:"labels"` // Labels specify the labels of the runner. Labels are declared on each startup
|
Labels []string `yaml:"labels"` // Labels specify the labels of the runner. Labels are declared on each startup
|
||||||
GithubMirror string `yaml:"github_mirror"` // GithubMirror defines what mirrors should be used when using github
|
GithubMirror string `yaml:"github_mirror"` // GithubMirror defines what mirrors should be used when using github
|
||||||
AllocatePTY bool `yaml:"allocate_pty"` // AllocatePTY allocates a pseudo-TTY for each step's process. Default is false, matching GitHub's actions/runner. Enable only for jobs that need an interactive terminal; tools like docker build emit redrawing progress frames into the captured log when a TTY is present. Applies to both host and docker backends.
|
AllocatePTY bool `yaml:"allocate_pty"` // AllocatePTY allocates a pseudo-TTY for each step's process. Default is false, matching GitHub's actions/runner. Enable only for jobs that need an interactive terminal; tools like docker build emit redrawing progress frames into the captured log when a TTY is present. Applies to both host and docker backends.
|
||||||
@@ -184,9 +183,6 @@ func LoadDefault(file string) (*Config, error) {
|
|||||||
if cfg.Runner.StateReportInterval <= 0 {
|
if cfg.Runner.StateReportInterval <= 0 {
|
||||||
cfg.Runner.StateReportInterval = 5 * time.Second
|
cfg.Runner.StateReportInterval = 5 * time.Second
|
||||||
}
|
}
|
||||||
if cfg.Runner.ReportCloseTimeout <= 0 {
|
|
||||||
cfg.Runner.ReportCloseTimeout = 10 * time.Second
|
|
||||||
}
|
|
||||||
if cfg.Metrics.Addr == "" {
|
if cfg.Metrics.Addr == "" {
|
||||||
cfg.Metrics.Addr = "127.0.0.1:9101"
|
cfg.Metrics.Addr = "127.0.0.1:9101"
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user