mirror of
https://gitea.com/gitea/runner.git
synced 2026-11-05 14:55:44 +00:00
Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
de9db9c0fa | ||
|
|
d1da6ffa52 | ||
|
|
880e0a148a | ||
|
|
2db24c7401 | ||
|
|
1745c7c841 | ||
|
|
7f53eca716 | ||
|
|
fca6b44c60 | ||
|
|
b9018aca31 | ||
|
|
6c77065295 | ||
|
|
235c035003 | ||
|
|
12dc9d26a2 |
No files matched your search
@@ -30,7 +30,7 @@ jobs:
|
|||||||
# daemon retains them between runs, so this is usually a fast manifest re-check.
|
# daemon retains them between runs, so this is usually a fast manifest re-check.
|
||||||
- name: pre-pull test images
|
- name: pre-pull test images
|
||||||
env:
|
env:
|
||||||
TEST_JOB_IMAGE: node:24-bookworm-slim@sha256:3638d9a6fe4030bd716be989438248074489337ba3275657f93595428be4fc03 # renovate: datasource=docker
|
TEST_JOB_IMAGE: node:24-bookworm-slim@sha256:ba849c60be29959425b8734d57b8b4b7d56f98edd9504c9af091d5281095a71e # renovate: datasource=docker
|
||||||
TEST_SERVICE_IMAGE: nginx:alpine@sha256:db35bfc6b2951e7f8a72db5db120288c127ffaeeb4a6d4b95a26fead017d5913 # renovate: datasource=docker
|
TEST_SERVICE_IMAGE: nginx:alpine@sha256:db35bfc6b2951e7f8a72db5db120288c127ffaeeb4a6d4b95a26fead017d5913 # renovate: datasource=docker
|
||||||
run: |
|
run: |
|
||||||
for image in "$TEST_JOB_IMAGE" "$TEST_SERVICE_IMAGE"; do
|
for image in "$TEST_JOB_IMAGE" "$TEST_SERVICE_IMAGE"; do
|
||||||
|
|||||||
@@ -171,7 +171,7 @@ coverage-report: ## turn coverage.txt from `make test` into .tmp/coverage.md
|
|||||||
test-dind: ## run the daemon-facing tests against the built dind image (TARGET=dind|dind-rootless)
|
test-dind: ## run the daemon-facing tests against the built dind image (TARGET=dind|dind-rootless)
|
||||||
@./scripts/test-dind.sh $(TARGET)
|
@./scripts/test-dind.sh $(TARGET)
|
||||||
|
|
||||||
E2E_JOB_IMAGE ?= node:24-bookworm@sha256:934240a162082fd8b8a2f90cd5114446443f1eba1c5378f6687167ca405e6584 # renovate: datasource=docker
|
E2E_JOB_IMAGE ?= node:24-bookworm@sha256:be23f54a88d34e8824c741b19b91064094f92c1c97b194144bfc8b50d67258e2 # renovate: datasource=docker
|
||||||
SERVICE_IMAGE ?= nginx:1.31.4-alpine@sha256:db35bfc6b2951e7f8a72db5db120288c127ffaeeb4a6d4b95a26fead017d5913 # renovate: datasource=docker
|
SERVICE_IMAGE ?= nginx:1.31.4-alpine@sha256:db35bfc6b2951e7f8a72db5db120288c127ffaeeb4a6d4b95a26fead017d5913 # renovate: datasource=docker
|
||||||
E2E_GITEA_IMAGE ?= gitea/gitea:main-nightly
|
E2E_GITEA_IMAGE ?= gitea/gitea:main-nightly
|
||||||
E2E_CONCURRENCY ?= 8
|
E2E_CONCURRENCY ?= 8
|
||||||
|
|||||||
@@ -201,10 +201,10 @@ A label is written as:
|
|||||||
| Part | Meaning |
|
| Part | Meaning |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `name` | The name a workflow refers to in `runs-on`, e.g. `ubuntu-latest`. |
|
| `name` | The name a workflow refers to in `runs-on`, e.g. `ubuntu-latest`. |
|
||||||
| `schema` | Either `docker` or `host`. Defaults to `host` when omitted. |
|
| `schema` | Either `docker`, `host`, or `macos-vm`. Defaults to `host` when omitted. |
|
||||||
| `args` | Only used by the `docker` schema: the image to run the job in. |
|
| `args` | Used by `docker` for the image, and by `macos-vm` for the source VM. |
|
||||||
|
|
||||||
Two schemas are supported:
|
Three schemas are supported:
|
||||||
|
|
||||||
- **`docker://<image>`** — the job runs inside a container created from `<image>`:
|
- **`docker://<image>`** — the job runs inside a container created from `<image>`:
|
||||||
|
|
||||||
@@ -218,15 +218,21 @@ Two schemas are supported:
|
|||||||
macos:host
|
macos:host
|
||||||
```
|
```
|
||||||
|
|
||||||
|
- **`macos-vm://<source-vm>`** — the job runs inside an ephemeral macOS VM created by `runner-executor-macos`. The VM image must include the runner guest agent. Service containers are not supported with this schema. See `docs/macos-vm-executor.md` for configuration, integration testing, and cleanup details:
|
||||||
|
|
||||||
|
```text
|
||||||
|
macos-latest:macos-vm://ghcr.io/cirruslabs/macos-sonoma-base:latest
|
||||||
|
```
|
||||||
|
|
||||||
So with the labels
|
So with the labels
|
||||||
|
|
||||||
```text
|
```text
|
||||||
ubuntu-latest:docker://docker.gitea.com/runner-images:ubuntu-latest,macos:host
|
ubuntu-latest:docker://docker.gitea.com/runner-images:ubuntu-latest,macos:host,macos-latest:macos-vm://ghcr.io/cirruslabs/macos-sonoma-base:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
a workflow with `runs-on: ubuntu-latest` is executed in the `runner-images:ubuntu-latest` container, and one with `runs-on: macos` is executed directly on the host.
|
a workflow with `runs-on: ubuntu-latest` is executed in the `runner-images:ubuntu-latest` container, one with `runs-on: macos` is executed directly on the host, and one with `runs-on: macos-latest` is executed in the macOS VM.
|
||||||
|
|
||||||
Names may themselves contain a colon (for example `pool:e57e18d4-10d4-406f-93bf-60f127221bdd`); only `host` and `docker` are treated as schemas.
|
Names may themselves contain a colon (for example `pool:e57e18d4-10d4-406f-93bf-60f127221bdd`); only `host`, `docker`, and `macos-vm` are treated as schemas.
|
||||||
|
|
||||||
If a job's `runs-on` matches none of the runner's labels, or sets no `runs-on` at all, it still runs: in `runner.default_image` where docker is available, on the host where it is not. Images maintained for this purpose are listed at [gitea/runner-images](https://gitea.com/gitea/runner-images).
|
If a job's `runs-on` matches none of the runner's labels, or sets no `runs-on` at all, it still runs: in `runner.default_image` where docker is available, on the host where it is not. Images maintained for this purpose are listed at [gitea/runner-images](https://gitea.com/gitea/runner-images).
|
||||||
|
|
||||||
|
|||||||
@@ -583,6 +583,14 @@ func (cr *containerReference) mergeContainerConfigs(ctx context.Context, config
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, fmt.Errorf("cannot process container options: '%s': '%w'", options, err)
|
return nil, nil, fmt.Errorf("cannot process container options: '%s': '%w'", options, err)
|
||||||
}
|
}
|
||||||
|
// workflow aliases join the runner's own, deduped so a re-create cannot grow the input
|
||||||
|
for _, endpoint := range containerConfig.NetworkingConfig.EndpointsConfig {
|
||||||
|
for _, alias := range endpoint.Aliases {
|
||||||
|
if !slices.Contains(cr.input.NetworkAliases, alias) {
|
||||||
|
cr.input.NetworkAliases = append(cr.input.NetworkAliases, alias)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// For Gitea, forcing --privileged off is not enough, other options reach the host too
|
// For Gitea, forcing --privileged off is not enough, other options reach the host too
|
||||||
if !hostConfig.Privileged {
|
if !hostConfig.Privileged {
|
||||||
|
|||||||
@@ -833,6 +833,17 @@ func TestMergeContainerConfigsWarnsOnlyAboutOptionsThatWereGiven(t *testing.T) {
|
|||||||
assert.Equal(t, 1, warnings("--network host", ""))
|
assert.Equal(t, 1, warnings("--network host", ""))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestMergeContainerConfigsKeepsNetworkAliasesFromOptions(t *testing.T) {
|
||||||
|
logger, _ := test.NewNullLogger()
|
||||||
|
cr := &containerReference{input: &NewContainerInput{
|
||||||
|
NetworkMode: "job-network", NetworkAliases: []string{"redis"}, WorkflowOptions: "--network-alias redis-primary",
|
||||||
|
}}
|
||||||
|
|
||||||
|
_, _, err := cr.mergeContainerConfigs(common.WithLogger(t.Context(), logger), &container.Config{}, &container.HostConfig{})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, []string{"redis", "redis-primary"}, cr.input.NetworkAliases)
|
||||||
|
}
|
||||||
|
|
||||||
// A dead daemon must fail the job, not panic through logrus and not silently
|
// A dead daemon must fail the job, not panic through logrus and not silently
|
||||||
// drop the requested platform.
|
// drop the requested platform.
|
||||||
func TestSupportsContainerImagePlatformDaemonError(t *testing.T) {
|
func TestSupportsContainerImagePlatformDaemonError(t *testing.T) {
|
||||||
|
|||||||
@@ -629,8 +629,11 @@ func (*HostEnvironment) JoinPathVariable(paths ...string) string {
|
|||||||
func goArchToActionArch(arch string) string {
|
func goArchToActionArch(arch string) string {
|
||||||
archMapper := map[string]string{
|
archMapper := map[string]string{
|
||||||
"x86_64": "X64",
|
"x86_64": "X64",
|
||||||
|
"amd64": "X64",
|
||||||
"386": "X86",
|
"386": "X86",
|
||||||
|
"arm": "ARM",
|
||||||
"aarch64": "ARM64",
|
"aarch64": "ARM64",
|
||||||
|
"arm64": "ARM64",
|
||||||
}
|
}
|
||||||
if arch, ok := archMapper[arch]; ok {
|
if arch, ok := archMapper[arch]; ok {
|
||||||
return arch
|
return arch
|
||||||
@@ -640,7 +643,9 @@ func goArchToActionArch(arch string) string {
|
|||||||
|
|
||||||
func goOsToActionOs(os string) string {
|
func goOsToActionOs(os string) string {
|
||||||
osMapper := map[string]string{
|
osMapper := map[string]string{
|
||||||
"darwin": "macOS",
|
"linux": "Linux",
|
||||||
|
"darwin": "macOS",
|
||||||
|
"windows": "Windows",
|
||||||
}
|
}
|
||||||
if os, ok := osMapper[os]; ok {
|
if os, ok := osMapper[os]; ok {
|
||||||
return os
|
return os
|
||||||
|
|||||||
@@ -27,6 +27,11 @@ import (
|
|||||||
// Type assert HostEnvironment implements ExecutionsEnvironment
|
// Type assert HostEnvironment implements ExecutionsEnvironment
|
||||||
var _ ExecutionsEnvironment = &HostEnvironment{}
|
var _ ExecutionsEnvironment = &HostEnvironment{}
|
||||||
|
|
||||||
|
func TestActionPlatformNames(t *testing.T) {
|
||||||
|
assert.Equal(t, []string{"Linux", "macOS", "Windows"}, []string{goOsToActionOs("linux"), goOsToActionOs("darwin"), goOsToActionOs("windows")})
|
||||||
|
assert.Equal(t, []string{"X86", "X64", "ARM", "ARM64"}, []string{goArchToActionArch("386"), goArchToActionArch("amd64"), goArchToActionArch("arm"), goArchToActionArch("arm64")})
|
||||||
|
}
|
||||||
|
|
||||||
func TestCopyDir(t *testing.T) {
|
func TestCopyDir(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package container
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type macOSVM struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
State string `json:"state"`
|
||||||
|
Running bool `json:"running"`
|
||||||
|
AccessedAt string `json:"accessed_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CleanupOrphanMacOSVMs deletes local macOS VMs left behind by this runner that
|
||||||
|
// have not been accessed since cutoff.
|
||||||
|
func CleanupOrphanMacOSVMs(ctx context.Context, executorPath, uuid string, cutoff time.Time) error {
|
||||||
|
if uuid == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
output, err := exec.CommandContext(ctx, executorPath, "list", "--format", "json").Output()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("list macOS VMs: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var vms []macOSVM
|
||||||
|
if err := json.Unmarshal(output, &vms); err != nil {
|
||||||
|
return fmt.Errorf("parse macOS VM list: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var errs []error
|
||||||
|
for _, name := range macOSVMsToDelete(vms, uuid, cutoff) {
|
||||||
|
if err := exec.CommandContext(ctx, executorPath, "destroy", name).Run(); err != nil {
|
||||||
|
errs = append(errs, fmt.Errorf("destroy macOS VM %s: %w", name, err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return errors.Join(errs...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func macOSVMsToDelete(vms []macOSVM, uuid string, cutoff time.Time) []string {
|
||||||
|
prefix := "GITEA-MACOS-VM-" + uuid + "-"
|
||||||
|
var stale []string
|
||||||
|
for _, vm := range vms {
|
||||||
|
if vm.Running || !strings.HasPrefix(vm.Name, prefix) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
accessed, err := parseMacOSVMTime(vm.AccessedAt)
|
||||||
|
if err != nil || accessed.After(cutoff) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
stale = append(stale, vm.Name)
|
||||||
|
}
|
||||||
|
return stale
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseMacOSVMTime(raw string) (time.Time, error) {
|
||||||
|
if raw == "" {
|
||||||
|
return time.Time{}, errors.New("empty access time")
|
||||||
|
}
|
||||||
|
if parsed, err := time.Parse(time.RFC3339Nano, raw); err == nil {
|
||||||
|
return parsed, nil
|
||||||
|
}
|
||||||
|
return time.Parse(time.RFC3339, raw)
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package container
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMacOSVMsToDelete(t *testing.T) {
|
||||||
|
cutoff := time.Date(2026, 9, 5, 10, 0, 0, 0, time.UTC)
|
||||||
|
vms := []macOSVM{
|
||||||
|
{Name: "GITEA-MACOS-VM-runner-1-stale", AccessedAt: "2026-09-04T10:00:00Z"},
|
||||||
|
{Name: "GITEA-MACOS-VM-runner-1-running", AccessedAt: "2026-09-04T10:00:00Z", Running: true},
|
||||||
|
{Name: "GITEA-MACOS-VM-runner-1-fresh", AccessedAt: "2026-09-05T11:00:00Z"},
|
||||||
|
{Name: "GITEA-MACOS-VM-runner-2-stale", AccessedAt: "2026-09-04T10:00:00Z"},
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Equal(t, []string{"GITEA-MACOS-VM-runner-1-stale"}, macOSVMsToDelete(vms, "runner-1", cutoff))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseMacOSVMTime(t *testing.T) {
|
||||||
|
parsed, err := parseMacOSVMTime("2026-09-05T10:00:00Z")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, time.Date(2026, 9, 5, 10, 0, 0, 0, time.UTC), parsed)
|
||||||
|
|
||||||
|
parsed, err = parseMacOSVMTime("2026-09-05T10:00:00.123456789Z")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, time.Date(2026, 9, 5, 10, 0, 0, 123456789, time.UTC), parsed)
|
||||||
|
|
||||||
|
_, err = parseMacOSVMTime("")
|
||||||
|
require.Error(t, err)
|
||||||
|
}
|
||||||
@@ -0,0 +1,450 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package container
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/tar"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"maps"
|
||||||
|
"os/exec"
|
||||||
|
"path"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"slices"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.com/gitea/runner/act/common"
|
||||||
|
"gitea.com/gitea/runner/act/filecollector"
|
||||||
|
|
||||||
|
"github.com/go-git/go-billy/v5/helper/polyfill"
|
||||||
|
"github.com/go-git/go-billy/v5/osfs"
|
||||||
|
"github.com/go-git/go-git/v5/plumbing/format/gitignore"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultMacOSVMBootTimeout = 5 * time.Minute
|
||||||
|
macOSVMReadyPollInterval = 500 * time.Millisecond
|
||||||
|
macOSVMStopWaitTimeout = 10 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
// MacOSVMEnvironment runs workflow steps in an ephemeral macOS VM.
|
||||||
|
type MacOSVMEnvironment struct {
|
||||||
|
Path string // guest scratch directory
|
||||||
|
TmpDir string // guest temp directory
|
||||||
|
ToolCache string // guest tool cache directory
|
||||||
|
Workdir string // host-side workspace path
|
||||||
|
GuestWorkdir string // guest-side workspace path
|
||||||
|
ActPath string // guest act directory
|
||||||
|
|
||||||
|
Image string
|
||||||
|
VMName string
|
||||||
|
ExecutorPath string
|
||||||
|
CPU int
|
||||||
|
Memory int
|
||||||
|
BootTimeout time.Duration
|
||||||
|
|
||||||
|
Stdout io.Writer
|
||||||
|
Stderr io.Writer
|
||||||
|
|
||||||
|
executorPath string
|
||||||
|
running bool
|
||||||
|
runCmd *exec.Cmd
|
||||||
|
runDone chan struct{}
|
||||||
|
runErr error
|
||||||
|
}
|
||||||
|
|
||||||
|
// MacOSVMEnvironmentInput is the input for NewMacOSVMEnvironment.
|
||||||
|
type MacOSVMEnvironmentInput struct {
|
||||||
|
Path string
|
||||||
|
TmpDir string
|
||||||
|
ToolCache string
|
||||||
|
Workdir string
|
||||||
|
GuestWorkdir string
|
||||||
|
ActPath string
|
||||||
|
Image string
|
||||||
|
VMName string
|
||||||
|
ExecutorPath string
|
||||||
|
CPU int
|
||||||
|
Memory int
|
||||||
|
BootTimeout time.Duration
|
||||||
|
Stdout io.Writer
|
||||||
|
Stderr io.Writer
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewMacOSVMEnvironment(input MacOSVMEnvironmentInput) (*MacOSVMEnvironment, error) {
|
||||||
|
if input.Image == "" {
|
||||||
|
return nil, errors.New("macOS VM image is required")
|
||||||
|
}
|
||||||
|
if input.VMName == "" {
|
||||||
|
return nil, errors.New("macOS VM name is required")
|
||||||
|
}
|
||||||
|
if input.BootTimeout <= 0 {
|
||||||
|
input.BootTimeout = defaultMacOSVMBootTimeout
|
||||||
|
}
|
||||||
|
if input.ExecutorPath == "" {
|
||||||
|
input.ExecutorPath = "runner-executor-macos"
|
||||||
|
}
|
||||||
|
return &MacOSVMEnvironment{
|
||||||
|
Path: input.Path,
|
||||||
|
TmpDir: input.TmpDir,
|
||||||
|
ToolCache: input.ToolCache,
|
||||||
|
Workdir: input.Workdir,
|
||||||
|
GuestWorkdir: input.GuestWorkdir,
|
||||||
|
ActPath: input.ActPath,
|
||||||
|
Image: input.Image,
|
||||||
|
VMName: input.VMName,
|
||||||
|
ExecutorPath: input.ExecutorPath,
|
||||||
|
CPU: input.CPU,
|
||||||
|
Memory: input.Memory,
|
||||||
|
BootTimeout: input.BootTimeout,
|
||||||
|
Stdout: input.Stdout,
|
||||||
|
Stderr: input.Stderr,
|
||||||
|
executorPath: input.ExecutorPath,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ ExecutionsEnvironment = &MacOSVMEnvironment{}
|
||||||
|
|
||||||
|
func (e *MacOSVMEnvironment) Create(_, _ []string) common.Executor {
|
||||||
|
return func(ctx context.Context) error {
|
||||||
|
if common.Dryrun(ctx) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// A previous run can leave the deterministic job VM behind. Destroy it first.
|
||||||
|
_ = e.runExecutor(ctx, nil, io.Discard, io.Discard, "destroy", e.VMName)
|
||||||
|
|
||||||
|
args := []string{"provision", e.Image, e.VMName}
|
||||||
|
if e.CPU > 0 {
|
||||||
|
args = append(args, "--cpu", strconv.Itoa(e.CPU))
|
||||||
|
}
|
||||||
|
if e.Memory > 0 {
|
||||||
|
args = append(args, "--memory", strconv.Itoa(e.Memory))
|
||||||
|
}
|
||||||
|
return e.runExecutor(ctx, nil, e.Stdout, e.Stderr, args...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *MacOSVMEnvironment) Copy(destPath string, files ...*FileEntry) common.Executor {
|
||||||
|
return func(ctx context.Context) error {
|
||||||
|
if common.Dryrun(ctx) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
for _, file := range files {
|
||||||
|
dest := path.Join(destPath, file.Name)
|
||||||
|
script := `mkdir -p "$1" && cat > "$2" && chmod "$3" "$2"`
|
||||||
|
mode := file.Mode
|
||||||
|
if mode == 0 {
|
||||||
|
mode = 0o644
|
||||||
|
}
|
||||||
|
args := []string{"exec", "--stdin", e.VMName, "/bin/sh", "-c", script, "sh", path.Dir(dest), dest, strconv.FormatInt(mode, 8)}
|
||||||
|
if err := e.runExecutor(ctx, strings.NewReader(file.Body), e.Stdout, e.Stderr, args...); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *MacOSVMEnvironment) CopyDir(destPath, srcPath string, useGitIgnore bool) common.Executor {
|
||||||
|
return func(ctx context.Context) error {
|
||||||
|
if common.Dryrun(ctx) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
pipeReader, pipeWriter := io.Pipe()
|
||||||
|
writeDone := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
err := e.writeTar(ctx, pipeWriter, srcPath, useGitIgnore)
|
||||||
|
_ = pipeWriter.CloseWithError(err)
|
||||||
|
writeDone <- err
|
||||||
|
}()
|
||||||
|
|
||||||
|
script := `export COPYFILE_DISABLE=1; mkdir -p "$1" && /usr/bin/tar -xf - -C "$1"`
|
||||||
|
cmdErr := e.runExecutor(ctx, pipeReader, e.Stdout, e.Stderr, "exec", "--stdin", e.VMName, "/bin/sh", "-c", script, "sh", destPath)
|
||||||
|
_ = pipeReader.Close()
|
||||||
|
writeErr := <-writeDone
|
||||||
|
return errors.Join(cmdErr, writeErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *MacOSVMEnvironment) writeTar(ctx context.Context, writer io.Writer, srcPath string, useGitIgnore bool) error {
|
||||||
|
tarWriter := tar.NewWriter(writer)
|
||||||
|
defer tarWriter.Close()
|
||||||
|
|
||||||
|
srcPrefix := filepath.Dir(srcPath)
|
||||||
|
if !strings.HasSuffix(srcPrefix, string(filepath.Separator)) {
|
||||||
|
srcPrefix += string(filepath.Separator)
|
||||||
|
}
|
||||||
|
|
||||||
|
var ignorer gitignore.Matcher
|
||||||
|
if useGitIgnore {
|
||||||
|
patterns, err := gitignore.ReadPatterns(polyfill.New(osfs.New(srcPath)), nil)
|
||||||
|
if err != nil {
|
||||||
|
common.Logger(ctx).Debugf("Error loading .gitignore: %v", err)
|
||||||
|
}
|
||||||
|
ignorer = gitignore.NewMatcher(patterns)
|
||||||
|
}
|
||||||
|
|
||||||
|
collector := &filecollector.FileCollector{
|
||||||
|
Ignorer: ignorer,
|
||||||
|
SrcPath: srcPath,
|
||||||
|
SrcPrefix: srcPrefix,
|
||||||
|
Handler: &filecollector.TarCollector{TarWriter: tarWriter},
|
||||||
|
}
|
||||||
|
return filepath.Walk(srcPath, collector.CollectFiles(ctx, []string{}))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *MacOSVMEnvironment) GetContainerArchive(ctx context.Context, srcPath string) (io.ReadCloser, error) {
|
||||||
|
if common.Dryrun(ctx) {
|
||||||
|
return nil, errors.New("DRYRUN is not supported in GetContainerArchive")
|
||||||
|
}
|
||||||
|
cmd := exec.CommandContext(ctx, e.executorPath, "exec", e.VMName, "/usr/bin/env", "COPYFILE_DISABLE=1", "/usr/bin/tar", "-cf", "-", "-C", "/", strings.TrimPrefix(srcPath, "/"))
|
||||||
|
stdout, err := cmd.StdoutPipe()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
cmd.Stderr = e.Stderr
|
||||||
|
if err := cmd.Start(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &macOSVMArchiveReader{ReadCloser: stdout, cmd: cmd}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type macOSVMArchiveReader struct {
|
||||||
|
io.ReadCloser
|
||||||
|
cmd *exec.Cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *macOSVMArchiveReader) Close() error {
|
||||||
|
closeErr := r.ReadCloser.Close()
|
||||||
|
return errors.Join(closeErr, r.cmd.Wait())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *MacOSVMEnvironment) Inspect(ctx context.Context) (*Info, error) {
|
||||||
|
if common.Dryrun(ctx) {
|
||||||
|
return &Info{Health: HealthNone, Ports: map[string]string{}}, nil
|
||||||
|
}
|
||||||
|
state := "exited"
|
||||||
|
if e.running {
|
||||||
|
state = StateRunning
|
||||||
|
}
|
||||||
|
return &Info{ID: e.VMName, State: state, Health: HealthNone, Ports: map[string]string{}}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *MacOSVMEnvironment) DumpLogs(context.Context) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *MacOSVMEnvironment) Pull(forcePull bool) common.Executor {
|
||||||
|
return func(ctx context.Context) error {
|
||||||
|
if common.Dryrun(ctx) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *MacOSVMEnvironment) Start(bool) common.Executor {
|
||||||
|
return func(ctx context.Context) error {
|
||||||
|
if common.Dryrun(ctx) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
cmd := exec.Command(e.executorPath, "start", "--no-graphics", e.VMName)
|
||||||
|
cmd.Stdout = e.Stdout
|
||||||
|
cmd.Stderr = e.Stderr
|
||||||
|
if err := cmd.Start(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
e.runCmd = cmd
|
||||||
|
e.runDone = make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
e.runErr = cmd.Wait()
|
||||||
|
close(e.runDone)
|
||||||
|
}()
|
||||||
|
e.running = true
|
||||||
|
return e.waitReady(ctx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *MacOSVMEnvironment) waitReady(ctx context.Context) error {
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, e.BootTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
var lastErr error
|
||||||
|
for {
|
||||||
|
if err := e.runExecutor(ctx, nil, io.Discard, io.Discard, "exec", e.VMName, "/usr/bin/true"); err == nil {
|
||||||
|
return nil
|
||||||
|
} else {
|
||||||
|
lastErr = err
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return fmt.Errorf("macOS VM %s did not become ready: %w", e.VMName, lastErr)
|
||||||
|
case <-e.runDone:
|
||||||
|
if e.runErr != nil {
|
||||||
|
return fmt.Errorf("macOS VM start exited before the VM became ready: %w", e.runErr)
|
||||||
|
}
|
||||||
|
return errors.New("macOS VM start exited before the VM became ready")
|
||||||
|
case <-time.After(macOSVMReadyPollInterval):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *MacOSVMEnvironment) Exec(command []string, env map[string]string, _, workdir string) common.Executor {
|
||||||
|
return func(ctx context.Context) error {
|
||||||
|
if common.Dryrun(ctx) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
err := e.runExecutor(ctx, nil, e.Stdout, e.Stderr, macOSVMExecArgs(e.VMName, command, env, e.resolveWorkdir(workdir))...)
|
||||||
|
if exitErr, ok := errors.AsType[*exec.ExitError](err); ok {
|
||||||
|
return ExitCodeError(exitErr.ExitCode())
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func macOSVMExecArgs(vmName string, command []string, env map[string]string, workdir string) []string {
|
||||||
|
args := []string{"exec", vmName, "/usr/bin/env"}
|
||||||
|
for _, key := range slices.Sorted(maps.Keys(env)) {
|
||||||
|
args = append(args, key+"="+env[key])
|
||||||
|
}
|
||||||
|
args = append(args, "/bin/sh", "-c", `cd "$1" && shift && exec "$@"`, "sh", workdir)
|
||||||
|
return append(args, command...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *MacOSVMEnvironment) resolveWorkdir(workdir string) string {
|
||||||
|
switch {
|
||||||
|
case workdir == "":
|
||||||
|
return e.GuestWorkdir
|
||||||
|
case strings.HasPrefix(workdir, "/"):
|
||||||
|
return workdir
|
||||||
|
default:
|
||||||
|
return path.Join(e.GuestWorkdir, workdir)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *MacOSVMEnvironment) UpdateFromEnv(srcPath string, env *map[string]string) common.Executor {
|
||||||
|
return parseEnvFile(e, srcPath, env)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *MacOSVMEnvironment) UpdateFromImageEnv(*map[string]string) common.Executor {
|
||||||
|
return func(context.Context) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *MacOSVMEnvironment) Remove() common.Executor {
|
||||||
|
return func(ctx context.Context) error {
|
||||||
|
if common.Dryrun(ctx) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
cleanCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
if e.runCmd != nil && e.runDone != nil {
|
||||||
|
select {
|
||||||
|
case <-e.runDone:
|
||||||
|
default:
|
||||||
|
if err := e.runExecutor(cleanCtx, nil, e.Stdout, e.Stderr, "stop", e.VMName, "--timeout", "20"); err != nil {
|
||||||
|
common.Logger(ctx).Debugf("macOS VM stop failed for VM %s: %v", e.VMName, err)
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-e.runDone:
|
||||||
|
case <-time.After(macOSVMStopWaitTimeout):
|
||||||
|
if e.runCmd.Process != nil {
|
||||||
|
_ = e.runCmd.Process.Kill()
|
||||||
|
}
|
||||||
|
common.Logger(ctx).Warnf("timed out waiting for macOS VM start to exit for VM %s", e.VMName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
e.running = false
|
||||||
|
if err := e.runExecutor(cleanCtx, nil, e.Stdout, e.Stderr, "destroy", e.VMName); err != nil {
|
||||||
|
common.Logger(ctx).Warnf("failed to destroy macOS VM %s: %v", e.VMName, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *MacOSVMEnvironment) Close() common.Executor {
|
||||||
|
return func(ctx context.Context) error {
|
||||||
|
if common.Dryrun(ctx) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if e.runCmd != nil && e.runCmd.Process != nil {
|
||||||
|
_ = e.runCmd.Process.Kill()
|
||||||
|
}
|
||||||
|
e.runCmd = nil
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *MacOSVMEnvironment) ReplaceLogWriter(stdout, stderr io.Writer) (io.Writer, io.Writer) {
|
||||||
|
oldOut := e.Stdout
|
||||||
|
oldErr := e.Stderr
|
||||||
|
e.Stdout = stdout
|
||||||
|
e.Stderr = stderr
|
||||||
|
return oldOut, oldErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *MacOSVMEnvironment) ToContainerPath(hostPath string) string {
|
||||||
|
if filepath.Clean(hostPath) == filepath.Clean(e.Workdir) {
|
||||||
|
return e.GuestWorkdir
|
||||||
|
}
|
||||||
|
if rel, err := filepath.Rel(e.Workdir, hostPath); err == nil && rel != "." && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
|
||||||
|
return path.Join(e.GuestWorkdir, filepath.ToSlash(rel))
|
||||||
|
}
|
||||||
|
return filepath.ToSlash(hostPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *MacOSVMEnvironment) GetActPath() string {
|
||||||
|
return e.ActPath
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*MacOSVMEnvironment) GetPathVariableName() string {
|
||||||
|
return "PATH"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*MacOSVMEnvironment) DefaultPathVariable() string {
|
||||||
|
return "/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*MacOSVMEnvironment) JoinPathVariable(paths ...string) string {
|
||||||
|
return strings.Join(paths, ":")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *MacOSVMEnvironment) GetRunnerContext(context.Context) map[string]any {
|
||||||
|
return map[string]any{
|
||||||
|
"os": "macOS",
|
||||||
|
"arch": goArchToActionArch(runtime.GOARCH),
|
||||||
|
"temp": e.TmpDir,
|
||||||
|
"tool_cache": e.ToolCache,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*MacOSVMEnvironment) IsEnvironmentCaseInsensitive() bool {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *MacOSVMEnvironment) runExecutor(ctx context.Context, stdin io.Reader, stdout, stderr io.Writer, args ...string) error {
|
||||||
|
if stdout == nil {
|
||||||
|
stdout = e.Stdout
|
||||||
|
}
|
||||||
|
if stderr == nil {
|
||||||
|
stderr = e.Stderr
|
||||||
|
}
|
||||||
|
cmd := exec.CommandContext(ctx, e.executorPath, args...)
|
||||||
|
cmd.Stdin = stdin
|
||||||
|
cmd.Stdout = stdout
|
||||||
|
cmd.Stderr = stderr
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
return fmt.Errorf("macOS VM executor %s: %w", args[0], err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
//go:build macos_vm_integration
|
||||||
|
|
||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package container
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/tar"
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"runtime"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMacOSVMEnvironmentIntegration(t *testing.T) {
|
||||||
|
if runtime.GOOS != "darwin" {
|
||||||
|
t.Skip("macOS VMs run on macOS")
|
||||||
|
}
|
||||||
|
if _, err := exec.LookPath("runner-executor-macos"); err != nil {
|
||||||
|
t.Skip("runner-executor-macos executable not found")
|
||||||
|
}
|
||||||
|
image := os.Getenv("MACOS_VM_TEST_IMAGE")
|
||||||
|
if image == "" {
|
||||||
|
t.Skip("set MACOS_VM_TEST_IMAGE to a macOS VM image to run this test")
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
vmName := fmt.Sprintf("gitea-runner-test-%d", time.Now().UnixNano())
|
||||||
|
base := "/Users/admin/runner"
|
||||||
|
if os.Getenv("RUNNER_EXECUTOR_MACOS_DEV_MODE") == "1" {
|
||||||
|
base = t.TempDir()
|
||||||
|
}
|
||||||
|
env, err := NewMacOSVMEnvironment(MacOSVMEnvironmentInput{
|
||||||
|
Path: base + "/scratch/" + vmName,
|
||||||
|
TmpDir: base + "/scratch/" + vmName + "/tmp",
|
||||||
|
ToolCache: base + "/scratch/" + vmName + "/tool_cache",
|
||||||
|
Workdir: t.TempDir(),
|
||||||
|
GuestWorkdir: base + "/workspace/" + vmName,
|
||||||
|
ActPath: base + "/scratch/" + vmName + "/act",
|
||||||
|
Image: image,
|
||||||
|
VMName: vmName,
|
||||||
|
ExecutorPath: "runner-executor-macos",
|
||||||
|
BootTimeout: 10 * time.Minute,
|
||||||
|
Stdout: io.Discard,
|
||||||
|
Stderr: io.Discard,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Cleanup(func() {
|
||||||
|
_ = env.Remove()(context.Background())
|
||||||
|
})
|
||||||
|
|
||||||
|
require.NoError(t, env.Create(nil, nil)(ctx))
|
||||||
|
require.NoError(t, env.Start(false)(ctx))
|
||||||
|
if os.Getenv("RUNNER_EXECUTOR_MACOS_DEV_MODE") == "1" {
|
||||||
|
require.NoError(t, os.MkdirAll(env.GuestWorkdir, 0o755))
|
||||||
|
}
|
||||||
|
require.NoError(t, env.Exec([]string{"/bin/mkdir", "-p", env.GuestWorkdir}, nil, "", "/")(ctx))
|
||||||
|
|
||||||
|
require.NoError(t, env.Copy("/tmp", &FileEntry{
|
||||||
|
Name: "gitea-runner-macos.txt",
|
||||||
|
Mode: 0o644,
|
||||||
|
Body: "hello from macos-vm",
|
||||||
|
})(ctx))
|
||||||
|
|
||||||
|
archive, err := env.GetContainerArchive(ctx, "/tmp/gitea-runner-macos.txt")
|
||||||
|
require.NoError(t, err)
|
||||||
|
body := readFirstTarFile(t, archive)
|
||||||
|
require.NoError(t, archive.Close())
|
||||||
|
require.Equal(t, "hello from macos-vm", body)
|
||||||
|
|
||||||
|
var output bytes.Buffer
|
||||||
|
oldOut, oldErr := env.ReplaceLogWriter(&output, &output)
|
||||||
|
defer env.ReplaceLogWriter(oldOut, oldErr)
|
||||||
|
require.NoError(t, env.Exec([]string{"/usr/bin/uname", "-s"}, nil, "", "")(ctx))
|
||||||
|
require.Contains(t, output.String(), "Darwin")
|
||||||
|
|
||||||
|
require.NoError(t, env.Remove()(ctx))
|
||||||
|
}
|
||||||
|
|
||||||
|
func readFirstTarFile(t *testing.T, archive io.Reader) string {
|
||||||
|
t.Helper()
|
||||||
|
reader := tar.NewReader(archive)
|
||||||
|
header, err := reader.Next()
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Contains(t, header.Name, "gitea-runner-macos.txt")
|
||||||
|
body, err := io.ReadAll(reader)
|
||||||
|
require.NoError(t, err)
|
||||||
|
return string(body)
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package container
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"runtime"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNewMacOSVMEnvironment(t *testing.T) {
|
||||||
|
env, err := NewMacOSVMEnvironment(MacOSVMEnvironmentInput{
|
||||||
|
Image: "ghcr.io/cirruslabs/macos-sonoma-base:latest",
|
||||||
|
VMName: "job-vm",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "ghcr.io/cirruslabs/macos-sonoma-base:latest", env.Image)
|
||||||
|
assert.Equal(t, "job-vm", env.VMName)
|
||||||
|
assert.Equal(t, defaultMacOSVMBootTimeout, env.BootTimeout)
|
||||||
|
assert.Equal(t, "runner-executor-macos", env.executorPath)
|
||||||
|
|
||||||
|
_, err = NewMacOSVMEnvironment(MacOSVMEnvironmentInput{VMName: "job-vm"})
|
||||||
|
require.Error(t, err)
|
||||||
|
_, err = NewMacOSVMEnvironment(MacOSVMEnvironmentInput{Image: "macos"})
|
||||||
|
require.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMacOSVMExecArgs(t *testing.T) {
|
||||||
|
args := macOSVMExecArgs("vm", []string{"bash", "-c", "echo hi"}, map[string]string{"B": "two", "A": "one"}, "/Users/admin/work")
|
||||||
|
assert.Equal(t, []string{
|
||||||
|
"exec", "vm", "/usr/bin/env",
|
||||||
|
"A=one", "B=two",
|
||||||
|
"/bin/sh", "-c", `cd "$1" && shift && exec "$@"`, "sh", "/Users/admin/work",
|
||||||
|
"bash", "-c", "echo hi",
|
||||||
|
}, args)
|
||||||
|
|
||||||
|
args = macOSVMExecArgs("vm", []string{"true"}, nil, "")
|
||||||
|
assert.Equal(t, []string{
|
||||||
|
"exec", "vm", "/usr/bin/env",
|
||||||
|
"/bin/sh", "-c", `cd "$1" && shift && exec "$@"`, "sh", "",
|
||||||
|
"true",
|
||||||
|
}, args)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMacOSVMEnvironmentResolveWorkdir(t *testing.T) {
|
||||||
|
env := &MacOSVMEnvironment{GuestWorkdir: "/Users/admin/work"}
|
||||||
|
assert.Equal(t, "/Users/admin/work", env.resolveWorkdir(""))
|
||||||
|
assert.Equal(t, "/Users/admin/work/sub", env.resolveWorkdir("sub"))
|
||||||
|
assert.Equal(t, "/tmp", env.resolveWorkdir("/tmp"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMacOSVMEnvironmentToContainerPath(t *testing.T) {
|
||||||
|
env := &MacOSVMEnvironment{Workdir: "/workspace/owner/repo", GuestWorkdir: "/Users/admin/work/owner/repo"}
|
||||||
|
assert.Equal(t, "/Users/admin/work/owner/repo", env.ToContainerPath("/workspace/owner/repo"))
|
||||||
|
assert.Equal(t, "/Users/admin/work/owner/repo/sub", env.ToContainerPath("/workspace/owner/repo/sub"))
|
||||||
|
assert.Equal(t, "/elsewhere", env.ToContainerPath("/elsewhere"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMacOSVMEnvironmentGetRunnerContext(t *testing.T) {
|
||||||
|
env := &MacOSVMEnvironment{
|
||||||
|
TmpDir: "/Users/admin/scratch/tmp",
|
||||||
|
ToolCache: "/Users/admin/scratch/tool_cache",
|
||||||
|
}
|
||||||
|
ctx := env.GetRunnerContext(context.Background())
|
||||||
|
assert.Equal(t, "macOS", ctx["os"])
|
||||||
|
assert.Equal(t, goArchToActionArch(runtime.GOARCH), ctx["arch"])
|
||||||
|
assert.Equal(t, "/Users/admin/scratch/tmp", ctx["temp"])
|
||||||
|
assert.Equal(t, "/Users/admin/scratch/tool_cache", ctx["tool_cache"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMacOSVMEnvironmentReplaceLogWriter(t *testing.T) {
|
||||||
|
oldOut := io.Discard
|
||||||
|
oldErr := io.Discard
|
||||||
|
env := &MacOSVMEnvironment{Stdout: oldOut, Stderr: oldErr}
|
||||||
|
newOut, newErr := env.ReplaceLogWriter(io.Discard, io.Discard)
|
||||||
|
assert.Equal(t, oldOut, newOut)
|
||||||
|
assert.Equal(t, oldErr, newErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMacOSVMEnvironmentDefaultPathVariable(t *testing.T) {
|
||||||
|
env := &MacOSVMEnvironment{}
|
||||||
|
assert.Equal(t, "PATH", env.GetPathVariableName())
|
||||||
|
assert.Equal(t, "/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin", env.DefaultPathVariable())
|
||||||
|
assert.Equal(t, "/a:/b", env.JoinPathVariable("/a", "/b"))
|
||||||
|
assert.False(t, env.IsEnvironmentCaseInsensitive())
|
||||||
|
}
|
||||||
@@ -50,7 +50,7 @@ func parseEnvFile(e Container, srcPath string, env *map[string]string) common.Ex
|
|||||||
case singleLineEnv != -1 && (multiLineEnv == -1 || singleLineEnv < multiLineEnv):
|
case singleLineEnv != -1 && (multiLineEnv == -1 || singleLineEnv < multiLineEnv):
|
||||||
localEnv[line[:singleLineEnv]] = line[singleLineEnv+1:]
|
localEnv[line[:singleLineEnv]] = line[singleLineEnv+1:]
|
||||||
case multiLineEnv != -1:
|
case multiLineEnv != -1:
|
||||||
multiLineEnvContent := ""
|
var multiLineEnvContent []string
|
||||||
multiLineEnvDelimiter := line[multiLineEnv+2:]
|
multiLineEnvDelimiter := line[multiLineEnv+2:]
|
||||||
delimiterFound := false
|
delimiterFound := false
|
||||||
for s.Scan() {
|
for s.Scan() {
|
||||||
@@ -59,10 +59,7 @@ func parseEnvFile(e Container, srcPath string, env *map[string]string) common.Ex
|
|||||||
delimiterFound = true
|
delimiterFound = true
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
if multiLineEnvContent != "" {
|
multiLineEnvContent = append(multiLineEnvContent, content)
|
||||||
multiLineEnvContent += "\n"
|
|
||||||
}
|
|
||||||
multiLineEnvContent += content
|
|
||||||
}
|
}
|
||||||
if err := s.Err(); err != nil {
|
if err := s.Err(); err != nil {
|
||||||
return fmt.Errorf("reading env file: %w", err)
|
return fmt.Errorf("reading env file: %w", err)
|
||||||
@@ -70,7 +67,7 @@ func parseEnvFile(e Container, srcPath string, env *map[string]string) common.Ex
|
|||||||
if !delimiterFound {
|
if !delimiterFound {
|
||||||
return fmt.Errorf("invalid format delimiter '%v' not found before end of file", multiLineEnvDelimiter)
|
return fmt.Errorf("invalid format delimiter '%v' not found before end of file", multiLineEnvDelimiter)
|
||||||
}
|
}
|
||||||
localEnv[line[:multiLineEnv]] = multiLineEnvContent
|
localEnv[line[:multiLineEnv]] = strings.Join(multiLineEnvContent, "\n")
|
||||||
default:
|
default:
|
||||||
return fmt.Errorf("invalid format '%v', expected a line with '=' or '<<'", line)
|
return fmt.Errorf("invalid format '%v', expected a line with '=' or '<<'", line)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -86,6 +86,11 @@ func TestParseEnvFileMultiLineKeepsBlankLines(t *testing.T) {
|
|||||||
env := map[string]string{}
|
env := map[string]string{}
|
||||||
require.NoError(t, parseEnvFile(e, envPath, &env)(context.Background()))
|
require.NoError(t, parseEnvFile(e, envPath, &env)(context.Background()))
|
||||||
assert.Equal(t, "line1\n\nline2", env["FOO"])
|
assert.Equal(t, "line1\n\nline2", env["FOO"])
|
||||||
|
|
||||||
|
require.NoError(t, os.WriteFile(envPath, []byte("FOO<<EOF\n\nline2\nEOF\n"), 0o600))
|
||||||
|
env = map[string]string{}
|
||||||
|
require.NoError(t, parseEnvFile(e, envPath, &env)(context.Background()))
|
||||||
|
assert.Equal(t, "\nline2", env["FOO"])
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestParseEnvFileUTF8BOM(t *testing.T) {
|
func TestParseEnvFileUTF8BOM(t *testing.T) {
|
||||||
|
|||||||
+80
-40
@@ -32,7 +32,7 @@ type actionStep interface {
|
|||||||
step
|
step
|
||||||
|
|
||||||
getActionModel() *model.Action
|
getActionModel() *model.Action
|
||||||
getCompositeRunContext(context.Context) *RunContext
|
getCompositeRunContext(context.Context) (*RunContext, error)
|
||||||
getCompositeSteps() *compositeSteps
|
getCompositeSteps() *compositeSteps
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -172,7 +172,9 @@ func runActionImpl(step actionStep, actionDir string, remoteAction *remoteAction
|
|||||||
|
|
||||||
rc.withGithubEnv(ctx, step.getGithubContext(ctx), *step.getEnv())
|
rc.withGithubEnv(ctx, step.getGithubContext(ctx), *step.getEnv())
|
||||||
populateEnvsFromSavedState(step.getEnv(), step, rc)
|
populateEnvsFromSavedState(step.getEnv(), step, rc)
|
||||||
populateEnvsFromInput(ctx, step.getEnv(), action, rc)
|
if err := populateEnvsFromInput(ctx, step.getEnv(), action, rc); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
actionLocation := path.Join(actionDir, actionPath)
|
actionLocation := path.Join(actionDir, actionPath)
|
||||||
actionName, containerActionDir := getContainerActionPaths(stepModel, actionLocation, rc)
|
actionName, containerActionDir := getContainerActionPaths(stepModel, actionLocation, rc)
|
||||||
@@ -185,7 +187,7 @@ func runActionImpl(step actionStep, actionDir string, remoteAction *remoteAction
|
|||||||
if err := maybeCopyToActionDir(ctx, step, actionDir, actionPath, containerActionDir); err != nil {
|
if err := maybeCopyToActionDir(ctx, step, actionDir, actionPath, containerActionDir); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
containerArgs := []string{"node", path.Join(containerActionDir, action.Runs.Main)}
|
containerArgs := nodeActionCommand(path.Join(containerActionDir, action.Runs.Main))
|
||||||
logger.Debugf("executing remote job container: %s", containerArgs)
|
logger.Debugf("executing remote job container: %s", containerArgs)
|
||||||
|
|
||||||
rc.ApplyExtraPath(ctx, step.getEnv())
|
rc.ApplyExtraPath(ctx, step.getEnv())
|
||||||
@@ -232,6 +234,11 @@ func runActionImpl(step actionStep, actionDir string, remoteAction *remoteAction
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// /var/run is a symlink, so without the flag node's import.meta.url differs from argv[1], which ESM actions compare.
|
||||||
|
func nodeActionCommand(script string) []string {
|
||||||
|
return []string{"node", "--preserve-symlinks-main", script}
|
||||||
|
}
|
||||||
|
|
||||||
// https://github.com/nektos/act/issues/228#issuecomment-629709055
|
// https://github.com/nektos/act/issues/228#issuecomment-629709055
|
||||||
// files in .gitignore are not copied in a Docker container
|
// files in .gitignore are not copied in a Docker container
|
||||||
// this causes issues with actions that ignore other important resources
|
// this causes issues with actions that ignore other important resources
|
||||||
@@ -347,19 +354,35 @@ func execAsDocker(ctx context.Context, step actionStep, actionName, actionDir, b
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
eval := rc.NewActionInputsExpressionEvaluator(ctx, step)
|
eval := rc.NewActionInputsExpressionEvaluator(ctx, step)
|
||||||
cmd, err := shellquote.Split(eval.Interpolate(ctx, step.getStepModel().With["args"]))
|
args, err := eval.Interpolate(ctx, step.getStepModel().With["args"])
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("unable to interpolate with.args: %w", err)
|
||||||
|
}
|
||||||
|
cmd, err := shellquote.Split(args)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if len(cmd) == 0 {
|
ee, err := evalDockerEnv(ctx, step, action)
|
||||||
cmd = action.Runs.Args
|
if err != nil {
|
||||||
evalDockerArgs(ctx, step, action, &cmd)
|
return err
|
||||||
|
}
|
||||||
|
if action.Runs.Args != nil {
|
||||||
|
// a fresh slice, the manifest is evaluated again for every stage
|
||||||
|
cmd = make([]string, len(action.Runs.Args))
|
||||||
|
for i, v := range action.Runs.Args {
|
||||||
|
if cmd[i], err = ee.Interpolate(ctx, v); err != nil {
|
||||||
|
return fmt.Errorf("unable to interpolate runs.args: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
entrypoint, err := dockerEntrypoint(ctx, step, eval, stage)
|
entrypoint, err := dockerEntrypoint(ctx, step, eval, stage)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
stepContainer := newStepContainer(ctx, step, image, cmd, entrypoint, rc.Config.ContainerOptions)
|
stepContainer, err := newStepContainer(ctx, step, image, cmd, entrypoint, rc.Config.ContainerOptions)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
return common.NewPipelineExecutor(
|
return common.NewPipelineExecutor(
|
||||||
prepImage,
|
prepImage,
|
||||||
stepContainer.Pull(forcePull),
|
stepContainer.Pull(forcePull),
|
||||||
@@ -381,10 +404,16 @@ func dockerEntrypoint(ctx context.Context, step actionStep, eval *expressionEval
|
|||||||
case stepStagePost:
|
case stepStagePost:
|
||||||
entrypoint = runs.PostEntrypoint
|
entrypoint = runs.PostEntrypoint
|
||||||
default:
|
default:
|
||||||
if fields := strings.Fields(eval.Interpolate(ctx, step.getStepModel().With["entrypoint"])); len(fields) > 0 {
|
|
||||||
return fields, nil
|
|
||||||
}
|
|
||||||
entrypoint = runs.Entrypoint
|
entrypoint = runs.Entrypoint
|
||||||
|
if entrypoint == "" {
|
||||||
|
withEntrypoint, err := eval.Interpolate(ctx, step.getStepModel().With["entrypoint"])
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("unable to interpolate with.entrypoint: %w", err)
|
||||||
|
}
|
||||||
|
if fields := strings.Fields(withEntrypoint); len(fields) > 0 {
|
||||||
|
return fields, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if entrypoint == "" {
|
if entrypoint == "" {
|
||||||
@@ -393,36 +422,41 @@ func dockerEntrypoint(ctx context.Context, step actionStep, eval *expressionEval
|
|||||||
return shellquote.Split(entrypoint)
|
return shellquote.Split(entrypoint)
|
||||||
}
|
}
|
||||||
|
|
||||||
func evalDockerArgs(ctx context.Context, step step, action *model.Action, cmd *[]string) {
|
// evalDockerEnv returns an evaluator bound to the environment it installed.
|
||||||
|
func evalDockerEnv(ctx context.Context, step step, action *model.Action) (*expressionEvaluator, error) {
|
||||||
rc := step.getRunContext()
|
rc := step.getRunContext()
|
||||||
stepModel := step.getStepModel()
|
stepModel := step.getStepModel()
|
||||||
|
|
||||||
|
var err error
|
||||||
inputs := make(map[string]string)
|
inputs := make(map[string]string)
|
||||||
eval := rc.NewExpressionEvaluator(ctx)
|
eval := rc.NewExpressionEvaluator(ctx)
|
||||||
// Set Defaults
|
// Set Defaults
|
||||||
for k, input := range action.Inputs {
|
for k, input := range action.Inputs {
|
||||||
inputs[k] = eval.Interpolate(ctx, input.Default)
|
if inputs[k], err = eval.Interpolate(ctx, input.Default); err != nil {
|
||||||
|
return nil, fmt.Errorf("unable to interpolate the default of input %s: %w", k, err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if stepModel.With != nil {
|
for k, v := range stepModel.With {
|
||||||
for k, v := range stepModel.With {
|
if inputs[k], err = eval.Interpolate(ctx, v); err != nil {
|
||||||
inputs[k] = eval.Interpolate(ctx, v)
|
return nil, fmt.Errorf("unable to interpolate with.%s: %w", k, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
mergeIntoMap(step, step.getEnv(), inputs)
|
mergeIntoMap(step, step.getEnv(), inputs)
|
||||||
|
|
||||||
stepEE := rc.NewActionInputsExpressionEvaluator(ctx, step)
|
env := make(map[string]string, len(action.Runs.Env)+len(*step.getEnv()))
|
||||||
for i, v := range *cmd {
|
mergeIntoMap(step, &env, action.Runs.Env, *step.getEnv())
|
||||||
(*cmd)[i] = stepEE.Interpolate(ctx, v)
|
*step.getEnv() = env
|
||||||
}
|
|
||||||
mergeIntoMap(step, step.getEnv(), action.Runs.Env)
|
|
||||||
|
|
||||||
ee := rc.NewActionInputsExpressionEvaluator(ctx, step)
|
ee := rc.NewActionInputsExpressionEvaluator(ctx, step)
|
||||||
for k, v := range *step.getEnv() {
|
for k, v := range *step.getEnv() {
|
||||||
(*step.getEnv())[k] = ee.Interpolate(ctx, v)
|
if (*step.getEnv())[k], err = ee.Interpolate(ctx, v); err != nil {
|
||||||
|
return nil, fmt.Errorf("unable to interpolate env %s: %w", k, err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
return ee, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func newStepContainer(ctx context.Context, step step, image string, cmd, entrypoint []string, runnerOptions string) container.Container {
|
func newStepContainer(ctx context.Context, step step, image string, cmd, entrypoint []string, runnerOptions string) (container.Container, error) {
|
||||||
rc := step.getRunContext()
|
rc := step.getRunContext()
|
||||||
logWriter := rc.commandLogWriter(ctx)
|
logWriter := rc.commandLogWriter(ctx)
|
||||||
envList := make([]string, 0)
|
envList := make([]string, 0)
|
||||||
@@ -432,9 +466,12 @@ func newStepContainer(ctx context.Context, step step, image string, cmd, entrypo
|
|||||||
|
|
||||||
envList = append(envList, rc.runnerEnv(ctx)...)
|
envList = append(envList, rc.runnerEnv(ctx)...)
|
||||||
|
|
||||||
binds, mounts := rc.GetBindsAndMounts()
|
binds, mounts, err := rc.GetBindsAndMounts()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
networkMode := "container:" + rc.jobContainerName()
|
networkMode := "container:" + rc.jobContainerName()
|
||||||
if rc.IsHostEnv(ctx) {
|
if rc.IsHostEnv() {
|
||||||
networkMode = "default"
|
networkMode = "default"
|
||||||
}
|
}
|
||||||
return ContainerNewContainer(&container.NewContainerInput{
|
return ContainerNewContainer(&container.NewContainerInput{
|
||||||
@@ -456,7 +493,7 @@ func newStepContainer(ctx context.Context, step step, image string, cmd, entrypo
|
|||||||
AutoRemove: true,
|
AutoRemove: true,
|
||||||
ValidVolumes: rc.validVolumes(),
|
ValidVolumes: rc.validVolumes(),
|
||||||
AllocatePTY: rc.Config.AllocatePTY,
|
AllocatePTY: rc.Config.AllocatePTY,
|
||||||
})
|
}), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func populateEnvsFromSavedState(env *map[string]string, step actionStep, rc *RunContext) {
|
func populateEnvsFromSavedState(env *map[string]string, step actionStep, rc *RunContext) {
|
||||||
@@ -469,15 +506,19 @@ func populateEnvsFromSavedState(env *map[string]string, step actionStep, rc *Run
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func populateEnvsFromInput(ctx context.Context, env *map[string]string, action *model.Action, rc *RunContext) {
|
func populateEnvsFromInput(ctx context.Context, env *map[string]string, action *model.Action, rc *RunContext) error {
|
||||||
eval := rc.NewExpressionEvaluator(ctx)
|
eval := rc.NewExpressionEvaluator(ctx)
|
||||||
for inputID, input := range action.Inputs {
|
for inputID, input := range action.Inputs {
|
||||||
envKey := regexp.MustCompile("[^A-Z0-9-]").ReplaceAllString(strings.ToUpper(inputID), "_")
|
envKey := regexp.MustCompile("[^A-Z0-9-]").ReplaceAllString(strings.ToUpper(inputID), "_")
|
||||||
envKey = "INPUT_" + envKey
|
envKey = "INPUT_" + envKey
|
||||||
if _, ok := (*env)[envKey]; !ok {
|
if _, ok := (*env)[envKey]; !ok {
|
||||||
(*env)[envKey] = eval.Interpolate(ctx, input.Default)
|
var err error
|
||||||
|
if (*env)[envKey], err = eval.Interpolate(ctx, input.Default); err != nil {
|
||||||
|
return fmt.Errorf("unable to interpolate the default of input %s: %w", inputID, err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func getContainerActionPaths(step *model.Step, actionDir string, rc *RunContext) (string, string) {
|
func getContainerActionPaths(step *model.Step, actionDir string, rc *RunContext) (string, string) {
|
||||||
@@ -577,16 +618,19 @@ func runPreStep(step actionStep) common.Executor {
|
|||||||
actionDir, actionPath, _, containerActionDir := actionStagePaths(step)
|
actionDir, actionPath, _, containerActionDir := actionStagePaths(step)
|
||||||
|
|
||||||
x := action.Runs.Using
|
x := action.Runs.Using
|
||||||
|
if !x.IsComposite() {
|
||||||
|
// defaults in pre steps were missing, however provided inputs are available
|
||||||
|
if err := populateEnvsFromInput(ctx, step.getEnv(), action, rc); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
switch {
|
switch {
|
||||||
case x.IsNode():
|
case x.IsNode():
|
||||||
// defaults in pre steps were missing, however provided inputs are available
|
|
||||||
populateEnvsFromInput(ctx, step.getEnv(), action, rc)
|
|
||||||
|
|
||||||
if err := maybeCopyToActionDir(ctx, step, actionDir, actionPath, containerActionDir); err != nil {
|
if err := maybeCopyToActionDir(ctx, step, actionDir, actionPath, containerActionDir); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
containerArgs := []string{"node", path.Join(containerActionDir, action.Runs.Pre)}
|
containerArgs := nodeActionCommand(path.Join(containerActionDir, action.Runs.Pre))
|
||||||
logger.Debugf("executing remote job container: %s", containerArgs)
|
logger.Debugf("executing remote job container: %s", containerArgs)
|
||||||
|
|
||||||
rc.ApplyExtraPath(ctx, step.getEnv())
|
rc.ApplyExtraPath(ctx, step.getEnv())
|
||||||
@@ -594,14 +638,13 @@ func runPreStep(step actionStep) common.Executor {
|
|||||||
return rc.JobContainer.Exec(containerArgs, *step.getEnv(), "", "")(ctx)
|
return rc.JobContainer.Exec(containerArgs, *step.getEnv(), "", "")(ctx)
|
||||||
|
|
||||||
case x.IsDocker():
|
case x.IsDocker():
|
||||||
// defaults in pre steps were missing, however provided inputs are available
|
|
||||||
populateEnvsFromInput(ctx, step.getEnv(), action, rc)
|
|
||||||
|
|
||||||
return execDockerActionStage(ctx, step, stepStagePre)
|
return execDockerActionStage(ctx, step, stepStagePre)
|
||||||
|
|
||||||
case x.IsComposite():
|
case x.IsComposite():
|
||||||
if step.getCompositeSteps() == nil {
|
if step.getCompositeSteps() == nil {
|
||||||
step.getCompositeRunContext(ctx)
|
if _, err := step.getCompositeRunContext(ctx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if steps := step.getCompositeSteps(); steps != nil && steps.pre != nil {
|
if steps := step.getCompositeSteps(); steps != nil && steps.pre != nil {
|
||||||
@@ -610,9 +653,6 @@ func runPreStep(step actionStep) common.Executor {
|
|||||||
return errors.New("missing steps in composite action")
|
return errors.New("missing steps in composite action")
|
||||||
|
|
||||||
case x == model.ActionRunsUsingGo:
|
case x == model.ActionRunsUsingGo:
|
||||||
// defaults in pre steps were missing, however provided inputs are available
|
|
||||||
populateEnvsFromInput(ctx, step.getEnv(), action, rc)
|
|
||||||
|
|
||||||
if err := maybeCopyToActionDir(ctx, step, actionDir, actionPath, containerActionDir); err != nil {
|
if err := maybeCopyToActionDir(ctx, step, actionDir, actionPath, containerActionDir); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -687,7 +727,7 @@ func runPostStep(step actionStep) common.Executor {
|
|||||||
|
|
||||||
populateEnvsFromSavedState(step.getEnv(), step, rc)
|
populateEnvsFromSavedState(step.getEnv(), step, rc)
|
||||||
|
|
||||||
containerArgs := []string{"node", path.Join(containerActionDir, action.Runs.Post)}
|
containerArgs := nodeActionCommand(path.Join(containerActionDir, action.Runs.Post))
|
||||||
logger.Debugf("executing remote job container: %s", containerArgs)
|
logger.Debugf("executing remote job container: %s", containerArgs)
|
||||||
|
|
||||||
rc.ApplyExtraPath(ctx, step.getEnv())
|
rc.ApplyExtraPath(ctx, step.getEnv())
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ package runner
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"regexp"
|
"regexp"
|
||||||
"slices"
|
"slices"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -17,7 +18,7 @@ import (
|
|||||||
"gitea.dev/actionslib/pkg/model"
|
"gitea.dev/actionslib/pkg/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
func evaluateCompositeInputAndEnv(ctx context.Context, parent *RunContext, step actionStep) map[string]string {
|
func evaluateCompositeInputAndEnv(ctx context.Context, parent *RunContext, step actionStep) (map[string]string, error) {
|
||||||
env := make(map[string]string)
|
env := make(map[string]string)
|
||||||
stepEnv := *step.getEnv()
|
stepEnv := *step.getEnv()
|
||||||
for k, v := range stepEnv {
|
for k, v := range stepEnv {
|
||||||
@@ -36,23 +37,44 @@ func evaluateCompositeInputAndEnv(ctx context.Context, parent *RunContext, step
|
|||||||
|
|
||||||
// lookup if key is defined in the step but the already
|
// lookup if key is defined in the step but the already
|
||||||
// evaluated value from the environment
|
// evaluated value from the environment
|
||||||
_, defined := step.getStepModel().With[inputID]
|
defined := false
|
||||||
|
for key := range step.getStepModel().With {
|
||||||
|
if strings.EqualFold(key, inputID) {
|
||||||
|
defined = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
if value, ok := stepEnv[envKey]; defined && ok {
|
if value, ok := stepEnv[envKey]; defined && ok {
|
||||||
env[envKey] = value
|
env[envKey] = value
|
||||||
} else {
|
} else {
|
||||||
// defaults could contain expressions
|
// defaults could contain expressions
|
||||||
env[envKey] = ee.Interpolate(ctx, input.Default)
|
var err error
|
||||||
|
if env[envKey], err = ee.Interpolate(ctx, input.Default); err != nil {
|
||||||
|
return nil, fmt.Errorf("unable to interpolate the default of input %s: %w", inputID, err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
gh := step.getGithubContext(ctx)
|
gh := step.getGithubContext(ctx)
|
||||||
env["GITHUB_ACTION_REPOSITORY"] = gh.ActionRepository
|
env["GITHUB_ACTION_REPOSITORY"] = gh.ActionRepository
|
||||||
env["GITHUB_ACTION_REF"] = gh.ActionRef
|
env["GITHUB_ACTION_REF"] = gh.ActionRef
|
||||||
|
|
||||||
return env
|
return env, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func newCompositeRunContext(ctx context.Context, parent *RunContext, step actionStep, actionPath string) *RunContext {
|
func (rc *RunContext) setCompositeActionEnv(env map[string]string) {
|
||||||
env := evaluateCompositeInputAndEnv(ctx, parent, step)
|
rc.setActionEnv(env)
|
||||||
|
for key := range rc.Env {
|
||||||
|
if strings.HasPrefix(key, "INPUT_") {
|
||||||
|
delete(rc.Env, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newCompositeRunContext(ctx context.Context, parent *RunContext, step actionStep, actionPath string) (*RunContext, error) {
|
||||||
|
env, err := evaluateCompositeInputAndEnv(ctx, parent, step)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
// run with the global config but without secrets
|
// run with the global config but without secrets
|
||||||
configCopy := *parent.Config
|
configCopy := *parent.Config
|
||||||
@@ -62,29 +84,31 @@ func newCompositeRunContext(ctx context.Context, parent *RunContext, step action
|
|||||||
compositerc := &RunContext{
|
compositerc := &RunContext{
|
||||||
Name: parent.Name,
|
Name: parent.Name,
|
||||||
JobName: parent.JobName,
|
JobName: parent.JobName,
|
||||||
|
Matrix: parent.Matrix,
|
||||||
Run: &model.Run{
|
Run: &model.Run{
|
||||||
JobID: parent.Run.JobID,
|
JobID: parent.Run.JobID,
|
||||||
Workflow: &model.Workflow{
|
Workflow: &model.Workflow{
|
||||||
Name: parent.Run.Workflow.Name,
|
Name: parent.Run.Workflow.Name,
|
||||||
Jobs: map[string]*model.Job{
|
Jobs: map[string]*model.Job{
|
||||||
parent.Run.JobID: {},
|
parent.Run.JobID: {Strategy: parent.Run.Job().Strategy},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
Config: &configCopy,
|
Config: &configCopy,
|
||||||
StepResults: map[string]*model.StepResult{},
|
StepResults: map[string]*model.StepResult{},
|
||||||
JobContainer: parent.JobContainer,
|
JobContainer: parent.JobContainer,
|
||||||
ActionPath: actionPath,
|
ActionPath: actionPath,
|
||||||
GlobalEnv: parent.GlobalEnv,
|
GlobalEnv: parent.GlobalEnv,
|
||||||
Masks: parent.Masks,
|
Masks: parent.Masks,
|
||||||
ExtraPath: parent.ExtraPath,
|
ExtraPath: parent.ExtraPath,
|
||||||
Parent: parent,
|
Parent: parent,
|
||||||
EventJSON: parent.EventJSON,
|
EventJSON: parent.EventJSON,
|
||||||
|
platformImage: parent.platformImage,
|
||||||
}
|
}
|
||||||
compositerc.setActionEnv(env)
|
compositerc.setCompositeActionEnv(env)
|
||||||
compositerc.ExprEval = compositerc.NewExpressionEvaluator(ctx)
|
compositerc.ExprEval = compositerc.NewExpressionEvaluator(ctx)
|
||||||
|
|
||||||
return compositerc
|
return compositerc, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// appendUniqueMasks appends the masks from src to dst, skipping any mask that
|
// appendUniqueMasks appends the masks from src to dst, skipping any mask that
|
||||||
@@ -105,7 +129,10 @@ func execAsComposite(step actionStep) common.Executor {
|
|||||||
action := step.getActionModel()
|
action := step.getActionModel()
|
||||||
|
|
||||||
return func(ctx context.Context) error {
|
return func(ctx context.Context) error {
|
||||||
compositeRC := step.getCompositeRunContext(ctx)
|
compositeRC, err := step.getCompositeRunContext(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
steps := step.getCompositeSteps()
|
steps := step.getCompositeSteps()
|
||||||
|
|
||||||
@@ -115,14 +142,17 @@ func execAsComposite(step actionStep) common.Executor {
|
|||||||
|
|
||||||
ctx = WithCompositeLogger(ctx, &compositeRC.Masks)
|
ctx = WithCompositeLogger(ctx, &compositeRC.Masks)
|
||||||
|
|
||||||
err := steps.main(ctx)
|
err = steps.main(ctx)
|
||||||
|
|
||||||
// Map outputs from composite RunContext to job RunContext
|
// Map outputs from composite RunContext to job RunContext
|
||||||
eval := compositeRC.NewExpressionEvaluator(ctx)
|
eval := compositeRC.NewExpressionEvaluator(ctx)
|
||||||
for outputName, output := range action.Outputs {
|
for outputName, output := range action.Outputs {
|
||||||
rc.setOutput(ctx, map[string]string{
|
value, outputErr := eval.Interpolate(ctx, output.Value)
|
||||||
"name": outputName,
|
if outputErr != nil {
|
||||||
}, eval.Interpolate(ctx, output.Value))
|
err = errors.Join(err, fmt.Errorf("unable to interpolate output %s: %w", outputName, outputErr))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
rc.setOutput(ctx, map[string]string{"name": outputName}, value)
|
||||||
}
|
}
|
||||||
|
|
||||||
// compositeRC.Masks is seeded with rc.Masks (see newCompositeRunContext)
|
// compositeRC.Masks is seeded with rc.Masks (see newCompositeRunContext)
|
||||||
@@ -131,7 +161,7 @@ func execAsComposite(step actionStep) common.Executor {
|
|||||||
// repeated composite actions grow rc.Masks exponentially.
|
// repeated composite actions grow rc.Masks exponentially.
|
||||||
rc.Masks = appendUniqueMasks(rc.Masks, compositeRC.Masks)
|
rc.Masks = appendUniqueMasks(rc.Masks, compositeRC.Masks)
|
||||||
rc.ExtraPath = compositeRC.ExtraPath
|
rc.ExtraPath = compositeRC.ExtraPath
|
||||||
// compositeRC.Env is dirty, contains INPUT_ and merged step env, only rely on compositeRC.GlobalEnv
|
// Propagate GlobalEnv only, so composite inputs and step-local values do not escape.
|
||||||
mergeIntoMap := mergeIntoMapCaseSensitive
|
mergeIntoMap := mergeIntoMapCaseSensitive
|
||||||
if rc.JobContainer.IsEnvironmentCaseInsensitive() {
|
if rc.JobContainer.IsEnvironmentCaseInsensitive() {
|
||||||
mergeIntoMap = mergeIntoMapCaseInsensitive
|
mergeIntoMap = mergeIntoMapCaseInsensitive
|
||||||
@@ -194,6 +224,7 @@ func (rc *RunContext) compositeExecutor(action *model.Action) *compositeSteps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
steps = append(steps, common.JobError)
|
steps = append(steps, common.JobError)
|
||||||
|
preSteps = append(preSteps, common.JobError)
|
||||||
return &compositeSteps{
|
return &compositeSteps{
|
||||||
pre: func(ctx context.Context) error {
|
pre: func(ctx context.Context) error {
|
||||||
return common.NewPipelineExecutor(preSteps...)(common.WithJobErrorContainer(ctx))
|
return common.NewPipelineExecutor(preSteps...)(common.WithJobErrorContainer(ctx))
|
||||||
|
|||||||
@@ -6,9 +6,58 @@ package runner
|
|||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"gitea.com/gitea/runner/act/common"
|
||||||
|
"gitea.com/gitea/runner/act/common/git"
|
||||||
|
|
||||||
|
"gitea.dev/actionslib/pkg/model"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func TestCompositeActionParity(t *testing.T) {
|
||||||
|
t.Run("inherits contexts without leaking inputs", func(t *testing.T) {
|
||||||
|
ctx := t.Context()
|
||||||
|
strategy := &model.Strategy{MaxParallel: 3}
|
||||||
|
parent := &RunContext{
|
||||||
|
Config: &Config{},
|
||||||
|
Matrix: map[string]any{"os": "linux"},
|
||||||
|
Run: &model.Run{JobID: "job", Workflow: &model.Workflow{Name: "workflow", Jobs: map[string]*model.Job{"job": {Strategy: strategy}}}},
|
||||||
|
JobContainer: &jobContainerMock{},
|
||||||
|
platformImage: "-self-hosted",
|
||||||
|
}
|
||||||
|
composite, err := newCompositeRunContext(ctx, parent, &stepActionRemote{
|
||||||
|
Step: &model.Step{With: map[string]string{"SHARED": "outer"}},
|
||||||
|
RunContext: parent,
|
||||||
|
action: &model.Action{Inputs: map[string]model.Input{"shared": {Default: "outer-default"}}},
|
||||||
|
env: map[string]string{"INPUT_SHARED": "outer"},
|
||||||
|
}, "/action")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Same(t, strategy, composite.Run.Job().Strategy)
|
||||||
|
assert.True(t, composite.IsHostEnv())
|
||||||
|
interpolated, err := composite.NewExpressionEvaluator(ctx).Interpolate(ctx,
|
||||||
|
"${{ matrix.os }}|${{ strategy.max-parallel }}|${{ inputs.shared }}")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "linux|3|outer", interpolated)
|
||||||
|
assert.NotContains(t, composite.Env, "INPUT_SHARED")
|
||||||
|
|
||||||
|
nestedEnv := composite.GetEnv()
|
||||||
|
require.NoError(t, populateEnvsFromInput(ctx, &nestedEnv, &model.Action{Inputs: map[string]model.Input{"shared": {Default: "inner-default"}}}, composite))
|
||||||
|
assert.Equal(t, "inner-default", nestedEnv["INPUT_SHARED"])
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("propagates pre failures", func(t *testing.T) {
|
||||||
|
setCloneExecutor(t, func(git.NewGitCloneExecutorInput) common.Executor { return common.NewErrorExecutor(assert.AnError) })
|
||||||
|
rc := &RunContext{
|
||||||
|
Config: &Config{GitHubInstance: "github.com", ActionCacheDir: t.TempDir()},
|
||||||
|
Run: &model.Run{JobID: "job", Workflow: &model.Workflow{Jobs: map[string]*model.Job{"job": {}}}},
|
||||||
|
JobContainer: &jobContainerMock{},
|
||||||
|
}
|
||||||
|
|
||||||
|
require.ErrorIs(t, rc.compositeExecutor(&model.Action{Runs: model.ActionRuns{Using: "composite", Steps: []model.Step{{ID: "nested", Uses: "org/action@v1"}}}}).pre(t.Context()), assert.AnError)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func TestAppendUniqueMasks(t *testing.T) {
|
func TestAppendUniqueMasks(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
|
|||||||
+82
-23
@@ -8,6 +8,10 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"io"
|
"io"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -234,7 +238,7 @@ func TestActionRunner(t *testing.T) {
|
|||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
|
|
||||||
cm.On("Exec", []string{"node", "/var/run/act/actions/dir/path"}, envMatcher, "", "").Return(func(ctx context.Context) error { return nil })
|
cm.On("Exec", []string{"node", "--preserve-symlinks-main", "/var/run/act/actions/dir/path"}, envMatcher, "", "").Return(func(ctx context.Context) error { return nil })
|
||||||
|
|
||||||
tt.step.getRunContext().JobContainer = cm
|
tt.step.getRunContext().JobContainer = cm
|
||||||
|
|
||||||
@@ -246,6 +250,32 @@ func TestActionRunner(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestNodeActionCommandPreservesSymlinkedEntrypoint(t *testing.T) {
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
t.Skip("symlink creation requires privileges on Windows")
|
||||||
|
}
|
||||||
|
requireHostTools(t, "node")
|
||||||
|
|
||||||
|
actionDir := t.TempDir()
|
||||||
|
entrypoint := filepath.Join(actionDir, "index.mjs")
|
||||||
|
require.NoError(t, os.WriteFile(entrypoint, []byte(`
|
||||||
|
import {fileURLToPath} from "node:url";
|
||||||
|
const self = fileURLToPath(import.meta.url);
|
||||||
|
if (process.argv[1] !== self) {
|
||||||
|
console.log("argv[1]:", process.argv[1], "import.meta.url:", self);
|
||||||
|
process.exitCode = 1;
|
||||||
|
}
|
||||||
|
`), 0o600))
|
||||||
|
|
||||||
|
symlinkedActionDir := filepath.Join(t.TempDir(), "action")
|
||||||
|
require.NoError(t, os.Symlink(actionDir, symlinkedActionDir))
|
||||||
|
symlinkedEntrypoint := filepath.Join(symlinkedActionDir, "index.mjs")
|
||||||
|
|
||||||
|
args := nodeActionCommand(symlinkedEntrypoint)
|
||||||
|
output, err := exec.CommandContext(t.Context(), args[0], args[1:]...).CombinedOutput()
|
||||||
|
require.NoError(t, err, "%s", output)
|
||||||
|
}
|
||||||
|
|
||||||
func TestNewStepContainerDoesNotUseDockerSecrets(t *testing.T) {
|
func TestNewStepContainerDoesNotUseDockerSecrets(t *testing.T) {
|
||||||
cm := &containerMock{}
|
cm := &containerMock{}
|
||||||
|
|
||||||
@@ -286,7 +316,7 @@ func TestNewStepContainerDoesNotUseDockerSecrets(t *testing.T) {
|
|||||||
step.On("getStepModel").Return(&model.Step{ID: "action"})
|
step.On("getStepModel").Return(&model.Step{ID: "action"})
|
||||||
step.On("getEnv").Return(&env)
|
step.On("getEnv").Return(&env)
|
||||||
|
|
||||||
_ = newStepContainer(ctx, step, "registry.example.com/action:tag", nil, nil, "")
|
_, _ = 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.
|
// DOCKER_USERNAME/DOCKER_PASSWORD should not be injected as pull credentials for docker action containers.
|
||||||
assert.Empty(t, captured.Username)
|
assert.Empty(t, captured.Username)
|
||||||
@@ -492,8 +522,6 @@ func TestDockerActionImageTag(t *testing.T) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only the entrypoint is stage specific: every stage of a docker action receives runs.args
|
|
||||||
// and runs.env, and the `entrypoint` input applies to the main stage alone.
|
|
||||||
func TestExecAsDockerStageEntrypoint(t *testing.T) {
|
func TestExecAsDockerStageEntrypoint(t *testing.T) {
|
||||||
orig := ContainerNewContainer
|
orig := ContainerNewContainer
|
||||||
defer func() { ContainerNewContainer = orig }()
|
defer func() { ContainerNewContainer = orig }()
|
||||||
@@ -501,25 +529,62 @@ func TestExecAsDockerStageEntrypoint(t *testing.T) {
|
|||||||
for _, tc := range []struct {
|
for _, tc := range []struct {
|
||||||
name string
|
name string
|
||||||
stage stepStage
|
stage stepStage
|
||||||
|
with map[string]string
|
||||||
|
runs model.ActionRuns
|
||||||
|
env map[string]string
|
||||||
|
wantCmd []string
|
||||||
wantEntrypoint []string
|
wantEntrypoint []string
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "main stage prefers the entrypoint input",
|
name: "main stage prefers manifest values",
|
||||||
stage: stepStageMain,
|
stage: stepStageMain,
|
||||||
wantEntrypoint: []string{"input.sh"},
|
with: map[string]string{"args": "caller", "entrypoint": "input.sh"},
|
||||||
|
runs: model.ActionRuns{
|
||||||
|
Entrypoint: "main.sh",
|
||||||
|
Args: []string{"manifest"},
|
||||||
|
Env: map[string]string{"ACTION_ONLY": "manifest"},
|
||||||
|
},
|
||||||
|
wantCmd: []string{"manifest"},
|
||||||
|
wantEntrypoint: []string{"main.sh"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "pre stage uses runs.pre-entrypoint",
|
name: "main stage uses caller fallbacks",
|
||||||
stage: stepStagePre,
|
stage: stepStageMain,
|
||||||
|
with: map[string]string{"args": "caller --flag", "entrypoint": "input.sh --verbose"},
|
||||||
|
runs: model.ActionRuns{Env: map[string]string{"ACTION_ONLY": "manifest"}},
|
||||||
|
wantCmd: []string{"caller", "--flag"},
|
||||||
|
wantEntrypoint: []string{"input.sh", "--verbose"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "explicit empty manifest args suppress caller args",
|
||||||
|
stage: stepStageMain,
|
||||||
|
with: map[string]string{"args": "caller"},
|
||||||
|
runs: model.ActionRuns{Args: []string{}},
|
||||||
|
wantCmd: []string{},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "pre stage keeps step environment",
|
||||||
|
stage: stepStagePre,
|
||||||
|
with: map[string]string{"entrypoint": "input.sh"},
|
||||||
|
runs: model.ActionRuns{
|
||||||
|
PreEntrypoint: "pre.sh --verbose",
|
||||||
|
Args: []string{"hello"},
|
||||||
|
Env: map[string]string{"SHARED": "manifest"},
|
||||||
|
},
|
||||||
|
env: map[string]string{"SHARED": "step"},
|
||||||
|
wantCmd: []string{"hello"},
|
||||||
wantEntrypoint: []string{"pre.sh", "--verbose"},
|
wantEntrypoint: []string{"pre.sh", "--verbose"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "post stage uses runs.post-entrypoint",
|
name: "post stage uses manifest entrypoint",
|
||||||
stage: stepStagePost,
|
stage: stepStagePost,
|
||||||
|
runs: model.ActionRuns{PostEntrypoint: "post.sh", Args: []string{"hello"}},
|
||||||
|
wantCmd: []string{"hello"},
|
||||||
wantEntrypoint: []string{"post.sh"},
|
wantEntrypoint: []string{"post.sh"},
|
||||||
},
|
},
|
||||||
} {
|
} {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
tc.runs.Using, tc.runs.Image = "docker", "docker://node:14"
|
||||||
cm := &containerMock{}
|
cm := &containerMock{}
|
||||||
var input *container.NewContainerInput
|
var input *container.NewContainerInput
|
||||||
ContainerNewContainer = func(in *container.NewContainerInput) container.ExecutionsEnvironment {
|
ContainerNewContainer = func(in *container.NewContainerInput) container.ExecutionsEnvironment {
|
||||||
@@ -528,22 +593,14 @@ func TestExecAsDockerStageEntrypoint(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
step := &stepActionRemote{
|
step := &stepActionRemote{
|
||||||
Step: &model.Step{ID: "1", Uses: "org/action@v1", With: map[string]string{"entrypoint": "input.sh"}},
|
Step: &model.Step{ID: "1", Uses: "org/action@v1", With: tc.with},
|
||||||
RunContext: &RunContext{
|
RunContext: &RunContext{
|
||||||
Config: &Config{},
|
Config: &Config{},
|
||||||
Run: &model.Run{JobID: "1", Workflow: &model.Workflow{Jobs: map[string]*model.Job{"1": {}}}},
|
Run: &model.Run{JobID: "1", Workflow: &model.Workflow{Jobs: map[string]*model.Job{"1": {}}}},
|
||||||
JobContainer: cm,
|
JobContainer: cm,
|
||||||
},
|
},
|
||||||
action: &model.Action{Runs: model.ActionRuns{
|
action: &model.Action{Runs: tc.runs},
|
||||||
Using: "docker",
|
env: mergeMaps(tc.env),
|
||||||
Image: "docker://node:14",
|
|
||||||
PreEntrypoint: "pre.sh --verbose",
|
|
||||||
Entrypoint: "main.sh",
|
|
||||||
PostEntrypoint: "post.sh",
|
|
||||||
Args: []string{"hello"},
|
|
||||||
Env: map[string]string{"MY_VAR": "world"},
|
|
||||||
}},
|
|
||||||
env: map[string]string{},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
cm.On("Pull", false).Return(func(context.Context) error { return nil })
|
cm.On("Pull", false).Return(func(context.Context) error { return nil })
|
||||||
@@ -554,9 +611,11 @@ func TestExecAsDockerStageEntrypoint(t *testing.T) {
|
|||||||
|
|
||||||
require.NoError(t, execAsDocker(context.Background(), step, "action", t.TempDir(), t.TempDir(), false, tc.stage))
|
require.NoError(t, execAsDocker(context.Background(), step, "action", t.TempDir(), t.TempDir(), false, tc.stage))
|
||||||
require.NotNil(t, input)
|
require.NotNil(t, input)
|
||||||
|
assert.Equal(t, tc.wantCmd, input.Cmd)
|
||||||
assert.Equal(t, tc.wantEntrypoint, input.Entrypoint)
|
assert.Equal(t, tc.wantEntrypoint, input.Entrypoint)
|
||||||
assert.Equal(t, []string{"hello"}, input.Cmd)
|
for key, value := range mergeMaps(tc.runs.Env, tc.env) {
|
||||||
assert.Contains(t, input.Env, "MY_VAR=world")
|
assert.Contains(t, input.Env, key+"="+value)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,6 +61,8 @@ func TestCancelledJobStatusEnablesAlwaysAndCancelledSteps(t *testing.T) {
|
|||||||
enabled, err := interp.Evaluate("always()", exprparser.DefaultStatusCheckSuccess)
|
enabled, err := interp.Evaluate("always()", exprparser.DefaultStatusCheckSuccess)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, true, enabled, "`if: always()` step must run on a cancelled job")
|
assert.Equal(t, true, enabled, "`if: always()` step must run on a cancelled job")
|
||||||
|
setJobResult(context.Background(), rc, rc, true)
|
||||||
|
assert.Equal(t, "cancelled", rc.Run.Job().Result)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestMainStepsExecutorRunsAlwaysStepsAfterCancel verifies that newMainStepsExecutor does
|
// TestMainStepsExecutorRunsAlwaysStepsAfterCancel verifies that newMainStepsExecutor does
|
||||||
|
|||||||
+43
-12
@@ -18,11 +18,17 @@ var commandPatternGA *regexp.Regexp
|
|||||||
var commandPatternADO *regexp.Regexp
|
var commandPatternADO *regexp.Regexp
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
commandPatternGA = regexp.MustCompile("^::([^ ]+)( (.+))?::([^\r\n]*)[\r\n]+$")
|
commandPatternGA = regexp.MustCompile("^::([^ ]+?)( (.+?))?::([^\r\n]*)[\r\n]*$")
|
||||||
commandPatternADO = regexp.MustCompile("^##\\[([^ ]+)( (.+))?]([^\r\n]*)[\r\n]+$")
|
// excluding ']' ends the command info at the first bracket, as GitHub does
|
||||||
|
commandPatternADO = regexp.MustCompile("^##\\[([^ \\]]+)( ([^\\]]*))?]([^\r\n]*)[\r\n]*$")
|
||||||
}
|
}
|
||||||
|
|
||||||
func tryParseRawActionCommand(line string) (command string, kvPairs map[string]string, arg string, ok bool) {
|
func tryParseRawActionCommand(line string) (command string, kvPairs map[string]string, arg string, ok bool) {
|
||||||
|
command, kvPairs, arg, _, ok = tryParseActionCommand(line)
|
||||||
|
return command, kvPairs, arg, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func tryParseActionCommand(line string) (command string, kvPairs map[string]string, arg string, legacy, ok bool) {
|
||||||
if m := commandPatternGA.FindStringSubmatch(line); m != nil {
|
if m := commandPatternGA.FindStringSubmatch(line); m != nil {
|
||||||
command = m[1]
|
command = m[1]
|
||||||
kvPairs = parseKeyValuePairs(m[3], ",")
|
kvPairs = parseKeyValuePairs(m[3], ",")
|
||||||
@@ -32,19 +38,21 @@ func tryParseRawActionCommand(line string) (command string, kvPairs map[string]s
|
|||||||
command = m[1]
|
command = m[1]
|
||||||
kvPairs = parseKeyValuePairs(m[3], ";")
|
kvPairs = parseKeyValuePairs(m[3], ";")
|
||||||
arg = m[4]
|
arg = m[4]
|
||||||
|
legacy = true
|
||||||
ok = true
|
ok = true
|
||||||
}
|
}
|
||||||
return command, kvPairs, arg, ok
|
return command, kvPairs, arg, legacy, ok
|
||||||
}
|
}
|
||||||
|
|
||||||
func (rc *RunContext) commandHandler(ctx context.Context) common.LineHandler {
|
func (rc *RunContext) commandHandler(ctx context.Context) common.LineHandler {
|
||||||
logger := common.Logger(ctx)
|
logger := common.Logger(ctx)
|
||||||
resumeCommand := ""
|
resumeCommand := ""
|
||||||
return func(line string) bool {
|
return func(line string) bool {
|
||||||
command, kvPairs, arg, ok := tryParseRawActionCommand(line)
|
command, kvPairs, arg, legacy, ok := tryParseActionCommand(line)
|
||||||
if !ok {
|
if !ok {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
command = strings.ToLower(command)
|
||||||
|
|
||||||
if resumeCommand != "" {
|
if resumeCommand != "" {
|
||||||
// There should not be any emojis in the log output for Gitea.
|
// There should not be any emojis in the log output for Gitea.
|
||||||
@@ -54,19 +62,24 @@ func (rc *RunContext) commandHandler(ctx context.Context) common.LineHandler {
|
|||||||
logger.Infof("%s", line)
|
logger.Infof("%s", line)
|
||||||
// Resumed here rather than from the switch, because the end token is arbitrary
|
// Resumed here rather than from the switch, because the end token is arbitrary
|
||||||
// and a token naming a real command would otherwise never resume.
|
// and a token naming a real command would otherwise never resume.
|
||||||
if command == resumeCommand {
|
if strings.EqualFold(command, resumeCommand) {
|
||||||
resumeCommand = ""
|
resumeCommand = ""
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
arg = UnescapeCommandData(arg)
|
if legacy {
|
||||||
kvPairs = unescapeKvPairs(kvPairs)
|
arg = UnescapeLegacyCommand(arg)
|
||||||
|
kvPairs = unescapeKvPairs(kvPairs, UnescapeLegacyCommand)
|
||||||
|
} else {
|
||||||
|
arg = UnescapeCommandData(arg)
|
||||||
|
kvPairs = unescapeKvPairs(kvPairs, unescapeCommandProperty)
|
||||||
|
}
|
||||||
if (command == "set-env" || command == "add-path") && rc.refuseUnsecureCommand(ctx, command) {
|
if (command == "set-env" || command == "add-path") && rc.refuseUnsecureCommand(ctx, command) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
switch command {
|
switch command {
|
||||||
case "set-env":
|
case "set-env":
|
||||||
rc.setEnv(ctx, kvPairs, arg)
|
rc.setEnv(ctx, kvPairs, arg, true)
|
||||||
case "set-output":
|
case "set-output":
|
||||||
rc.setOutput(ctx, kvPairs, arg)
|
rc.setOutput(ctx, kvPairs, arg)
|
||||||
case "add-path":
|
case "add-path":
|
||||||
@@ -139,8 +152,16 @@ func (rc *RunContext) takeUnsecureCommandError() error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (rc *RunContext) setEnv(ctx context.Context, kvPairs map[string]string, arg string) {
|
func (rc *RunContext) setEnv(ctx context.Context, kvPairs map[string]string, arg string, fromCommand bool) {
|
||||||
name := kvPairs["name"]
|
name := kvPairs["name"]
|
||||||
|
if strings.EqualFold(name, "NODE_OPTIONS") {
|
||||||
|
message := "Can't store NODE_OPTIONS output parameter using '$GITHUB_ENV' command."
|
||||||
|
if fromCommand {
|
||||||
|
message = "Can't update NODE_OPTIONS environment variable using ::set-env:: command."
|
||||||
|
}
|
||||||
|
common.Logger(ctx).WithField(rawOutputField, true).Errorf("##[error]%s", EscapeCommandData(message))
|
||||||
|
return
|
||||||
|
}
|
||||||
common.Logger(ctx).Infof("::set-env:: %s=%s", name, arg)
|
common.Logger(ctx).Infof("::set-env:: %s=%s", name, arg)
|
||||||
if rc.Env == nil {
|
if rc.Env == nil {
|
||||||
rc.Env = make(map[string]string)
|
rc.Env = make(map[string]string)
|
||||||
@@ -159,6 +180,10 @@ func (rc *RunContext) setEnv(ctx context.Context, kvPairs map[string]string, arg
|
|||||||
mergeIntoMap(rc.GlobalEnv, newenv)
|
mergeIntoMap(rc.GlobalEnv, newenv)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (rc *RunContext) setEnvFile(ctx context.Context, kvPairs map[string]string, arg string) {
|
||||||
|
rc.setEnv(ctx, kvPairs, arg, false)
|
||||||
|
}
|
||||||
|
|
||||||
func (rc *RunContext) setOutput(ctx context.Context, kvPairs map[string]string, arg string) {
|
func (rc *RunContext) setOutput(ctx context.Context, kvPairs map[string]string, arg string) {
|
||||||
logger := common.Logger(ctx)
|
logger := common.Logger(ctx)
|
||||||
stepID := rc.CurrentStep
|
stepID := rc.CurrentStep
|
||||||
@@ -189,7 +214,7 @@ func parseKeyValuePairs(kvPairs, separator string) map[string]string {
|
|||||||
rtn := make(map[string]string)
|
rtn := make(map[string]string)
|
||||||
kvPairList := strings.SplitSeq(kvPairs, separator)
|
kvPairList := strings.SplitSeq(kvPairs, separator)
|
||||||
for kvPair := range kvPairList {
|
for kvPair := range kvPairList {
|
||||||
kv := strings.Split(kvPair, "=")
|
kv := strings.SplitN(kvPair, "=", 2)
|
||||||
if len(kv) == 2 {
|
if len(kv) == 2 {
|
||||||
rtn[kv[0]] = kv[1]
|
rtn[kv[0]] = kv[1]
|
||||||
}
|
}
|
||||||
@@ -202,6 +227,7 @@ var (
|
|||||||
commandDataEscaper = strings.NewReplacer("%", "%25", "\r", "%0D", "\n", "%0A")
|
commandDataEscaper = strings.NewReplacer("%", "%25", "\r", "%0D", "\n", "%0A")
|
||||||
commandDataUnescaper = strings.NewReplacer("%25", "%", "%0D", "\r", "%0A", "\n")
|
commandDataUnescaper = strings.NewReplacer("%25", "%", "%0D", "\r", "%0A", "\n")
|
||||||
commandPropertyUnescaper = strings.NewReplacer("%25", "%", "%0D", "\r", "%0A", "\n", "%3A", ":", "%2C", ",")
|
commandPropertyUnescaper = strings.NewReplacer("%25", "%", "%0D", "\r", "%0A", "\n", "%3A", ":", "%2C", ",")
|
||||||
|
legacyCommandUnescaper = strings.NewReplacer("%3B", ";", "%0D", "\r", "%0A", "\n", "%5D", "]", "%25", "%")
|
||||||
)
|
)
|
||||||
|
|
||||||
// EscapeCommandData encodes the data part of a "::cmd::" or "##[cmd]" line the runner writes itself,
|
// EscapeCommandData encodes the data part of a "::cmd::" or "##[cmd]" line the runner writes itself,
|
||||||
@@ -218,9 +244,14 @@ func unescapeCommandProperty(arg string) string {
|
|||||||
return commandPropertyUnescaper.Replace(arg)
|
return commandPropertyUnescaper.Replace(arg)
|
||||||
}
|
}
|
||||||
|
|
||||||
func unescapeKvPairs(kvPairs map[string]string) map[string]string {
|
// UnescapeLegacyCommand decodes a "##[cmd]" line, which also spells ";" and "]" escaped.
|
||||||
|
func UnescapeLegacyCommand(arg string) string {
|
||||||
|
return legacyCommandUnescaper.Replace(arg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func unescapeKvPairs(kvPairs map[string]string, unescape func(string) string) map[string]string {
|
||||||
for k, v := range kvPairs {
|
for k, v := range kvPairs {
|
||||||
kvPairs[k] = unescapeCommandProperty(v)
|
kvPairs[k] = unescape(v)
|
||||||
}
|
}
|
||||||
return kvPairs
|
return kvPairs
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,12 +26,21 @@ func unsecureRC() *RunContext {
|
|||||||
|
|
||||||
func TestSetEnv(t *testing.T) {
|
func TestSetEnv(t *testing.T) {
|
||||||
a := assert.New(t)
|
a := assert.New(t)
|
||||||
ctx := context.Background()
|
logger, hook := test.NewNullLogger()
|
||||||
|
ctx := common.WithLogger(context.Background(), logger)
|
||||||
rc := unsecureRC()
|
rc := unsecureRC()
|
||||||
handler := rc.commandHandler(ctx)
|
handler := rc.commandHandler(ctx)
|
||||||
|
|
||||||
handler("::set-env name=x::valz\n")
|
handler("::set-env name=x::valz\n")
|
||||||
a.Equal("valz", rc.Env["x"])
|
a.Equal("valz", rc.Env["x"])
|
||||||
|
handler("::SET-ENV name=NODE_OPTIONS::--require command.js\n")
|
||||||
|
rc.setEnvFile(ctx, map[string]string{"name": "node_options"}, "--require env.js")
|
||||||
|
a.NotContains(rc.Env, "NODE_OPTIONS")
|
||||||
|
a.NotContains(rc.Env, "node_options")
|
||||||
|
entries := hook.AllEntries()
|
||||||
|
require.Len(t, entries, 3)
|
||||||
|
a.Equal("##[error]Can't update NODE_OPTIONS environment variable using ::set-env:: command.", entries[1].Message)
|
||||||
|
a.Equal("##[error]Can't store NODE_OPTIONS output parameter using '$GITHUB_ENV' command.", entries[2].Message)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestStopCommandsKeepsSuppressedLinesInLog(t *testing.T) {
|
func TestStopCommandsKeepsSuppressedLinesInLog(t *testing.T) {
|
||||||
@@ -85,6 +94,16 @@ func TestSetOutput(t *testing.T) {
|
|||||||
|
|
||||||
handler("::set-output name=x%3A%2C%0A%25%0D%3A::percent2%25%0Atest\n")
|
handler("::set-output name=x%3A%2C%0A%25%0D%3A::percent2%25%0Atest\n")
|
||||||
a.Equal("percent2%\ntest", rc.StepResults["my-step"].Outputs["x:,\n%\r:"])
|
a.Equal("percent2%\ntest", rc.StepResults["my-step"].Outputs["x:,\n%\r:"])
|
||||||
|
handler("::set-output name=symbol::std::vector")
|
||||||
|
a.Equal("std::vector", rc.StepResults["my-step"].Outputs["symbol"])
|
||||||
|
handler("::set-output name=a=b::value\n")
|
||||||
|
a.Equal("value", rc.StepResults["my-step"].Outputs["a=b"])
|
||||||
|
handler("##[set-output name=legacy%3B%5D]value%3B%5D")
|
||||||
|
a.Equal("value;]", rc.StepResults["my-step"].Outputs["legacy;]"])
|
||||||
|
handler("##[set-output name=bracket]value]tail")
|
||||||
|
a.Equal("value]tail", rc.StepResults["my-step"].Outputs["bracket"])
|
||||||
|
handler("::set-output name=modern%3B%5D::value%3B%5D\n")
|
||||||
|
a.Equal("value%3B%5D", rc.StepResults["my-step"].Outputs["modern%3B%5D"])
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAddpath(t *testing.T) {
|
func TestAddpath(t *testing.T) {
|
||||||
@@ -110,7 +129,7 @@ func TestStopCommands(t *testing.T) {
|
|||||||
|
|
||||||
handler("::set-env name=x::valz\n")
|
handler("::set-env name=x::valz\n")
|
||||||
a.Equal("valz", rc.Env["x"])
|
a.Equal("valz", rc.Env["x"])
|
||||||
handler("::stop-commands::my-end-token\n")
|
handler("::stop-commands::MY-END-TOKEN\n")
|
||||||
handler("::set-env name=x::abcd\n")
|
handler("::set-env name=x::abcd\n")
|
||||||
a.Equal("valz", rc.Env["x"])
|
a.Equal("valz", rc.Env["x"])
|
||||||
handler("::my-end-token::\n")
|
handler("::my-end-token::\n")
|
||||||
@@ -163,10 +182,10 @@ func TestAddmask(t *testing.T) {
|
|||||||
|
|
||||||
rc := new(RunContext)
|
rc := new(RunContext)
|
||||||
handler := rc.commandHandler(loggerCtx)
|
handler := rc.commandHandler(loggerCtx)
|
||||||
handler("::add-mask::my-secret-value\n")
|
handler("::ADD-MASK::my::secret")
|
||||||
|
|
||||||
a.Equal("***", hook.LastEntry().Message)
|
a.Equal("***", hook.LastEntry().Message)
|
||||||
a.NotEqual("*my-secret-value", hook.LastEntry().Message)
|
a.Equal([]string{"my::secret"}, rc.Masks)
|
||||||
}
|
}
|
||||||
|
|
||||||
// based on https://stackoverflow.com/a/10476304
|
// based on https://stackoverflow.com/a/10476304
|
||||||
|
|||||||
+51
-54
@@ -13,6 +13,7 @@ import (
|
|||||||
"reflect"
|
"reflect"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
@@ -71,7 +72,7 @@ func (rc *RunContext) NewExpressionEvaluatorWithEnv(ctx context.Context, env map
|
|||||||
}
|
}
|
||||||
|
|
||||||
ghc := rc.getGithubContext(ctx)
|
ghc := rc.getGithubContext(ctx)
|
||||||
inputs := getEvaluatorInputs(ctx, rc, rc.actionInputs, ghc)
|
inputs := getEvaluatorInputs(rc, rc.actionInputs, ghc)
|
||||||
|
|
||||||
ee := &exprparser.EvaluationEnvironment{
|
ee := &exprparser.EvaluationEnvironment{
|
||||||
Github: ghc,
|
Github: ghc,
|
||||||
@@ -81,7 +82,7 @@ func (rc *RunContext) NewExpressionEvaluatorWithEnv(ctx context.Context, env map
|
|||||||
// todo: should be unavailable
|
// todo: should be unavailable
|
||||||
// but required to interpolate/evaluate the step outputs on the job
|
// but required to interpolate/evaluate the step outputs on the job
|
||||||
Steps: rc.getStepsContext(),
|
Steps: rc.getStepsContext(),
|
||||||
Secrets: getWorkflowSecrets(ctx, rc),
|
Secrets: getWorkflowSecrets(rc),
|
||||||
Vars: getWorkflowVars(ctx, rc),
|
Vars: getWorkflowVars(ctx, rc),
|
||||||
Strategy: strategy,
|
Strategy: strategy,
|
||||||
Matrix: rc.Matrix,
|
Matrix: rc.Matrix,
|
||||||
@@ -137,14 +138,14 @@ func (rc *RunContext) newStepExpressionEvaluator(ctx context.Context, step step,
|
|||||||
Env: *step.getEnv(),
|
Env: *step.getEnv(),
|
||||||
Job: rc.getJobContext(),
|
Job: rc.getJobContext(),
|
||||||
Steps: rc.getStepsContext(),
|
Steps: rc.getStepsContext(),
|
||||||
Secrets: getWorkflowSecrets(ctx, rc),
|
Secrets: getWorkflowSecrets(rc),
|
||||||
Vars: getWorkflowVars(ctx, rc),
|
Vars: getWorkflowVars(ctx, rc),
|
||||||
Strategy: strategy,
|
Strategy: strategy,
|
||||||
Matrix: rc.Matrix,
|
Matrix: rc.Matrix,
|
||||||
Needs: using,
|
Needs: using,
|
||||||
// todo: should be unavailable
|
// todo: should be unavailable
|
||||||
// but required to interpolate/evaluate the inputs in actions/composite
|
// but required to interpolate/evaluate the inputs in actions/composite
|
||||||
Inputs: getEvaluatorInputs(ctx, rc, stepInputs, rc.getGithubContext(ctx)),
|
Inputs: getEvaluatorInputs(rc, stepInputs, rc.getGithubContext(ctx)),
|
||||||
HashFiles: getHashFilesFunction(ctx, rc),
|
HashFiles: getHashFilesFunction(ctx, rc),
|
||||||
}
|
}
|
||||||
ee.Runner = rc.getRunnerContext(ctx)
|
ee.Runner = rc.getRunnerContext(ctx)
|
||||||
@@ -246,17 +247,18 @@ func (ee expressionEvaluator) EvaluateYamlNode(ctx context.Context, node *yaml.N
|
|||||||
return ee.shared(ctx).EvaluateYamlNode(node)
|
return ee.shared(ctx).EvaluateYamlNode(node)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ee expressionEvaluator) Interpolate(ctx context.Context, in string) string {
|
func (ee expressionEvaluator) Interpolate(ctx context.Context, in string) (string, error) {
|
||||||
out, err := ee.interpolate(ctx, in)
|
return ee.shared(ctx).Interpolate(in)
|
||||||
if err != nil {
|
|
||||||
common.Logger(ctx).Errorf("Unable to interpolate expression '%s': %s", in, err)
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ee expressionEvaluator) interpolate(ctx context.Context, in string) (string, error) {
|
// InterpolateName keeps the source text of a job or step name that cannot be evaluated, as GitHub does.
|
||||||
return ee.shared(ctx).Interpolate(in)
|
func (ee expressionEvaluator) InterpolateName(ctx context.Context, in string) string {
|
||||||
|
out, err := ee.Interpolate(ctx, in)
|
||||||
|
if err != nil {
|
||||||
|
common.Logger(ctx).Warnf("Unable to evaluate the display name '%s': %s", in, err)
|
||||||
|
return in
|
||||||
|
}
|
||||||
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// EvalBool evaluates an expression against given evaluator. An `if:` is an expression even without
|
// EvalBool evaluates an expression against given evaluator. An `if:` is an expression even without
|
||||||
@@ -277,10 +279,10 @@ func inputsFromEnv(env map[string]string) map[string]any {
|
|||||||
return inputs
|
return inputs
|
||||||
}
|
}
|
||||||
|
|
||||||
func getEvaluatorInputs(ctx context.Context, rc *RunContext, stepInputs map[string]any, ghc *model.GithubContext) map[string]any {
|
func getEvaluatorInputs(rc *RunContext, stepInputs map[string]any, ghc *model.GithubContext) map[string]any {
|
||||||
inputs := map[string]any{}
|
inputs := map[string]any{}
|
||||||
|
|
||||||
setupWorkflowInputs(ctx, &inputs, rc)
|
maps.Copy(inputs, rc.workflowCallInputs)
|
||||||
maps.Copy(inputs, stepInputs)
|
maps.Copy(inputs, stepInputs)
|
||||||
|
|
||||||
if ghc.EventName == "workflow_dispatch" {
|
if ghc.EventName == "workflow_dispatch" {
|
||||||
@@ -324,54 +326,49 @@ func coerceInputValue(value any, inputType string) any {
|
|||||||
return value == "true"
|
return value == "true"
|
||||||
}
|
}
|
||||||
|
|
||||||
func setupWorkflowInputs(ctx context.Context, inputs *map[string]any, rc *RunContext) {
|
// resolveWorkflowCall evaluates the caller's with: and secrets: once, as the server does before dispatching a called workflow.
|
||||||
if rc.caller != nil {
|
func (rc *RunContext) resolveWorkflowCall(ctx context.Context) error {
|
||||||
config := rc.Run.Workflow.WorkflowCallConfig()
|
if rc.caller == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
callerJob := rc.caller.runContext.Run.Job()
|
||||||
|
callerEval := rc.caller.runContext.ExprEval
|
||||||
|
calleeEval := sync.OnceValue(func() *expressionEvaluator { return rc.NewExpressionEvaluator(ctx) })
|
||||||
|
config := rc.Run.Workflow.WorkflowCallConfig()
|
||||||
|
|
||||||
for name, input := range config.Inputs {
|
rc.workflowCallInputs = make(map[string]any, len(config.Inputs))
|
||||||
value := rc.caller.runContext.Run.Job().With[name]
|
for name, input := range config.Inputs {
|
||||||
if value != nil {
|
value, eval, label := callerJob.With[name], callerEval, "input"
|
||||||
if str, ok := value.(string); ok {
|
if value == nil {
|
||||||
// evaluate using the calling RunContext (outside)
|
value, eval, label = input.Default, calleeEval(), "the default of input"
|
||||||
value = rc.caller.runContext.ExprEval.Interpolate(ctx, str)
|
}
|
||||||
}
|
if str, ok := value.(string); ok {
|
||||||
|
var err error
|
||||||
|
if value, err = eval.Interpolate(ctx, str); err != nil {
|
||||||
|
return fmt.Errorf("unable to interpolate %s %s: %w", label, name, err)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
rc.workflowCallInputs[name] = coerceInputValue(value, input.Type)
|
||||||
|
}
|
||||||
|
|
||||||
if value == nil && config != nil && config.Inputs != nil {
|
secrets := callerJob.Secrets()
|
||||||
value = input.Default
|
if secrets == nil && callerJob.InheritSecrets() {
|
||||||
if rc.ExprEval != nil {
|
secrets = rc.caller.runContext.Config.Secrets
|
||||||
if str, ok := value.(string); ok {
|
}
|
||||||
// evaluate using the called RunContext (inside)
|
rc.workflowCallSecrets = make(map[string]string, len(secrets))
|
||||||
value = rc.ExprEval.Interpolate(ctx, str)
|
for k, v := range secrets {
|
||||||
}
|
var err error
|
||||||
}
|
if rc.workflowCallSecrets[k], err = callerEval.Interpolate(ctx, v); err != nil {
|
||||||
}
|
return fmt.Errorf("unable to interpolate secret %s: %w", k, err)
|
||||||
|
|
||||||
(*inputs)[name] = coerceInputValue(value, input.Type)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func getWorkflowSecrets(ctx context.Context, rc *RunContext) map[string]string {
|
func getWorkflowSecrets(rc *RunContext) map[string]string {
|
||||||
if rc.caller != nil {
|
if rc.caller != nil {
|
||||||
job := rc.caller.runContext.Run.Job()
|
return rc.workflowCallSecrets
|
||||||
secrets := job.Secrets()
|
|
||||||
|
|
||||||
if secrets == nil && job.InheritSecrets() {
|
|
||||||
secrets = rc.caller.runContext.Config.Secrets
|
|
||||||
}
|
|
||||||
|
|
||||||
// Interpolate into a new map. secrets may be the shared Config.Secrets (or the job's
|
|
||||||
// map), which other parallel jobs read concurrently (e.g. log masking), so mutating it
|
|
||||||
// in place is a data race.
|
|
||||||
interpolated := make(map[string]string, len(secrets))
|
|
||||||
for k, v := range secrets {
|
|
||||||
interpolated[k] = rc.caller.runContext.ExprEval.Interpolate(ctx, v)
|
|
||||||
}
|
|
||||||
|
|
||||||
return interpolated
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return rc.Config.Secrets
|
return rc.Config.Secrets
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -266,19 +266,21 @@ func TestInterpolate(t *testing.T) {
|
|||||||
{"${{ null }}", ""},
|
{"${{ null }}", ""},
|
||||||
{"${{ fromJSON('[1,2]') }}", "Array"},
|
{"${{ fromJSON('[1,2]') }}", "Array"},
|
||||||
{"${{ fromJSON('{\"a\":1}') }}", "Object"},
|
{"${{ fromJSON('{\"a\":1}') }}", "Object"},
|
||||||
// a malformed part must not restructure its neighbours, and it interpolates to nothing
|
|
||||||
{"${{ 1) && (2 }}", ""},
|
|
||||||
{"run ${{ 1) && (2 }} now", ""},
|
|
||||||
{"${{ 1", "${{ 1"},
|
{"${{ 1", "${{ 1"},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, table := range tables {
|
for _, table := range tables {
|
||||||
t.Run("interpolate", func(t *testing.T) {
|
t.Run("interpolate", func(t *testing.T) {
|
||||||
assertObject := assert.New(t)
|
out, err := ee.Interpolate(context.Background(), table.in)
|
||||||
out := ee.Interpolate(context.Background(), table.in)
|
require.NoError(t, err, table.in)
|
||||||
assertObject.Equal(table.out, out, table.in)
|
assert.Equal(t, table.out, out, table.in)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for _, in := range []string{"${{ 1) && (2 }}", "run ${{ 1) && (2 }} now"} {
|
||||||
|
_, err := ee.Interpolate(context.Background(), in)
|
||||||
|
assert.Error(t, err, in)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGetEvaluatorInputsBoolean(t *testing.T) {
|
func TestGetEvaluatorInputsBoolean(t *testing.T) {
|
||||||
@@ -352,7 +354,7 @@ on:
|
|||||||
}
|
}
|
||||||
ghc := &model.GithubContext{EventName: eventName, Event: table.event}
|
ghc := &model.GithubContext{EventName: eventName, Event: table.event}
|
||||||
|
|
||||||
inputs := getEvaluatorInputs(context.Background(), rc, nil, ghc)
|
inputs := getEvaluatorInputs(rc, nil, ghc)
|
||||||
assert.Equal(t, table.flag, inputs["flag"])
|
assert.Equal(t, table.flag, inputs["flag"])
|
||||||
assert.Equal(t, "gitea", inputs["name"])
|
assert.Equal(t, "gitea", inputs["name"])
|
||||||
})
|
})
|
||||||
@@ -372,7 +374,8 @@ jobs:
|
|||||||
|
|
||||||
runner := &runnerImpl{config: &Config{Secrets: map[string]string{"A": "s3cr3t-a", "B": "s3cr3t-b"}}}
|
runner := &runnerImpl{config: &Config{Secrets: map[string]string{"A": "s3cr3t-a", "B": "s3cr3t-b"}}}
|
||||||
containerName := func(jobID string) string {
|
containerName := func(jobID string) string {
|
||||||
rc := runner.newRunContext(t.Context(), &model.Run{JobID: jobID, Workflow: workflow}, nil)
|
rc, err := runner.newRunContext(t.Context(), &model.Run{JobID: jobID, Workflow: workflow}, nil)
|
||||||
|
require.NoError(t, err)
|
||||||
assert.NotContains(t, rc.Name, "s3cr3t")
|
assert.NotContains(t, rc.Name, "s3cr3t")
|
||||||
return rc.jobContainerName()
|
return rc.jobContainerName()
|
||||||
}
|
}
|
||||||
|
|||||||
+71
-30
@@ -18,6 +18,7 @@ import (
|
|||||||
"slices"
|
"slices"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
"unicode"
|
"unicode"
|
||||||
|
|
||||||
@@ -154,11 +155,9 @@ func newJobExecutor(info jobInfo, sf stepFactory, rc *RunContext) common.Executo
|
|||||||
if rc.Run == nil {
|
if rc.Run == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
rc.ExprEval = rc.NewExpressionEvaluator(ctx)
|
if err := evaluateJobEnvAndDefaults(ctx, rc); err != nil {
|
||||||
// evaluate environment variables since they can contain
|
reportStepError(ctx, rc, err)
|
||||||
// GitHub's special environment variables.
|
return err
|
||||||
for k, v := range rc.GetEnv() {
|
|
||||||
rc.Env[k] = rc.ExprEval.Interpolate(ctx, v)
|
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
@@ -236,6 +235,15 @@ func newJobExecutor(info jobInfo, sf stepFactory, rc *RunContext) common.Executo
|
|||||||
|
|
||||||
// Ahead of the teardown below, while the job environment is still up.
|
// Ahead of the teardown below, while the job environment is still up.
|
||||||
postExecutor = postExecutor.Finally(rc.runJobCompletedHook)
|
postExecutor = postExecutor.Finally(rc.runJobCompletedHook)
|
||||||
|
postExecutor = postExecutor.Finally(func(ctx context.Context) error {
|
||||||
|
// swallowed: a bad output fails this job, it must not abandon the rest of the plan
|
||||||
|
if err := info.interpolateOutputs()(ctx); err != nil {
|
||||||
|
reportStepError(ctx, rc, err)
|
||||||
|
} else if err := setJobOutputs(ctx, rc); err != nil {
|
||||||
|
reportStepError(ctx, rc, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
postExecutor = postExecutor.Finally(func(ctx context.Context) error {
|
postExecutor = postExecutor.Finally(func(ctx context.Context) error {
|
||||||
jobError := common.JobError(ctx)
|
jobError := common.JobError(ctx)
|
||||||
@@ -251,7 +259,6 @@ func newJobExecutor(info jobInfo, sf stepFactory, rc *RunContext) common.Executo
|
|||||||
logger.Errorf("##[error]%s", EscapeCommandData("Error while stop job container: "+err.Error()))
|
logger.Errorf("##[error]%s", EscapeCommandData("Error while stop job container: "+err.Error()))
|
||||||
}
|
}
|
||||||
setJobResult(ctx, info, rc, jobError == nil)
|
setJobResult(ctx, info, rc, jobError == nil)
|
||||||
setJobOutputs(ctx, rc)
|
|
||||||
|
|
||||||
return err
|
return err
|
||||||
})
|
})
|
||||||
@@ -267,7 +274,6 @@ func newJobExecutor(info jobInfo, sf stepFactory, rc *RunContext) common.Executo
|
|||||||
defer cancel()
|
defer cancel()
|
||||||
return postExecutor(postCtx)
|
return postExecutor(postCtx)
|
||||||
}).
|
}).
|
||||||
Finally(info.interpolateOutputs()).
|
|
||||||
Finally(info.closeContainer()))
|
Finally(info.closeContainer()))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -365,7 +371,7 @@ func setJobResult(ctx context.Context, info jobInfo, rc *RunContext, success boo
|
|||||||
// concurrent succeeding one.
|
// concurrent succeeding one.
|
||||||
job := rc.Run.Job()
|
job := rc.Run.Job()
|
||||||
var continueOnError bool
|
var continueOnError bool
|
||||||
if !success {
|
if !success && !rc.jobCancelled {
|
||||||
// Use a fresh context so an expired job timeout cannot block expression evaluation.
|
// Use a fresh context so an expired job timeout cannot block expression evaluation.
|
||||||
evalCtx := common.WithLogger(context.Background(), common.Logger(ctx))
|
evalCtx := common.WithLogger(context.Background(), common.Logger(ctx))
|
||||||
continueOnError = evaluateJobContinueOnError(evalCtx, rc, job)
|
continueOnError = evaluateJobContinueOnError(evalCtx, rc, job)
|
||||||
@@ -378,7 +384,11 @@ func setJobResult(ctx context.Context, info jobInfo, rc *RunContext, success boo
|
|||||||
if len(info.matrix()) > 0 && job.Result != "" {
|
if len(info.matrix()) > 0 && job.Result != "" {
|
||||||
result = job.Result
|
result = job.Result
|
||||||
}
|
}
|
||||||
if !success {
|
// cancelled is sticky, so a sibling combination finishing last cannot mask it
|
||||||
|
switch {
|
||||||
|
case rc.jobCancelled:
|
||||||
|
result = "cancelled"
|
||||||
|
case !success && result != "cancelled":
|
||||||
result = "failure"
|
result = "failure"
|
||||||
job.SetContinueOnError(continueOnError)
|
job.SetContinueOnError(continueOnError)
|
||||||
}
|
}
|
||||||
@@ -392,38 +402,69 @@ func setJobResult(ctx context.Context, info jobInfo, rc *RunContext, success boo
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
jobResultMessage := "succeeded"
|
jobResultMessage := "failed"
|
||||||
if jobResult != "success" {
|
switch jobResult {
|
||||||
jobResultMessage = "failed"
|
case "success":
|
||||||
|
jobResultMessage = "succeeded"
|
||||||
|
case "cancelled":
|
||||||
|
jobResultMessage = "cancelled"
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.WithField("jobResult", jobResult).Infof("Job %s", jobResultMessage)
|
logger.WithField("jobResult", jobResult).Infof("Job %s", jobResultMessage)
|
||||||
}
|
}
|
||||||
|
|
||||||
func setJobOutputs(ctx context.Context, rc *RunContext) {
|
// evaluateJobEnvAndDefaults resolves the job's env and defaults.run once, as GitHub does at job setup.
|
||||||
if rc.caller != nil {
|
func evaluateJobEnvAndDefaults(ctx context.Context, rc *RunContext) error {
|
||||||
// map outputs for reusable workflows
|
rc.ExprEval = rc.NewExpressionEvaluator(ctx)
|
||||||
callerOutputs := make(map[string]string)
|
var err error
|
||||||
|
for k, v := range rc.GetEnv() {
|
||||||
ee := rc.NewExpressionEvaluator(ctx)
|
if rc.Env[k], err = rc.ExprEval.Interpolate(ctx, v); err != nil {
|
||||||
|
return fmt.Errorf("unable to interpolate env %s: %w", k, err)
|
||||||
for k, v := range rc.Run.Workflow.WorkflowCallConfig().Outputs {
|
|
||||||
callerOutputs[k] = ee.Interpolate(ctx, ee.Interpolate(ctx, v.Value))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Matrix combinations of a reusable-workflow caller share the caller's *model.Job;
|
|
||||||
// serialize the write so parallel combos don't race on its Outputs field.
|
|
||||||
callerJob := rc.caller.runContext.Run.Job()
|
|
||||||
defer lockJob(callerJob)()
|
|
||||||
callerJob.Outputs = callerOutputs
|
|
||||||
}
|
}
|
||||||
|
defaults := rc.Run.Job().Defaults.Run
|
||||||
|
if rc.jobRunDefaults.Shell, err = rc.ExprEval.Interpolate(ctx, defaults.Shell); err != nil {
|
||||||
|
return fmt.Errorf("unable to interpolate defaults.run.shell: %w", err)
|
||||||
|
}
|
||||||
|
if rc.jobRunDefaults.WorkingDirectory, err = rc.ExprEval.Interpolate(ctx, defaults.WorkingDirectory); err != nil {
|
||||||
|
return fmt.Errorf("unable to interpolate defaults.run.working-directory: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func setJobOutputs(ctx context.Context, rc *RunContext) error {
|
||||||
|
if rc.caller == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
outputs := rc.Run.Workflow.WorkflowCallConfig().Outputs
|
||||||
|
callerOutputs := make(map[string]string, len(outputs))
|
||||||
|
ee := sync.OnceValue(func() *expressionEvaluator { return rc.NewExpressionEvaluator(ctx) })
|
||||||
|
for k, v := range outputs {
|
||||||
|
value := v.Value
|
||||||
|
for range 2 { // two passes, the value resolves through a job output
|
||||||
|
var err error
|
||||||
|
if value, err = ee().Interpolate(ctx, value); err != nil {
|
||||||
|
return fmt.Errorf("unable to interpolate workflow output %s: %w", k, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
callerOutputs[k] = value
|
||||||
|
}
|
||||||
|
|
||||||
|
// Matrix combinations of a reusable-workflow caller share the caller's *model.Job;
|
||||||
|
// serialize the write so parallel combos don't race on its Outputs field.
|
||||||
|
callerJob := rc.caller.runContext.Run.Job()
|
||||||
|
defer lockJob(callerJob)()
|
||||||
|
callerJob.Outputs = callerOutputs
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// applyJobTimeout applies the job-level timeout-minutes to ctx, mirroring the
|
// applyJobTimeout applies the job-level timeout-minutes to ctx, mirroring the
|
||||||
// step-level evaluateStepTimeout in step.go.
|
// step-level evaluateStepTimeout in step.go.
|
||||||
func applyJobTimeout(ctx context.Context, rc *RunContext, job *model.Job) (context.Context, context.CancelFunc) {
|
func applyJobTimeout(ctx context.Context, rc *RunContext, job *model.Job) (context.Context, context.CancelFunc) {
|
||||||
timeout := rc.ExprEval.Interpolate(ctx, job.TimeoutMinutes)
|
timeout, err := rc.ExprEval.Interpolate(ctx, job.TimeoutMinutes)
|
||||||
if timeout != "" {
|
if err != nil {
|
||||||
|
common.Logger(ctx).Errorf("An error occurred when attempting to determine the job timeout: %s", err)
|
||||||
|
} else if timeout != "" {
|
||||||
if timeoutMinutes, err := strconv.ParseInt(timeout, 10, 64); err == nil {
|
if timeoutMinutes, err := strconv.ParseInt(timeout, 10, 64); err == nil {
|
||||||
return context.WithTimeout(ctx, time.Duration(timeoutMinutes)*time.Minute)
|
return context.WithTimeout(ctx, time.Duration(timeoutMinutes)*time.Minute)
|
||||||
}
|
}
|
||||||
@@ -622,7 +663,7 @@ func archiveEntryMatchesPath(entryName, requestedPath string) bool {
|
|||||||
|
|
||||||
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.InterpolateName(ctx, stepModel.String()), stage.String())
|
||||||
|
|
||||||
logWriter := rc.commandLogWriter(ctx)
|
logWriter := rc.commandLogWriter(ctx)
|
||||||
|
|
||||||
|
|||||||
@@ -299,6 +299,7 @@ func TestNewJobExecutor(t *testing.T) {
|
|||||||
executedSteps []string
|
executedSteps []string
|
||||||
result string
|
result string
|
||||||
hasError bool
|
hasError bool
|
||||||
|
output string
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "zeroSteps",
|
name: "zeroSteps",
|
||||||
@@ -319,8 +320,8 @@ func TestNewJobExecutor(t *testing.T) {
|
|||||||
executedSteps: []string{
|
executedSteps: []string{
|
||||||
"startContainer",
|
"startContainer",
|
||||||
"step1",
|
"step1",
|
||||||
"stopContainer",
|
|
||||||
"interpolateOutputs",
|
"interpolateOutputs",
|
||||||
|
"stopContainer",
|
||||||
"closeContainer",
|
"closeContainer",
|
||||||
},
|
},
|
||||||
result: "success",
|
result: "success",
|
||||||
@@ -336,8 +337,8 @@ func TestNewJobExecutor(t *testing.T) {
|
|||||||
executedSteps: []string{
|
executedSteps: []string{
|
||||||
"startContainer",
|
"startContainer",
|
||||||
"step1",
|
"step1",
|
||||||
"stopContainer",
|
|
||||||
"interpolateOutputs",
|
"interpolateOutputs",
|
||||||
|
"stopContainer",
|
||||||
"closeContainer",
|
"closeContainer",
|
||||||
},
|
},
|
||||||
result: "failure",
|
result: "failure",
|
||||||
@@ -354,8 +355,8 @@ func TestNewJobExecutor(t *testing.T) {
|
|||||||
"startContainer",
|
"startContainer",
|
||||||
"pre1",
|
"pre1",
|
||||||
"step1",
|
"step1",
|
||||||
"stopContainer",
|
|
||||||
"interpolateOutputs",
|
"interpolateOutputs",
|
||||||
|
"stopContainer",
|
||||||
"closeContainer",
|
"closeContainer",
|
||||||
},
|
},
|
||||||
result: "success",
|
result: "success",
|
||||||
@@ -372,8 +373,8 @@ func TestNewJobExecutor(t *testing.T) {
|
|||||||
"startContainer",
|
"startContainer",
|
||||||
"step1",
|
"step1",
|
||||||
"post1",
|
"post1",
|
||||||
"stopContainer",
|
|
||||||
"interpolateOutputs",
|
"interpolateOutputs",
|
||||||
|
"stopContainer",
|
||||||
"closeContainer",
|
"closeContainer",
|
||||||
},
|
},
|
||||||
result: "success",
|
result: "success",
|
||||||
@@ -391,8 +392,8 @@ func TestNewJobExecutor(t *testing.T) {
|
|||||||
"pre1",
|
"pre1",
|
||||||
"step1",
|
"step1",
|
||||||
"post1",
|
"post1",
|
||||||
"stopContainer",
|
|
||||||
"interpolateOutputs",
|
"interpolateOutputs",
|
||||||
|
"stopContainer",
|
||||||
"closeContainer",
|
"closeContainer",
|
||||||
},
|
},
|
||||||
result: "success",
|
result: "success",
|
||||||
@@ -418,13 +419,22 @@ func TestNewJobExecutor(t *testing.T) {
|
|||||||
"step3",
|
"step3",
|
||||||
"post3",
|
"post3",
|
||||||
"post2",
|
"post2",
|
||||||
"stopContainer",
|
|
||||||
"interpolateOutputs",
|
"interpolateOutputs",
|
||||||
|
"stopContainer",
|
||||||
"closeContainer",
|
"closeContainer",
|
||||||
},
|
},
|
||||||
result: "success",
|
result: "success",
|
||||||
hasError: false,
|
hasError: false,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "jobOutputExpressionFailure",
|
||||||
|
steps: []*model.Step{{ID: "1"}},
|
||||||
|
preSteps: []bool{false},
|
||||||
|
postSteps: []bool{false},
|
||||||
|
executedSteps: []string{"startContainer", "step1", "interpolateOutputs", "stopContainer", "closeContainer"},
|
||||||
|
result: "failure",
|
||||||
|
output: "${{ 'test' != test }}",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
contains := func(needle string, haystack []string) bool {
|
contains := func(needle string, haystack []string) bool {
|
||||||
@@ -450,6 +460,10 @@ func TestNewJobExecutor(t *testing.T) {
|
|||||||
},
|
},
|
||||||
Config: &Config{},
|
Config: &Config{},
|
||||||
}
|
}
|
||||||
|
if tt.output != "" {
|
||||||
|
rc.Run.Job().Outputs = map[string]string{"bad": tt.output}
|
||||||
|
rc.outputTemplate = map[string]string{"bad": tt.output}
|
||||||
|
}
|
||||||
rc.ExprEval = rc.NewExpressionEvaluator(ctx)
|
rc.ExprEval = rc.NewExpressionEvaluator(ctx)
|
||||||
executorOrder := make([]string, 0)
|
executorOrder := make([]string, 0)
|
||||||
|
|
||||||
@@ -497,6 +511,9 @@ func TestNewJobExecutor(t *testing.T) {
|
|||||||
|
|
||||||
jim.On("interpolateOutputs").Return(func(ctx context.Context) error {
|
jim.On("interpolateOutputs").Return(func(ctx context.Context) error {
|
||||||
executorOrder = append(executorOrder, "interpolateOutputs")
|
executorOrder = append(executorOrder, "interpolateOutputs")
|
||||||
|
if tt.output != "" {
|
||||||
|
return rc.interpolateOutputs()(ctx)
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -518,6 +535,7 @@ func TestNewJobExecutor(t *testing.T) {
|
|||||||
executor := newJobExecutor(jim, sfm, rc)
|
executor := newJobExecutor(jim, sfm, rc)
|
||||||
err := executor(ctx)
|
err := executor(ctx)
|
||||||
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.Empty(t, rc.Run.Job().Outputs["bad"])
|
||||||
assert.Equal(t, tt.executedSteps, executorOrder)
|
assert.Equal(t, tt.executedSteps, executorOrder)
|
||||||
|
|
||||||
jim.AssertExpectations(t)
|
jim.AssertExpectations(t)
|
||||||
|
|||||||
@@ -51,10 +51,11 @@ func (rc *RunContext) runJobHook(ctx context.Context, hookPath, name string) err
|
|||||||
rawLogger.Infof("shell: %s", shell)
|
rawLogger.Infof("shell: %s", shell)
|
||||||
}
|
}
|
||||||
|
|
||||||
env := maps.Clone(rc.GetEnv())
|
env := map[string]string{}
|
||||||
if jobContainer := rc.Run.Job().Container(); jobContainer != nil {
|
if jobContainer := rc.Run.Job().Container(); jobContainer != nil {
|
||||||
maps.Copy(env, jobContainer.Env)
|
maps.Copy(env, jobContainer.Env)
|
||||||
}
|
}
|
||||||
|
maps.Copy(env, rc.GetEnv())
|
||||||
rc.withGithubEnv(ctx, rc.getGithubContext(ctx), env)
|
rc.withGithubEnv(ctx, rc.getGithubContext(ctx), env)
|
||||||
rc.ApplyExtraPath(ctx, &env)
|
rc.ApplyExtraPath(ctx, &env)
|
||||||
|
|
||||||
@@ -95,10 +96,11 @@ func (rc *RunContext) setupHookFileCommands(ctx context.Context, env map[string]
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (rc *RunContext) processHookFileCommands(ctx context.Context) error {
|
func (rc *RunContext) processHookFileCommands(ctx context.Context) error {
|
||||||
if err := processRunnerEnvFileCommand(ctx, hookEnvFileCommand, rc, rc.setEnv); err != nil {
|
err := processRunnerEnvFileCommand(ctx, hookEnvFileCommand, rc, rc.setEnvFile)
|
||||||
return err
|
if pathErr := rc.UpdateExtraPath(ctx, path.Join(rc.JobContainer.GetActPath(), hookPathFileCommand)); pathErr != nil && err == nil {
|
||||||
|
err = pathErr
|
||||||
}
|
}
|
||||||
return rc.UpdateExtraPath(ctx, path.Join(rc.JobContainer.GetActPath(), hookPathFileCommand))
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// hookCommand mirrors actions/runner, which deliberately does not apply the shell flags it
|
// hookCommand mirrors actions/runner, which deliberately does not apply the shell flags it
|
||||||
|
|||||||
+68
-42
@@ -168,49 +168,77 @@ 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
|
||||||
|
|
||||||
// secretValueEncoders are the shapes a secret takes on its way into a log: a base64
|
|
||||||
// payload, a JSON string, or a URL component. An action that serializes a secret leaks
|
|
||||||
// it in one of these forms, which a mask of the verbatim value alone does not catch, so
|
|
||||||
// every form is masked as well. This mirrors the value encoders of GitHub's runner.
|
|
||||||
var secretValueEncoders = []func(string) string{
|
var secretValueEncoders = []func(string) string{
|
||||||
func(v string) string { return base64.StdEncoding.EncodeToString([]byte(v)) },
|
func(v string) string { return base64.StdEncoding.EncodeToString([]byte(v)) },
|
||||||
base64ShiftEncoder(1),
|
base64ShiftEncoder(1),
|
||||||
base64ShiftEncoder(2),
|
base64ShiftEncoder(2),
|
||||||
|
base64InteriorEncoder(0),
|
||||||
|
base64InteriorEncoder(1),
|
||||||
|
base64InteriorEncoder(2),
|
||||||
|
expressionStringEscape,
|
||||||
jsonStringEscape,
|
jsonStringEscape,
|
||||||
jsonStringEscapeNoHTML,
|
jsonStringEscapeNoHTML,
|
||||||
url.QueryEscape,
|
uriDataEscape,
|
||||||
|
url.QueryEscape, // the form-encoded twin of uriDataEscape, which spells a space "+"
|
||||||
url.PathEscape,
|
url.PathEscape,
|
||||||
|
xmlDataEscape,
|
||||||
|
trimDoubleQuotes,
|
||||||
}
|
}
|
||||||
|
|
||||||
// minShiftedBase64Len is the shortest shifted base64 fragment worth masking. A shorter
|
// base64ShiftEncoder reproduces the 3-byte alignments of `Basic base64("user:token")`, and
|
||||||
// one carries too few bytes of the secret to identify it and would mask unrelated output.
|
// its padded tail only matches a secret that ends the payload.
|
||||||
const minShiftedBase64Len = 8
|
|
||||||
|
|
||||||
// base64ShiftEncoder returns the part of a secret's base64 form that survives when the
|
|
||||||
// secret does not start on a 3-byte boundary of the payload it is embedded in. base64
|
|
||||||
// encodes three bytes at a time, so `Authorization: Basic base64("user:token")` contains
|
|
||||||
// the base64 of the token alone only when the prefix length happens to be a multiple of
|
|
||||||
// three; at the other two alignments the encoding of the whole value differs. Encoding
|
|
||||||
// the secret behind shift filler bytes reproduces those alignments, which is what the
|
|
||||||
// Base64StringEscapeShift1/2 encoders of GitHub's runner do.
|
|
||||||
//
|
|
||||||
// The leading group (filler mixed with the secret's first bytes) and the trailing group
|
|
||||||
// (padded here, but continuing into whatever follows the secret) are dropped, leaving the
|
|
||||||
// group-aligned middle that does appear verbatim in the log.
|
|
||||||
func base64ShiftEncoder(shift int) func(string) string {
|
func base64ShiftEncoder(shift int) func(string) string {
|
||||||
|
return func(v string) string {
|
||||||
|
value := []byte(v)
|
||||||
|
if len(value) > shift {
|
||||||
|
value = value[shift:]
|
||||||
|
}
|
||||||
|
return base64.StdEncoding.EncodeToString(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const minInteriorBase64Len = 8 // below this a fragment matches unrelated output
|
||||||
|
|
||||||
|
// base64InteriorEncoder keeps the aligned middle, so a secret with data after it still matches.
|
||||||
|
func base64InteriorEncoder(shift int) func(string) string {
|
||||||
return func(v string) string {
|
return func(v string) string {
|
||||||
buf := make([]byte, shift+len(v))
|
buf := make([]byte, shift+len(v))
|
||||||
copy(buf[shift:], v)
|
copy(buf[shift:], v)
|
||||||
encoded := base64.StdEncoding.EncodeToString(buf)
|
encoded := base64.StdEncoding.EncodeToString(buf)
|
||||||
// Keep only the aligned middle, and only when enough of it is left to be a
|
if len(encoded) < 8+minInteriorBase64Len {
|
||||||
// distinctive pattern rather than a fragment that matches unrelated output.
|
|
||||||
if len(encoded) < 8+minShiftedBase64Len {
|
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
return encoded[4 : len(encoded)-4]
|
return encoded[4 : len(encoded)-4]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func expressionStringEscape(v string) string {
|
||||||
|
return strings.ReplaceAll(v, "'", "''")
|
||||||
|
}
|
||||||
|
|
||||||
|
func uriDataEscape(v string) string {
|
||||||
|
return strings.ReplaceAll(url.QueryEscape(v), "+", "%20")
|
||||||
|
}
|
||||||
|
|
||||||
|
var xmlDataEscaper = strings.NewReplacer(
|
||||||
|
"&", "&",
|
||||||
|
"<", "<",
|
||||||
|
">", ">",
|
||||||
|
`"`, """,
|
||||||
|
"'", "'",
|
||||||
|
)
|
||||||
|
|
||||||
|
func xmlDataEscape(v string) string {
|
||||||
|
return xmlDataEscaper.Replace(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
func trimDoubleQuotes(v string) string {
|
||||||
|
if len(v) > 8 && strings.HasPrefix(v, `"`) && strings.HasSuffix(v, `"`) {
|
||||||
|
return v[1 : len(v)-1]
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
// jsonStringEscape returns v as it appears inside a JSON string, without the quotes,
|
// jsonStringEscape returns v as it appears inside a JSON string, without the quotes,
|
||||||
// which is what `toJSON(secrets)` or any action logging a JSON body produces. Go's encoder
|
// which is what `toJSON(secrets)` or any action logging a JSON body produces. Go's encoder
|
||||||
// escapes <, >, & (as act's own toJSON does); the non-HTML variant below covers the runtimes
|
// escapes <, >, & (as act's own toJSON does); the non-HTML variant below covers the runtimes
|
||||||
@@ -245,36 +273,34 @@ func AppendSecretMaskers(oldnew []string, secrets map[string]string) []string {
|
|||||||
return oldnew
|
return oldnew
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AppendSecretMasker registers v and each of its lines, as GitHub does.
|
||||||
func AppendSecretMasker(oldnew []string, v string) []string {
|
func AppendSecretMasker(oldnew []string, v string) []string {
|
||||||
ret := oldnew
|
ret := appendMaskedValue(oldnew, v)
|
||||||
|
for l := range strings.FieldsFuncSeq(v, func(r rune) bool { return r == '\r' || r == '\n' }) {
|
||||||
for l := range strings.SplitSeq(v, "\n") {
|
ret = appendMaskedValue(ret, strings.TrimSpace(l))
|
||||||
tm := strings.TrimSpace(l)
|
|
||||||
// formatted JSON secrets could otherwise mask {,[,],} everywhere
|
|
||||||
if len(tm) > 1 {
|
|
||||||
ret = append(ret, tm, "***")
|
|
||||||
// command data reaches the log escaped, so "pass%word" also arrives as "pass%25word"
|
|
||||||
if strings.ContainsAny(tm, "%\r\n") {
|
|
||||||
ret = append(ret, EscapeCommandData(tm), "***")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
return ret
|
||||||
|
}
|
||||||
|
|
||||||
// The encoded forms are derived from the whole value: a multi-line secret is
|
// appendMaskedValue registers one value and every shape it takes on its way into a log.
|
||||||
// encoded as one string, not line by line.
|
func appendMaskedValue(ret []string, v string) []string {
|
||||||
trimmed := strings.TrimSpace(v)
|
// formatted JSON secrets could otherwise mask {,[,],} everywhere
|
||||||
if len(trimmed) <= 1 {
|
if len(strings.TrimSpace(v)) <= 1 || slices.Contains(ret, v) {
|
||||||
return ret
|
return ret
|
||||||
}
|
}
|
||||||
|
ret = append(ret, v, "***")
|
||||||
|
// command data reaches the log escaped, so "pass%word" also arrives as "pass%25word"
|
||||||
|
if strings.ContainsAny(v, "%\r\n") {
|
||||||
|
ret = append(ret, EscapeCommandData(v), "***")
|
||||||
|
}
|
||||||
for _, encode := range secretValueEncoders {
|
for _, encode := range secretValueEncoders {
|
||||||
encoded := encode(trimmed)
|
encoded := encode(v)
|
||||||
// An encoding that leaves the value unchanged is already masked above.
|
// An encoding that leaves the value unchanged is already masked above.
|
||||||
if encoded == trimmed || len(encoded) <= 1 || slices.Contains(ret, encoded) {
|
if encoded == v || len(encoded) <= 1 || slices.Contains(ret, encoded) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
ret = append(ret, encoded, "***")
|
ret = append(ret, encoded, "***")
|
||||||
}
|
}
|
||||||
|
|
||||||
return ret
|
return ret
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+33
-29
@@ -64,26 +64,27 @@ func TestValueMasker(t *testing.T) {
|
|||||||
// A secret that reaches the log through an encoding — a base64 payload, a JSON body, a
|
// A secret that reaches the log through an encoding — a base64 payload, a JSON body, a
|
||||||
// URL — must be masked as well: masking only the verbatim value leaks it.
|
// URL — must be masked as well: masking only the verbatim value leaks it.
|
||||||
func TestValueMaskerEncodedSecrets(t *testing.T) {
|
func TestValueMaskerEncodedSecrets(t *testing.T) {
|
||||||
secret := `p@ss w"rd/1`
|
|
||||||
masker := valueMasker(false, AppendSecretMaskers(nil, map[string]string{"TOKEN": secret}))
|
|
||||||
|
|
||||||
for _, tc := range []struct {
|
for _, tc := range []struct {
|
||||||
name string
|
name, secret string
|
||||||
line string
|
encoded []string
|
||||||
}{
|
}{
|
||||||
{"verbatim", "the token is " + secret},
|
{"common encodings", `p@ss w"rd/1`, []string{
|
||||||
{"base64", "Authorization: Basic " + base64.StdEncoding.EncodeToString([]byte(secret))},
|
`p@ss w"rd/1`, base64.StdEncoding.EncodeToString([]byte(`p@ss w"rd/1`)),
|
||||||
{"json", `{"token":"` + jsonStringEscape(secret) + `"}`},
|
jsonStringEscape(`p@ss w"rd/1`), url.PathEscape(`p@ss w"rd/1`),
|
||||||
{"query escaped", "https://example.com/?token=" + url.QueryEscape(secret)},
|
}},
|
||||||
{"path escaped", "https://example.com/" + url.PathEscape(secret) + "/x"},
|
{"XML expression and quotes", `"a'b&c<d>"`, []string{
|
||||||
|
`"a'b&c<d>"`, `"a''b&c<d>"`, `a'b&c<d>`,
|
||||||
|
}},
|
||||||
|
{"URI spaces", "a b", []string{"a%20b", "a+b"}},
|
||||||
} {
|
} {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
entry := masker(&logrus.Entry{Context: t.Context(), Message: tc.line})
|
masker := valueMasker(false, AppendSecretMaskers(nil, map[string]string{"TOKEN": tc.secret}))
|
||||||
|
entry := masker(&logrus.Entry{Context: t.Context(), Message: strings.Join(tc.encoded, " ")})
|
||||||
|
|
||||||
assert.Contains(t, entry.Message, "***")
|
assert.Contains(t, entry.Message, "***")
|
||||||
assert.NotContains(t, entry.Message, secret)
|
for _, disallowed := range tc.encoded {
|
||||||
assert.NotContains(t, entry.Message, base64.StdEncoding.EncodeToString([]byte(secret)))
|
assert.NotContains(t, entry.Message, disallowed)
|
||||||
assert.NotContains(t, entry.Message, url.QueryEscape(secret))
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -124,15 +125,21 @@ func TestValueMaskerHidesExtraMasks(t *testing.T) {
|
|||||||
|
|
||||||
// ::add-mask:: values go through the same masker, so they get the same treatment.
|
// ::add-mask:: values go through the same masker, so they get the same treatment.
|
||||||
func TestValueMaskerEncodedMasks(t *testing.T) {
|
func TestValueMaskerEncodedMasks(t *testing.T) {
|
||||||
masks := []string{"s3cr3t value"}
|
masks := []string{"s3cr3t value", "first\rsecond", " s3cr3t "}
|
||||||
masker := valueMasker(false, AppendSecretMaskers(nil, nil))
|
masker := valueMasker(false, AppendSecretMaskers(nil, nil))
|
||||||
|
|
||||||
entry := masker(&logrus.Entry{
|
for _, tc := range []struct {
|
||||||
Context: WithMasks(t.Context(), &masks),
|
line, want string
|
||||||
Message: "encoded: " + base64.StdEncoding.EncodeToString([]byte("s3cr3t value")),
|
}{
|
||||||
})
|
{"encoded: " + base64.StdEncoding.EncodeToString([]byte("s3cr3t value")), "encoded: ***"},
|
||||||
|
{"first and second", "*** and ***"},
|
||||||
assert.Equal(t, "encoded: ***", entry.Message)
|
{"encoded: " + base64.StdEncoding.EncodeToString([]byte(" s3cr3t ")), "encoded: ***"},
|
||||||
|
{"encoded: " + base64.StdEncoding.EncodeToString([]byte("s3cr3t")), "encoded: ***"},
|
||||||
|
{"encoded: " + base64.StdEncoding.EncodeToString([]byte("first")), "encoded: ***"},
|
||||||
|
} {
|
||||||
|
entry := masker(&logrus.Entry{Context: WithMasks(t.Context(), &masks), Message: tc.line})
|
||||||
|
assert.Equal(t, tc.want, entry.Message, tc.line)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// A token in a Basic auth header is base64'd together with the user name, so the token's
|
// A token in a Basic auth header is base64'd together with the user name, so the token's
|
||||||
@@ -140,7 +147,7 @@ func TestValueMaskerEncodedMasks(t *testing.T) {
|
|||||||
// alignments must be masked as well, or `Authorization: Basic base64("user:token")` leaks
|
// alignments must be masked as well, or `Authorization: Basic base64("user:token")` leaks
|
||||||
// the token to anyone who can decode the log.
|
// the token to anyone who can decode the log.
|
||||||
func TestValueMaskerBase64Alignments(t *testing.T) {
|
func TestValueMaskerBase64Alignments(t *testing.T) {
|
||||||
secret := "s3cr3t-token-value"
|
secret := "s3cr3t"
|
||||||
masker := valueMasker(false, AppendSecretMaskers(nil, map[string]string{"TOKEN": secret}))
|
masker := valueMasker(false, AppendSecretMaskers(nil, map[string]string{"TOKEN": secret}))
|
||||||
|
|
||||||
// One prefix per alignment: len%3 of 0, 1 and 2.
|
// One prefix per alignment: len%3 of 0, 1 and 2.
|
||||||
@@ -150,8 +157,6 @@ func TestValueMaskerBase64Alignments(t *testing.T) {
|
|||||||
entry := masker(&logrus.Entry{Context: t.Context(), Message: "Authorization: Basic " + encoded})
|
entry := masker(&logrus.Entry{Context: t.Context(), Message: "Authorization: Basic " + encoded})
|
||||||
|
|
||||||
assert.Contains(t, entry.Message, "***")
|
assert.Contains(t, entry.Message, "***")
|
||||||
// The aligned middle of the secret must be gone, so the payload can no longer be
|
|
||||||
// decoded back into the token.
|
|
||||||
assert.NotEqual(t, "Authorization: Basic "+encoded, entry.Message)
|
assert.NotEqual(t, "Authorization: Basic "+encoded, entry.Message)
|
||||||
decodable := strings.TrimPrefix(entry.Message, "Authorization: Basic ")
|
decodable := strings.TrimPrefix(entry.Message, "Authorization: Basic ")
|
||||||
decoded, err := base64.StdEncoding.DecodeString(decodable)
|
decoded, err := base64.StdEncoding.DecodeString(decodable)
|
||||||
@@ -190,15 +195,14 @@ func TestAppendSecretMaskerSkipsUselessEncodings(t *testing.T) {
|
|||||||
// JSON, query and path escaping all leave it unchanged.
|
// JSON, query and path escaping all leave it unchanged.
|
||||||
pairs := AppendSecretMasker(nil, "plaintoken")
|
pairs := AppendSecretMasker(nil, "plaintoken")
|
||||||
assert.Equal(t, []string{
|
assert.Equal(t, []string{
|
||||||
"plaintoken", "***",
|
"plaintoken", "***", "cGxhaW50b2tlbg==", "***", "bGFpbnRva2Vu", "***", "YWludG9rZW4=", "***",
|
||||||
base64.StdEncoding.EncodeToString([]byte("plaintoken")), "***",
|
"aW50b2tl", "***", "YWludG9r", "***", "bGFpbnRv", "***",
|
||||||
// The two shifted alignments, each without its leading and trailing group.
|
|
||||||
"YWludG9r", "***",
|
|
||||||
"bGFpbnRv", "***",
|
|
||||||
}, pairs)
|
}, pairs)
|
||||||
|
|
||||||
// Too short to mask.
|
// Too short to mask.
|
||||||
assert.Empty(t, AppendSecretMasker(nil, "x"))
|
assert.Empty(t, AppendSecretMasker(nil, "x"))
|
||||||
|
assert.Empty(t, AppendSecretMasker(nil, " \t"))
|
||||||
|
assert.NotContains(t, AppendSecretMasker(nil, `"123456"`), "123456")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestJobLogFormatterDecodesCommandData(t *testing.T) {
|
func TestJobLogFormatterDecodesCommandData(t *testing.T) {
|
||||||
|
|||||||
@@ -95,9 +95,12 @@ func newRemoteReusableWorkflowExecutor(rc *RunContext) common.Executor {
|
|||||||
// remoteReusableWorkflow.URL = rc.getGithubContext(ctx).ServerURL
|
// remoteReusableWorkflow.URL = rc.getGithubContext(ctx).ServerURL
|
||||||
func cloneRemoteReusableWorkflow(rc *RunContext, cloneURL, ref, targetDirectory, token string) common.Executor {
|
func cloneRemoteReusableWorkflow(rc *RunContext, cloneURL, ref, targetDirectory, token string) common.Executor {
|
||||||
return func(ctx context.Context) error {
|
return func(ctx context.Context) error {
|
||||||
cloneURL = rc.NewExpressionEvaluator(ctx).Interpolate(ctx, cloneURL)
|
interpolatedURL, err := rc.NewExpressionEvaluator(ctx).Interpolate(ctx, cloneURL)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("unable to interpolate the workflow clone URL: %w", err)
|
||||||
|
}
|
||||||
return git.NewGitCloneExecutor(git.NewGitCloneExecutorInput{
|
return git.NewGitCloneExecutor(git.NewGitCloneExecutorInput{
|
||||||
URL: cloneURL,
|
URL: interpolatedURL,
|
||||||
Ref: ref,
|
Ref: ref,
|
||||||
Dir: targetDirectory,
|
Dir: targetDirectory,
|
||||||
Token: token,
|
Token: token,
|
||||||
|
|||||||
+261
-65
@@ -17,6 +17,7 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
maps0 "maps"
|
maps0 "maps"
|
||||||
"os"
|
"os"
|
||||||
|
"path"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
"runtime"
|
"runtime"
|
||||||
@@ -28,6 +29,7 @@ import (
|
|||||||
"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/ghcontext"
|
"gitea.com/gitea/runner/act/ghcontext"
|
||||||
|
"gitea.com/gitea/runner/internal/pkg/labels"
|
||||||
"gitea.com/gitea/runner/internal/pkg/lock"
|
"gitea.com/gitea/runner/internal/pkg/lock"
|
||||||
|
|
||||||
"gitea.dev/actionslib/pkg/exprparser"
|
"gitea.dev/actionslib/pkg/exprparser"
|
||||||
@@ -37,6 +39,8 @@ import (
|
|||||||
"github.com/moby/moby/api/types/mount"
|
"github.com/moby/moby/api/types/mount"
|
||||||
"github.com/opencontainers/selinux/go-selinux"
|
"github.com/opencontainers/selinux/go-selinux"
|
||||||
"golang.org/x/sync/errgroup"
|
"golang.org/x/sync/errgroup"
|
||||||
|
"golang.org/x/text/encoding/unicode"
|
||||||
|
"golang.org/x/text/transform"
|
||||||
)
|
)
|
||||||
|
|
||||||
// RunContext contains info about current job
|
// RunContext contains info about current job
|
||||||
@@ -65,7 +69,11 @@ type RunContext struct {
|
|||||||
actionInputs map[string]any // inputs of the composite action this runs, nil for a job
|
actionInputs map[string]any // inputs of the composite action this runs, nil for a job
|
||||||
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)
|
||||||
|
workflowCallInputs map[string]any // the caller's with:, resolved once by resolveWorkflowCall
|
||||||
|
workflowCallSecrets map[string]string // the caller's secrets:, resolved once by resolveWorkflowCall
|
||||||
|
jobRunDefaults model.RunDefaults // defaults.run, resolved once at job setup as GitHub does
|
||||||
|
platformImage string // container.image or the runs-on pick, resolved once by isEnabled
|
||||||
// summaryFileInitialized tracks which per-step summary files (workflow/step-summary-N.md)
|
// 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
|
// 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
|
// via JobContainer.Copy at the start of every phase, which truncates them — fine for
|
||||||
@@ -88,6 +96,7 @@ type RunContext struct {
|
|||||||
jobFailed bool
|
jobFailed bool
|
||||||
// empty for a host-mode job, which starts no container
|
// empty for a host-mode job, which starts no container
|
||||||
jobContainerID string
|
jobContainerID string
|
||||||
|
hasBash *bool // memoized implicit-shell probe, only set on the top-level RunContext
|
||||||
jobNetworkName string
|
jobNetworkName string
|
||||||
// stepEnv is a copy of the running step's environment, so that workflow commands parsed out
|
// stepEnv is a copy of the running step's environment, so that workflow commands parsed out
|
||||||
// of the container's output can be judged against it. Written by runStepExecutor and read on
|
// of the container's output can be judged against it. Written by runStepExecutor and read on
|
||||||
@@ -243,6 +252,9 @@ func (rc *RunContext) validVolumes() []string {
|
|||||||
if rc.Config.SharedToolCache {
|
if rc.Config.SharedToolCache {
|
||||||
volumes = append(volumes, sharedToolCacheVolume)
|
volumes = append(volumes, sharedToolCacheVolume)
|
||||||
}
|
}
|
||||||
|
if rc.Config.BindWorkdir {
|
||||||
|
volumes = append(volumes, rc.Config.Workdir)
|
||||||
|
}
|
||||||
// TODO: add a new configuration to control whether the docker daemon can be mounted
|
// TODO: add a new configuration to control whether the docker daemon can be mounted
|
||||||
return append(volumes, name, name+"-env",
|
return append(volumes, name, name+"-env",
|
||||||
getDockerDaemonSocketMountPath(rc.containerDaemonSocket()))
|
getDockerDaemonSocketMountPath(rc.containerDaemonSocket()))
|
||||||
@@ -296,7 +308,7 @@ func splitVolumes(specs []string) ([]string, map[string]string, map[string]bool)
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 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, error) {
|
||||||
name := rc.jobContainerName()
|
name := rc.jobContainerName()
|
||||||
ext := container.LinuxContainerEnvironmentExtensions{}
|
ext := container.LinuxContainerEnvironmentExtensions{}
|
||||||
|
|
||||||
@@ -305,7 +317,10 @@ func (rc *RunContext) GetBindsAndMounts() ([]string, map[string]string) {
|
|||||||
if container := job.Container(); container != nil {
|
if container := job.Container(); container != nil {
|
||||||
for _, v := range container.Volumes {
|
for _, v := range container.Volumes {
|
||||||
if rc.ExprEval != nil {
|
if rc.ExprEval != nil {
|
||||||
v = rc.ExprEval.Interpolate(context.Background(), v)
|
var err error
|
||||||
|
if v, err = rc.ExprEval.Interpolate(context.Background(), v); err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("unable to interpolate container.volumes: %w", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
volumes = append(volumes, v)
|
volumes = append(volumes, v)
|
||||||
}
|
}
|
||||||
@@ -339,7 +354,7 @@ func (rc *RunContext) GetBindsAndMounts() ([]string, map[string]string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return binds, mounts
|
return binds, mounts, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (rc *RunContext) startHostEnvironment() common.Executor {
|
func (rc *RunContext) startHostEnvironment() common.Executor {
|
||||||
@@ -411,6 +426,113 @@ func (rc *RunContext) startHostEnvironment() common.Executor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// printStartMacOSVMGroup mirrors the "Starting job container" section for macOS VMs.
|
||||||
|
func printStartMacOSVMGroup(ctx context.Context, image, name string) func() {
|
||||||
|
rawLogger := common.Logger(ctx).WithField(rawOutputField, true)
|
||||||
|
rawLogger.Infof("::group::Starting job VM")
|
||||||
|
rawLogger.Infof("image: %s", image)
|
||||||
|
rawLogger.Infof("name: %s", name)
|
||||||
|
return func() {
|
||||||
|
rawLogger.Infof("::endgroup::")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rc *RunContext) startMacOSVMEnvironment() common.Executor {
|
||||||
|
return func(ctx context.Context) error {
|
||||||
|
if len(rc.Run.Job().Services) > 0 {
|
||||||
|
return errors.New("service containers are not supported with the macOS VM executor; see docs/macos-vm-executor.md")
|
||||||
|
}
|
||||||
|
|
||||||
|
image := rc.macOSVMImage()
|
||||||
|
logWriter := rc.commandLogWriter(ctx)
|
||||||
|
name := rc.macOSVMName()
|
||||||
|
rc.Env["JOB_CONTAINER_NAME"] = name
|
||||||
|
|
||||||
|
guestWorkdir := rc.macOSVMGuestWorkdir()
|
||||||
|
scratch := path.Join("/", rc.macOSVMWorkdirParent(), "scratch", name)
|
||||||
|
actPath := path.Join(scratch, "act")
|
||||||
|
tmpDir := path.Join(scratch, "tmp")
|
||||||
|
toolCache := rc.toolCache(path.Join(scratch, "tool_cache"))
|
||||||
|
|
||||||
|
jobContainer, err := container.NewMacOSVMEnvironment(container.MacOSVMEnvironmentInput{
|
||||||
|
Path: scratch,
|
||||||
|
TmpDir: tmpDir,
|
||||||
|
ToolCache: toolCache,
|
||||||
|
Workdir: rc.Config.Workdir,
|
||||||
|
GuestWorkdir: guestWorkdir,
|
||||||
|
ActPath: actPath,
|
||||||
|
Image: image,
|
||||||
|
VMName: name,
|
||||||
|
ExecutorPath: rc.Config.MacOSVM.ExecutorPath,
|
||||||
|
CPU: rc.Config.MacOSVM.CPU,
|
||||||
|
Memory: rc.Config.MacOSVM.Memory,
|
||||||
|
BootTimeout: rc.Config.MacOSVM.BootTimeout,
|
||||||
|
Stdout: logWriter,
|
||||||
|
Stderr: logWriter,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rc.JobContainer = jobContainer
|
||||||
|
rc.cleanUpJobContainer = jobContainer.Remove()
|
||||||
|
|
||||||
|
for k, v := range rc.getRunnerContext(ctx) {
|
||||||
|
if v, ok := v.(string); ok {
|
||||||
|
rc.Env["RUNNER_"+strings.ToUpper(k)] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, env := range os.Environ() {
|
||||||
|
if k, v, ok := strings.Cut(env, "="); ok {
|
||||||
|
if _, ok := rc.Env[k]; !ok {
|
||||||
|
rc.Env[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
defer printStartMacOSVMGroup(ctx, image, name)()
|
||||||
|
return common.NewPipelineExecutor(
|
||||||
|
jobContainer.Pull(rc.Config.ForcePull),
|
||||||
|
jobContainer.Create(nil, nil),
|
||||||
|
jobContainer.Start(false),
|
||||||
|
jobContainer.Exec([]string{"/bin/mkdir", "-p", guestWorkdir, actPath, tmpDir, toolCache}, nil, "", "/"),
|
||||||
|
rc.captureJobContainerInfo(),
|
||||||
|
jobContainer.Copy(jobContainer.GetActPath()+"/", &container.FileEntry{
|
||||||
|
Name: "workflow/event.json",
|
||||||
|
Mode: 0o644,
|
||||||
|
Body: rc.EventJSON,
|
||||||
|
}, &container.FileEntry{
|
||||||
|
Name: "workflow/envs.txt",
|
||||||
|
Mode: 0o666,
|
||||||
|
Body: "",
|
||||||
|
}),
|
||||||
|
)(ctx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rc *RunContext) macOSVMImage() string {
|
||||||
|
return strings.TrimPrefix(rc.platformImage, labels.MacOSVMSchemePrefix)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rc *RunContext) macOSVMName() string {
|
||||||
|
nameParts := []string{"GITEA-MACOS-VM", rc.Config.RunnerUUID, rc.Config.ContainerNamePrefix, "WORKFLOW-" + rc.Run.Workflow.Name, "JOB-" + rc.Run.JobID, rc.Name}
|
||||||
|
if rc.caller != nil {
|
||||||
|
nameParts = append(nameParts, "CALLED-BY-"+rc.caller.runContext.JobName)
|
||||||
|
}
|
||||||
|
return createContainerName(nameParts...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rc *RunContext) macOSVMGuestWorkdir() string {
|
||||||
|
rel := strings.TrimLeft(filepath.ToSlash(rc.Config.Workdir), "/")
|
||||||
|
return path.Join("/", rc.macOSVMWorkdirParent(), rel)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rc *RunContext) macOSVMWorkdirParent() string {
|
||||||
|
if rc.Config.MacOSVM.WorkdirParent != "" {
|
||||||
|
return rc.Config.MacOSVM.WorkdirParent
|
||||||
|
}
|
||||||
|
return "/Users/admin/runner"
|
||||||
|
}
|
||||||
|
|
||||||
// printStartJobContainerGroup mirrors actions/runner's "Starting job container"
|
// printStartJobContainerGroup mirrors actions/runner's "Starting job container"
|
||||||
// section: emit the group header and summary, return a closer for ::endgroup::.
|
// section: emit the group header and summary, return a closer for ::endgroup::.
|
||||||
func printStartJobContainerGroup(ctx context.Context, image, name, network string) func() {
|
func printStartJobContainerGroup(ctx context.Context, image, name, network string) func() {
|
||||||
@@ -430,7 +552,7 @@ var newContainer = container.NewContainer
|
|||||||
func (rc *RunContext) startJobContainer() common.Executor {
|
func (rc *RunContext) startJobContainer() common.Executor {
|
||||||
return func(ctx context.Context) error {
|
return func(ctx context.Context) error {
|
||||||
logger := common.Logger(ctx)
|
logger := common.Logger(ctx)
|
||||||
image := rc.platformImage(ctx)
|
image := rc.platformImage
|
||||||
logWriter := rc.commandLogWriter(ctx)
|
logWriter := rc.commandLogWriter(ctx)
|
||||||
|
|
||||||
username, password, err := rc.handleCredentials(ctx)
|
username, password, err := rc.handleCredentials(ctx)
|
||||||
@@ -449,7 +571,10 @@ func (rc *RunContext) startJobContainer() common.Executor {
|
|||||||
envList = append(envList, fmt.Sprintf("%s=%s", "LANG", "C.UTF-8")) // Use same locale as GitHub Actions
|
envList = append(envList, fmt.Sprintf("%s=%s", "LANG", "C.UTF-8")) // Use same locale as GitHub Actions
|
||||||
|
|
||||||
ext := container.LinuxContainerEnvironmentExtensions{}
|
ext := container.LinuxContainerEnvironmentExtensions{}
|
||||||
binds, mounts := rc.GetBindsAndMounts()
|
binds, mounts, err := rc.GetBindsAndMounts()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
// specify the network to which the container will connect when `docker create` stage. (like execute command line: docker create --network <networkName> <image>)
|
// specify the network to which the container will connect when `docker create` stage. (like execute command line: docker create --network <networkName> <image>)
|
||||||
// if using service containers, will create a new network for the containers.
|
// if using service containers, will create a new network for the containers.
|
||||||
@@ -460,7 +585,10 @@ func (rc *RunContext) startJobContainer() common.Executor {
|
|||||||
for serviceID, spec := range rc.Run.Job().Services {
|
for serviceID, spec := range rc.Run.Job().Services {
|
||||||
// GitHub compatibility: skip services whose image evaluates to an
|
// GitHub compatibility: skip services whose image evaluates to an
|
||||||
// empty string, enabling conditional services via expressions
|
// empty string, enabling conditional services via expressions
|
||||||
serviceImage := rc.ExprEval.Interpolate(ctx, spec.Image)
|
serviceImage, err := rc.ExprEval.Interpolate(ctx, spec.Image)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("unable to interpolate service %s image: %w", serviceID, err)
|
||||||
|
}
|
||||||
if serviceImage == "" {
|
if serviceImage == "" {
|
||||||
logger.Infof("The service '%s' will not be started because the container definition has an empty image.", serviceID)
|
logger.Infof("The service '%s' will not be started because the container definition has an empty image.", serviceID)
|
||||||
continue
|
continue
|
||||||
@@ -470,16 +598,20 @@ func (rc *RunContext) startJobContainer() common.Executor {
|
|||||||
// a service reaches the internet the way the job does; its own env still wins
|
// a service reaches the internet the way the job does; its own env still wins
|
||||||
maps0.Copy(interpolatedEnvs, rc.Config.ProxyEnv)
|
maps0.Copy(interpolatedEnvs, rc.Config.ProxyEnv)
|
||||||
for k, v := range spec.Env {
|
for k, v := range spec.Env {
|
||||||
interpolatedEnvs[k] = rc.ExprEval.Interpolate(ctx, v)
|
if interpolatedEnvs[k], err = rc.ExprEval.Interpolate(ctx, v); err != nil {
|
||||||
|
return fmt.Errorf("unable to interpolate service %s env %s: %w", serviceID, k, err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
envs := make([]string, 0, len(interpolatedEnvs))
|
envs := make([]string, 0, len(interpolatedEnvs))
|
||||||
for k, v := range interpolatedEnvs {
|
for k, v := range interpolatedEnvs {
|
||||||
envs = append(envs, fmt.Sprintf("%s=%s", k, v))
|
envs = append(envs, fmt.Sprintf("%s=%s", k, v))
|
||||||
}
|
}
|
||||||
// interpolate cmd
|
// interpolate cmd
|
||||||
interpolatedCmd := make([]string, 0, len(spec.Cmd))
|
interpolatedCmd := make([]string, len(spec.Cmd))
|
||||||
for _, v := range spec.Cmd {
|
for i, v := range spec.Cmd {
|
||||||
interpolatedCmd = append(interpolatedCmd, rc.ExprEval.Interpolate(ctx, v))
|
if interpolatedCmd[i], err = rc.ExprEval.Interpolate(ctx, v); err != nil {
|
||||||
|
return fmt.Errorf("unable to interpolate service %s command: %w", serviceID, err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// keep these local: reusing username/password would overwrite the
|
// keep these local: reusing username/password would overwrite the
|
||||||
// credentials the job container is pulled with further down
|
// credentials the job container is pulled with further down
|
||||||
@@ -488,25 +620,32 @@ func (rc *RunContext) startJobContainer() common.Executor {
|
|||||||
return fmt.Errorf("failed to handle service %s credentials: %w", serviceID, err)
|
return fmt.Errorf("failed to handle service %s credentials: %w", serviceID, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
interpolatedVolumes := make([]string, 0, len(spec.Volumes))
|
interpolatedVolumes := make([]string, len(spec.Volumes))
|
||||||
for _, volume := range spec.Volumes {
|
for i, volume := range spec.Volumes {
|
||||||
interpolatedVolumes = append(interpolatedVolumes, rc.ExprEval.Interpolate(ctx, volume))
|
if interpolatedVolumes[i], err = rc.ExprEval.Interpolate(ctx, volume); err != nil {
|
||||||
|
return fmt.Errorf("unable to interpolate service %s volumes: %w", serviceID, err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
serviceBinds, serviceMounts, _ := splitVolumes(interpolatedVolumes)
|
serviceBinds, serviceMounts, _ := splitVolumes(interpolatedVolumes)
|
||||||
|
|
||||||
interpolatedPorts := make([]string, 0, len(spec.Ports))
|
interpolatedPorts := make([]string, len(spec.Ports))
|
||||||
for _, port := range spec.Ports {
|
for i, port := range spec.Ports {
|
||||||
interpolatedPorts = append(interpolatedPorts, rc.ExprEval.Interpolate(ctx, port))
|
if interpolatedPorts[i], err = rc.ExprEval.Interpolate(ctx, port); err != nil {
|
||||||
|
return fmt.Errorf("unable to interpolate service %s ports: %w", serviceID, err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
exposedPorts, portBindings, err := nat.ParsePortSpecs(interpolatedPorts)
|
exposedPorts, portBindings, err := nat.ParsePortSpecs(interpolatedPorts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to parse service %s ports: %w", serviceID, err)
|
return fmt.Errorf("failed to parse service %s ports: %w", serviceID, err)
|
||||||
}
|
}
|
||||||
|
serviceOptions, err := rc.ExprEval.Interpolate(ctx, spec.Options)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("unable to interpolate service %s options: %w", serviceID, err)
|
||||||
|
}
|
||||||
|
|
||||||
serviceContainerName := createContainerName(rc.jobContainerName(), serviceID)
|
serviceContainerName := createContainerName(rc.jobContainerName(), serviceID)
|
||||||
c := newContainer(&container.NewContainerInput{
|
c := newContainer(&container.NewContainerInput{
|
||||||
Name: serviceContainerName,
|
Name: serviceContainerName,
|
||||||
WorkingDir: ext.ToContainerPath(rc.Config.Workdir),
|
|
||||||
Image: serviceImage,
|
Image: serviceImage,
|
||||||
Username: serviceUsername,
|
Username: serviceUsername,
|
||||||
Password: servicePassword,
|
Password: servicePassword,
|
||||||
@@ -520,7 +659,7 @@ func (rc *RunContext) startJobContainer() common.Executor {
|
|||||||
UsernsMode: rc.Config.UsernsMode,
|
UsernsMode: rc.Config.UsernsMode,
|
||||||
Platform: rc.Config.ContainerArchitecture,
|
Platform: rc.Config.ContainerArchitecture,
|
||||||
AutoRemove: false, // so a dead service's log survives, cleanupJobResources removes it
|
AutoRemove: false, // so a dead service's log survives, cleanupJobResources removes it
|
||||||
WorkflowOptions: rc.ExprEval.Interpolate(ctx, spec.Options),
|
WorkflowOptions: serviceOptions,
|
||||||
NetworkMode: networkName,
|
NetworkMode: networkName,
|
||||||
NetworkAliases: []string{serviceID},
|
NetworkAliases: []string{serviceID},
|
||||||
ExposedPorts: exposedPorts,
|
ExposedPorts: exposedPorts,
|
||||||
@@ -536,6 +675,10 @@ func (rc *RunContext) startJobContainer() common.Executor {
|
|||||||
// For Gitea, `jobContainerNetwork` should be the same as `networkName`
|
// For Gitea, `jobContainerNetwork` should be the same as `networkName`
|
||||||
jobContainerNetwork := networkName
|
jobContainerNetwork := networkName
|
||||||
|
|
||||||
|
workflowOptions, err := rc.workflowOptions(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
rc.JobContainer = newContainer(&container.NewContainerInput{
|
rc.JobContainer = newContainer(&container.NewContainerInput{
|
||||||
Cmd: nil,
|
Cmd: nil,
|
||||||
Entrypoint: []string{"/bin/sleep", fmt.Sprint(rc.Config.ContainerMaxLifetime.Round(time.Second).Seconds())},
|
Entrypoint: []string{"/bin/sleep", fmt.Sprint(rc.Config.ContainerMaxLifetime.Round(time.Second).Seconds())},
|
||||||
@@ -555,7 +698,7 @@ func (rc *RunContext) startJobContainer() common.Executor {
|
|||||||
UsernsMode: rc.Config.UsernsMode,
|
UsernsMode: rc.Config.UsernsMode,
|
||||||
Platform: rc.Config.ContainerArchitecture,
|
Platform: rc.Config.ContainerArchitecture,
|
||||||
RunnerOptions: rc.Config.ContainerOptions,
|
RunnerOptions: rc.Config.ContainerOptions,
|
||||||
WorkflowOptions: rc.workflowOptions(ctx),
|
WorkflowOptions: workflowOptions,
|
||||||
AutoRemove: true,
|
AutoRemove: true,
|
||||||
ValidVolumes: rc.validVolumes(),
|
ValidVolumes: rc.validVolumes(),
|
||||||
AllocatePTY: rc.Config.AllocatePTY,
|
AllocatePTY: rc.Config.AllocatePTY,
|
||||||
@@ -679,13 +822,18 @@ func (rc *RunContext) UpdateExtraPath(ctx context.Context, githubEnvPath string)
|
|||||||
if err != nil && err != io.EOF {
|
if err != nil && err != io.EOF {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
s := bufio.NewScanner(reader)
|
decoded := transform.NewReader(reader, unicode.BOMOverride(unicode.UTF8.NewDecoder()))
|
||||||
|
s := bufio.NewScanner(decoded)
|
||||||
|
s.Buffer(make([]byte, 0, 64*1024), 16*1024*1024)
|
||||||
for s.Scan() {
|
for s.Scan() {
|
||||||
line := s.Text()
|
line := s.Text()
|
||||||
if len(line) > 0 {
|
if len(line) > 0 {
|
||||||
rc.addPath(ctx, line)
|
rc.addPath(ctx, line)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if err := s.Err(); err != nil {
|
||||||
|
return fmt.Errorf("reading path file: %w", err)
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -917,20 +1065,36 @@ func (rc *RunContext) interpolateOutputs() common.Executor {
|
|||||||
// pristine snapshot (outputTemplate) and write under the lock, so each combo overwrites
|
// pristine snapshot (outputTemplate) and write under the lock, so each combo overwrites
|
||||||
// with its own resolved values (last wins, as on GitHub) instead of the first combo's
|
// with its own resolved values (last wins, as on GitHub) instead of the first combo's
|
||||||
// resolved values freezing the shared template against later combos.
|
// resolved values freezing the shared template against later combos.
|
||||||
defer lockJob(job)()
|
// Resolved up front so one failure publishes none of them, as GitHub does.
|
||||||
|
outputs := make(map[string]string, len(rc.outputTemplate))
|
||||||
|
var err error
|
||||||
for k, v := range rc.outputTemplate {
|
for k, v := range rc.outputTemplate {
|
||||||
job.Outputs[k] = ee.Interpolate(ctx, v)
|
if outputs[k], err = ee.Interpolate(ctx, v); err != nil {
|
||||||
|
err = fmt.Errorf("failed to evaluate job output %q: %w", k, err)
|
||||||
|
break
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return nil
|
defer lockJob(job)()
|
||||||
|
for k := range rc.outputTemplate {
|
||||||
|
if err != nil {
|
||||||
|
job.Outputs[k] = ""
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
job.Outputs[k] = outputs[k]
|
||||||
|
}
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (rc *RunContext) startContainer() common.Executor {
|
func (rc *RunContext) startContainer() common.Executor {
|
||||||
return func(ctx context.Context) error {
|
return func(ctx context.Context) error {
|
||||||
var err error
|
var err error
|
||||||
if rc.IsHostEnv(ctx) {
|
switch {
|
||||||
|
case rc.IsHostEnv():
|
||||||
err = rc.startHostEnvironment()(ctx)
|
err = rc.startHostEnvironment()(ctx)
|
||||||
} else {
|
case rc.IsMacOSVMEnv():
|
||||||
|
err = rc.startMacOSVMEnvironment()(ctx)
|
||||||
|
default:
|
||||||
err = rc.startJobContainer()(ctx)
|
err = rc.startJobContainer()(ctx)
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -958,10 +1122,13 @@ func (rc *RunContext) cleanupFailedStart(ctx context.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (rc *RunContext) IsHostEnv(ctx context.Context) bool {
|
func (rc *RunContext) IsHostEnv() bool {
|
||||||
platform := rc.runsOnImage(ctx)
|
return strings.EqualFold(rc.platformImage, "-self-hosted")
|
||||||
image := rc.containerImage(ctx)
|
}
|
||||||
return image == "" && strings.EqualFold(platform, "-self-hosted")
|
|
||||||
|
// IsMacOSVMEnv reports whether the resolved platform image requests a macOS VM.
|
||||||
|
func (rc *RunContext) IsMacOSVMEnv() bool {
|
||||||
|
return strings.HasPrefix(rc.platformImage, labels.MacOSVMSchemePrefix)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (rc *RunContext) stopContainer() common.Executor {
|
func (rc *RunContext) stopContainer() common.Executor {
|
||||||
@@ -1046,32 +1213,35 @@ func (rc *RunContext) Executor() (common.Executor, error) {
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (rc *RunContext) containerImage(ctx context.Context) string {
|
func (rc *RunContext) containerImage(ctx context.Context) (string, error) {
|
||||||
job := rc.Run.Job()
|
c := rc.Run.Job().Container()
|
||||||
|
if c == nil {
|
||||||
c := job.Container()
|
return "", nil
|
||||||
if c != nil {
|
|
||||||
return rc.ExprEval.Interpolate(ctx, c.Image)
|
|
||||||
}
|
}
|
||||||
|
image, err := rc.ExprEval.Interpolate(ctx, c.Image)
|
||||||
return ""
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("unable to interpolate container.image: %w", err)
|
||||||
|
}
|
||||||
|
return image, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (rc *RunContext) runsOnImage(ctx context.Context) string {
|
func (rc *RunContext) runsOnImage(ctx context.Context) (string, error) {
|
||||||
if rc.Run.Job().RunsOn() == nil {
|
if rc.Run.Job().RunsOn() == nil {
|
||||||
common.Logger(ctx).Errorf("'runs-on' key not defined in %s", rc.String())
|
common.Logger(ctx).Errorf("'runs-on' key not defined in %s", rc.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
job := rc.Run.Job()
|
runsOn := rc.Run.Job().RunsOn()
|
||||||
runsOn := job.RunsOn()
|
|
||||||
for i, v := range runsOn {
|
for i, v := range runsOn {
|
||||||
runsOn[i] = rc.ExprEval.Interpolate(ctx, v)
|
var err error
|
||||||
|
if runsOn[i], err = rc.ExprEval.Interpolate(ctx, v); err != nil {
|
||||||
|
return "", fmt.Errorf("unable to interpolate runs-on: %w", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if rc.Config.PlatformPicker != nil {
|
if rc.Config.PlatformPicker != nil {
|
||||||
return rc.Config.PlatformPicker(runsOn)
|
return rc.Config.PlatformPicker(runsOn), nil
|
||||||
}
|
}
|
||||||
return ""
|
return "", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (rc *RunContext) runsOnPlatformNames(ctx context.Context) []string {
|
func (rc *RunContext) runsOnPlatformNames(ctx context.Context) []string {
|
||||||
@@ -1092,21 +1262,26 @@ func (rc *RunContext) runsOnPlatformNames(ctx context.Context) []string {
|
|||||||
return model.RunsOnFromNode(rawRunsOn)
|
return model.RunsOnFromNode(rawRunsOn)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (rc *RunContext) platformImage(ctx context.Context) string {
|
// resolvePlatformImage evaluates the job's image once, so every consumer sees the image the job started with.
|
||||||
if containerImage := rc.containerImage(ctx); containerImage != "" {
|
func (rc *RunContext) resolvePlatformImage(ctx context.Context) error {
|
||||||
return containerImage
|
image, err := rc.containerImage(ctx)
|
||||||
|
if err == nil && image == "" {
|
||||||
|
image, err = rc.runsOnImage(ctx)
|
||||||
}
|
}
|
||||||
|
rc.platformImage = image
|
||||||
return rc.runsOnImage(ctx)
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (rc *RunContext) workflowOptions(ctx context.Context) string {
|
func (rc *RunContext) workflowOptions(ctx context.Context) (string, error) {
|
||||||
c := rc.Run.Job().Container()
|
c := rc.Run.Job().Container()
|
||||||
if c == nil {
|
if c == nil {
|
||||||
return ""
|
return "", nil
|
||||||
}
|
}
|
||||||
|
options, err := rc.ExprEval.Interpolate(ctx, c.Options)
|
||||||
return rc.ExprEval.Interpolate(ctx, c.Options)
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("unable to interpolate container.options: %w", err)
|
||||||
|
}
|
||||||
|
return options, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (rc *RunContext) isEnabled(ctx context.Context) (bool, error) {
|
func (rc *RunContext) isEnabled(ctx context.Context) (bool, error) {
|
||||||
@@ -1136,8 +1311,10 @@ func (rc *RunContext) isEnabled(ctx context.Context) (bool, error) {
|
|||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
img := rc.platformImage(ctx)
|
if err := rc.resolvePlatformImage(ctx); err != nil {
|
||||||
if img == "" {
|
return false, err
|
||||||
|
}
|
||||||
|
if rc.platformImage == "" {
|
||||||
for _, platformName := range rc.runsOnPlatformNames(ctx) {
|
for _, platformName := range rc.runsOnPlatformNames(ctx) {
|
||||||
l.Infof("Skipping unsupported platform -- Try running with `-P %+v=...`", platformName)
|
l.Infof("Skipping unsupported platform -- Try running with `-P %+v=...`", platformName)
|
||||||
}
|
}
|
||||||
@@ -1245,12 +1422,23 @@ func (rc *RunContext) getRunnerContext(ctx context.Context) map[string]any {
|
|||||||
}
|
}
|
||||||
runnerContext["name"] = rc.Config.RunnerName
|
runnerContext["name"] = rc.Config.RunnerName
|
||||||
runnerContext["environment"] = "self-hosted"
|
runnerContext["environment"] = "self-hosted"
|
||||||
|
runnerContext["workspace"] = parentDir(rc.githubWorkspace())
|
||||||
if rc.Config.RunnerDebug() {
|
if rc.Config.RunnerDebug() {
|
||||||
runnerContext["debug"] = "1"
|
runnerContext["debug"] = "1"
|
||||||
}
|
}
|
||||||
return runnerContext
|
return runnerContext
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (rc *RunContext) githubWorkspace() string {
|
||||||
|
if rc.JobContainer != nil {
|
||||||
|
return rc.JobContainer.ToContainerPath(rc.Config.Workdir)
|
||||||
|
}
|
||||||
|
if workspace := rc.Config.Env["GITHUB_WORKSPACE"]; workspace != "" {
|
||||||
|
return workspace
|
||||||
|
}
|
||||||
|
return rc.Config.Workdir
|
||||||
|
}
|
||||||
|
|
||||||
func (rc *RunContext) getGithubContext(ctx context.Context) *model.GithubContext {
|
func (rc *RunContext) getGithubContext(ctx context.Context) *model.GithubContext {
|
||||||
logger := common.Logger(ctx)
|
logger := common.Logger(ctx)
|
||||||
ghc := &model.GithubContext{
|
ghc := &model.GithubContext{
|
||||||
@@ -1277,11 +1465,10 @@ func (rc *RunContext) getGithubContext(ctx context.Context) *model.GithubContext
|
|||||||
RefType: rc.Config.Env["GITHUB_REF_TYPE"],
|
RefType: rc.Config.Env["GITHUB_REF_TYPE"],
|
||||||
BaseRef: rc.Config.Env["GITHUB_BASE_REF"],
|
BaseRef: rc.Config.Env["GITHUB_BASE_REF"],
|
||||||
HeadRef: rc.Config.Env["GITHUB_HEAD_REF"],
|
HeadRef: rc.Config.Env["GITHUB_HEAD_REF"],
|
||||||
Workspace: rc.Config.Env["GITHUB_WORKSPACE"],
|
Workspace: rc.githubWorkspace(),
|
||||||
}
|
}
|
||||||
if rc.JobContainer != nil {
|
if rc.JobContainer != nil {
|
||||||
ghc.EventPath = rc.JobContainer.GetActPath() + "/workflow/event.json"
|
ghc.EventPath = rc.JobContainer.GetActPath() + "/workflow/event.json"
|
||||||
ghc.Workspace = rc.JobContainer.ToContainerPath(rc.Config.Workdir)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if ghc.RunID == "" {
|
if ghc.RunID == "" {
|
||||||
@@ -1514,7 +1701,7 @@ func (rc *RunContext) imageOS(ctx context.Context) string {
|
|||||||
// log that runs-on is missing.
|
// log that runs-on is missing.
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
if imageOS := imageOSFromImage(rc.platformImage(ctx)); imageOS != "" {
|
if imageOS := imageOSFromImage(rc.platformImage); imageOS != "" {
|
||||||
return imageOS
|
return imageOS
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1576,14 +1763,23 @@ func (rc *RunContext) interpolateCredentials(ctx context.Context, credentials ma
|
|||||||
}
|
}
|
||||||
|
|
||||||
ee := rc.NewExpressionEvaluator(ctx)
|
ee := rc.NewExpressionEvaluator(ctx)
|
||||||
username := ee.Interpolate(ctx, credentials["username"])
|
interpolate := func(key string) (string, error) {
|
||||||
if username == "" {
|
value, err := ee.Interpolate(ctx, credentials[key])
|
||||||
return "", "", errors.New("failed to interpolate " + prefix + "credentials.username")
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to interpolate %scredentials.%s: %w", prefix, key, err)
|
||||||
|
}
|
||||||
|
if value == "" {
|
||||||
|
return "", fmt.Errorf("failed to interpolate %scredentials.%s", prefix, key)
|
||||||
|
}
|
||||||
|
return value, nil
|
||||||
}
|
}
|
||||||
password := ee.Interpolate(ctx, credentials["password"])
|
username, err := interpolate("username")
|
||||||
if password == "" {
|
if err != nil {
|
||||||
return "", "", errors.New("failed to interpolate " + prefix + "credentials.password")
|
return "", "", err
|
||||||
|
}
|
||||||
|
password, err := interpolate("password")
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
return username, password, nil
|
return username, password, nil
|
||||||
}
|
}
|
||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"runtime"
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -17,6 +18,7 @@ import (
|
|||||||
|
|
||||||
"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/internal/pkg/labels"
|
||||||
|
|
||||||
"gitea.dev/actionslib/pkg/exprparser"
|
"gitea.dev/actionslib/pkg/exprparser"
|
||||||
"gitea.dev/actionslib/pkg/model"
|
"gitea.dev/actionslib/pkg/model"
|
||||||
@@ -266,6 +268,7 @@ func startJobContainerInputs(t *testing.T, workflowYAML string, cfg *Config) []*
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
rc.ExprEval = rc.NewExpressionEvaluator(t.Context())
|
rc.ExprEval = rc.NewExpressionEvaluator(t.Context())
|
||||||
|
require.NoError(t, rc.resolvePlatformImage(t.Context()))
|
||||||
|
|
||||||
// the inputs are built before the missing daemon fails the first call
|
// the inputs are built before the missing daemon fails the first call
|
||||||
t.Setenv("DOCKER_HOST", "unix:///nonexistent.sock")
|
t.Setenv("DOCKER_HOST", "unix:///nonexistent.sock")
|
||||||
@@ -326,6 +329,7 @@ jobs:
|
|||||||
require.Equal(t, []string{"data"}, redis.ValidVolumes)
|
require.Equal(t, []string{"data"}, redis.ValidVolumes)
|
||||||
require.Equal(t, map[string]string{"data": "/data"}, redis.Mounts)
|
require.Equal(t, map[string]string{"data": "/data"}, redis.Mounts)
|
||||||
require.Empty(t, redis.Binds) // the docker socket is the job container's alone
|
require.Empty(t, redis.Binds) // the docker socket is the job container's alone
|
||||||
|
require.Empty(t, redis.WorkingDir)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only the workflow's options may be stripped later, so the two sources have to reach the
|
// Only the workflow's options may be stripped later, so the two sources have to reach the
|
||||||
@@ -449,7 +453,8 @@ func TestRunContext_GetBindsAndMounts(t *testing.T) {
|
|||||||
config := testcase.rc.Config
|
config := testcase.rc.Config
|
||||||
config.Workdir = testcase.name
|
config.Workdir = testcase.name
|
||||||
config.BindWorkdir = bindWorkDir
|
config.BindWorkdir = bindWorkDir
|
||||||
gotbind, gotmount := rctemplate.GetBindsAndMounts()
|
gotbind, gotmount, err := rctemplate.GetBindsAndMounts()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Name binds/mounts are either/or
|
// Name binds/mounts are either/or
|
||||||
if config.BindWorkdir {
|
if config.BindWorkdir {
|
||||||
@@ -508,7 +513,8 @@ func TestRunContext_GetBindsAndMounts(t *testing.T) {
|
|||||||
rc.Run.Workflow.Jobs = map[string]*model.Job{"job1": job}
|
rc.Run.Workflow.Jobs = map[string]*model.Job{"job1": job}
|
||||||
rc.ExprEval = rc.NewExpressionEvaluator(context.Background())
|
rc.ExprEval = rc.NewExpressionEvaluator(context.Background())
|
||||||
|
|
||||||
gotbind, gotmount := rc.GetBindsAndMounts()
|
gotbind, gotmount, err := rc.GetBindsAndMounts()
|
||||||
|
require.NoError(t, err)
|
||||||
assert.Contains(t, gotbind, "/host/mame/roms:/root/.mame/roms:ro")
|
assert.Contains(t, gotbind, "/host/mame/roms:/root/.mame/roms:ro")
|
||||||
assert.NotContains(t, gotbind, "${{ secrets.MAME }}")
|
assert.NotContains(t, gotbind, "${{ secrets.MAME }}")
|
||||||
assert.NotContains(t, gotmount, "${{ secrets.MAME }}")
|
assert.NotContains(t, gotmount, "${{ secrets.MAME }}")
|
||||||
@@ -537,7 +543,8 @@ func TestRunContext_GetBindsAndMounts(t *testing.T) {
|
|||||||
rc.Run.JobID = "job1"
|
rc.Run.JobID = "job1"
|
||||||
rc.Run.Workflow.Jobs = map[string]*model.Job{"job1": job}
|
rc.Run.Workflow.Jobs = map[string]*model.Job{"job1": job}
|
||||||
|
|
||||||
gotbind, gotmount := rc.GetBindsAndMounts()
|
gotbind, gotmount, err := rc.GetBindsAndMounts()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
if len(testcase.wantbind) > 0 {
|
if len(testcase.wantbind) > 0 {
|
||||||
assert.Contains(t, gotbind, testcase.wantbind)
|
assert.Contains(t, gotbind, testcase.wantbind)
|
||||||
@@ -572,11 +579,13 @@ func TestRunContext_GetBindsAndMounts(t *testing.T) {
|
|||||||
Config: &Config{},
|
Config: &Config{},
|
||||||
}
|
}
|
||||||
|
|
||||||
_, gotmount := rc.GetBindsAndMounts()
|
_, gotmount, err := rc.GetBindsAndMounts()
|
||||||
|
require.NoError(t, err)
|
||||||
assert.NotContains(t, gotmount, sharedToolCacheVolume)
|
assert.NotContains(t, gotmount, sharedToolCacheVolume)
|
||||||
|
|
||||||
rc.Config.SharedToolCache = true
|
rc.Config.SharedToolCache = true
|
||||||
_, gotmount = rc.GetBindsAndMounts()
|
_, gotmount, err = rc.GetBindsAndMounts()
|
||||||
|
require.NoError(t, err)
|
||||||
assert.Equal(t, container.DefaultToolCache, gotmount[sharedToolCacheVolume])
|
assert.Equal(t, container.DefaultToolCache, gotmount[sharedToolCacheVolume])
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -602,6 +611,11 @@ func TestRunContextValidVolumes(t *testing.T) {
|
|||||||
// a job may mount it only while the runner does
|
// a job may mount it only while the runner does
|
||||||
rc.Config.SharedToolCache = false
|
rc.Config.SharedToolCache = false
|
||||||
assert.NotContains(t, rc.validVolumes(), sharedToolCacheVolume)
|
assert.NotContains(t, rc.validVolumes(), sharedToolCacheVolume)
|
||||||
|
|
||||||
|
rc.Config.Workdir = "/workspace/1/owner/repo"
|
||||||
|
assert.NotContains(t, rc.validVolumes(), rc.Config.Workdir)
|
||||||
|
rc.Config.BindWorkdir = true
|
||||||
|
assert.Contains(t, rc.validVolumes(), rc.Config.Workdir)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCleanupJobResourcesCleansServicesWithoutJobContainer(t *testing.T) {
|
func TestCleanupJobResourcesCleansServicesWithoutJobContainer(t *testing.T) {
|
||||||
@@ -653,8 +667,10 @@ func TestInterpolateOutputsIsPerMatrixCombo(t *testing.T) {
|
|||||||
r := &runnerImpl{config: &Config{}}
|
r := &runnerImpl{config: &Config{}}
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
rcA := r.newRunContext(ctx, run, map[string]any{"v": "a"})
|
rcA, err := r.newRunContext(ctx, run, map[string]any{"v": "a"})
|
||||||
rcB := r.newRunContext(ctx, run, map[string]any{"v": "b"})
|
require.NoError(t, err)
|
||||||
|
rcB, err := r.newRunContext(ctx, run, map[string]any{"v": "b"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
require.NoError(t, rcA.interpolateOutputs()(ctx))
|
require.NoError(t, rcA.interpolateOutputs()(ctx))
|
||||||
require.NoError(t, rcB.interpolateOutputs()(ctx))
|
require.NoError(t, rcB.interpolateOutputs()(ctx))
|
||||||
@@ -1327,12 +1343,14 @@ func TestRunContextImageOS(t *testing.T) {
|
|||||||
t.Run("prefers the release in the resolved image tag", func(t *testing.T) {
|
t.Run("prefers the release in the resolved image tag", func(t *testing.T) {
|
||||||
rc := createRunsOnRunContext(t, "ubuntu-latest")
|
rc := createRunsOnRunContext(t, "ubuntu-latest")
|
||||||
rc.Config.PlatformPicker = func([]string) string { return "docker.gitea.com/runner-images:ubuntu-24.04" }
|
rc.Config.PlatformPicker = func([]string) string { return "docker.gitea.com/runner-images:ubuntu-24.04" }
|
||||||
|
require.NoError(t, rc.resolvePlatformImage(ctx))
|
||||||
assert.Equal(t, "ubuntu24", rc.imageOS(ctx))
|
assert.Equal(t, "ubuntu24", rc.imageOS(ctx))
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("falls back to the runs-on label", func(t *testing.T) {
|
t.Run("falls back to the runs-on label", func(t *testing.T) {
|
||||||
rc := createRunsOnRunContext(t, "ubuntu-22.04")
|
rc := createRunsOnRunContext(t, "ubuntu-22.04")
|
||||||
rc.Config.PlatformPicker = func([]string) string { return "some-image" }
|
rc.Config.PlatformPicker = func([]string) string { return "some-image" }
|
||||||
|
require.NoError(t, rc.resolvePlatformImage(ctx))
|
||||||
assert.Equal(t, "ubuntu22", rc.imageOS(ctx))
|
assert.Equal(t, "ubuntu22", rc.imageOS(ctx))
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -1352,10 +1370,15 @@ func TestRunContextGetRunnerContext(t *testing.T) {
|
|||||||
t.Run("adds the runner values the container cannot know", func(t *testing.T) {
|
t.Run("adds the runner values the container cannot know", func(t *testing.T) {
|
||||||
rc := createRunsOnRunContext(t, "ubuntu-latest")
|
rc := createRunsOnRunContext(t, "ubuntu-latest")
|
||||||
rc.Config.RunnerName = "runner-1"
|
rc.Config.RunnerName = "runner-1"
|
||||||
|
rc.Config.Workdir = "/workspace/owner/repo"
|
||||||
|
|
||||||
runnerContext := rc.getRunnerContext(ctx)
|
runnerContext := rc.getRunnerContext(ctx)
|
||||||
assert.Equal(t, "runner-1", runnerContext["name"])
|
assert.Equal(t, "runner-1", runnerContext["name"])
|
||||||
assert.Equal(t, "self-hosted", runnerContext["environment"])
|
assert.Equal(t, "self-hosted", runnerContext["environment"])
|
||||||
|
assert.Equal(t, "/workspace/owner", runnerContext["workspace"])
|
||||||
|
assert.Equal(t, "/workspace/owner/repo", rc.getGithubContext(ctx).Workspace)
|
||||||
|
rc.Config.Env = map[string]string{"GITHUB_WORKSPACE": "/configured/work"}
|
||||||
|
assert.Equal(t, "/configured/work", rc.getGithubContext(ctx).Workspace)
|
||||||
assert.NotContains(t, runnerContext, "debug")
|
assert.NotContains(t, runnerContext, "debug")
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -1368,15 +1391,52 @@ func TestRunContextGetRunnerContext(t *testing.T) {
|
|||||||
|
|
||||||
t.Run("keeps the execution environment values", func(t *testing.T) {
|
t.Run("keeps the execution environment values", func(t *testing.T) {
|
||||||
rc := createRunsOnRunContext(t, "ubuntu-latest")
|
rc := createRunsOnRunContext(t, "ubuntu-latest")
|
||||||
rc.JobContainer = &container.HostEnvironment{TmpDir: "/tmp/act", ToolCache: "/tmp/tool_cache"}
|
rc.Config.Workdir = "/host/work/owner/repo"
|
||||||
|
rc.JobContainer = &container.HostEnvironment{Workdir: rc.Config.Workdir, Path: "/container/work/owner/repo", TmpDir: "/tmp/act", ToolCache: "/tmp/tool_cache"}
|
||||||
|
|
||||||
runnerContext := rc.getRunnerContext(ctx)
|
runnerContext := rc.getRunnerContext(ctx)
|
||||||
assert.Equal(t, "/tmp/act", runnerContext["temp"])
|
assert.Equal(t, "/tmp/act", runnerContext["temp"])
|
||||||
assert.Equal(t, "/tmp/tool_cache", runnerContext["tool_cache"])
|
assert.Equal(t, "/tmp/tool_cache", runnerContext["tool_cache"])
|
||||||
|
assert.Equal(t, "/container/work/owner", runnerContext["workspace"])
|
||||||
|
assert.Equal(t, "/container/work/owner/repo", rc.getGithubContext(ctx).Workspace)
|
||||||
assert.NotEmpty(t, runnerContext["os"])
|
assert.NotEmpty(t, runnerContext["os"])
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRunContextUpdateExtraPath(t *testing.T) {
|
||||||
|
longPath := strings.Repeat("x", 64*1024+1)
|
||||||
|
for _, testcase := range []struct {
|
||||||
|
name string
|
||||||
|
content []byte
|
||||||
|
want string
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{name: "UTF-8 BOM", content: []byte("\xef\xbb\xbf/utf8/tools\n"), want: "/utf8/tools"},
|
||||||
|
{name: "UTF-16 BOM", content: []byte{0xff, 0xfe, 'C', 0, ':', 0, '\\', 0, 't', 0, 'o', 0, 'o', 0, 'l', 0, 's', 0, '\n', 0}, want: `C:\tools`},
|
||||||
|
{name: "over 64 KiB", content: []byte(longPath + "\n"), want: longPath},
|
||||||
|
{name: "over 16 MiB", content: []byte(strings.Repeat("x", 16*1024*1024+1)), wantErr: true},
|
||||||
|
} {
|
||||||
|
t.Run(testcase.name, func(t *testing.T) {
|
||||||
|
logger := log.New()
|
||||||
|
logger.SetOutput(io.Discard)
|
||||||
|
ctx := common.WithLogger(t.Context(), logger)
|
||||||
|
jobContainer := &containerMock{}
|
||||||
|
jobContainer.On("GetContainerArchive", mock.Anything, "/github/path").
|
||||||
|
Return(io.NopCloser(bytes.NewReader(tarArchive(t, tarEntry{name: "path", body: string(testcase.content)}))), nil).Once()
|
||||||
|
defer jobContainer.AssertExpectations(t)
|
||||||
|
rc := &RunContext{JobContainer: jobContainer}
|
||||||
|
|
||||||
|
err := rc.UpdateExtraPath(ctx, "/github/path")
|
||||||
|
if testcase.wantErr {
|
||||||
|
require.ErrorContains(t, err, "reading path file")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, []string{testcase.want}, rc.ExtraPath)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestParentDir(t *testing.T) {
|
func TestParentDir(t *testing.T) {
|
||||||
assert.Empty(t, parentDir(""))
|
assert.Empty(t, parentDir(""))
|
||||||
assert.Empty(t, parentDir("repo"))
|
assert.Empty(t, parentDir("repo"))
|
||||||
@@ -1399,3 +1459,25 @@ func TestRunContextWithGithubEnvRunnerValues(t *testing.T) {
|
|||||||
assert.Equal(t, "/workspace/owner", env["RUNNER_WORKSPACE"])
|
assert.Equal(t, "/workspace/owner", env["RUNNER_WORKSPACE"])
|
||||||
assert.Equal(t, "1", env["RUNNER_DEBUG"])
|
assert.Equal(t, "1", env["RUNNER_DEBUG"])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRunContextMacOSVMPlatform(t *testing.T) {
|
||||||
|
rc := createRunsOnRunContext(t, "macos-latest")
|
||||||
|
rc.Config.PlatformPicker = func([]string) string {
|
||||||
|
return labels.MacOSVMSchemePrefix + "ghcr.io/cirruslabs/macos-sonoma-base:latest"
|
||||||
|
}
|
||||||
|
require.NoError(t, rc.resolvePlatformImage(t.Context()))
|
||||||
|
|
||||||
|
assert.True(t, rc.IsMacOSVMEnv())
|
||||||
|
assert.False(t, rc.IsHostEnv())
|
||||||
|
assert.Equal(t, "ghcr.io/cirruslabs/macos-sonoma-base:latest", rc.macOSVMImage())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunContextMacOSVMGuestWorkdir(t *testing.T) {
|
||||||
|
rc := createRunsOnRunContext(t, "macos-latest")
|
||||||
|
rc.Config.Workdir = "/workspace/owner/repo"
|
||||||
|
rc.Config.MacOSVM.WorkdirParent = "/Users/admin/runner"
|
||||||
|
rc.Config.RunnerUUID = "runner-1"
|
||||||
|
|
||||||
|
assert.Equal(t, "/Users/admin/runner/workspace/owner/repo", rc.macOSVMGuestWorkdir())
|
||||||
|
assert.Contains(t, rc.macOSVMName(), "GITEA-MACOS-VM-runner-1-")
|
||||||
|
}
|
||||||
+25
-5
@@ -79,9 +79,20 @@ type Config struct {
|
|||||||
MaxParallel int // max parallel jobs to run across all workflows (0 = no limit, uses CPU count)
|
MaxParallel int // max parallel jobs to run across all workflows (0 = no limit, uses CPU count)
|
||||||
AllocatePTY bool // allocate a pseudo-TTY for each step's process
|
AllocatePTY bool // allocate a pseudo-TTY for each step's process
|
||||||
ServiceReadyTimeout time.Duration // how long a job waits for its service containers to report healthy (0 uses the default)
|
ServiceReadyTimeout time.Duration // how long a job waits for its service containers to report healthy (0 uses the default)
|
||||||
|
RunnerUUID string // UUID this runner registered with, used to identify its macOS VMs
|
||||||
RunnerName string // name this runner registered with, reported as `runner.name`, defaults to the hostname
|
RunnerName string // name this runner registered with, reported as `runner.name`, defaults to the hostname
|
||||||
JobStartedHook string // script run inside the job environment before the job's first step; ACTIONS_RUNNER_HOOK_JOB_STARTED is read from Env when empty
|
JobStartedHook string // script run inside the job environment before the job's first step; ACTIONS_RUNNER_HOOK_JOB_STARTED is read from Env when empty
|
||||||
JobCompletedHook string // script run inside the job environment after the job's last step; ACTIONS_RUNNER_HOOK_JOB_COMPLETED is read from Env when empty
|
JobCompletedHook string // script run inside the job environment after the job's last step; ACTIONS_RUNNER_HOOK_JOB_COMPLETED is read from Env when empty
|
||||||
|
MacOSVM MacOSVMConfig // configures the macOS VM executor
|
||||||
|
}
|
||||||
|
|
||||||
|
// MacOSVMConfig contains the macOS VM executor settings that come from the runner config.
|
||||||
|
type MacOSVMConfig struct {
|
||||||
|
ExecutorPath string // path to the runner-executor-macos binary
|
||||||
|
WorkdirParent string // guest directory under which the job workspace is created
|
||||||
|
CPU int // number of virtual CPUs, 0 keeps the image default
|
||||||
|
Memory int // VM memory in megabytes, 0 keeps the image default
|
||||||
|
BootTimeout time.Duration // how long to wait for the VM to become ready
|
||||||
}
|
}
|
||||||
|
|
||||||
// RunnerDebug reports whether debug logging is on, exposed as `runner.debug` and
|
// RunnerDebug reports whether debug logging is on, exposed as `runner.debug` and
|
||||||
@@ -198,7 +209,10 @@ func (runner *runnerImpl) NewPlanExecutor(plan *model.Plan) common.Executor {
|
|||||||
log.Debugf("Job.Strategy.MaxParallelString: %v", job.Strategy.MaxParallelString)
|
log.Debugf("Job.Strategy.MaxParallelString: %v", job.Strategy.MaxParallelString)
|
||||||
log.Debugf("Job.Strategy.RawMatrix: %v", job.Strategy.RawMatrix)
|
log.Debugf("Job.Strategy.RawMatrix: %v", job.Strategy.RawMatrix)
|
||||||
|
|
||||||
strategyRc := runner.newRunContext(ctx, run, nil)
|
strategyRc, err := runner.newRunContext(ctx, run, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
// Resolve template expressions in the matrix node before Matrix() is called.
|
// Resolve template expressions in the matrix node before Matrix() is called.
|
||||||
// On failure the literal string is kept and normalizeMatrixValue wraps it as a fallback.
|
// On failure the literal string is kept and normalizeMatrixValue wraps it as a fallback.
|
||||||
if err := strategyRc.NewExpressionEvaluator(ctx).EvaluateYamlNode(ctx, &job.Strategy.RawMatrix); err != nil {
|
if err := strategyRc.NewExpressionEvaluator(ctx).EvaluateYamlNode(ctx, &job.Strategy.RawMatrix); err != nil {
|
||||||
@@ -230,7 +244,10 @@ func (runner *runnerImpl) NewPlanExecutor(plan *model.Plan) common.Executor {
|
|||||||
log.Infof("Running job with maxParallel=%d for %d matrix combinations", maxParallel, len(matrixes))
|
log.Infof("Running job with maxParallel=%d for %d matrix combinations", maxParallel, len(matrixes))
|
||||||
|
|
||||||
for i, matrix := range matrixes {
|
for i, matrix := range matrixes {
|
||||||
rc := runner.newRunContext(ctx, run, matrix)
|
rc, err := runner.newRunContext(ctx, run, matrix)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
rc.JobName = rc.Name
|
rc.JobName = rc.Name
|
||||||
if len(matrixes) > 1 {
|
if len(matrixes) > 1 {
|
||||||
rc.Name = fmt.Sprintf("%s-%d", rc.Name, i+1)
|
rc.Name = fmt.Sprintf("%s-%d", rc.Name, i+1)
|
||||||
@@ -315,7 +332,7 @@ func handleFailure(plan *model.Plan) common.Executor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (runner *runnerImpl) newRunContext(ctx context.Context, run *model.Run, matrix map[string]any) *RunContext {
|
func (runner *runnerImpl) newRunContext(ctx context.Context, run *model.Run, matrix map[string]any) (*RunContext, error) {
|
||||||
rc := &RunContext{
|
rc := &RunContext{
|
||||||
Config: runner.config,
|
Config: runner.config,
|
||||||
Run: run,
|
Run: run,
|
||||||
@@ -324,15 +341,18 @@ func (runner *runnerImpl) newRunContext(ctx context.Context, run *model.Run, mat
|
|||||||
Matrix: matrix,
|
Matrix: matrix,
|
||||||
caller: runner.caller,
|
caller: runner.caller,
|
||||||
}
|
}
|
||||||
|
if err := rc.resolveWorkflowCall(ctx); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
rc.ExprEval = rc.NewExpressionEvaluator(ctx)
|
rc.ExprEval = rc.NewExpressionEvaluator(ctx)
|
||||||
rc.Name = rc.maskSecrets(rc.ExprEval.Interpolate(ctx, run.String()))
|
rc.Name = rc.maskSecrets(rc.ExprEval.InterpolateName(ctx, run.String()))
|
||||||
// Snapshot the job's pristine output expressions now, before any matrix combo runs and
|
// Snapshot the job's pristine output expressions now, before any matrix combo runs and
|
||||||
// rewrites the shared Job.Outputs (see interpolateOutputs).
|
// rewrites the shared Job.Outputs (see interpolateOutputs).
|
||||||
if job := run.Job(); job != nil {
|
if job := run.Job(); job != nil {
|
||||||
rc.outputTemplate = maps.Clone(job.Outputs)
|
rc.outputTemplate = maps.Clone(job.Outputs)
|
||||||
}
|
}
|
||||||
|
|
||||||
return rc
|
return rc, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// For Gitea
|
// For Gitea
|
||||||
|
|||||||
+45
-42
@@ -11,6 +11,7 @@ import (
|
|||||||
"path"
|
"path"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
@@ -58,13 +59,10 @@ func (s stepStage) String() string {
|
|||||||
func processRunnerEnvFileCommand(ctx context.Context, fileName string, rc *RunContext, setter func(context.Context, map[string]string, string)) error {
|
func processRunnerEnvFileCommand(ctx context.Context, fileName string, rc *RunContext, setter func(context.Context, map[string]string, string)) error {
|
||||||
env := map[string]string{}
|
env := map[string]string{}
|
||||||
err := rc.JobContainer.UpdateFromEnv(path.Join(rc.JobContainer.GetActPath(), fileName), &env)(ctx)
|
err := rc.JobContainer.UpdateFromEnv(path.Join(rc.JobContainer.GetActPath(), fileName), &env)(ctx)
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
for k, v := range env {
|
for k, v := range env {
|
||||||
setter(ctx, map[string]string{"name": k}, v)
|
setter(ctx, map[string]string{"name": k}, v)
|
||||||
}
|
}
|
||||||
return nil
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func runStepExecutor(step step, stage stepStage, executor common.Executor) common.Executor {
|
func runStepExecutor(step step, stage stepStage, executor common.Executor) common.Executor {
|
||||||
@@ -85,23 +83,26 @@ func runStepExecutor(step step, stage stepStage, executor common.Executor) commo
|
|||||||
rc.StepResults[rc.CurrentStep] = stepResult
|
rc.StepResults[rc.CurrentStep] = stepResult
|
||||||
}
|
}
|
||||||
|
|
||||||
setupEnv(ctx, step)
|
err := setupEnv(ctx, step)
|
||||||
|
var runStep bool
|
||||||
runStep, err := isStepEnabled(ctx, ifExpression, step, stage)
|
if err == nil {
|
||||||
|
runStep, err = isStepEnabled(ctx, ifExpression, step, stage)
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
stepResult.Conclusion = model.StepStatusFailure
|
stepResult.Conclusion = model.StepStatusFailure
|
||||||
stepResult.Outcome = model.StepStatusFailure
|
stepResult.Outcome = model.StepStatusFailure
|
||||||
|
logger.WithField("stepResult", stepResult.Conclusion).Infof("Failure - %s %s", stage, stepModel)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if !runStep {
|
if !runStep {
|
||||||
stepResult.Conclusion = model.StepStatusSkipped
|
stepResult.Conclusion = model.StepStatusSkipped
|
||||||
stepResult.Outcome = model.StepStatusSkipped
|
stepResult.Outcome = model.StepStatusSkipped
|
||||||
logger.WithField("stepResult", stepResult.Outcome).Debugf("Skipping step '%s' due to '%s'", stepModel, ifExpression)
|
logger.WithField("stepResult", stepResult.Conclusion).Debugf("Skipping step '%s' due to '%s'", stepModel, ifExpression)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
stepString := rc.ExprEval.Interpolate(ctx, stepModel.String())
|
stepString := rc.ExprEval.InterpolateName(ctx, stepModel.String())
|
||||||
if strings.Contains(stepString, "::add-mask::") {
|
if strings.Contains(stepString, "::add-mask::") {
|
||||||
stepString = "add-mask command"
|
stepString = "add-mask command"
|
||||||
}
|
}
|
||||||
@@ -157,11 +158,11 @@ func runStepExecutor(step step, stage stepStage, executor common.Executor) commo
|
|||||||
if topRC.summaryFileInitialized == nil {
|
if topRC.summaryFileInitialized == nil {
|
||||||
topRC.summaryFileInitialized = map[int]bool{}
|
topRC.summaryFileInitialized = map[int]bool{}
|
||||||
}
|
}
|
||||||
|
_ = rc.JobContainer.Copy(actPath, files...)(ctx)
|
||||||
if !topRC.summaryFileInitialized[stepSummaryIndex] {
|
if !topRC.summaryFileInitialized[stepSummaryIndex] {
|
||||||
files = append(files, &container.FileEntry{Name: summaryFileCommand, Mode: 0o666})
|
_ = rc.JobContainer.Copy(actPath, &container.FileEntry{Name: summaryFileCommand, Mode: 0o666})(ctx)
|
||||||
topRC.summaryFileInitialized[stepSummaryIndex] = true
|
topRC.summaryFileInitialized[stepSummaryIndex] = true
|
||||||
}
|
}
|
||||||
_ = rc.JobContainer.Copy(actPath, files...)(ctx)
|
|
||||||
|
|
||||||
// The command handler needs the step's env to judge ACTIONS_ALLOW_UNSECURE_COMMANDS.
|
// The command handler needs the step's env to judge ACTIONS_ALLOW_UNSECURE_COMMANDS.
|
||||||
// Cloned: the step executor keeps writing to its own env map after this point, on a
|
// Cloned: the step executor keeps writing to its own env map after this point, on a
|
||||||
@@ -179,15 +180,29 @@ func runStepExecutor(step step, stage stepStage, executor common.Executor) commo
|
|||||||
if err == nil {
|
if err == nil {
|
||||||
err = insecureErr
|
err = insecureErr
|
||||||
}
|
}
|
||||||
|
if fileErr := processRunnerEnvFileCommand(ctx, envFileCommand, rc, rc.setEnvFile); fileErr != nil && err == nil {
|
||||||
|
err = fileErr
|
||||||
|
}
|
||||||
|
if fileErr := processRunnerEnvFileCommand(ctx, stateFileCommand, rc, rc.saveState); fileErr != nil && err == nil {
|
||||||
|
err = fileErr
|
||||||
|
}
|
||||||
|
if fileErr := processRunnerEnvFileCommand(ctx, outputFileCommand, rc, rc.setOutput); fileErr != nil && err == nil {
|
||||||
|
err = fileErr
|
||||||
|
}
|
||||||
|
if fileErr := rc.UpdateExtraPath(ctx, path.Join(actPath, pathFileCommand)); fileErr != nil && err == nil {
|
||||||
|
err = fileErr
|
||||||
|
}
|
||||||
|
_ = rc.JobContainer.Copy(actPath, files...)(ctx)
|
||||||
|
|
||||||
if err == nil {
|
if err == nil {
|
||||||
logger.WithField("stepResult", stepResult.Outcome).Infof("Success - %s %s", stage, stepString)
|
logger.WithField("stepResult", stepResult.Conclusion).Infof("Success - %s %s", stage, stepString)
|
||||||
} else {
|
} else {
|
||||||
stepResult.Outcome = model.StepStatusFailure
|
stepResult.Outcome = model.StepStatusFailure
|
||||||
|
|
||||||
continueOnError, parseErr := isContinueOnError(ctx, stepModel.RawContinueOnError, step, stage)
|
continueOnError, parseErr := isContinueOnError(ctx, stepModel.RawContinueOnError, step, stage)
|
||||||
if parseErr != nil {
|
if parseErr != nil {
|
||||||
stepResult.Conclusion = model.StepStatusFailure
|
stepResult.Conclusion = model.StepStatusFailure
|
||||||
|
logger.WithField("stepResult", stepResult.Conclusion).Infof("Failure - %s %s", stage, stepString)
|
||||||
return parseErr
|
return parseErr
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -202,64 +217,51 @@ func runStepExecutor(step step, stage stepStage, executor common.Executor) commo
|
|||||||
|
|
||||||
// Infof: Errorf entries are promoted to the user log by the reporter,
|
// Infof: Errorf entries are promoted to the user log by the reporter,
|
||||||
// which would duplicate the ##[error] annotation emitted elsewhere.
|
// which would duplicate the ##[error] annotation emitted elsewhere.
|
||||||
logger.WithField("stepResult", stepResult.Outcome).Infof("Failure - %s %s", stage, stepString)
|
logger.WithField("stepResult", stepResult.Conclusion).Infof("Failure - %s %s", stage, stepString)
|
||||||
}
|
|
||||||
// Process Runner File Commands
|
|
||||||
orgerr := err
|
|
||||||
err = processRunnerEnvFileCommand(ctx, envFileCommand, rc, rc.setEnv)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
err = processRunnerEnvFileCommand(ctx, stateFileCommand, rc, rc.saveState)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
err = processRunnerEnvFileCommand(ctx, outputFileCommand, rc, rc.setOutput)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
err = rc.UpdateExtraPath(ctx, path.Join(actPath, pathFileCommand))
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if orgerr != nil {
|
|
||||||
return orgerr
|
|
||||||
}
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func evaluateStepTimeout(ctx context.Context, exprEval *expressionEvaluator, stepModel *model.Step) (context.Context, context.CancelFunc) {
|
func evaluateStepTimeout(ctx context.Context, exprEval *expressionEvaluator, stepModel *model.Step) (context.Context, context.CancelFunc) {
|
||||||
timeout := exprEval.Interpolate(ctx, stepModel.TimeoutMinutes)
|
timeout, err := exprEval.Interpolate(ctx, stepModel.TimeoutMinutes)
|
||||||
if timeout != "" {
|
if err != nil {
|
||||||
if timeOutMinutes, err := strconv.ParseInt(timeout, 10, 64); err == nil {
|
common.Logger(ctx).Errorf("An error occurred when attempting to determine the step timeout: %s", err)
|
||||||
|
} else if timeout != "" {
|
||||||
|
if timeOutMinutes, err := strconv.ParseInt(timeout, 10, 64); err == nil && timeOutMinutes > 0 {
|
||||||
return context.WithTimeout(ctx, time.Duration(timeOutMinutes)*time.Minute)
|
return context.WithTimeout(ctx, time.Duration(timeOutMinutes)*time.Minute)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return ctx, func() {}
|
return ctx, func() {}
|
||||||
}
|
}
|
||||||
|
|
||||||
func setupEnv(ctx context.Context, step step) {
|
func setupEnv(ctx context.Context, step step) error {
|
||||||
rc := step.getRunContext()
|
rc := step.getRunContext()
|
||||||
|
|
||||||
mergeEnv(ctx, step)
|
mergeEnv(ctx, step)
|
||||||
// merge step env last, since it should not be overwritten
|
// merge step env last, since it should not be overwritten
|
||||||
mergeIntoMap(step, step.getEnv(), step.getStepModel().GetEnv())
|
mergeIntoMap(step, step.getEnv(), step.getStepModel().GetEnv())
|
||||||
|
|
||||||
|
var err error
|
||||||
exprEval := rc.NewExpressionEvaluator(ctx)
|
exprEval := rc.NewExpressionEvaluator(ctx)
|
||||||
for k, v := range *step.getEnv() {
|
for k, v := range *step.getEnv() {
|
||||||
if !strings.HasPrefix(k, "INPUT_") {
|
if !strings.HasPrefix(k, "INPUT_") {
|
||||||
(*step.getEnv())[k] = exprEval.Interpolate(ctx, v)
|
if (*step.getEnv())[k], err = exprEval.Interpolate(ctx, v); err != nil {
|
||||||
|
return fmt.Errorf("unable to interpolate env %s: %w", k, err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// after we have an evaluated step context, update the expressions evaluator with a new env context
|
// after we have an evaluated step context, update the expressions evaluator with a new env context
|
||||||
// you can use step level env in the with property of a uses construct
|
// you can use step level env in the with property of a uses construct
|
||||||
exprEval = rc.NewExpressionEvaluatorWithEnv(ctx, *step.getEnv())
|
inputEval := sync.OnceValue(func() *expressionEvaluator { return rc.NewExpressionEvaluatorWithEnv(ctx, *step.getEnv()) })
|
||||||
for k, v := range *step.getEnv() {
|
for k, v := range *step.getEnv() {
|
||||||
if strings.HasPrefix(k, "INPUT_") {
|
if strings.HasPrefix(k, "INPUT_") {
|
||||||
(*step.getEnv())[k] = exprEval.Interpolate(ctx, v)
|
if (*step.getEnv())[k], err = inputEval().Interpolate(ctx, v); err != nil {
|
||||||
|
return fmt.Errorf("unable to interpolate env %s: %w", k, err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func mergeEnv(ctx context.Context, step step) {
|
func mergeEnv(ctx context.Context, step step) {
|
||||||
@@ -269,7 +271,8 @@ func mergeEnv(ctx context.Context, step step) {
|
|||||||
|
|
||||||
c := job.Container()
|
c := job.Container()
|
||||||
if c != nil {
|
if c != nil {
|
||||||
mergeIntoMap(step, env, rc.GetEnv(), c.Env)
|
// container env is the image's baseline, which job env and $GITHUB_ENV override
|
||||||
|
mergeIntoMap(step, env, c.Env, rc.GetEnv())
|
||||||
} else {
|
} else {
|
||||||
mergeIntoMap(step, env, rc.GetEnv())
|
mergeIntoMap(step, env, rc.GetEnv())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -122,15 +122,19 @@ func (sal *stepActionLocal) getActionModel() *model.Action {
|
|||||||
return sal.action
|
return sal.action
|
||||||
}
|
}
|
||||||
|
|
||||||
func (sal *stepActionLocal) getCompositeRunContext(ctx context.Context) *RunContext {
|
func (sal *stepActionLocal) getCompositeRunContext(ctx context.Context) (*RunContext, error) {
|
||||||
if sal.compositeRunContext == nil {
|
if sal.compositeRunContext == nil {
|
||||||
actionDir := filepath.Join(sal.RunContext.Config.Workdir, sal.Step.Uses)
|
actionDir := filepath.Join(sal.RunContext.Config.Workdir, sal.Step.Uses)
|
||||||
_, containerActionDir := getContainerActionPaths(sal.getStepModel(), actionDir, sal.RunContext)
|
_, containerActionDir := getContainerActionPaths(sal.getStepModel(), actionDir, sal.RunContext)
|
||||||
|
|
||||||
sal.compositeRunContext = newCompositeRunContext(ctx, sal.RunContext, sal, containerActionDir)
|
compositeRunContext, err := newCompositeRunContext(ctx, sal.RunContext, sal, containerActionDir)
|
||||||
sal.compositeSteps = sal.compositeRunContext.compositeExecutor(sal.action)
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
sal.compositeRunContext = compositeRunContext
|
||||||
|
sal.compositeSteps = compositeRunContext.compositeExecutor(sal.action)
|
||||||
}
|
}
|
||||||
return sal.compositeRunContext
|
return sal.compositeRunContext, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (sal *stepActionLocal) getCompositeSteps() *compositeSteps {
|
func (sal *stepActionLocal) getCompositeSteps() *compositeSteps {
|
||||||
|
|||||||
@@ -254,7 +254,8 @@ func TestStepActionLocalPost(t *testing.T) {
|
|||||||
if tt.mocks.exec {
|
if tt.mocks.exec {
|
||||||
suffixMatcher := func(suffix string) any {
|
suffixMatcher := func(suffix string) any {
|
||||||
return mock.MatchedBy(func(array []string) bool {
|
return mock.MatchedBy(func(array []string) bool {
|
||||||
return strings.HasSuffix(array[1], suffix)
|
return len(array) == 3 && array[0] == "node" && array[1] == "--preserve-symlinks-main" &&
|
||||||
|
strings.HasSuffix(array[2], suffix)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
cm.On("Exec", suffixMatcher("runner/local/action/post.js"), sal.env, "", "").Return(func(ctx context.Context) error { return tt.err })
|
cm.On("Exec", suffixMatcher("runner/local/action/post.js"), sal.env, "", "").Return(func(ctx context.Context) error { return tt.err })
|
||||||
|
|||||||
@@ -51,7 +51,11 @@ func (sar *stepActionRemote) prepareActionExecutor() common.Executor {
|
|||||||
// Since actions can specify the download source via a url prefix.
|
// Since actions can specify the download source via a url prefix.
|
||||||
// The prefix may contain some sensitive information that needs to be stored in secrets,
|
// The prefix may contain some sensitive information that needs to be stored in secrets,
|
||||||
// so we need to interpolate the expression value for uses first.
|
// so we need to interpolate the expression value for uses first.
|
||||||
sar.Step.Uses = sar.RunContext.NewExpressionEvaluator(ctx).Interpolate(ctx, sar.Step.Uses)
|
uses, err := sar.RunContext.NewExpressionEvaluator(ctx).Interpolate(ctx, sar.Step.Uses)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("unable to interpolate uses: %w", err)
|
||||||
|
}
|
||||||
|
sar.Step.Uses = uses
|
||||||
|
|
||||||
github := sar.getGithubContext(ctx) // read before remoteAction is set, so `$/` resolves against the enclosing action
|
github := sar.getGithubContext(ctx) // read before remoteAction is set, so `$/` resolves against the enclosing action
|
||||||
if strings.HasPrefix(sar.Step.Uses, selfRepoPrefix) {
|
if strings.HasPrefix(sar.Step.Uses, selfRepoPrefix) {
|
||||||
@@ -160,8 +164,11 @@ func (sar *stepActionRemote) main() common.Executor {
|
|||||||
common.Logger(ctx).Debugf("Skipping local actions/checkout because you bound your workspace")
|
common.Logger(ctx).Debugf("Skipping local actions/checkout because you bound your workspace")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
eval := sar.RunContext.NewExpressionEvaluator(ctx)
|
checkoutPath, err := sar.RunContext.NewExpressionEvaluator(ctx).Interpolate(ctx, sar.Step.With["path"])
|
||||||
copyToPath := path.Join(sar.RunContext.JobContainer.ToContainerPath(sar.RunContext.Config.Workdir), eval.Interpolate(ctx, sar.Step.With["path"]))
|
if err != nil {
|
||||||
|
return fmt.Errorf("unable to interpolate with.path: %w", err)
|
||||||
|
}
|
||||||
|
copyToPath := path.Join(sar.RunContext.JobContainer.ToContainerPath(sar.RunContext.Config.Workdir), checkoutPath)
|
||||||
return sar.RunContext.JobContainer.CopyDir(copyToPath, sar.RunContext.Config.Workdir+string(filepath.Separator)+".", sar.RunContext.Config.UseGitIgnore)(ctx)
|
return sar.RunContext.JobContainer.CopyDir(copyToPath, sar.RunContext.Config.Workdir+string(filepath.Separator)+".", sar.RunContext.Config.UseGitIgnore)(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -229,14 +236,18 @@ func (sar *stepActionRemote) getActionModel() *model.Action {
|
|||||||
return sar.action
|
return sar.action
|
||||||
}
|
}
|
||||||
|
|
||||||
func (sar *stepActionRemote) getCompositeRunContext(ctx context.Context) *RunContext {
|
func (sar *stepActionRemote) getCompositeRunContext(ctx context.Context) (*RunContext, error) {
|
||||||
if sar.compositeRunContext == nil {
|
if sar.compositeRunContext == nil {
|
||||||
actionDir := sar.actionDir()
|
actionDir := sar.actionDir()
|
||||||
actionLocation := path.Join(actionDir, sar.remoteAction.Path)
|
actionLocation := path.Join(actionDir, sar.remoteAction.Path)
|
||||||
_, containerActionDir := getContainerActionPaths(sar.getStepModel(), actionLocation, sar.RunContext)
|
_, containerActionDir := getContainerActionPaths(sar.getStepModel(), actionLocation, sar.RunContext)
|
||||||
|
|
||||||
sar.compositeRunContext = newCompositeRunContext(ctx, sar.RunContext, sar, containerActionDir)
|
compositeRunContext, err := newCompositeRunContext(ctx, sar.RunContext, sar, containerActionDir)
|
||||||
sar.compositeSteps = sar.compositeRunContext.compositeExecutor(sar.action)
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
sar.compositeRunContext = compositeRunContext
|
||||||
|
sar.compositeSteps = compositeRunContext.compositeExecutor(sar.action)
|
||||||
} else {
|
} else {
|
||||||
// Re-evaluate environment here. For remote actions the environment
|
// Re-evaluate environment here. For remote actions the environment
|
||||||
// need to be re-created for every stage (pre, main, post) as there
|
// need to be re-created for every stage (pre, main, post) as there
|
||||||
@@ -244,11 +255,14 @@ func (sar *stepActionRemote) getCompositeRunContext(ctx context.Context) *RunCon
|
|||||||
// stages are executed. (e.g. the output of another action is the
|
// stages are executed. (e.g. the output of another action is the
|
||||||
// input for this action during the main stage, but the env
|
// input for this action during the main stage, but the env
|
||||||
// was already created during the pre stage)
|
// was already created during the pre stage)
|
||||||
env := evaluateCompositeInputAndEnv(ctx, sar.RunContext, sar)
|
env, err := evaluateCompositeInputAndEnv(ctx, sar.RunContext, sar)
|
||||||
sar.compositeRunContext.setActionEnv(env)
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
sar.compositeRunContext.setCompositeActionEnv(env)
|
||||||
sar.compositeRunContext.ExtraPath = sar.RunContext.ExtraPath
|
sar.compositeRunContext.ExtraPath = sar.RunContext.ExtraPath
|
||||||
}
|
}
|
||||||
return sar.compositeRunContext
|
return sar.compositeRunContext, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (sar *stepActionRemote) getCompositeSteps() *compositeSteps {
|
func (sar *stepActionRemote) getCompositeSteps() *compositeSteps {
|
||||||
|
|||||||
@@ -242,6 +242,24 @@ func TestStepActionRemote(t *testing.T) {
|
|||||||
cm.AssertExpectations(t)
|
cm.AssertExpectations(t)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
t.Run("refreshes composite inputs without leaking environment", func(t *testing.T) {
|
||||||
|
step := &stepActionRemote{
|
||||||
|
Step: &model.Step{Uses: "org/composite@v1", With: map[string]string{"SHARED": "first"}},
|
||||||
|
RunContext: &RunContext{Config: &Config{ActionCacheDir: t.TempDir()}, Run: &model.Run{JobID: "job", Workflow: &model.Workflow{Jobs: map[string]*model.Job{"job": {}}}}, JobContainer: &jobContainerMock{}},
|
||||||
|
action: &model.Action{Inputs: map[string]model.Input{"shared": {}}},
|
||||||
|
env: map[string]string{},
|
||||||
|
remoteAction: newRemoteAction("org/composite@v1"),
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, value := range []string{"first", "second"} {
|
||||||
|
step.env["INPUT_SHARED"] = value
|
||||||
|
composite, err := step.getCompositeRunContext(t.Context())
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, map[string]any{"shared": value}, composite.actionInputs)
|
||||||
|
assert.NotContains(t, composite.Env, "INPUT_SHARED")
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestStepActionRemotePrepare(t *testing.T) {
|
func TestStepActionRemotePrepare(t *testing.T) {
|
||||||
@@ -453,10 +471,10 @@ func TestStepActionRemotePost(t *testing.T) {
|
|||||||
if tt.mocks.exec {
|
if tt.mocks.exec {
|
||||||
// Use mock.MatchedBy to match the exec command with hash-based path
|
// Use mock.MatchedBy to match the exec command with hash-based path
|
||||||
execMatcher := mock.MatchedBy(func(args []string) bool {
|
execMatcher := mock.MatchedBy(func(args []string) bool {
|
||||||
if len(args) != 2 {
|
if len(args) != 3 {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return args[0] == "node" && strings.Contains(args[1], "post.js")
|
return args[0] == "node" && args[1] == "--preserve-symlinks-main" && strings.Contains(args[2], "post.js")
|
||||||
})
|
})
|
||||||
|
|
||||||
cm.On("Exec", execMatcher, sar.env, "", "").Return(func(ctx context.Context) error { return tt.err })
|
cm.On("Exec", execMatcher, sar.env, "", "").Return(func(ctx context.Context) error { return tt.err })
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ package runner
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
@@ -58,17 +59,28 @@ func (sd *stepDocker) runUsesContainer() common.Executor {
|
|||||||
return func(ctx context.Context) error {
|
return func(ctx context.Context) error {
|
||||||
image := strings.TrimPrefix(step.Uses, "docker://")
|
image := strings.TrimPrefix(step.Uses, "docker://")
|
||||||
eval := rc.NewExpressionEvaluator(ctx)
|
eval := rc.NewExpressionEvaluator(ctx)
|
||||||
cmd, err := shellquote.Split(eval.Interpolate(ctx, step.With["args"]))
|
args, err := eval.Interpolate(ctx, step.With["args"])
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("unable to interpolate with.args: %w", err)
|
||||||
|
}
|
||||||
|
cmd, err := shellquote.Split(args)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
var entrypoint []string
|
var entrypoint []string
|
||||||
if entry := eval.Interpolate(ctx, step.With["entrypoint"]); entry != "" {
|
entry, err := eval.Interpolate(ctx, step.With["entrypoint"])
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("unable to interpolate with.entrypoint: %w", err)
|
||||||
|
}
|
||||||
|
if entry != "" {
|
||||||
entrypoint = []string{entry}
|
entrypoint = []string{entry}
|
||||||
}
|
}
|
||||||
|
|
||||||
stepContainer := newStepContainer(ctx, sd, image, cmd, entrypoint, "")
|
stepContainer, err := newStepContainer(ctx, sd, image, cmd, entrypoint, "")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
return common.NewPipelineExecutor(
|
return common.NewPipelineExecutor(
|
||||||
stepContainer.Pull(rc.Config.ForcePull),
|
stepContainer.Pull(rc.Config.ForcePull),
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import (
|
|||||||
"gitea.dev/actionslib/pkg/model"
|
"gitea.dev/actionslib/pkg/model"
|
||||||
"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"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestStepDockerMain(t *testing.T) {
|
func TestStepDockerMain(t *testing.T) {
|
||||||
@@ -144,7 +145,7 @@ func TestStepDockerNewStepContainerAllocatePTY(t *testing.T) {
|
|||||||
}
|
}
|
||||||
sd.RunContext.ExprEval = sd.RunContext.NewExpressionEvaluator(ctx)
|
sd.RunContext.ExprEval = sd.RunContext.NewExpressionEvaluator(ctx)
|
||||||
|
|
||||||
_ = newStepContainer(ctx, sd, "node:14", []string{"echo", "hi"}, nil, "")
|
_, _ = newStepContainer(ctx, sd, "node:14", []string{"echo", "hi"}, nil, "")
|
||||||
assert.Equal(t, tc.allocPTY, captured.AllocatePTY)
|
assert.Equal(t, tc.allocPTY, captured.AllocatePTY)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -210,10 +211,10 @@ func TestStepDockerNewStepContainerNetworkMode(t *testing.T) {
|
|||||||
}
|
}
|
||||||
sd.RunContext.ExprEval = sd.RunContext.NewExpressionEvaluator(ctx)
|
sd.RunContext.ExprEval = sd.RunContext.NewExpressionEvaluator(ctx)
|
||||||
|
|
||||||
assert.Equal(t, tc.expectDefault, sd.RunContext.IsHostEnv(ctx),
|
require.NoError(t, sd.RunContext.resolvePlatformImage(ctx))
|
||||||
"IsHostEnv mismatch for platform %q", tc.platform)
|
assert.Equal(t, tc.expectDefault, sd.RunContext.IsHostEnv(), "IsHostEnv mismatch for platform %q", tc.platform)
|
||||||
|
|
||||||
_ = newStepContainer(ctx, sd, "alpine:3.20", []string{"echo", "hello"}, nil, "")
|
_, _ = newStepContainer(ctx, sd, "alpine:3.20", []string{"echo", "hello"}, nil, "")
|
||||||
|
|
||||||
if tc.expectDefault {
|
if tc.expectDefault {
|
||||||
assert.Equal(t, "default", captured.NetworkMode,
|
assert.Equal(t, "default", captured.NetworkMode,
|
||||||
|
|||||||
+71
-33
@@ -7,6 +7,7 @@ package runner
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"maps"
|
"maps"
|
||||||
"runtime"
|
"runtime"
|
||||||
"slices"
|
"slices"
|
||||||
@@ -22,6 +23,8 @@ import (
|
|||||||
yaml "go.yaml.in/yaml/v4"
|
yaml "go.yaml.in/yaml/v4"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var builtinShells = []string{"bash", "sh", "pwsh", "powershell", "cmd", "python"}
|
||||||
|
|
||||||
type stepRun struct {
|
type stepRun struct {
|
||||||
Step *model.Step
|
Step *model.Step
|
||||||
RunContext *RunContext
|
RunContext *RunContext
|
||||||
@@ -251,15 +254,35 @@ func getScriptName(rc *RunContext, step *model.Step) string {
|
|||||||
// OCI runtime exec failed: exec failed: container_linux.go:380: starting container process caused: exec: "${{": executable file not found in $PATH: unknown
|
// OCI runtime exec failed: exec failed: container_linux.go:380: starting container process caused: exec: "${{": executable file not found in $PATH: unknown
|
||||||
func (sr *stepRun) setupShellCommand(ctx context.Context) (name, script string, err error) {
|
func (sr *stepRun) setupShellCommand(ctx context.Context) (name, script string, err error) {
|
||||||
logger := common.Logger(ctx)
|
logger := common.Logger(ctx)
|
||||||
sr.setupShell(ctx)
|
eval := sr.RunContext.NewStepExpressionEvaluator(ctx, sr)
|
||||||
sr.setupWorkingDirectory(ctx)
|
implicitShell, err := sr.setupShell(ctx, eval)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
if err := sr.setupWorkingDirectory(ctx, eval); err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
|
||||||
step := sr.Step
|
step := sr.Step
|
||||||
|
|
||||||
script = sr.RunContext.NewStepExpressionEvaluator(ctx, sr).Interpolate(ctx, step.Run)
|
script, err = eval.Interpolate(ctx, step.Run)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", fmt.Errorf("unable to interpolate the run script: %w", err)
|
||||||
|
}
|
||||||
sr.interpolatedScript = script
|
sr.interpolatedScript = script
|
||||||
|
|
||||||
|
// GitHub matches the built-in names case-insensitively, so `shell: PWSH` is valid
|
||||||
|
if slices.Contains(builtinShells, strings.ToLower(step.Shell)) {
|
||||||
|
step.Shell = strings.ToLower(step.Shell)
|
||||||
|
}
|
||||||
|
|
||||||
scCmd := step.ShellCommand()
|
scCmd := step.ShellCommand()
|
||||||
|
if implicitShell && (step.Shell == "bash" || step.Shell == "sh") {
|
||||||
|
scCmd = step.Shell + " -e {0}"
|
||||||
|
}
|
||||||
|
if !strings.Contains(scCmd, "{0}") {
|
||||||
|
return "", "", fmt.Errorf("invalid shell option %q: format must contain {0}", step.Shell)
|
||||||
|
}
|
||||||
sr.shellCommand = scCmd
|
sr.shellCommand = scCmd
|
||||||
|
|
||||||
name = getScriptName(sr.RunContext, step)
|
name = getScriptName(sr.RunContext, step)
|
||||||
@@ -268,7 +291,8 @@ func (sr *stepRun) setupShellCommand(ctx context.Context) (name, script string,
|
|||||||
// Reference: https://github.com/actions/runner/blob/8109c962f09d9acc473d92c595ff43afceddb347/src/Runner.Worker/Handlers/ScriptHandlerHelpers.cs#L19-L27
|
// Reference: https://github.com/actions/runner/blob/8109c962f09d9acc473d92c595ff43afceddb347/src/Runner.Worker/Handlers/ScriptHandlerHelpers.cs#L19-L27
|
||||||
runPrepend := ""
|
runPrepend := ""
|
||||||
runAppend := ""
|
runAppend := ""
|
||||||
switch step.Shell {
|
shellCommand, _, _ := strings.Cut(step.Shell, " ")
|
||||||
|
switch shellCommand {
|
||||||
case "bash", "sh":
|
case "bash", "sh":
|
||||||
name += ".sh"
|
name += ".sh"
|
||||||
case "pwsh", "powershell":
|
case "pwsh", "powershell":
|
||||||
@@ -292,64 +316,78 @@ func (sr *stepRun) setupShellCommand(ctx context.Context) (name, script string,
|
|||||||
|
|
||||||
rc := sr.getRunContext()
|
rc := sr.getRunContext()
|
||||||
scriptPath := fmt.Sprintf("%s/%s", rc.JobContainer.GetActPath(), name)
|
scriptPath := fmt.Sprintf("%s/%s", rc.JobContainer.GetActPath(), name)
|
||||||
sr.cmdline = strings.Replace(scCmd, `{0}`, scriptPath, 1)
|
sr.cmdline = strings.ReplaceAll(scCmd, `{0}`, scriptPath)
|
||||||
sr.cmd, err = shellquote.Split(sr.cmdline)
|
sr.cmd, err = shellquote.Split(sr.cmdline)
|
||||||
|
|
||||||
return name, script, err
|
return name, script, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (sr *stepRun) setupShell(ctx context.Context) {
|
func (sr *stepRun) setupShell(ctx context.Context, eval *expressionEvaluator) (bool, error) {
|
||||||
rc := sr.RunContext
|
rc := sr.RunContext
|
||||||
step := sr.Step
|
step := sr.Step
|
||||||
|
|
||||||
if step.Shell == "" {
|
shell, err := eval.Interpolate(ctx, step.Shell)
|
||||||
step.Shell = rc.Run.Job().Defaults.Run.Shell
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("unable to interpolate the shell: %w", err)
|
||||||
}
|
}
|
||||||
|
if shell == "" {
|
||||||
step.Shell = rc.NewExpressionEvaluator(ctx).Interpolate(ctx, step.Shell)
|
shell = rc.jobRunDefaults.Shell
|
||||||
|
|
||||||
if step.Shell == "" {
|
|
||||||
step.Shell = rc.Run.Workflow.Defaults.Run.Shell
|
|
||||||
}
|
}
|
||||||
|
if shell == "" {
|
||||||
|
shell = rc.Run.Workflow.Defaults.Run.Shell
|
||||||
|
}
|
||||||
|
step.Shell = shell
|
||||||
|
|
||||||
if step.Shell == "" {
|
implicitShell := step.Shell == ""
|
||||||
|
if implicitShell {
|
||||||
|
shellWithFallback := []string{"bash", "sh"}
|
||||||
|
env := maps.Clone(sr.env)
|
||||||
|
rc.ApplyExtraPath(ctx, &env)
|
||||||
if _, ok := rc.JobContainer.(*container.HostEnvironment); ok {
|
if _, ok := rc.JobContainer.(*container.HostEnvironment); ok {
|
||||||
shellWithFallback := []string{"bash", "sh"}
|
|
||||||
// Don't use bash on windows by default, if not using a docker container
|
// Don't use bash on windows by default, if not using a docker container
|
||||||
if runtime.GOOS == "windows" {
|
if runtime.GOOS == "windows" {
|
||||||
shellWithFallback = []string{"pwsh", "powershell"}
|
shellWithFallback = []string{"pwsh", "powershell"}
|
||||||
}
|
}
|
||||||
step.Shell = shellWithFallback[0]
|
step.Shell = shellWithFallback[0]
|
||||||
env := maps.Clone(sr.env)
|
|
||||||
sr.getRunContext().ApplyExtraPath(ctx, &env)
|
|
||||||
_, err := lookpath.LookPath2(shellWithFallback[0], env)
|
_, err := lookpath.LookPath2(shellWithFallback[0], env)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
step.Shell = shellWithFallback[1]
|
step.Shell = shellWithFallback[1]
|
||||||
}
|
}
|
||||||
} else if containerImage := rc.containerImage(ctx); containerImage != "" {
|
} else {
|
||||||
// Currently only linux containers are supported, use sh by default like actions/runner
|
step.Shell = shellWithFallback[0]
|
||||||
step.Shell = "sh"
|
if !rc.containerHasBash(ctx, env) {
|
||||||
|
step.Shell = shellWithFallback[1]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return implicitShell, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (sr *stepRun) setupWorkingDirectory(ctx context.Context) {
|
// containerHasBash probes once per job, else every implicit-shell step pays for an exec.
|
||||||
rc := sr.RunContext
|
func (rc *RunContext) containerHasBash(ctx context.Context, env map[string]string) bool {
|
||||||
step := sr.Step
|
top := rc.topLevelRunContext()
|
||||||
var workingdirectory string
|
if top.hasBash == nil {
|
||||||
|
stdout, stderr := rc.JobContainer.ReplaceLogWriter(io.Discard, io.Discard)
|
||||||
if step.WorkingDirectory == "" {
|
found := rc.JobContainer.Exec([]string{"sh", "-c", "command -v bash >/dev/null 2>&1"}, env, "", "")(ctx) == nil
|
||||||
workingdirectory = rc.Run.Job().Defaults.Run.WorkingDirectory
|
rc.JobContainer.ReplaceLogWriter(stdout, stderr)
|
||||||
} else {
|
top.hasBash = &found
|
||||||
workingdirectory = step.WorkingDirectory
|
|
||||||
}
|
}
|
||||||
|
return *top.hasBash
|
||||||
|
}
|
||||||
|
|
||||||
// jobs can receive context values, so we interpolate
|
func (sr *stepRun) setupWorkingDirectory(ctx context.Context, eval *expressionEvaluator) error {
|
||||||
workingdirectory = rc.NewExpressionEvaluator(ctx).Interpolate(ctx, workingdirectory)
|
rc := sr.RunContext
|
||||||
|
workingdirectory, err := eval.Interpolate(ctx, sr.Step.WorkingDirectory)
|
||||||
// but top level keys in workflow file like `defaults` or `env` can't
|
if err != nil {
|
||||||
|
return fmt.Errorf("unable to interpolate the working directory: %w", err)
|
||||||
|
}
|
||||||
|
if workingdirectory == "" {
|
||||||
|
workingdirectory = rc.jobRunDefaults.WorkingDirectory
|
||||||
|
}
|
||||||
|
// top level keys in workflow file like `defaults` or `env` can't hold expressions
|
||||||
if workingdirectory == "" {
|
if workingdirectory == "" {
|
||||||
workingdirectory = rc.Run.Workflow.Defaults.Run.WorkingDirectory
|
workingdirectory = rc.Run.Workflow.Defaults.Run.WorkingDirectory
|
||||||
}
|
}
|
||||||
sr.WorkingDirectory = workingdirectory
|
sr.WorkingDirectory = workingdirectory
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
+127
-8
@@ -6,8 +6,12 @@ package runner
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"cmp"
|
||||||
"context"
|
"context"
|
||||||
"io"
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/container"
|
"gitea.com/gitea/runner/act/container"
|
||||||
@@ -15,8 +19,13 @@ import (
|
|||||||
"gitea.dev/actionslib/pkg/model"
|
"gitea.dev/actionslib/pkg/model"
|
||||||
"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"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type shellContainerMock struct{ *containerMock }
|
||||||
|
|
||||||
|
func (*shellContainerMock) ReplaceLogWriter(_, _ io.Writer) (io.Writer, io.Writer) { return nil, nil }
|
||||||
|
|
||||||
func TestStepRun(t *testing.T) {
|
func TestStepRun(t *testing.T) {
|
||||||
cm := &containerMock{}
|
cm := &containerMock{}
|
||||||
fileEntry := &container.FileEntry{
|
fileEntry := &container.FileEntry{
|
||||||
@@ -34,17 +43,12 @@ func TestStepRun(t *testing.T) {
|
|||||||
JobID: "1",
|
JobID: "1",
|
||||||
Workflow: &model.Workflow{
|
Workflow: &model.Workflow{
|
||||||
Jobs: map[string]*model.Job{
|
Jobs: map[string]*model.Job{
|
||||||
"1": {
|
"1": {},
|
||||||
Defaults: model.Defaults{
|
|
||||||
Run: model.RunDefaults{
|
|
||||||
Shell: "bash",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
JobContainer: cm,
|
JobContainer: cm,
|
||||||
|
jobRunDefaults: model.RunDefaults{Shell: "bash"},
|
||||||
},
|
},
|
||||||
Step: &model.Step{
|
Step: &model.Step{
|
||||||
ID: "1",
|
ID: "1",
|
||||||
@@ -73,3 +77,118 @@ func TestStepRun(t *testing.T) {
|
|||||||
|
|
||||||
cm.AssertExpectations(t)
|
cm.AssertExpectations(t)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestStepRunShellParity(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name, run, shell, workingDir string
|
||||||
|
env map[string]string
|
||||||
|
host bool
|
||||||
|
probeErr error
|
||||||
|
wantExt string
|
||||||
|
wantCmd []string
|
||||||
|
wantErr string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "implicit host bash",
|
||||||
|
host: true,
|
||||||
|
wantCmd: []string{"bash", "-e", "/var/run/act/workflow/1.sh"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "implicit container bash",
|
||||||
|
wantCmd: []string{"bash", "-e", "/var/run/act/workflow/1.sh"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "implicit container sh fallback",
|
||||||
|
probeErr: assert.AnError,
|
||||||
|
wantCmd: []string{"sh", "-e", "/var/run/act/workflow/1.sh"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "custom pwsh template",
|
||||||
|
shell: "pwsh -NoProfile -File {0}",
|
||||||
|
wantExt: ".ps1",
|
||||||
|
wantCmd: []string{"pwsh", "-NoProfile", "-File", "/var/run/act/workflow/1.ps1"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing placeholder",
|
||||||
|
shell: "bash -e",
|
||||||
|
wantErr: `invalid shell option "bash -e": format must contain {0}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "all placeholders",
|
||||||
|
shell: "bash -c '. {0}; . {0}'",
|
||||||
|
wantCmd: []string{"bash", "-c", ". /var/run/act/workflow/1.sh; . /var/run/act/workflow/1.sh"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "step env expressions",
|
||||||
|
shell: "${{ env.SHELL }}",
|
||||||
|
workingDir: "${{ env.DIR }}",
|
||||||
|
env: map[string]string{"SHELL": "python {0}", "DIR": "subdir"},
|
||||||
|
wantExt: ".py",
|
||||||
|
wantCmd: []string{"python", "/var/run/act/workflow/1.py"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "run expression without a context",
|
||||||
|
run: "echo ${{ COMMIT_SHA }}",
|
||||||
|
wantErr: "unable to interpolate the run script:",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "shell expression without a context",
|
||||||
|
shell: "${{ SHELL }}",
|
||||||
|
wantErr: "unable to interpolate the shell:",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "working directory expression without a context",
|
||||||
|
workingDir: "${{ DIR }}",
|
||||||
|
wantErr: "unable to interpolate the working directory:",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
cm := &containerMock{}
|
||||||
|
var jobContainer container.ExecutionsEnvironment = &shellContainerMock{cm}
|
||||||
|
if test.host {
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
t.Skip("Linux host shell selection")
|
||||||
|
}
|
||||||
|
test.env = map[string]string{"PATH": t.TempDir()}
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(test.env["PATH"], "bash"), nil, 0o755))
|
||||||
|
jobContainer = &container.HostEnvironment{ActPath: "/var/run/act"}
|
||||||
|
} else if test.shell == "" {
|
||||||
|
cm.On("Exec", []string{"sh", "-c", "command -v bash >/dev/null 2>&1"},
|
||||||
|
mock.AnythingOfType("map[string]string"), "", "").Return(func(context.Context) error {
|
||||||
|
return test.probeErr
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
sr := &stepRun{
|
||||||
|
RunContext: &RunContext{
|
||||||
|
Config: &Config{},
|
||||||
|
Run: &model.Run{JobID: "1", Workflow: &model.Workflow{Jobs: map[string]*model.Job{"1": {}}}},
|
||||||
|
JobContainer: jobContainer,
|
||||||
|
},
|
||||||
|
Step: &model.Step{ID: "1", Run: cmp.Or(test.run, "echo hi"), Shell: test.shell, WorkingDirectory: test.workingDir},
|
||||||
|
env: test.env,
|
||||||
|
}
|
||||||
|
|
||||||
|
name, script, err := sr.setupShellCommand(t.Context())
|
||||||
|
if test.wantErr != "" {
|
||||||
|
require.ErrorContains(t, err, test.wantErr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
require.NoError(t, err)
|
||||||
|
if test.wantExt == "" {
|
||||||
|
test.wantExt = ".sh"
|
||||||
|
}
|
||||||
|
wantScript := "\necho hi\n"
|
||||||
|
if test.wantExt == ".ps1" {
|
||||||
|
wantScript = "$ErrorActionPreference = 'stop'\necho hi\nif ((Test-Path -LiteralPath variable:/LASTEXITCODE)) { exit $LASTEXITCODE }"
|
||||||
|
}
|
||||||
|
assert.Equal(t, "workflow/1"+test.wantExt, name)
|
||||||
|
assert.Equal(t, wantScript, script)
|
||||||
|
assert.Equal(t, test.wantCmd, sr.cmd)
|
||||||
|
assert.Equal(t, test.env["DIR"], sr.WorkingDirectory)
|
||||||
|
cm.AssertExpectations(t)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
+93
-1
@@ -7,12 +7,15 @@ package runner
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"os"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
|
"gitea.com/gitea/runner/act/container"
|
||||||
|
|
||||||
"gitea.dev/actionslib/pkg/model"
|
"gitea.dev/actionslib/pkg/model"
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
|
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"
|
||||||
@@ -157,7 +160,7 @@ func TestSetupEnv(t *testing.T) {
|
|||||||
sm.On("getStepModel").Return(step)
|
sm.On("getStepModel").Return(step)
|
||||||
sm.On("getEnv").Return(&env)
|
sm.On("getEnv").Return(&env)
|
||||||
|
|
||||||
setupEnv(context.Background(), sm)
|
require.NoError(t, setupEnv(context.Background(), sm))
|
||||||
|
|
||||||
// These are commit or system specific
|
// These are commit or system specific
|
||||||
delete(env, "GITHUB_REF")
|
delete(env, "GITHUB_REF")
|
||||||
@@ -404,3 +407,92 @@ func TestRunStepExecutorDoesNotLeakRefusalToNextStep(t *testing.T) {
|
|||||||
errB := runStepExecutor(stepB, stepStageMain, func(context.Context) error { return nil })(ctx)
|
errB := runStepExecutor(stepB, stepStageMain, func(context.Context) error { return nil })(ctx)
|
||||||
require.NoError(t, errB)
|
require.NoError(t, errB)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRunStepExecutorParity(t *testing.T) {
|
||||||
|
newStep := func(t *testing.T, stepModel *model.Step) *stepRun {
|
||||||
|
rc := createRunContext(t)
|
||||||
|
rc.JobContainer = &container.HostEnvironment{ActPath: t.TempDir()}
|
||||||
|
rc.ExprEval = rc.NewExpressionEvaluator(context.Background())
|
||||||
|
return &stepRun{RunContext: rc, Step: stepModel, env: map[string]string{}}
|
||||||
|
}
|
||||||
|
badExpression := "${{ 'test' != test }}"
|
||||||
|
for _, test := range []struct {
|
||||||
|
name, wantError string
|
||||||
|
step *model.Step
|
||||||
|
executor common.Executor
|
||||||
|
}{
|
||||||
|
{"condition error", "if-expression", &model.Step{ID: "condition", If: yaml.Node{Value: badExpression}}, noopExecutor},
|
||||||
|
{"continue-on-error expression error", "continue-on-error expression", &model.Step{ID: "continue", RawContinueOnError: badExpression}, common.NewErrorExecutor(assert.AnError)},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
step := newStep(t, test.step)
|
||||||
|
logger, hook := logrustest.NewNullLogger()
|
||||||
|
err := runStepExecutor(step, stepStageMain, test.executor)(common.WithLogger(context.Background(), logger))
|
||||||
|
|
||||||
|
require.ErrorContains(t, err, test.wantError)
|
||||||
|
assert.Equal(t, model.StepStatusFailure, step.RunContext.StepResults[test.step.ID].Conclusion)
|
||||||
|
assert.Equal(t, model.StepStatusFailure, hook.LastEntry().Data["stepResult"])
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("file command error honors continue-on-error", func(t *testing.T) {
|
||||||
|
step := newStep(t, &model.Step{ID: "commands", RawContinueOnError: "true"})
|
||||||
|
logger, hook := logrustest.NewNullLogger()
|
||||||
|
err := runStepExecutor(step, stepStageMain, func(context.Context) error {
|
||||||
|
require.NoError(t, os.WriteFile(step.env["GITHUB_ENV"], []byte("GOOD=1\nmalformed\n"), 0o600))
|
||||||
|
require.NoError(t, os.WriteFile(step.env["GITHUB_OUTPUT"], []byte("kept=value\n"), 0o600))
|
||||||
|
require.NoError(t, os.WriteFile(step.env["GITHUB_STATE"], []byte("saved=value\n"), 0o600))
|
||||||
|
return nil
|
||||||
|
})(common.WithLogger(context.Background(), logger))
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
result := step.RunContext.StepResults[step.Step.ID]
|
||||||
|
assert.Equal(t, model.StepStatusFailure, result.Outcome)
|
||||||
|
assert.Equal(t, model.StepStatusSuccess, result.Conclusion)
|
||||||
|
assert.Equal(t, model.StepStatusSuccess, hook.LastEntry().Data["stepResult"])
|
||||||
|
assert.Equal(t, "1", step.RunContext.Env["GOOD"])
|
||||||
|
assert.Equal(t, "value", result.Outputs["kept"])
|
||||||
|
assert.Equal(t, "value", step.RunContext.IntraActionState[step.Step.ID]["saved"])
|
||||||
|
for _, name := range []string{"GITHUB_ENV", "GITHUB_OUTPUT", "GITHUB_STATE", "GITHUB_PATH"} {
|
||||||
|
contents, readErr := os.ReadFile(step.env[name])
|
||||||
|
require.NoError(t, readErr)
|
||||||
|
assert.Empty(t, contents)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("composite child files do not leak", func(t *testing.T) {
|
||||||
|
outer := newStep(t, &model.Step{ID: "outer"})
|
||||||
|
childRC := &RunContext{
|
||||||
|
Config: outer.RunContext.Config, Run: outer.RunContext.Run, Env: map[string]string{}, StepResults: map[string]*model.StepResult{},
|
||||||
|
JobContainer: outer.RunContext.JobContainer, Parent: outer.RunContext,
|
||||||
|
}
|
||||||
|
childRC.ExprEval = childRC.NewExpressionEvaluator(context.Background())
|
||||||
|
child := &stepRun{RunContext: childRC, Step: &model.Step{ID: "child"}, env: map[string]string{}}
|
||||||
|
|
||||||
|
err := runStepExecutor(outer, stepStageMain, func(ctx context.Context) error {
|
||||||
|
require.NoError(t, runStepExecutor(child, stepStageMain, func(context.Context) error {
|
||||||
|
require.NoError(t, os.WriteFile(child.env["GITHUB_OUTPUT"], []byte("declared=child\nundeclared=leak\n"), 0o600))
|
||||||
|
require.NoError(t, os.WriteFile(child.env["GITHUB_STATE"], []byte("saved=child\n"), 0o600))
|
||||||
|
return nil
|
||||||
|
})(ctx))
|
||||||
|
outer.RunContext.setOutput(ctx, map[string]string{"name": "declared"}, "outer")
|
||||||
|
return nil
|
||||||
|
})(context.Background())
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, map[string]string{"declared": "outer"}, outer.RunContext.StepResults[outer.Step.ID].Outputs)
|
||||||
|
assert.Equal(t, map[string]string{"declared": "child", "undeclared": "leak"}, childRC.StepResults[child.Step.ID].Outputs)
|
||||||
|
assert.Empty(t, outer.RunContext.IntraActionState)
|
||||||
|
assert.Equal(t, "child", childRC.IntraActionState[child.Step.ID]["saved"])
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("timeout must be positive", func(t *testing.T) {
|
||||||
|
exprEval := createRunContext(t).NewExpressionEvaluator(context.Background())
|
||||||
|
for timeout, wantDeadline := range map[string]bool{"-1": false, "0": false, "1": true} {
|
||||||
|
ctx, cancel := evaluateStepTimeout(context.Background(), exprEval, &model.Step{TimeoutMinutes: timeout})
|
||||||
|
_, hasDeadline := ctx.Deadline()
|
||||||
|
cancel()
|
||||||
|
assert.Equal(t, wantDeadline, hasDeadline, timeout)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
# macOS VM executor
|
||||||
|
|
||||||
|
The runner can execute a job in an ephemeral macOS VM by registering a
|
||||||
|
`macos-vm` label:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
runner:
|
||||||
|
labels:
|
||||||
|
- macos-latest:macos-vm://registry.example.com/macos-15.2-xcode-26.1
|
||||||
|
```
|
||||||
|
|
||||||
|
The VM is created and managed by `runner-executor-macos`, a dedicated Swift
|
||||||
|
binary built on Apple Virtualization.framework. The source VM must include the
|
||||||
|
runner guest agent.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
macos_vm:
|
||||||
|
executor_path: runner-executor-macos
|
||||||
|
workdir_parent: /Users/admin/runner
|
||||||
|
cpu: 0
|
||||||
|
memory: 0
|
||||||
|
boot_timeout: 5m
|
||||||
|
```
|
||||||
|
|
||||||
|
`workdir_parent` is the guest directory under which job workspaces are created.
|
||||||
|
`executor_path` is the binary to invoke; an empty value uses PATH lookup.
|
||||||
|
|
||||||
|
## Executor CLI
|
||||||
|
|
||||||
|
The runner uses a narrow, runner-oriented command set:
|
||||||
|
|
||||||
|
```text
|
||||||
|
runner-executor-macos provision <image> <name> [--cpu N] [--memory MB]
|
||||||
|
runner-executor-macos start <name> [--no-graphics]
|
||||||
|
runner-executor-macos exec <name> [--stdin] <argv...>
|
||||||
|
runner-executor-macos stop <name> [--timeout N]
|
||||||
|
runner-executor-macos destroy <name>
|
||||||
|
runner-executor-macos list --format json
|
||||||
|
runner-executor-macos version
|
||||||
|
```
|
||||||
|
|
||||||
|
`list --format json` returns:
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"name": "GITEA-MACOS-VM-<runner-uuid>-...",
|
||||||
|
"state": "stopped",
|
||||||
|
"running": false,
|
||||||
|
"accessed_at": "2026-09-05T12:00:00Z"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Integration test
|
||||||
|
|
||||||
|
The end-to-end test is opt-in because it provisions and boots a real VM:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
MACOS_VM_TEST_IMAGE=/path/or/reference \
|
||||||
|
go test -tags macos_vm_integration ./act/container -run '^TestMacOSVMEnvironmentIntegration$' -v
|
||||||
|
```
|
||||||
|
|
||||||
|
The test skips when `MACOS_VM_TEST_IMAGE` is unset.
|
||||||
|
|
||||||
|
## Orphaned VM cleanup
|
||||||
|
|
||||||
|
Job VM names start with `GITEA-MACOS-VM-<runner-uuid>-`. While the runner is
|
||||||
|
idle, it lists local VMs and destroys the non-running ones whose name has this
|
||||||
|
runner's UUID and whose access time is older than
|
||||||
|
`runner.workdir_cleanup_age`.
|
||||||
|
|
||||||
|
## Service containers
|
||||||
|
|
||||||
|
Service containers are intentionally rejected for macOS VM jobs today. Docker
|
||||||
|
service containers live on a Docker network and are reached by workflow service
|
||||||
|
id, which a macOS VM cannot resolve.
|
||||||
|
|
||||||
|
Possible future designs:
|
||||||
|
|
||||||
|
1. **Host-published Docker services + guest host mapping** — publish each
|
||||||
|
service port on the runner host and inject `<service-id> -> <host-ip>` into
|
||||||
|
the VM through the guest agent.
|
||||||
|
2. **macOS VM services** — run each service as another VM; this requires
|
||||||
|
service discovery and is much heavier than Docker containers.
|
||||||
|
3. **Environment-based discovery** — require workflows to read service
|
||||||
|
addresses from env vars instead of hostnames, breaking GitHub Actions
|
||||||
|
compatibility.
|
||||||
|
|
||||||
|
Option 1 is the most compatible direction once the guest agent and host network
|
||||||
|
configuration are stable.
|
||||||
@@ -102,6 +102,6 @@ require (
|
|||||||
go.opentelemetry.io/otel/metric v1.45.0 // indirect
|
go.opentelemetry.io/otel/metric v1.45.0 // indirect
|
||||||
go.opentelemetry.io/otel/trace v1.45.0 // indirect
|
go.opentelemetry.io/otel/trace v1.45.0 // indirect
|
||||||
go.yaml.in/yaml/v3 v3.0.5 // indirect
|
go.yaml.in/yaml/v3 v3.0.5 // indirect
|
||||||
golang.org/x/crypto v0.55.0 // indirect
|
golang.org/x/crypto v0.56.0 // indirect
|
||||||
gopkg.in/warnings.v0 v0.1.2 // indirect
|
gopkg.in/warnings.v0 v0.1.2 // indirect
|
||||||
)
|
)
|
||||||
@@ -234,8 +234,8 @@ go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
|
|||||||
go.yaml.in/yaml/v4 v4.0.0-rc.3 h1:3h1fjsh1CTAPjW7q/EMe+C8shx5d8ctzZTrLcs/j8Go=
|
go.yaml.in/yaml/v4 v4.0.0-rc.3 h1:3h1fjsh1CTAPjW7q/EMe+C8shx5d8ctzZTrLcs/j8Go=
|
||||||
go.yaml.in/yaml/v4 v4.0.0-rc.3/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0=
|
go.yaml.in/yaml/v4 v4.0.0-rc.3/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0=
|
||||||
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||||
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
|
golang.org/x/crypto v0.56.0 h1:GUh5Ii4J5jtcseSMiRqr1jXCNHoxjeV9Fmekc2oLy6Y=
|
||||||
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
|
golang.org/x/crypto v0.56.0/go.mod h1:OMW5y6CY9l38uPLmxU6l6pwcXp1obtLo3e6gT7gQR2I=
|
||||||
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM=
|
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM=
|
||||||
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80=
|
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80=
|
||||||
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||||
|
|||||||
+118
-61
@@ -8,6 +8,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
|
"encoding/json/v2"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"maps"
|
"maps"
|
||||||
@@ -45,6 +46,8 @@ type executeArgs struct {
|
|||||||
forcePull bool
|
forcePull bool
|
||||||
forceRebuild bool
|
forceRebuild bool
|
||||||
jsonLogger bool
|
jsonLogger bool
|
||||||
|
inputs []string
|
||||||
|
inputfile string
|
||||||
envs []string
|
envs []string
|
||||||
envfile string
|
envfile string
|
||||||
secrets []string
|
secrets []string
|
||||||
@@ -90,6 +93,11 @@ func (i *executeArgs) Envfile() string {
|
|||||||
return i.resolve(i.envfile)
|
return i.resolve(i.envfile)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Inputfile returns path to .env-format inputfile
|
||||||
|
func (i *executeArgs) Inputfile() string {
|
||||||
|
return i.resolve(i.inputfile)
|
||||||
|
}
|
||||||
|
|
||||||
func (i *executeArgs) LoadSecrets() map[string]string {
|
func (i *executeArgs) LoadSecrets() map[string]string {
|
||||||
s := make(map[string]string)
|
s := make(map[string]string)
|
||||||
for _, secretPair := range i.secrets {
|
for _, secretPair := range i.secrets {
|
||||||
@@ -128,19 +136,55 @@ func readEnvs(path string, envs map[string]string) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
func (i *executeArgs) LoadEnvs() map[string]string {
|
func (i *executeArgs) LoadVars() map[string]string {
|
||||||
envs := make(map[string]string)
|
return parseKVAndFile(i.vars, "")
|
||||||
if i.envs != nil {
|
}
|
||||||
for _, envVar := range i.envs {
|
|
||||||
e := strings.SplitN(envVar, `=`, 2)
|
func (i *executeArgs) LoadInputs() map[string]string {
|
||||||
if len(e) == 2 {
|
return parseKVAndFile(i.inputs, i.Inputfile())
|
||||||
envs[e[0]] = e[1]
|
}
|
||||||
} else {
|
|
||||||
envs[e[0]] = ""
|
// eventJSON assembles the payload the run is triggered with, the `--input` values overriding
|
||||||
}
|
// the inputs the `--eventpath` file carries.
|
||||||
|
func (i *executeArgs) eventJSON() (string, error) {
|
||||||
|
payload := []byte("{}")
|
||||||
|
if path := i.resolve(i.eventpath); path != "" {
|
||||||
|
var err error
|
||||||
|
if payload, err = os.ReadFile(path); err != nil {
|
||||||
|
return "", fmt.Errorf("failed to read %s: %w", path, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ = readEnvs(i.Envfile(), envs)
|
|
||||||
|
cliInputs := i.LoadInputs()
|
||||||
|
if len(cliInputs) == 0 {
|
||||||
|
return string(payload), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var event map[string]any
|
||||||
|
if err := json.Unmarshal(payload, &event); err != nil {
|
||||||
|
return "", fmt.Errorf("failed to parse event payload: %w", err)
|
||||||
|
}
|
||||||
|
if event == nil { // a JSON `null` payload unmarshals into a nil map
|
||||||
|
event = make(map[string]any)
|
||||||
|
}
|
||||||
|
inputs, ok := event["inputs"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
inputs = make(map[string]any)
|
||||||
|
}
|
||||||
|
for name, value := range cliInputs {
|
||||||
|
inputs[name] = value
|
||||||
|
}
|
||||||
|
event["inputs"] = inputs
|
||||||
|
|
||||||
|
merged, err := json.Marshal(event)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to marshal event payload: %w", err)
|
||||||
|
}
|
||||||
|
return string(merged), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i *executeArgs) LoadEnvs() map[string]string {
|
||||||
|
envs := parseKVAndFile(i.envs, i.Envfile())
|
||||||
|
|
||||||
envs["ACTIONS_CACHE_URL"] = i.cacheHandler.ExternalURL() + "/"
|
envs["ACTIONS_CACHE_URL"] = i.cacheHandler.ExternalURL() + "/"
|
||||||
// The same server answers the cache service v2 API, so let the actions reach it.
|
// The same server answers the cache service v2 API, so let the actions reach it.
|
||||||
@@ -149,20 +193,19 @@ func (i *executeArgs) LoadEnvs() map[string]string {
|
|||||||
return envs
|
return envs
|
||||||
}
|
}
|
||||||
|
|
||||||
func (i *executeArgs) LoadVars() map[string]string {
|
func parseKVAndFile(rawKVs []string, filePath string) map[string]string {
|
||||||
vars := make(map[string]string)
|
result := make(map[string]string)
|
||||||
if i.vars != nil {
|
_ = readEnvs(filePath, result)
|
||||||
for _, runVar := range i.vars {
|
|
||||||
e := strings.SplitN(runVar, `=`, 2)
|
for _, raw := range rawKVs {
|
||||||
if len(e) == 2 {
|
parts := strings.SplitN(raw, "=", 2)
|
||||||
vars[e[0]] = e[1]
|
if len(parts) == 2 {
|
||||||
} else {
|
result[parts[0]] = parts[1]
|
||||||
vars[e[0]] = ""
|
} else {
|
||||||
}
|
result[parts[0]] = ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return result
|
||||||
return vars
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Workdir returns path to workdir
|
// Workdir returns path to workdir
|
||||||
@@ -329,6 +372,52 @@ func runExecList(planner model.WorkflowPlanner, execArgs *executeArgs) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (i *executeArgs) runnerConfig(eventName string, env, proxyEnv map[string]string, maxLifetime time.Duration, sharedToolCache bool) (*runner.Config, error) {
|
||||||
|
eventJSON, err := i.eventJSON()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &runner.Config{
|
||||||
|
Workdir: i.Workdir(),
|
||||||
|
BindWorkdir: false,
|
||||||
|
ForcePull: i.forcePull,
|
||||||
|
ForceRebuild: i.forceRebuild,
|
||||||
|
JSONLogger: i.jsonLogger,
|
||||||
|
Env: env,
|
||||||
|
ProxyEnv: proxyEnv,
|
||||||
|
Vars: i.LoadVars(),
|
||||||
|
Secrets: i.LoadSecrets(),
|
||||||
|
InsecureSecrets: i.insecureSecrets,
|
||||||
|
Privileged: i.privileged,
|
||||||
|
UsernsMode: i.usernsMode,
|
||||||
|
ContainerArchitecture: i.containerArchitecture,
|
||||||
|
ContainerDaemonSocket: i.containerDaemonSocket,
|
||||||
|
UseGitIgnore: i.useGitIgnore,
|
||||||
|
GitHubInstance: i.githubInstance,
|
||||||
|
ContainerCapAdd: i.containerCapAdd,
|
||||||
|
ContainerCapDrop: i.containerCapDrop,
|
||||||
|
ContainerOptions: i.containerOptions,
|
||||||
|
ArtifactServerPath: i.artifactServerPath,
|
||||||
|
ArtifactServerPort: i.artifactServerPort,
|
||||||
|
ArtifactServerAddr: i.artifactServerAddr,
|
||||||
|
NoSkipCheckout: i.noSkipCheckout,
|
||||||
|
EventName: eventName,
|
||||||
|
EventJSON: eventJSON,
|
||||||
|
// PresetGitHubContext: preset,
|
||||||
|
ContainerNamePrefix: "GITEA-ACTIONS-TASK-" + eventName,
|
||||||
|
ContainerMaxLifetime: maxLifetime,
|
||||||
|
ContainerNetworkMode: container.NetworkMode(i.network),
|
||||||
|
DefaultActionInstance: i.defaultActionsURL,
|
||||||
|
DefaultActionInstanceIsSelfHosted: i.defaultActionsURL != "" && i.defaultActionsURL != "https://github.com",
|
||||||
|
PlatformPicker: func(_ []string) string {
|
||||||
|
return i.image
|
||||||
|
},
|
||||||
|
ValidVolumes: []string{"**"}, // All volumes are allowed for `exec` command
|
||||||
|
SharedToolCache: sharedToolCache,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
func runExec(ctx context.Context, execArgs *executeArgs) func(cmd *cobra.Command, args []string) error {
|
func runExec(ctx context.Context, execArgs *executeArgs) func(cmd *cobra.Command, args []string) error {
|
||||||
return func(cmd *cobra.Command, args []string) error {
|
return func(cmd *cobra.Command, args []string) error {
|
||||||
planner, err := model.NewWorkflowPlanner(execArgs.WorkflowsPath(), execArgs.noWorkflowRecurse)
|
planner, err := model.NewWorkflowPlanner(execArgs.WorkflowsPath(), execArgs.noWorkflowRecurse)
|
||||||
@@ -445,43 +534,9 @@ func runExec(ctx context.Context, execArgs *executeArgs) func(cmd *cobra.Command
|
|||||||
}
|
}
|
||||||
|
|
||||||
// run the plan
|
// run the plan
|
||||||
config := &runner.Config{
|
config, err := execArgs.runnerConfig(eventName, env, proxyEnv, maxLifetime, shared)
|
||||||
Workdir: execArgs.Workdir(),
|
if err != nil {
|
||||||
BindWorkdir: false,
|
return err
|
||||||
ForcePull: execArgs.forcePull,
|
|
||||||
ForceRebuild: execArgs.forceRebuild,
|
|
||||||
JSONLogger: execArgs.jsonLogger,
|
|
||||||
Env: env,
|
|
||||||
ProxyEnv: proxyEnv,
|
|
||||||
Vars: execArgs.LoadVars(),
|
|
||||||
Secrets: execArgs.LoadSecrets(),
|
|
||||||
InsecureSecrets: execArgs.insecureSecrets,
|
|
||||||
Privileged: execArgs.privileged,
|
|
||||||
UsernsMode: execArgs.usernsMode,
|
|
||||||
ContainerArchitecture: execArgs.containerArchitecture,
|
|
||||||
ContainerDaemonSocket: execArgs.containerDaemonSocket,
|
|
||||||
UseGitIgnore: execArgs.useGitIgnore,
|
|
||||||
GitHubInstance: execArgs.githubInstance,
|
|
||||||
ContainerCapAdd: execArgs.containerCapAdd,
|
|
||||||
ContainerCapDrop: execArgs.containerCapDrop,
|
|
||||||
ContainerOptions: execArgs.containerOptions,
|
|
||||||
ArtifactServerPath: execArgs.artifactServerPath,
|
|
||||||
ArtifactServerPort: execArgs.artifactServerPort,
|
|
||||||
ArtifactServerAddr: execArgs.artifactServerAddr,
|
|
||||||
NoSkipCheckout: execArgs.noSkipCheckout,
|
|
||||||
EventPath: execArgs.resolve(execArgs.eventpath),
|
|
||||||
// PresetGitHubContext: preset,
|
|
||||||
// EventJSON: string(eventJSON),
|
|
||||||
ContainerNamePrefix: "GITEA-ACTIONS-TASK-" + eventName,
|
|
||||||
ContainerMaxLifetime: maxLifetime,
|
|
||||||
ContainerNetworkMode: container.NetworkMode(execArgs.network),
|
|
||||||
DefaultActionInstance: execArgs.defaultActionsURL,
|
|
||||||
DefaultActionInstanceIsSelfHosted: execArgs.defaultActionsURL != "" && execArgs.defaultActionsURL != "https://github.com",
|
|
||||||
PlatformPicker: func(_ []string) string {
|
|
||||||
return execArgs.image
|
|
||||||
},
|
|
||||||
ValidVolumes: []string{"**"}, // All volumes are allowed for `exec` command
|
|
||||||
SharedToolCache: shared,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
config.Env["ACT_EXEC"] = "true"
|
config.Env["ACT_EXEC"] = "true"
|
||||||
@@ -536,7 +591,9 @@ func loadExecCmd(ctx context.Context) *cobra.Command {
|
|||||||
execCmd.Flags().BoolVarP(&execArg.forcePull, "pull", "p", false, "pull docker image(s) even if already present")
|
execCmd.Flags().BoolVarP(&execArg.forcePull, "pull", "p", false, "pull docker image(s) even if already present")
|
||||||
execCmd.Flags().BoolVarP(&execArg.forceRebuild, "rebuild", "", false, "rebuild local action docker image(s) even if already present")
|
execCmd.Flags().BoolVarP(&execArg.forceRebuild, "rebuild", "", false, "rebuild local action docker image(s) even if already present")
|
||||||
execCmd.PersistentFlags().BoolVar(&execArg.jsonLogger, "json", false, "Output logs in json format")
|
execCmd.PersistentFlags().BoolVar(&execArg.jsonLogger, "json", false, "Output logs in json format")
|
||||||
execCmd.Flags().StringArrayVarP(&execArg.envs, "env", "", []string{}, "env to make available to actions with optional value (e.g. --env myenv=foo or --env myenv)")
|
execCmd.Flags().StringArrayVarP(&execArg.inputs, "input", "", []string{}, "set an input the workflow declares under its workflow_dispatch or workflow_call trigger, others stay invisible to the inputs context (e.g. --input name=bar; can be specified multiple times with highest precedence)")
|
||||||
|
execCmd.Flags().StringVarP(&execArg.inputfile, "input-file", "", "", "path to an .env-format file containing key=value pairs as baseline workflow inputs (override event inputs)")
|
||||||
|
execCmd.Flags().StringArrayVarP(&execArg.envs, "env", "", []string{}, "env to make available to actions with optional value (e.g. --env myenv=foo or --env myenv; override env-file)")
|
||||||
execCmd.PersistentFlags().StringVarP(&execArg.envfile, "env-file", "", ".env", "environment file to read and use as env in the containers")
|
execCmd.PersistentFlags().StringVarP(&execArg.envfile, "env-file", "", ".env", "environment file to read and use as env in the containers")
|
||||||
execCmd.Flags().StringArrayVarP(&execArg.secrets, "secret", "s", []string{}, "secret to make available to actions with optional value (e.g. -s mysecret=foo or -s mysecret)")
|
execCmd.Flags().StringArrayVarP(&execArg.secrets, "secret", "s", []string{}, "secret to make available to actions with optional value (e.g. -s mysecret=foo or -s mysecret)")
|
||||||
execCmd.Flags().StringArrayVarP(&execArg.vars, "var", "", []string{}, "variable to make available to actions with optional value (e.g. --var myvar=foo or --var myvar)")
|
execCmd.Flags().StringArrayVarP(&execArg.vars, "var", "", []string{}, "variable to make available to actions with optional value (e.g. --var myvar=foo or --var myvar)")
|
||||||
|
|||||||
@@ -56,6 +56,81 @@ func TestExecuteArgsPaths(t *testing.T) {
|
|||||||
require.Equal(t, workdir, args.Workdir())
|
require.Equal(t, workdir, args.Workdir())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestExecuteArgsLoadInputs(t *testing.T) {
|
||||||
|
require.Empty(t, (&executeArgs{}).LoadInputs())
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
envFile := filepath.Join(dir, ".env")
|
||||||
|
require.NoError(t, os.WriteFile(envFile, []byte("BAZ=qux\nOVERRIDE=from_file\n"), 0o600))
|
||||||
|
|
||||||
|
args := &executeArgs{inputs: []string{"FOO=bar", "EMPTY", "WITH=eq=sign", "OVERRIDE=from_cli"}, inputfile: envFile}
|
||||||
|
require.Equal(t, map[string]string{
|
||||||
|
"FOO": "bar",
|
||||||
|
"EMPTY": "",
|
||||||
|
"WITH": "eq=sign",
|
||||||
|
"BAZ": "qux",
|
||||||
|
"OVERRIDE": "from_cli",
|
||||||
|
}, args.LoadInputs())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecuteArgsEventJSON(t *testing.T) {
|
||||||
|
payload := func(content string) string {
|
||||||
|
path := filepath.Join(t.TempDir(), "event.json")
|
||||||
|
require.NoError(t, os.WriteFile(path, []byte(content), 0o600))
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
eventpath string
|
||||||
|
inputs []string
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "keeps a payload without inputs to merge",
|
||||||
|
eventpath: payload(`{"inputs": {"kept": "from_event"}}`),
|
||||||
|
expected: `{"inputs": {"kept": "from_event"}}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "overrides the event inputs it names, keeping the rest",
|
||||||
|
eventpath: payload(`{"inputs": {"kept": "from_event", "named": "from_event"}}`),
|
||||||
|
inputs: []string{"named=from_cli"},
|
||||||
|
expected: `{"inputs": {"kept": "from_event", "named": "from_cli"}}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "adds inputs to a payload carrying none",
|
||||||
|
eventpath: payload(`{"repository": {"name": "test-repo"}}`),
|
||||||
|
inputs: []string{"flag=val"},
|
||||||
|
expected: `{"repository": {"name": "test-repo"}, "inputs": {"flag": "val"}}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "handles a null payload",
|
||||||
|
eventpath: payload("null"),
|
||||||
|
inputs: []string{"flag=val"},
|
||||||
|
expected: `{"inputs": {"flag": "val"}}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "builds a payload without an event file",
|
||||||
|
inputs: []string{"flag=val"},
|
||||||
|
expected: `{"inputs": {"flag": "val"}}`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
eventJSON, err := (&executeArgs{eventpath: tt.eventpath, inputs: tt.inputs}).eventJSON()
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.JSONEq(t, tt.expected, eventJSON)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
args := &executeArgs{inputs: []string{"flag=val"}}
|
||||||
|
config, err := args.runnerConfig("workflow_dispatch", nil, nil, 0, false)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.JSONEq(t, `{"inputs": {"flag": "val"}}`, config.EventJSON)
|
||||||
|
require.Equal(t, "workflow_dispatch", config.EventName) // the inputs context stays empty without it
|
||||||
|
}
|
||||||
|
|
||||||
func TestExecuteArgsLoadVars(t *testing.T) {
|
func TestExecuteArgsLoadVars(t *testing.T) {
|
||||||
require.Empty(t, (&executeArgs{}).LoadVars())
|
require.Empty(t, (&executeArgs{}).LoadVars())
|
||||||
|
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ func TestRegisterInputsValidate(t *testing.T) {
|
|||||||
|
|
||||||
func TestValidateLabels(t *testing.T) {
|
func TestValidateLabels(t *testing.T) {
|
||||||
require.NoError(t, validateLabels([]string{"ubuntu:host", "ubuntu:docker://node:18"}))
|
require.NoError(t, validateLabels([]string{"ubuntu:host", "ubuntu:docker://node:18"}))
|
||||||
|
require.NoError(t, validateLabels([]string{"macos-latest:macos-vm://ghcr.io/cirruslabs/macos-sonoma-base:latest"}))
|
||||||
// a colon that is not a supported schema is part of the label name
|
// a colon that is not a supported schema is part of the label name
|
||||||
require.NoError(t, validateLabels([]string{"pool:e57e18d4-10d4-406f-93bf-60f127221bdd"}))
|
require.NoError(t, validateLabels([]string{"pool:e57e18d4-10d4-406f-93bf-60f127221bdd"}))
|
||||||
require.Error(t, validateLabels([]string{"ubuntu:host", ""}))
|
require.Error(t, validateLabels([]string{"ubuntu:host", ""}))
|
||||||
|
|||||||
@@ -144,6 +144,9 @@ func (r *Runner) Close() error {
|
|||||||
// removeOrphanNetworks is a variable so tests can substitute one that needs no Docker daemon.
|
// removeOrphanNetworks is a variable so tests can substitute one that needs no Docker daemon.
|
||||||
var removeOrphanNetworks = container.RemoveOrphanNetworks
|
var removeOrphanNetworks = container.RemoveOrphanNetworks
|
||||||
|
|
||||||
|
// removeOrphanMacOSVMs is a variable so tests can substitute one that needs no macOS VM executor.
|
||||||
|
var removeOrphanMacOSVMs = container.CleanupOrphanMacOSVMs
|
||||||
|
|
||||||
// OnIdle performs lightweight maintenance during polling idle windows.
|
// OnIdle performs lightweight maintenance during polling idle windows.
|
||||||
// It runs synchronously on the poller goroutine; shouldRunIdleCleanup
|
// It runs synchronously on the poller goroutine; shouldRunIdleCleanup
|
||||||
// throttles invocations to runner.idle_cleanup_interval so the impact on
|
// throttles invocations to runner.idle_cleanup_interval so the impact on
|
||||||
@@ -166,6 +169,7 @@ func (r *Runner) OnIdle(ctx context.Context) {
|
|||||||
r.cleanupStaleDirs(ctx, hostRoot, isHostScratchDir)
|
r.cleanupStaleDirs(ctx, hostRoot, isHostScratchDir)
|
||||||
}
|
}
|
||||||
r.cleanupOrphanNetworks(ctx)
|
r.cleanupOrphanNetworks(ctx)
|
||||||
|
r.cleanupOrphanMacOSVMs(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
// cleanupOrphanNetworks reclaims the per-job networks of jobs this runner did not live to
|
// cleanupOrphanNetworks reclaims the per-job networks of jobs this runner did not live to
|
||||||
@@ -182,6 +186,16 @@ func (r *Runner) cleanupOrphanNetworks(ctx context.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *Runner) cleanupOrphanMacOSVMs(ctx context.Context) {
|
||||||
|
if r.uuid == "" || !r.labels.RequireMacOSVM() || r.cfg.Runner.WorkdirCleanupAge <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cutoff := r.now().Add(-r.cfg.Runner.WorkdirCleanupAge)
|
||||||
|
if err := removeOrphanMacOSVMs(ctx, r.cfg.MacOSVM.ExecutorPath, r.uuid, cutoff); err != nil {
|
||||||
|
log.Warnf("failed to clean up macOS VMs left behind by earlier jobs: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (r *Runner) shouldRunIdleCleanup() bool {
|
func (r *Runner) shouldRunIdleCleanup() bool {
|
||||||
if r.cfg.Runner.WorkdirCleanupAge <= 0 || r.cfg.Runner.IdleCleanupInterval <= 0 {
|
if r.cfg.Runner.WorkdirCleanupAge <= 0 || r.cfg.Runner.IdleCleanupInterval <= 0 {
|
||||||
return false
|
return false
|
||||||
@@ -548,8 +562,16 @@ func (r *Runner) run(ctx context.Context, task *runnerv1.Task, reporter *report.
|
|||||||
Vars: task.Vars,
|
Vars: task.Vars,
|
||||||
ValidVolumes: r.cfg.Container.ValidVolumes,
|
ValidVolumes: r.cfg.Container.ValidVolumes,
|
||||||
SharedToolCache: r.cfg.Runner.ToolCacheMode == config.ToolCacheModeShared,
|
SharedToolCache: r.cfg.Runner.ToolCacheMode == config.ToolCacheModeShared,
|
||||||
InsecureSkipTLS: r.cfg.Runner.Insecure,
|
MacOSVM: runner.MacOSVMConfig{
|
||||||
RunnerName: r.name,
|
ExecutorPath: r.cfg.MacOSVM.ExecutorPath,
|
||||||
|
WorkdirParent: r.cfg.MacOSVM.WorkdirParent,
|
||||||
|
CPU: r.cfg.MacOSVM.CPU,
|
||||||
|
Memory: r.cfg.MacOSVM.Memory,
|
||||||
|
BootTimeout: r.cfg.MacOSVM.BootTimeout,
|
||||||
|
},
|
||||||
|
InsecureSkipTLS: r.cfg.Runner.Insecure,
|
||||||
|
RunnerUUID: r.uuid,
|
||||||
|
RunnerName: r.name,
|
||||||
}
|
}
|
||||||
|
|
||||||
rr, err := runner.New(runnerConfig)
|
rr, err := runner.New(runnerConfig)
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.com/gitea/runner/internal/pkg/config"
|
"gitea.com/gitea/runner/internal/pkg/config"
|
||||||
|
"gitea.com/gitea/runner/internal/pkg/labels"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
@@ -331,3 +332,37 @@ func TestRunnerOnIdleRemovesOrphanNetworks(t *testing.T) {
|
|||||||
hostOnly.OnIdle(context.Background())
|
hostOnly.OnIdle(context.Background())
|
||||||
assert.Equal(t, []string{"runner-1"}, swept)
|
assert.Equal(t, []string{"runner-1"}, swept)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRunnerOnIdleRemovesOrphanMacOSVMs(t *testing.T) {
|
||||||
|
now := time.Date(2026, time.April, 29, 20, 0, 0, 0, time.UTC)
|
||||||
|
cfg := &config.Config{
|
||||||
|
Runner: config.Runner{
|
||||||
|
WorkdirCleanupAge: 24 * time.Hour,
|
||||||
|
IdleCleanupInterval: time.Minute,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
macOSVMLabel, err := labels.Parse("macos-latest:macos-vm://ghcr.io/cirruslabs/macos-sonoma-base:latest")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
var swept []string
|
||||||
|
var sweptCutoff time.Time
|
||||||
|
origRemoveOrphanMacOSVMs := removeOrphanMacOSVMs
|
||||||
|
removeOrphanMacOSVMs = func(_ context.Context, _, runnerUUID string, accessedBefore time.Time) error {
|
||||||
|
swept = append(swept, runnerUUID)
|
||||||
|
sweptCutoff = accessedBefore
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { removeOrphanMacOSVMs = origRemoveOrphanMacOSVMs })
|
||||||
|
|
||||||
|
r := &Runner{uuid: "runner-1", cfg: cfg, labels: labels.Labels{macOSVMLabel}, now: func() time.Time { return now }}
|
||||||
|
r.OnIdle(context.Background())
|
||||||
|
assert.Equal(t, []string{"runner-1"}, swept)
|
||||||
|
assert.Equal(t, now.Add(-24*time.Hour), sweptCutoff)
|
||||||
|
|
||||||
|
// a docker-only runner has no macOS VMs to sweep
|
||||||
|
dockerLabel, err := labels.Parse("ubuntu:docker://node:18")
|
||||||
|
require.NoError(t, err)
|
||||||
|
dockerOnly := &Runner{uuid: "runner-2", cfg: &config.Config{Runner: cfg.Runner}, labels: labels.Labels{dockerLabel}, now: func() time.Time { return now }}
|
||||||
|
dockerOnly.OnIdle(context.Background())
|
||||||
|
assert.Equal(t, []string{"runner-1"}, swept)
|
||||||
|
}
|
||||||
@@ -92,7 +92,8 @@ runner:
|
|||||||
# at the price of the stock artifact actions refusing and the cache client keeping to v1.
|
# at the price of the stock artifact actions refusing and the cache client keeping to v1.
|
||||||
#patch_actions: true
|
#patch_actions: true
|
||||||
# The labels of a runner are used to determine which jobs the runner can run, and how to run them.
|
# The labels of a runner are used to determine which jobs the runner can run, and how to run them.
|
||||||
# Like: "macos-arm64:host" or "ubuntu-latest:docker://docker.gitea.com/runner-images:ubuntu-latest"
|
# Like: "macos-arm64:host", "ubuntu-latest:docker://docker.gitea.com/runner-images:ubuntu-latest",
|
||||||
|
# or "macos-latest:macos-vm://ghcr.io/cirruslabs/macos-sonoma-base:latest"
|
||||||
# Find more images provided by Gitea at https://gitea.com/gitea/runner-images .
|
# Find more images provided by Gitea at https://gitea.com/gitea/runner-images .
|
||||||
# If it's empty when registering, it will ask for inputting labels.
|
# If it's empty when registering, it will ask for inputting labels.
|
||||||
# If it's empty when execute `daemon`, will use labels in `.runner` file.
|
# If it's empty when execute `daemon`, will use labels in `.runner` file.
|
||||||
@@ -260,7 +261,7 @@ container:
|
|||||||
# This is required for Docker-in-Docker (DinD) setups when jobs use docker compose
|
# This is required for Docker-in-Docker (DinD) setups when jobs use docker compose
|
||||||
# with bind mounts (e.g., ".:/app"), as volume-based workspaces are not accessible
|
# with bind mounts (e.g., ".:/app"), as volume-based workspaces are not accessible
|
||||||
# from the DinD daemon's filesystem. When enabled, ensure the workspace parent
|
# from the DinD daemon's filesystem. When enabled, ensure the workspace parent
|
||||||
# directory is also mounted into the runner container and listed in valid_volumes.
|
# directory is also mounted into the runner container.
|
||||||
#bind_workdir: false
|
#bind_workdir: false
|
||||||
# How long a job waits for a service container that declares a healthcheck to become
|
# How long a job waits for a service container that declares a healthcheck to become
|
||||||
# healthy. A negative value (e.g. -1s) starts the steps without waiting.
|
# healthy. A negative value (e.g. -1s) starts the steps without waiting.
|
||||||
@@ -271,6 +272,19 @@ host:
|
|||||||
# If it's empty, $HOME/.cache/act/ will be used.
|
# If it's empty, $HOME/.cache/act/ will be used.
|
||||||
#workdir_parent:
|
#workdir_parent:
|
||||||
|
|
||||||
|
macos_vm:
|
||||||
|
# Path to the runner-executor-macos binary. Empty uses PATH lookup.
|
||||||
|
#executor_path: runner-executor-macos
|
||||||
|
# The guest directory under which the job workspace is created. The VM must run
|
||||||
|
# as a user that can write there.
|
||||||
|
#workdir_parent: /Users/admin/runner
|
||||||
|
# Number of virtual CPUs. Omit or set to 0 to keep the image default.
|
||||||
|
#cpu: 0
|
||||||
|
# VM memory in megabytes. Omit or set to 0 to keep the image default.
|
||||||
|
#memory: 0
|
||||||
|
# How long to wait for the VM to become ready after it starts.
|
||||||
|
#boot_timeout: 5m
|
||||||
|
|
||||||
# Optional local task-admission checks. Disabled by default. When enabled, low
|
# Optional local task-admission checks. Disabled by default. When enabled, low
|
||||||
# disk space or a failing script pauses new task fetching; existing jobs continue.
|
# disk space or a failing script pauses new task fetching; existing jobs continue.
|
||||||
# No health checks run while any job is active; the last result is reused until idle.
|
# No health checks run while any job is active; the last result is reused until idle.
|
||||||
|
|||||||
@@ -179,6 +179,15 @@ type Host struct {
|
|||||||
WorkdirParent string `yaml:"workdir_parent"` // WorkdirParent specifies the parent directory for the host's working directory.
|
WorkdirParent string `yaml:"workdir_parent"` // WorkdirParent specifies the parent directory for the host's working directory.
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MacOSVM represents the configuration for the macOS VM executor.
|
||||||
|
type MacOSVM struct {
|
||||||
|
ExecutorPath string `yaml:"executor_path"` // ExecutorPath is the runner-executor-macos binary to invoke.
|
||||||
|
WorkdirParent string `yaml:"workdir_parent"` // WorkdirParent is the guest directory under which the job workspace is created.
|
||||||
|
CPU int `yaml:"cpu"` // CPU is the number of virtual CPUs. Zero keeps the image default.
|
||||||
|
Memory int `yaml:"memory"` // Memory is the VM memory in megabytes. Zero keeps the image default.
|
||||||
|
BootTimeout time.Duration `yaml:"boot_timeout"` // BootTimeout bounds how long to wait for the VM to become ready.
|
||||||
|
}
|
||||||
|
|
||||||
// Metrics represents the configuration for the Prometheus metrics endpoint.
|
// Metrics represents the configuration for the Prometheus metrics endpoint.
|
||||||
type Metrics struct {
|
type Metrics struct {
|
||||||
Enabled bool `yaml:"enabled"` // Enabled indicates whether the metrics endpoint is exposed.
|
Enabled bool `yaml:"enabled"` // Enabled indicates whether the metrics endpoint is exposed.
|
||||||
@@ -203,6 +212,7 @@ type Config struct {
|
|||||||
Cache Cache `yaml:"cache"` // Cache represents the configuration for caching.
|
Cache Cache `yaml:"cache"` // Cache represents the configuration for caching.
|
||||||
Container Container `yaml:"container"` // Container represents the configuration for the container.
|
Container Container `yaml:"container"` // Container represents the configuration for the container.
|
||||||
Host Host `yaml:"host"` // Host represents the configuration for the host.
|
Host Host `yaml:"host"` // Host represents the configuration for the host.
|
||||||
|
MacOSVM MacOSVM `yaml:"macos_vm"` // MacOSVM represents the configuration for the macOS VM executor.
|
||||||
Metrics Metrics `yaml:"metrics"` // Metrics represents the configuration for the Prometheus metrics endpoint.
|
Metrics Metrics `yaml:"metrics"` // Metrics represents the configuration for the Prometheus metrics endpoint.
|
||||||
HealthCheck HealthCheck `yaml:"health_check"` // HealthCheck controls opt-in local task-admission checks.
|
HealthCheck HealthCheck `yaml:"health_check"` // HealthCheck controls opt-in local task-admission checks.
|
||||||
}
|
}
|
||||||
@@ -300,6 +310,15 @@ func LoadDefault(file string) (*Config, error) {
|
|||||||
}
|
}
|
||||||
cfg.Host.WorkdirParent = filepath.Join(home, ".cache", "act")
|
cfg.Host.WorkdirParent = filepath.Join(home, ".cache", "act")
|
||||||
}
|
}
|
||||||
|
if cfg.MacOSVM.ExecutorPath == "" {
|
||||||
|
cfg.MacOSVM.ExecutorPath = "runner-executor-macos"
|
||||||
|
}
|
||||||
|
if cfg.MacOSVM.WorkdirParent == "" {
|
||||||
|
cfg.MacOSVM.WorkdirParent = "/Users/admin/runner"
|
||||||
|
}
|
||||||
|
if cfg.MacOSVM.BootTimeout <= 0 {
|
||||||
|
cfg.MacOSVM.BootTimeout = 5 * time.Minute
|
||||||
|
}
|
||||||
if cfg.Runner.FetchTimeout <= 0 {
|
if cfg.Runner.FetchTimeout <= 0 {
|
||||||
cfg.Runner.FetchTimeout = 5 * time.Second
|
cfg.Runner.FetchTimeout = 5 * time.Second
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,6 +67,31 @@ func TestLoadDefault_DefaultsWorkdirCleanupAge(t *testing.T) {
|
|||||||
assert.Equal(t, DefaultImage, cfg.Runner.DefaultImage)
|
assert.Equal(t, DefaultImage, cfg.Runner.DefaultImage)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLoadDefault_MacOSVMDefaults(t *testing.T) {
|
||||||
|
cfg, err := LoadDefault("")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "runner-executor-macos", cfg.MacOSVM.ExecutorPath)
|
||||||
|
assert.Equal(t, "/Users/admin/runner", cfg.MacOSVM.WorkdirParent)
|
||||||
|
assert.Equal(t, 5*time.Minute, cfg.MacOSVM.BootTimeout)
|
||||||
|
|
||||||
|
path := filepath.Join(t.TempDir(), "config.yaml")
|
||||||
|
require.NoError(t, os.WriteFile(path, []byte(`
|
||||||
|
macos_vm:
|
||||||
|
executor_path: /usr/local/bin/runner-executor-macos
|
||||||
|
workdir_parent: /Users/runner
|
||||||
|
cpu: 4
|
||||||
|
memory: 8192
|
||||||
|
boot_timeout: 3m
|
||||||
|
`), 0o600))
|
||||||
|
cfg, err = LoadDefault(path)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "/usr/local/bin/runner-executor-macos", cfg.MacOSVM.ExecutorPath)
|
||||||
|
assert.Equal(t, "/Users/runner", cfg.MacOSVM.WorkdirParent)
|
||||||
|
assert.Equal(t, 4, cfg.MacOSVM.CPU)
|
||||||
|
assert.Equal(t, 8192, cfg.MacOSVM.Memory)
|
||||||
|
assert.Equal(t, 3*time.Minute, cfg.MacOSVM.BootTimeout)
|
||||||
|
}
|
||||||
|
|
||||||
func TestLoadDefault_HealthChecksAreOptIn(t *testing.T) {
|
func TestLoadDefault_HealthChecksAreOptIn(t *testing.T) {
|
||||||
cfg, err := LoadDefault("")
|
cfg, err := LoadDefault("")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|||||||
@@ -9,11 +9,15 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
SchemeHost = "host"
|
SchemeHost = "host"
|
||||||
SchemeDocker = "docker"
|
SchemeDocker = "docker"
|
||||||
|
SchemeMacOSVM = "macos-vm"
|
||||||
|
|
||||||
// SelfHostedPlatform is the platform marker act treats as "run on the host".
|
// SelfHostedPlatform is the platform marker act treats as "run on the host".
|
||||||
SelfHostedPlatform = "-self-hosted"
|
SelfHostedPlatform = "-self-hosted"
|
||||||
|
|
||||||
|
// MacOSVMSchemePrefix marks a platform image that act should run in a macOS VM.
|
||||||
|
MacOSVMSchemePrefix = "macos-vm://"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Label struct {
|
type Label struct {
|
||||||
@@ -42,7 +46,7 @@ func Parse(str string) (*Label, error) {
|
|||||||
if len(splits) >= 3 {
|
if len(splits) >= 3 {
|
||||||
label.Arg = splits[2]
|
label.Arg = splits[2]
|
||||||
}
|
}
|
||||||
if label.Schema != SchemeHost && label.Schema != SchemeDocker {
|
if label.Schema != SchemeHost && label.Schema != SchemeDocker && label.Schema != SchemeMacOSVM {
|
||||||
// Not a schema we know: the colon belongs to the label name itself.
|
// Not a schema we know: the colon belongs to the label name itself.
|
||||||
return &Label{
|
return &Label{
|
||||||
Name: str,
|
Name: str,
|
||||||
@@ -64,6 +68,15 @@ func (l Labels) RequireDocker() bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (l Labels) RequireMacOSVM() bool {
|
||||||
|
for _, label := range l {
|
||||||
|
if label.Schema == SchemeMacOSVM {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// PickPlatform returns the platform of the first runs-on entry this runner has a label for, or "".
|
// PickPlatform returns the platform of the first runs-on entry this runner has a label for, or "".
|
||||||
func (l Labels) PickPlatform(runsOn []string) string {
|
func (l Labels) PickPlatform(runsOn []string) string {
|
||||||
platforms := make(map[string]string, len(l))
|
platforms := make(map[string]string, len(l))
|
||||||
@@ -72,10 +85,12 @@ func (l Labels) PickPlatform(runsOn []string) string {
|
|||||||
case SchemeDocker:
|
case SchemeDocker:
|
||||||
// "//" will be ignored
|
// "//" will be ignored
|
||||||
platforms[label.Name] = strings.TrimPrefix(label.Arg, "//")
|
platforms[label.Name] = strings.TrimPrefix(label.Arg, "//")
|
||||||
|
case SchemeMacOSVM:
|
||||||
|
platforms[label.Name] = MacOSVMSchemePrefix + strings.TrimPrefix(label.Arg, "//")
|
||||||
case SchemeHost:
|
case SchemeHost:
|
||||||
platforms[label.Name] = SelfHostedPlatform
|
platforms[label.Name] = SelfHostedPlatform
|
||||||
default:
|
default:
|
||||||
// unreachable: Parse only produces host or docker schemas
|
// unreachable: Parse only produces host, docker, or macOS VM schemas
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,15 @@ func TestParse(t *testing.T) {
|
|||||||
},
|
},
|
||||||
wantErr: false,
|
wantErr: false,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
args: "macos-latest:macos-vm://ghcr.io/cirruslabs/macos-sonoma-base:latest",
|
||||||
|
want: &Label{
|
||||||
|
Name: "macos-latest",
|
||||||
|
Schema: "macos-vm",
|
||||||
|
Arg: "//ghcr.io/cirruslabs/macos-sonoma-base:latest",
|
||||||
|
},
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
args: "ubuntu:host",
|
args: "ubuntu:host",
|
||||||
want: &Label{
|
want: &Label{
|
||||||
@@ -103,6 +112,7 @@ func TestRequireDocker(t *testing.T) {
|
|||||||
{"empty", nil, false},
|
{"empty", nil, false},
|
||||||
{"only host", []string{"ubuntu:host", "self-hosted"}, false},
|
{"only host", []string{"ubuntu:host", "self-hosted"}, false},
|
||||||
{"has docker", []string{"ubuntu:host", "ubuntu:docker://node:18"}, true},
|
{"has docker", []string{"ubuntu:host", "ubuntu:docker://node:18"}, true},
|
||||||
|
{"macos-vm does not require docker", []string{"macos-latest:macos-vm://ghcr.io/cirruslabs/macos-sonoma-base:latest"}, false},
|
||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
@@ -111,10 +121,29 @@ func TestRequireDocker(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRequireMacOSVM(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
strs []string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"empty", nil, false},
|
||||||
|
{"only host", []string{"ubuntu:host", "self-hosted"}, false},
|
||||||
|
{"only docker", []string{"ubuntu:docker://node:18"}, false},
|
||||||
|
{"has macos-vm", []string{"macos-latest:macos-vm://ghcr.io/cirruslabs/macos-sonoma-base:latest"}, true},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
require.Equal(t, tt.want, mustParse(t, tt.strs...).RequireMacOSVM())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestPickPlatform(t *testing.T) {
|
func TestPickPlatform(t *testing.T) {
|
||||||
ls := mustParse(t,
|
ls := mustParse(t,
|
||||||
"ubuntu:docker://node:18",
|
"ubuntu:docker://node:18",
|
||||||
"self-hosted:host",
|
"self-hosted:host",
|
||||||
|
"macos-latest:macos-vm://ghcr.io/cirruslabs/macos-sonoma-base:latest",
|
||||||
)
|
)
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
@@ -124,6 +153,7 @@ func TestPickPlatform(t *testing.T) {
|
|||||||
}{
|
}{
|
||||||
{"docker strips leading slashes", []string{"ubuntu"}, "node:18"},
|
{"docker strips leading slashes", []string{"ubuntu"}, "node:18"},
|
||||||
{"host maps to self-hosted marker", []string{"self-hosted"}, SelfHostedPlatform},
|
{"host maps to self-hosted marker", []string{"self-hosted"}, SelfHostedPlatform},
|
||||||
|
{"macos-vm prefixes platform marker", []string{"macos-latest"}, MacOSVMSchemePrefix + "ghcr.io/cirruslabs/macos-sonoma-base:latest"},
|
||||||
{"first match wins", []string{"self-hosted", "ubuntu"}, SelfHostedPlatform},
|
{"first match wins", []string{"self-hosted", "ubuntu"}, SelfHostedPlatform},
|
||||||
{"unknown label picks nothing", []string{"windows"}, ""},
|
{"unknown label picks nothing", []string{"windows"}, ""},
|
||||||
{"no runsOn picks nothing", nil, ""},
|
{"no runsOn picks nothing", nil, ""},
|
||||||
@@ -145,12 +175,14 @@ func TestToStrings(t *testing.T) {
|
|||||||
ls := mustParse(t,
|
ls := mustParse(t,
|
||||||
"ubuntu:docker://node:18",
|
"ubuntu:docker://node:18",
|
||||||
"self-hosted:host",
|
"self-hosted:host",
|
||||||
|
"macos-latest:macos-vm://ghcr.io/cirruslabs/macos-sonoma-base:latest",
|
||||||
"bare",
|
"bare",
|
||||||
"pool:e57e18d4",
|
"pool:e57e18d4",
|
||||||
)
|
)
|
||||||
require.Equal(t, []string{
|
require.Equal(t, []string{
|
||||||
"ubuntu:docker://node:18",
|
"ubuntu:docker://node:18",
|
||||||
"self-hosted:host",
|
"self-hosted:host",
|
||||||
|
"macos-latest:macos-vm://ghcr.io/cirruslabs/macos-sonoma-base:latest",
|
||||||
"bare:host",
|
"bare:host",
|
||||||
"pool:e57e18d4",
|
"pool:e57e18d4",
|
||||||
}, ls.ToStrings())
|
}, ls.ToStrings())
|
||||||
|
|||||||
@@ -783,8 +783,9 @@ var cmdRegex = regexp.MustCompile(`^::([^ :]+)( [^:]*)?::(.*)$`)
|
|||||||
// handleCommand takes value still escaped, so that the web UI decodes it exactly once. Only
|
// handleCommand takes value still escaped, so that the web UI decodes it exactly once. Only
|
||||||
// the branches that consume the payload here decode it.
|
// the branches that consume the payload here decode it.
|
||||||
func (r *Reporter) handleCommand(originalContent, command, properties, value string) *string {
|
func (r *Reporter) handleCommand(originalContent, command, properties, value string) *string {
|
||||||
|
command = strings.ToLower(command) // GitHub matches command names case-insensitively
|
||||||
if r.stopCommandEndToken != "" {
|
if r.stopCommandEndToken != "" {
|
||||||
if command != r.stopCommandEndToken {
|
if !strings.EqualFold(command, r.stopCommandEndToken) {
|
||||||
return &originalContent
|
return &originalContent
|
||||||
}
|
}
|
||||||
// Resumed here rather than from the switch, because the end token is arbitrary and a
|
// Resumed here rather than from the switch, because the end token is arbitrary and a
|
||||||
@@ -871,13 +872,20 @@ func parseCommandProperties(properties string) map[string]string {
|
|||||||
return props
|
return props
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func cutPrefixFold(s, prefix string) (string, bool) {
|
||||||
|
if len(s) < len(prefix) || !strings.EqualFold(s[:len(prefix)], prefix) {
|
||||||
|
return s, false
|
||||||
|
}
|
||||||
|
return s[len(prefix):], true
|
||||||
|
}
|
||||||
|
|
||||||
func (r *Reporter) parseLogRow(entry *log.Entry) *runnerv1.LogRow {
|
func (r *Reporter) parseLogRow(entry *log.Entry) *runnerv1.LogRow {
|
||||||
content := strings.TrimRight(entry.Message, "\r\n")
|
content := strings.TrimRight(entry.Message, "\r\n")
|
||||||
|
|
||||||
// cmdRegex only covers the ::cmd:: form, so the ##[add-mask] one would otherwise reach
|
// cmdRegex only covers the ::cmd:: form, so the ##[add-mask] one would otherwise reach
|
||||||
// the log carrying its own secret. Registered and dropped like its ::add-mask:: twin.
|
// the log carrying its own secret. Registered and dropped like its ::add-mask:: twin.
|
||||||
if arg, ok := strings.CutPrefix(content, "##[add-mask]"); ok {
|
if arg, ok := cutPrefixFold(content, "##[add-mask]"); ok {
|
||||||
r.addMask(runner.UnescapeCommandData(arg))
|
r.addMask(runner.UnescapeLegacyCommand(arg))
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import (
|
|||||||
"gitea.com/gitea/runner/internal/pkg/metrics"
|
"gitea.com/gitea/runner/internal/pkg/metrics"
|
||||||
|
|
||||||
connect_go "connectrpc.com/connect"
|
connect_go "connectrpc.com/connect"
|
||||||
|
"gitea.dev/actionslib/pkg/model"
|
||||||
runnerv1 "gitea.dev/actionslib/runner/v1"
|
runnerv1 "gitea.dev/actionslib/runner/v1"
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
logrustest "github.com/sirupsen/logrus/hooks/test"
|
logrustest "github.com/sirupsen/logrus/hooks/test"
|
||||||
@@ -273,14 +274,14 @@ func TestReporter_Fire(t *testing.T) {
|
|||||||
"stepID": []string{"0", "0"},
|
"stepID": []string{"0", "0"},
|
||||||
"stepNumber": 0,
|
"stepNumber": 0,
|
||||||
"raw_output": true,
|
"raw_output": true,
|
||||||
"stepResult": "failure",
|
"stepResult": model.StepStatusFailure,
|
||||||
}}))
|
}}))
|
||||||
assert.Equal(t, runnerv1.Result_RESULT_UNSPECIFIED, reporter.state.Steps[0].Result)
|
assert.Equal(t, runnerv1.Result_RESULT_UNSPECIFIED, reporter.state.Steps[0].Result)
|
||||||
require.NoError(t, reporter.Fire(&log.Entry{Message: "step result", Data: map[string]any{
|
require.NoError(t, reporter.Fire(&log.Entry{Message: "step result", Data: map[string]any{
|
||||||
"stage": "Main",
|
"stage": "Main",
|
||||||
"stepNumber": 0,
|
"stepNumber": 0,
|
||||||
"raw_output": true,
|
"raw_output": true,
|
||||||
"stepResult": "success",
|
"stepResult": model.StepStatusSuccess,
|
||||||
}}))
|
}}))
|
||||||
assert.Equal(t, runnerv1.Result_RESULT_SUCCESS, reporter.state.Steps[0].Result)
|
assert.Equal(t, runnerv1.Result_RESULT_SUCCESS, reporter.state.Steps[0].Result)
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user