mirror of
https://gitea.com/gitea/runner.git
synced 2026-11-09 16:50:28 +00:00
fix: align Docker actions, job env and host mode with GitHub (#1268)
- Docker actions evaluate `runs.args` and `runs.env` with their own `inputs`, so a same-named `workflow_dispatch` input no longer replaces the step's `with:` value - Dockerfile action images are tagged per step and job and removed afterwards, so changed Dockerfiles rebuild and concurrent jobs never share an image - Workflow and job `env` are evaluated before job and service container credentials and service `env` use them - Docker actions in host jobs get the host workspace and file-command directory mounted, and no longer inherit the runner's own `PATH` - Cache server address detection accepts a single interface address Fixes: https://gitea.com/gitea/runner/issues/390 Fixes: https://gitea.com/gitea/runner/issues/406 Fixes: https://gitea.com/gitea/runner/issues/578 Related: https://gitea.com/gitea/runner/issues/64 Reviewed-on: https://gitea.com/gitea/runner/pulls/1268 Reviewed-by: bircni <[email protected]> Co-authored-by: silverwind <[email protected]>
This commit is contained in:
1 parent
629e062f86
commit
ddee2e054a
15 files changed
+255
-59
No files matched your search
+40
-34
@@ -10,6 +10,11 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type interfaceIP struct {
|
||||||
|
net.IP
|
||||||
|
net.Interface
|
||||||
|
}
|
||||||
|
|
||||||
// GetOutboundIP returns an outbound IP address of this machine.
|
// GetOutboundIP returns an outbound IP address of this machine.
|
||||||
// It tries to access the internet and returns the local IP address of the connection.
|
// It tries to access the internet and returns the local IP address of the connection.
|
||||||
// If the machine cannot access the internet, it returns a preferred IP address from network interfaces.
|
// If the machine cannot access the internet, it returns a preferred IP address from network interfaces.
|
||||||
@@ -26,11 +31,7 @@ func GetOutboundIP() net.IP {
|
|||||||
|
|
||||||
// So the machine cannot access the internet. Pick an IP address from network interfaces.
|
// So the machine cannot access the internet. Pick an IP address from network interfaces.
|
||||||
if ifs, err := net.Interfaces(); err == nil {
|
if ifs, err := net.Interfaces(); err == nil {
|
||||||
type IP struct {
|
var ips []interfaceIP
|
||||||
net.IP
|
|
||||||
net.Interface
|
|
||||||
}
|
|
||||||
var ips []IP
|
|
||||||
for _, i := range ifs {
|
for _, i := range ifs {
|
||||||
if addrs, err := i.Addrs(); err == nil {
|
if addrs, err := i.Addrs(); err == nil {
|
||||||
for _, addr := range addrs {
|
for _, addr := range addrs {
|
||||||
@@ -42,40 +43,45 @@ func GetOutboundIP() net.IP {
|
|||||||
ip = v.IP
|
ip = v.IP
|
||||||
}
|
}
|
||||||
if ip.IsGlobalUnicast() {
|
if ip.IsGlobalUnicast() {
|
||||||
ips = append(ips, IP{ip, i})
|
ips = append(ips, interfaceIP{ip, i})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(ips) > 1 {
|
return preferredIP(ips)
|
||||||
sort.Slice(ips, func(i, j int) bool {
|
|
||||||
ifi := ips[i].Interface
|
|
||||||
ifj := ips[j].Interface
|
|
||||||
|
|
||||||
// ethernet is preferred
|
|
||||||
if vi, vj := strings.HasPrefix(ifi.Name, "e"), strings.HasPrefix(ifj.Name, "e"); vi != vj {
|
|
||||||
return vi
|
|
||||||
}
|
|
||||||
|
|
||||||
ipi := ips[i].IP
|
|
||||||
ipj := ips[j].IP
|
|
||||||
|
|
||||||
// IPv4 is preferred
|
|
||||||
if vi, vj := ipi.To4() != nil, ipj.To4() != nil; vi != vj {
|
|
||||||
return vi
|
|
||||||
}
|
|
||||||
|
|
||||||
// en0 is preferred to en1
|
|
||||||
if ifi.Name != ifj.Name {
|
|
||||||
return ifi.Name < ifj.Name
|
|
||||||
}
|
|
||||||
|
|
||||||
// fallback
|
|
||||||
return ipi.String() < ipj.String()
|
|
||||||
})
|
|
||||||
return ips[0].IP
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func preferredIP(ips []interfaceIP) net.IP {
|
||||||
|
if len(ips) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
sort.Slice(ips, func(i, j int) bool {
|
||||||
|
ifi := ips[i].Interface
|
||||||
|
ifj := ips[j].Interface
|
||||||
|
|
||||||
|
// ethernet is preferred
|
||||||
|
if vi, vj := strings.HasPrefix(ifi.Name, "e"), strings.HasPrefix(ifj.Name, "e"); vi != vj {
|
||||||
|
return vi
|
||||||
|
}
|
||||||
|
|
||||||
|
ipi := ips[i].IP
|
||||||
|
ipj := ips[j].IP
|
||||||
|
|
||||||
|
// IPv4 is preferred
|
||||||
|
if vi, vj := ipi.To4() != nil, ipj.To4() != nil; vi != vj {
|
||||||
|
return vi
|
||||||
|
}
|
||||||
|
|
||||||
|
// en0 is preferred to en1
|
||||||
|
if ifi.Name != ifj.Name {
|
||||||
|
return ifi.Name < ifj.Name
|
||||||
|
}
|
||||||
|
|
||||||
|
// fallback
|
||||||
|
return ipi.String() < ipj.String()
|
||||||
|
})
|
||||||
|
return ips[0].IP
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package common
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPreferredIP(t *testing.T) {
|
||||||
|
assert.Nil(t, preferredIP(nil))
|
||||||
|
assert.Equal(t, net.ParseIP("2001:db8::1"), preferredIP([]interfaceIP{{IP: net.ParseIP("2001:db8::1")}}))
|
||||||
|
assert.Equal(t, net.ParseIP("2001:db8::1"), preferredIP([]interfaceIP{
|
||||||
|
{IP: net.ParseIP("192.0.2.1"), Name: "wlan0"},
|
||||||
|
{IP: net.ParseIP("2001:db8::1"), Name: "eth0"},
|
||||||
|
}))
|
||||||
|
}
|
||||||
@@ -46,14 +46,13 @@ func RemoveImage(ctx context.Context, imageName string, force, pruneChildren boo
|
|||||||
}
|
}
|
||||||
defer cli.Close()
|
defer cli.Close()
|
||||||
|
|
||||||
inspectImage, err := cli.ImageInspect(ctx, imageName)
|
if _, err := cli.ImageInspect(ctx, imageName); cerrdefs.IsNotFound(err) {
|
||||||
if cerrdefs.IsNotFound(err) {
|
|
||||||
return false, nil
|
return false, nil
|
||||||
} else if err != nil {
|
} else if err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err = cli.ImageRemove(ctx, inspectImage.ID, client.ImageRemoveOptions{
|
if _, err = cli.ImageRemove(ctx, imageName, client.ImageRemoveOptions{
|
||||||
Force: force,
|
Force: force,
|
||||||
PruneChildren: pruneChildren,
|
PruneChildren: pruneChildren,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
|
|||||||
+21
-1
@@ -6,6 +6,7 @@ package runner
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"embed"
|
"embed"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
@@ -287,6 +288,22 @@ func dockerActionImageTag(repository, actionName string, localAction bool) strin
|
|||||||
return strings.ToLower(image)
|
return strings.ToLower(image)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// One image per step and job: its stages share a build, and concurrent jobs never run each other's.
|
||||||
|
func (rc *RunContext) dockerActionImage(ctx context.Context, step actionStep, actionName string, localAction bool) string {
|
||||||
|
job := rc.topLevelRunContext()
|
||||||
|
if job.dockerActionImages == nil {
|
||||||
|
job.dockerActionImages = map[*model.Step]string{}
|
||||||
|
}
|
||||||
|
image, ok := job.dockerActionImages[step.getStepModel()]
|
||||||
|
if !ok {
|
||||||
|
image = dockerActionImageTag(step.getGithubContext(ctx).Repository, actionName, localAction) + "-" + strings.ToLower(rand.Text())
|
||||||
|
if !common.Dryrun(ctx) { // a dry run builds nothing to remove
|
||||||
|
job.dockerActionImages[step.getStepModel()] = image
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return image
|
||||||
|
}
|
||||||
|
|
||||||
// TODO: break out parts of function to reduce complexicity
|
// TODO: break out parts of function to reduce complexicity
|
||||||
func execAsDocker(ctx context.Context, step actionStep, actionName, actionDir, basedir string, localAction bool, stage stepStage) error {
|
func execAsDocker(ctx context.Context, step actionStep, actionName, actionDir, basedir string, localAction bool, stage stepStage) error {
|
||||||
logger := common.Logger(ctx)
|
logger := common.Logger(ctx)
|
||||||
@@ -304,7 +321,7 @@ func execAsDocker(ctx context.Context, step actionStep, actionName, actionDir, b
|
|||||||
// Apply forcePull only for prebuild docker images
|
// Apply forcePull only for prebuild docker images
|
||||||
forcePull = rc.Config.ForcePull
|
forcePull = rc.Config.ForcePull
|
||||||
} else {
|
} else {
|
||||||
image = dockerActionImageTag(step.getGithubContext(ctx).Repository, actionName, localAction)
|
image = rc.dockerActionImage(ctx, step, actionName, localAction)
|
||||||
contextDir, fileName := filepath.Split(filepath.Join(basedir, action.Runs.Image))
|
contextDir, fileName := filepath.Split(filepath.Join(basedir, action.Runs.Image))
|
||||||
|
|
||||||
anyArchExists, err := ContainerImageExistsLocally(ctx, image, "any")
|
anyArchExists, err := ContainerImageExistsLocally(ctx, image, "any")
|
||||||
@@ -445,6 +462,9 @@ func newStepContainer(ctx context.Context, step step, image string, cmd, entrypo
|
|||||||
logWriter := rc.commandLogWriter(ctx)
|
logWriter := rc.commandLogWriter(ctx)
|
||||||
envList := make([]string, 0)
|
envList := make([]string, 0)
|
||||||
for k, v := range *step.getEnv() {
|
for k, v := range *step.getEnv() {
|
||||||
|
if rc.IsHostEnv() && strings.EqualFold(k, rc.JobContainer.GetPathVariableName()) && v == rc.JobContainer.DefaultPathVariable() { // the runner's own PATH stays out, as on GitHub
|
||||||
|
continue
|
||||||
|
}
|
||||||
envList = append(envList, fmt.Sprintf("%s=%s", k, v))
|
envList = append(envList, fmt.Sprintf("%s=%s", k, v))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ func evaluateCompositeInputAndEnv(ctx context.Context, parent *RunContext, step
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ee := parent.NewActionInputsExpressionEvaluator(ctx, step)
|
ee := parent.NewStepExpressionEvaluator(ctx, step)
|
||||||
|
|
||||||
for inputID, input := range step.getActionModel().Inputs {
|
for inputID, input := range step.getActionModel().Inputs {
|
||||||
envKey := regexp.MustCompile("[^A-Z0-9-]").ReplaceAllString(strings.ToUpper(inputID), "_")
|
envKey := regexp.MustCompile("[^A-Z0-9-]").ReplaceAllString(strings.ToUpper(inputID), "_")
|
||||||
|
|||||||
@@ -513,6 +513,17 @@ func TestDockerActionImageTag(t *testing.T) {
|
|||||||
dockerActionImageTag("owner/repo", "./", true),
|
dockerActionImageTag("owner/repo", "./", true),
|
||||||
dockerActionImageTag("owner/repo", "./sub", true),
|
dockerActionImageTag("owner/repo", "./sub", true),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
workflow := &model.Workflow{Jobs: map[string]*model.Job{"job1": {}}}
|
||||||
|
job := newTestRC(workflow, nil)
|
||||||
|
step := &stepActionRemote{Step: &model.Step{}, RunContext: job}
|
||||||
|
image := job.dockerActionImage(t.Context(), step, "abc123", false)
|
||||||
|
assert.True(t, strings.HasPrefix(image, "act-abc123-dockeraction:latest-"))
|
||||||
|
assert.Equal(t, image, (&RunContext{Parent: job}).dockerActionImage(t.Context(), step, "abc123", false))
|
||||||
|
assert.NotEqual(t, image, job.dockerActionImage(t.Context(), &stepActionRemote{Step: &model.Step{}, RunContext: job}, "abc123", false))
|
||||||
|
assert.NotEqual(t, image, newTestRC(workflow, nil).dockerActionImage(t.Context(), step, "abc123", false))
|
||||||
|
job.dockerActionImage(common.WithDryrun(t.Context(), true), &stepActionRemote{Step: &model.Step{}, RunContext: job}, "abc123", false)
|
||||||
|
assert.Len(t, job.dockerActionImages, 2)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestExecAsDockerStageEntrypoint(t *testing.T) {
|
func TestExecAsDockerStageEntrypoint(t *testing.T) {
|
||||||
|
|||||||
@@ -246,18 +246,19 @@ func inputsFromEnv(env map[string]string) map[string]any {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func getEvaluatorInputs(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 {
|
||||||
|
if stepInputs != nil { // an action's own inputs, which workflow inputs must not shadow
|
||||||
|
return maps.Clone(stepInputs)
|
||||||
|
}
|
||||||
inputs := map[string]any{}
|
inputs := map[string]any{}
|
||||||
|
|
||||||
// only a called workflow's job itself takes the inputs Gitea resolved,
|
// only a called workflow's job itself takes the inputs Gitea resolved,
|
||||||
// composite actions copy its Config but have their own `inputs`
|
// composite actions copy its Config but have their own `inputs`
|
||||||
if rc.Config.WorkflowCallInputs != nil && rc.Parent == nil {
|
if rc.Config.WorkflowCallInputs != nil && rc.Parent == nil {
|
||||||
maps.Copy(inputs, rc.Config.WorkflowCallInputs)
|
maps.Copy(inputs, rc.Config.WorkflowCallInputs)
|
||||||
maps.Copy(inputs, stepInputs)
|
|
||||||
return inputs
|
return inputs
|
||||||
}
|
}
|
||||||
|
|
||||||
maps.Copy(inputs, rc.workflowCallInputs)
|
maps.Copy(inputs, rc.workflowCallInputs)
|
||||||
maps.Copy(inputs, stepInputs)
|
|
||||||
|
|
||||||
switch ghc.EventName {
|
switch ghc.EventName {
|
||||||
case "workflow_dispatch":
|
case "workflow_dispatch":
|
||||||
|
|||||||
@@ -414,6 +414,31 @@ on:
|
|||||||
assert.Equal(t, map[string]any{"arg": "x"}, getEvaluatorInputs(composite, map[string]any{"arg": "x"}, ghc))
|
assert.Equal(t, map[string]any{"arg": "x"}, getEvaluatorInputs(composite, map[string]any{"arg": "x"}, ghc))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestActionInputsShadowDispatchInputs(t *testing.T) {
|
||||||
|
wf, err := model.ReadWorkflow(strings.NewReader(`
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
message: {type: string}
|
||||||
|
jobs:
|
||||||
|
job1: {}
|
||||||
|
`))
|
||||||
|
require.NoError(t, err)
|
||||||
|
rc := newTestRC(wf, nil)
|
||||||
|
rc.Config.EventName = "workflow_dispatch"
|
||||||
|
rc.EventJSON = `{"inputs":{"message":"workflow"}}`
|
||||||
|
step := &stepActionRemote{RunContext: rc, env: map[string]string{"INPUT_MESSAGE": "step"}}
|
||||||
|
assert.Equal(t, "step", rc.NewActionInputsExpressionEvaluator(t.Context(), step).InterpolateName(t.Context(), "${{ inputs.message }}"))
|
||||||
|
assert.Equal(t, "workflow", rc.NewStepExpressionEvaluator(t.Context(), step).InterpolateName(t.Context(), "${{ inputs.message }}"))
|
||||||
|
enabled, err := isStepEnabled(t.Context(), "inputs.message == 'workflow'", step, stepStageMain)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, enabled)
|
||||||
|
step.Step, step.action = &model.Step{}, &model.Action{Inputs: map[string]model.Input{"fallback": {Default: "${{ inputs.message }}"}}}
|
||||||
|
env, err := evaluateCompositeInputAndEnv(t.Context(), rc, step)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "workflow", env["INPUT_FALLBACK"])
|
||||||
|
}
|
||||||
|
|
||||||
func TestResolveWorkflowCallToleratesMismatchesAndPassesInheritedSecretsVerbatim(t *testing.T) {
|
func TestResolveWorkflowCallToleratesMismatchesAndPassesInheritedSecretsVerbatim(t *testing.T) {
|
||||||
callerWorkflow, err := model.ReadWorkflow(strings.NewReader(`
|
callerWorkflow, err := model.ReadWorkflow(strings.NewReader(`
|
||||||
jobs:
|
jobs:
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ package runner
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"runtime"
|
"runtime"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -40,6 +41,16 @@ func requireDocker(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func requireDockerSharingFiles(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
requireDocker(t)
|
||||||
|
for _, marker := range []string{"/.dockerenv", "/run/.containerenv"} {
|
||||||
|
if _, err := os.Stat(marker); err == nil {
|
||||||
|
t.Skip("skipping: inside a container, the docker daemon cannot bind-mount this process's files")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// requireHostTools skips the test unless every named executable is on PATH. Used by the
|
// requireHostTools skips the test unless every named executable is on PATH. Used by the
|
||||||
// self-hosted (host environment) suite, which runs steps directly on the host.
|
// self-hosted (host environment) suite, which runs steps directly on the host.
|
||||||
func requireHostTools(t *testing.T, tools ...string) {
|
func requireHostTools(t *testing.T, tools ...string) {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"maps"
|
||||||
"net/http"
|
"net/http"
|
||||||
"path"
|
"path"
|
||||||
"slices"
|
"slices"
|
||||||
@@ -258,6 +259,7 @@ 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)
|
||||||
|
rc.removeDockerActionImages(ctx)
|
||||||
|
|
||||||
return err
|
return err
|
||||||
})
|
})
|
||||||
@@ -410,26 +412,38 @@ func setJobResult(ctx context.Context, info jobInfo, rc *RunContext, success boo
|
|||||||
logger.WithField("jobResult", jobResult).Infof("Job %s", jobResultMessage)
|
logger.WithField("jobResult", jobResult).Infof("Job %s", jobResultMessage)
|
||||||
}
|
}
|
||||||
|
|
||||||
// evaluateJobEnvAndDefaults resolves the job's env, defaults.run and container env once, as GitHub does at job setup.
|
// evaluateJobEnv returns the workflow and job env evaluated, as far as it got on error, leaving rc.Env raw.
|
||||||
func evaluateJobEnvAndDefaults(ctx context.Context, rc *RunContext) error {
|
func evaluateJobEnv(ctx context.Context, rc *RunContext) (map[string]string, error) {
|
||||||
rc.ExprEval = rc.NewExpressionEvaluator(ctx)
|
|
||||||
env := rc.GetEnv()
|
env := rc.GetEnv()
|
||||||
|
evaluated := maps.Clone(env)
|
||||||
|
evaluator := rc.NewExpressionEvaluatorWithEnv(ctx, evaluated)
|
||||||
var workflowEnv map[string]string
|
var workflowEnv map[string]string
|
||||||
if rc.Run.Workflow.Env == nil {
|
if rc.Run.Workflow.Env == nil {
|
||||||
if err := model.DecodeEvaluated("workflow env", rc.Run.Workflow.RawEnv, rc.ExprEval.shared(ctx).EvaluateYamlNode, &workflowEnv); err != nil {
|
if err := model.DecodeEvaluated("workflow env", rc.Run.Workflow.RawEnv, evaluator.shared(ctx).EvaluateYamlNode, &workflowEnv); err != nil {
|
||||||
return err
|
return evaluated, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if workflowEnv != nil {
|
if workflowEnv != nil {
|
||||||
rc.Env = mergeMaps(workflowEnv, env)
|
evaluated = mergeMaps(workflowEnv, env)
|
||||||
rc.ExprEval = rc.NewExpressionEvaluator(ctx)
|
evaluator = rc.NewExpressionEvaluatorWithEnv(ctx, evaluated)
|
||||||
}
|
}
|
||||||
var err error
|
var err error
|
||||||
for k, v := range env {
|
for k, v := range env {
|
||||||
if rc.Env[k], err = rc.ExprEval.Interpolate(ctx, v); err != nil {
|
if evaluated[k], err = evaluator.Interpolate(ctx, v); err != nil {
|
||||||
return fmt.Errorf("unable to interpolate env %s: %w", k, err)
|
return evaluated, fmt.Errorf("unable to interpolate env %s: %w", k, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return evaluated, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// evaluateJobEnvAndDefaults resolves the job's env, defaults.run and container env once, as GitHub does at job setup.
|
||||||
|
func evaluateJobEnvAndDefaults(ctx context.Context, rc *RunContext) error {
|
||||||
|
env, err := evaluateJobEnv(ctx, rc)
|
||||||
|
rc.Env = env
|
||||||
|
rc.ExprEval = rc.NewExpressionEvaluator(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
var defaults model.Defaults
|
var defaults model.Defaults
|
||||||
if err := model.DecodeEvaluated("defaults", rc.Run.Job().RawDefaults, rc.ExprEval.shared(ctx).EvaluateYamlNode, &defaults); err != nil {
|
if err := model.DecodeEvaluated("defaults", rc.Run.Job().RawDefaults, rc.ExprEval.shared(ctx).EvaluateYamlNode, &defaults); err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -82,6 +82,10 @@ type RunContext struct {
|
|||||||
jobRunDefaults model.RunDefaults // defaults.run, resolved once at job setup as GitHub does
|
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
|
platformImage string // container.image or the runs-on pick, resolved once by isEnabled
|
||||||
jobIndex, jobTotal int // strategy.job-index and job-total, total 0 when unknown
|
jobIndex, jobTotal int // strategy.job-index and job-total, total 0 when unknown
|
||||||
|
|
||||||
|
jobEnv map[string]string // workflow and job env as evaluated for the container and service specs
|
||||||
|
dockerActionImages map[*model.Step]string // Dockerfile action images built for this job, removed when it ends
|
||||||
|
|
||||||
// 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
|
||||||
@@ -256,6 +260,9 @@ const sharedToolCacheVolume = "act-toolcache" // mounted only when the tool cach
|
|||||||
func (rc *RunContext) validVolumes() []string {
|
func (rc *RunContext) validVolumes() []string {
|
||||||
name := rc.jobContainerName()
|
name := rc.jobContainerName()
|
||||||
volumes := slices.Clone(rc.Config.ValidVolumes)
|
volumes := slices.Clone(rc.Config.ValidVolumes)
|
||||||
|
if hostEnv, ok := rc.JobContainer.(*container.HostEnvironment); ok {
|
||||||
|
volumes = append(volumes, hostEnv.Path, hostEnv.GetActPath())
|
||||||
|
}
|
||||||
if rc.Config.SharedToolCache {
|
if rc.Config.SharedToolCache {
|
||||||
volumes = append(volumes, sharedToolCacheVolume)
|
volumes = append(volumes, sharedToolCacheVolume)
|
||||||
}
|
}
|
||||||
@@ -354,6 +361,9 @@ func (rc *RunContext) GetBindsAndMounts(ctx context.Context) ([]string, map[stri
|
|||||||
mounts[sharedToolCacheVolume] = toolCache
|
mounts[sharedToolCacheVolume] = toolCache
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if hostEnv, ok := rc.JobContainer.(*container.HostEnvironment); ok {
|
||||||
|
binds = append(binds, hostEnv.Path+":"+hostEnv.Path, hostEnv.GetActPath()+":"+hostEnv.GetActPath())
|
||||||
|
}
|
||||||
mounts[name+"-env"] = ext.GetActPath() // runner-internal, never overridable
|
mounts[name+"-env"] = ext.GetActPath() // runner-internal, never overridable
|
||||||
|
|
||||||
if workdir := ext.ToContainerPath(rc.Config.Workdir); !claimed[workdir] {
|
if workdir := ext.ToContainerPath(rc.Config.Workdir); !claimed[workdir] {
|
||||||
@@ -535,6 +545,7 @@ func (rc *RunContext) startJobContainer() common.Executor {
|
|||||||
// For gitea, to support --volumes-from <container_name_or_id> in options.
|
// For gitea, to support --volumes-from <container_name_or_id> in options.
|
||||||
// We need to set the container name to the environment variable.
|
// We need to set the container name to the environment variable.
|
||||||
rc.Env["JOB_CONTAINER_NAME"] = name
|
rc.Env["JOB_CONTAINER_NAME"] = name
|
||||||
|
delete(rc.jobEnv, "JOB_CONTAINER_NAME") // the runtime name wins over a workflow env of the same name
|
||||||
|
|
||||||
envList := make([]string, 0)
|
envList := make([]string, 0)
|
||||||
|
|
||||||
@@ -618,7 +629,7 @@ func (rc *RunContext) startJobContainer() common.Executor {
|
|||||||
|
|
||||||
func (rc *RunContext) jobServices(ctx context.Context, networkName string) (map[string]*container.NewContainerInput, error) {
|
func (rc *RunContext) jobServices(ctx context.Context, networkName string) (map[string]*container.NewContainerInput, error) {
|
||||||
var services map[string]*model.ContainerSpec
|
var services map[string]*model.ContainerSpec
|
||||||
if err := model.DecodeEvaluated("job services", rc.Run.Job().RawServices, rc.ExprEval.shared(ctx).EvaluateYamlNode, &services); err != nil {
|
if err := model.DecodeEvaluated("job services", rc.Run.Job().RawServices, rc.NewExpressionEvaluatorWithEnv(ctx, mergeMaps(rc.GetEnv(), rc.jobEnv)).shared(ctx).EvaluateYamlNode, &services); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
inputs := map[string]*container.NewContainerInput{}
|
inputs := map[string]*container.NewContainerInput{}
|
||||||
@@ -836,6 +847,14 @@ func (rc *RunContext) stopJobContainer() common.Executor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (rc *RunContext) removeDockerActionImages(ctx context.Context) {
|
||||||
|
for _, image := range rc.dockerActionImages {
|
||||||
|
if _, err := container.RemoveImage(ctx, image, true, false); err != nil {
|
||||||
|
common.Logger(ctx).Warnf("failed to remove action image %s: %v", image, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (rc *RunContext) pullServicesImages(forcePull bool) common.Executor {
|
func (rc *RunContext) pullServicesImages(forcePull bool) common.Executor {
|
||||||
return func(ctx context.Context) error {
|
return func(ctx context.Context) error {
|
||||||
execs := []common.Executor{}
|
execs := []common.Executor{}
|
||||||
@@ -1255,9 +1274,10 @@ func (rc *RunContext) runsOnPlatformNames(ctx context.Context) []string {
|
|||||||
|
|
||||||
// resolvePlatformImage evaluates the job's container once for every consumer, skipping one whose image is empty as GitHub does.
|
// resolvePlatformImage evaluates the job's container once for every consumer, skipping one whose image is empty as GitHub does.
|
||||||
func (rc *RunContext) resolvePlatformImage(ctx context.Context) error {
|
func (rc *RunContext) resolvePlatformImage(ctx context.Context) error {
|
||||||
|
rc.jobEnv, _ = evaluateJobEnv(ctx, rc) // a broken env fails the job at setup instead
|
||||||
withoutEnv, env := splitContainerEnv(rc.Run.Job().RawContainer)
|
withoutEnv, env := splitContainerEnv(rc.Run.Job().RawContainer)
|
||||||
var spec model.ContainerSpec
|
var spec model.ContainerSpec
|
||||||
if err := model.DecodeEvaluated("container", withoutEnv, rc.ExprEval.shared(ctx).EvaluateYamlNode, &spec); err != nil {
|
if err := model.DecodeEvaluated("container", withoutEnv, rc.NewExpressionEvaluatorWithEnv(ctx, rc.jobEnv).shared(ctx).EvaluateYamlNode, &spec); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
spec.Image = strings.TrimPrefix(spec.Image, "docker://")
|
spec.Image = strings.TrimPrefix(spec.Image, "docker://")
|
||||||
|
|||||||
@@ -251,7 +251,6 @@ func startJobContainerInputs(t *testing.T, workflowYAML string, cfg *Config) []*
|
|||||||
rc := &RunContext{
|
rc := &RunContext{
|
||||||
Name: "test",
|
Name: "test",
|
||||||
Config: cfg,
|
Config: cfg,
|
||||||
Env: map[string]string{},
|
|
||||||
Run: &model.Run{
|
Run: &model.Run{
|
||||||
JobID: "job",
|
JobID: "job",
|
||||||
Workflow: workflow,
|
Workflow: workflow,
|
||||||
@@ -362,6 +361,29 @@ jobs:
|
|||||||
require.Equal(t, "sleep", job.Entrypoint[0])
|
require.Equal(t, "sleep", job.Entrypoint[0])
|
||||||
require.Equal(t, "--label ${{ github.job }}", job.WorkflowOptions)
|
require.Equal(t, "--label ${{ github.job }}", job.WorkflowOptions)
|
||||||
require.Equal(t, "${{ vars.TITLE }}", job.Password)
|
require.Equal(t, "${{ vars.TITLE }}", job.Password)
|
||||||
|
|
||||||
|
inputs = startJobContainerInputs(t, `
|
||||||
|
env:
|
||||||
|
REG_USER: ${{ vars.REG_USER }}
|
||||||
|
TEXT: ${{ vars.TEXT }}
|
||||||
|
JOB_CONTAINER_NAME: from-workflow
|
||||||
|
jobs:
|
||||||
|
job:
|
||||||
|
container:
|
||||||
|
image: node:20
|
||||||
|
credentials: {username: "${{ env.REG_USER }}", password: pass}
|
||||||
|
services:
|
||||||
|
redis:
|
||||||
|
image: redis:latest
|
||||||
|
credentials: {username: "${{ env.REG_USER }}", password: pass}
|
||||||
|
env: {TEXT: "${{ env.TEXT }}"}
|
||||||
|
options: --volumes-from ${{ env.JOB_CONTAINER_NAME }}
|
||||||
|
`, &Config{Vars: map[string]string{"REG_USER": "user", "TEXT": "${{ 'literal' }}"}})
|
||||||
|
redis, job = inputs[0], inputs[1]
|
||||||
|
require.Equal(t, "user", job.Username)
|
||||||
|
require.Equal(t, "user", redis.Username)
|
||||||
|
require.Equal(t, []string{"TEXT=${{ 'literal' }}"}, redis.Env)
|
||||||
|
require.Equal(t, "--volumes-from "+job.Name, redis.WorkflowOptions)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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
|
||||||
@@ -662,6 +684,15 @@ func TestRunContextValidVolumes(t *testing.T) {
|
|||||||
assert.Contains(t, rc.validVolumes(), rc.Config.Workdir)
|
assert.Contains(t, rc.validVolumes(), rc.Config.Workdir)
|
||||||
rc.Config.PresetGitHubContext = &model.GithubContext{}
|
rc.Config.PresetGitHubContext = &model.GithubContext{}
|
||||||
assert.Contains(t, rc.validVolumes(), filepath.FromSlash("/workspace/1"))
|
assert.Contains(t, rc.validVolumes(), filepath.FromSlash("/workspace/1"))
|
||||||
|
|
||||||
|
hostEnv := &container.HostEnvironment{Path: t.TempDir(), ActPath: t.TempDir()}
|
||||||
|
rc.JobContainer = hostEnv
|
||||||
|
rc.Config.BindWorkdir = false
|
||||||
|
rc.Config.SharedToolCache = true
|
||||||
|
binds, mounts := rc.GetBindsAndMounts(t.Context())
|
||||||
|
assert.Subset(t, binds, []string{hostEnv.Path + ":" + hostEnv.Path, hostEnv.GetActPath() + ":" + hostEnv.GetActPath()})
|
||||||
|
assert.Contains(t, mounts, sharedToolCacheVolume)
|
||||||
|
assert.Equal(t, binds, container.SanitizeBinds(t.Context(), rc.validVolumes(), binds))
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCleanupJobResourcesCleansServicesWithoutJobContainer(t *testing.T) {
|
func TestCleanupJobResourcesCleansServicesWithoutJobContainer(t *testing.T) {
|
||||||
@@ -730,6 +761,20 @@ func TestCleanupJobVolumesReapsAbandonedDeferredCleanup(t *testing.T) {
|
|||||||
assert.ElementsMatch(t, []string{"became-active", "remove-failed", "foreign", "fresh", "unknown-age"}, slices.Collect(maps.Keys(volumes)))
|
assert.ElementsMatch(t, []string{"became-active", "remove-failed", "foreign", "fresh", "unknown-age"}, slices.Collect(maps.Keys(volumes)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRemoveDockerActionImagesByTag(t *testing.T) {
|
||||||
|
var removed []string
|
||||||
|
fakeDockerDaemon(t, func(writer http.ResponseWriter, request *http.Request) {
|
||||||
|
if request.Method == http.MethodDelete {
|
||||||
|
removed = append(removed, strings.TrimPrefix(request.URL.Path, "/v1.47/images/"))
|
||||||
|
_, _ = io.WriteString(writer, "[]")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = io.WriteString(writer, `{"Id":"sha256:shared"}`)
|
||||||
|
})
|
||||||
|
(&RunContext{dockerActionImages: map[*model.Step]string{{}: "act-action-dockeraction:latest-job"}}).removeDockerActionImages(t.Context())
|
||||||
|
assert.Equal(t, []string{"act-action-dockeraction:latest-job"}, removed)
|
||||||
|
}
|
||||||
|
|
||||||
func TestCleanupJobResourcesContinuesAfterFailure(t *testing.T) {
|
func TestCleanupJobResourcesContinuesAfterFailure(t *testing.T) {
|
||||||
t.Setenv("TMPDIR", "/tmp")
|
t.Setenv("TMPDIR", "/tmp")
|
||||||
proxyDir := t.TempDir()
|
proxyDir := t.TempDir()
|
||||||
@@ -960,7 +1005,6 @@ func TestGetGithubContextRef(t *testing.T) {
|
|||||||
func createIfTestRunContext(jobs map[string]*model.Job) *RunContext {
|
func createIfTestRunContext(jobs map[string]*model.Job) *RunContext {
|
||||||
rc := &RunContext{
|
rc := &RunContext{
|
||||||
Config: &Config{Workdir: ".", PlatformPicker: func([]string) string { return "ubuntu-latest" }},
|
Config: &Config{Workdir: ".", PlatformPicker: func([]string) string { return "ubuntu-latest" }},
|
||||||
Env: map[string]string{},
|
|
||||||
Run: &model.Run{
|
Run: &model.Run{
|
||||||
JobID: "job1",
|
JobID: "job1",
|
||||||
Workflow: &model.Workflow{
|
Workflow: &model.Workflow{
|
||||||
@@ -1037,9 +1081,12 @@ func TestRunContextIsEnabled(t *testing.T) {
|
|||||||
// success()
|
// success()
|
||||||
rc := createIfTestRunContext(map[string]*model.Job{
|
rc := createIfTestRunContext(map[string]*model.Job{
|
||||||
"job1": createJob(t, `runs-on: ubuntu-latest
|
"job1": createJob(t, `runs-on: ubuntu-latest
|
||||||
|
env:
|
||||||
|
BROKEN: ${{ fromJSON('invalid') }}
|
||||||
if: success()`, ""),
|
if: success()`, ""),
|
||||||
})
|
})
|
||||||
assertObject.True(rc.isEnabled(context.Background()))
|
assertObject.True(rc.isEnabled(context.Background()))
|
||||||
|
require.Error(t, evaluateJobEnvAndDefaults(context.Background(), rc))
|
||||||
|
|
||||||
rc = createIfTestRunContext(map[string]*model.Job{
|
rc = createIfTestRunContext(map[string]*model.Job{
|
||||||
"job1": createJob(t, `runs-on: ubuntu-latest`, "failure"),
|
"job1": createJob(t, `runs-on: ubuntu-latest`, "failure"),
|
||||||
|
|||||||
@@ -221,7 +221,6 @@ func (j *TestJobFileInfo) runTest(ctx context.Context, t *testing.T, cfg *Config
|
|||||||
ContainerNamePrefix: strings.ReplaceAll(t.Name(), "/", "-") + "-" + testRunID,
|
ContainerNamePrefix: strings.ReplaceAll(t.Name(), "/", "-") + "-" + testRunID,
|
||||||
// 0 would run jobs runtime.NumCPU()-wide, making the network peak machine-dependent
|
// 0 would run jobs runtime.NumCPU()-wide, making the network peak machine-dependent
|
||||||
MaxParallel: 2,
|
MaxParallel: 2,
|
||||||
ForceRebuild: true,
|
|
||||||
Env: cfg.Env,
|
Env: cfg.Env,
|
||||||
Secrets: cfg.Secrets,
|
Secrets: cfg.Secrets,
|
||||||
GitHubInstance: "github.com",
|
GitHubInstance: "github.com",
|
||||||
@@ -434,6 +433,8 @@ func TestRunEventHostEnvironment(t *testing.T) {
|
|||||||
|
|
||||||
tables = append(tables, []TestJobFileInfo{
|
tables = append(tables, []TestJobFileInfo{
|
||||||
{workdir, "nix-prepend-path", "push", "", platforms, secrets},
|
{workdir, "nix-prepend-path", "push", "", platforms, secrets},
|
||||||
|
{workdir, "uses-docker-url", "push", "", platforms, secrets},
|
||||||
|
{workdir, "local-action-dockerfile", "push", "", platforms, secrets},
|
||||||
{workdir, "builtin-checkout", "push", "", platforms, secrets},
|
{workdir, "builtin-checkout", "push", "", platforms, secrets},
|
||||||
{workdir, "inputs-via-env-context", "push", "", platforms, secrets},
|
{workdir, "inputs-via-env-context", "push", "", platforms, secrets},
|
||||||
{workdir, "do-not-leak-step-env-in-composite", "push", "", platforms, secrets},
|
{workdir, "do-not-leak-step-env-in-composite", "push", "", platforms, secrets},
|
||||||
@@ -451,6 +452,8 @@ func TestRunEventHostEnvironment(t *testing.T) {
|
|||||||
requireHostTools(t, "pwsh")
|
requireHostTools(t, "pwsh")
|
||||||
case "nix-prepend-path":
|
case "nix-prepend-path":
|
||||||
requireHostTools(t, "nix")
|
requireHostTools(t, "nix")
|
||||||
|
case "uses-docker-url", "local-action-dockerfile":
|
||||||
|
requireDockerSharingFiles(t)
|
||||||
}
|
}
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
hostPlanSlots <- struct{}{}
|
hostPlanSlots <- struct{}{}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"io"
|
"io"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -151,22 +152,35 @@ func TestStepDockerNewStepContainerAllocatePTY(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestStepDockerNewStepContainerNetworkMode(t *testing.T) {
|
func TestStepDockerNewStepContainerNetworkModeAndPath(t *testing.T) {
|
||||||
|
inheritedPath := (&container.LinuxContainerEnvironmentExtensions{}).DefaultPathVariable()
|
||||||
cases := []struct {
|
cases := []struct {
|
||||||
name string
|
name string
|
||||||
platform string
|
platform string
|
||||||
|
path string
|
||||||
expectDefault bool
|
expectDefault bool
|
||||||
|
keepsPath bool
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "docker mode attaches to job container network",
|
name: "docker mode attaches to job container network",
|
||||||
platform: "node:14",
|
platform: "node:14",
|
||||||
|
path: inheritedPath,
|
||||||
expectDefault: false,
|
expectDefault: false,
|
||||||
|
keepsPath: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "host mode uses default network",
|
name: "host mode uses default network and the image's PATH",
|
||||||
platform: "-self-hosted",
|
platform: "-self-hosted",
|
||||||
|
path: inheritedPath,
|
||||||
expectDefault: true,
|
expectDefault: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "host mode keeps a PATH that is not the runner's",
|
||||||
|
platform: "-self-hosted",
|
||||||
|
path: "/custom/bin",
|
||||||
|
expectDefault: true,
|
||||||
|
keepsPath: true,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tc := range cases {
|
for _, tc := range cases {
|
||||||
@@ -208,6 +222,7 @@ func TestStepDockerNewStepContainerNetworkMode(t *testing.T) {
|
|||||||
ID: "1",
|
ID: "1",
|
||||||
Uses: "docker://alpine:3.20",
|
Uses: "docker://alpine:3.20",
|
||||||
},
|
},
|
||||||
|
env: map[string]string{"PATH": tc.path},
|
||||||
}
|
}
|
||||||
sd.RunContext.ExprEval = sd.RunContext.NewExpressionEvaluator(ctx)
|
sd.RunContext.ExprEval = sd.RunContext.NewExpressionEvaluator(ctx)
|
||||||
|
|
||||||
@@ -215,6 +230,7 @@ func TestStepDockerNewStepContainerNetworkMode(t *testing.T) {
|
|||||||
assert.Equal(t, tc.expectDefault, sd.RunContext.IsHostEnv(), "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, "")
|
||||||
|
assert.Equal(t, tc.keepsPath, slices.Contains(captured.Env, "PATH="+tc.path))
|
||||||
|
|
||||||
if tc.expectDefault {
|
if tc.expectDefault {
|
||||||
assert.Equal(t, "default", captured.NetworkMode,
|
assert.Equal(t, "default", captured.NetworkMode,
|
||||||
|
|||||||
+5
-2
@@ -5,6 +5,7 @@ jobs:
|
|||||||
test:
|
test:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
|
- run: printf 'workspace=from-host-step\n' > workspace-marker
|
||||||
- uses: docker://node:24-bookworm-slim
|
- uses: docker://node:24-bookworm-slim
|
||||||
with:
|
with:
|
||||||
somekey: somevalue
|
somekey: somevalue
|
||||||
@@ -16,8 +17,10 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
entrypoint: /bin/sh
|
entrypoint: /bin/sh
|
||||||
args: -c -- uname -a
|
args: -c -- uname -a
|
||||||
- uses: docker://node:24-bookworm-slim
|
- id: workspace
|
||||||
|
uses: docker://node:24-bookworm-slim
|
||||||
with:
|
with:
|
||||||
entrypoint: /bin/sh
|
entrypoint: /bin/sh
|
||||||
args: -c 'uname -a'
|
args: -c 'uname -a && cat "$GITHUB_WORKSPACE/workspace-marker" >> "$GITHUB_OUTPUT"'
|
||||||
|
- run: test '${{ steps.workspace.outputs.workspace }}' = 'from-host-step'
|
||||||
|
|
||||||
Reference in new issue
Block a user