mirror of
https://gitea.com/gitea/act_runner.git
synced 2026-10-31 12:28:20 +08:00
Without a docker daemon six `act/runner` tests failed instead of passing. A dry run still created and removed job networks, the job teardown dropped the dry-run flag along with every other context value, and the container mocks asked the daemon for the runner arch. This also speeds up the tests and CI without dropping assertions. Most of the time went to git and docker round trips, which are now pinned, batched or coalesced, and the dind and e2e steps cache or overlap more of their setup. On CI, `act/runner` went from 77s to 55s and `test-dind` from 108s to 55s. Locally, `act/runner` without docker went from 53s to 7s. Split out of https://gitea.com/gitea/runner/pulls/1348, which adds Windows CI on top. The go cache changes moved to https://gitea.com/gitea/runner/pulls/1350. Written by Claude Code. --------- Co-authored-by: bircni <bircni@icloud.com> Reviewed-on: https://gitea.com/gitea/runner/pulls/1349 Reviewed-by: bircni <bircni@icloud.com> Co-authored-by: silverwind <me@silverwind.io>
1146 lines
34 KiB
Go
1146 lines
34 KiB
Go
// Copyright 2022 The Gitea Authors. All rights reserved.
|
|
// Copyright 2022 The nektos/act Authors. All rights reserved.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package runner
|
|
|
|
import (
|
|
"archive/tar"
|
|
"bytes"
|
|
"context"
|
|
"encoding/base64"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"path/filepath"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
"sync/atomic"
|
|
"testing"
|
|
"testing/synctest"
|
|
"time"
|
|
|
|
"gitea.com/gitea/runner/act/common"
|
|
"gitea.com/gitea/runner/act/common/git"
|
|
"gitea.com/gitea/runner/act/common/logging"
|
|
"gitea.com/gitea/runner/act/common/logging/logtest"
|
|
"gitea.com/gitea/runner/act/container"
|
|
|
|
"gitea.dev/actionslib/pkg/model"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/mock"
|
|
"github.com/stretchr/testify/require"
|
|
yaml "go.yaml.in/yaml/v4"
|
|
)
|
|
|
|
func TestJobExecutor(t *testing.T) {
|
|
t.Parallel()
|
|
// Dryrun only checks syntax/planning; all cases resolve locally, so this runs offline.
|
|
tables := []TestJobFileInfo{
|
|
{workdir, "uses-and-run-in-one-step", "push", "Invalid run/uses syntax for job:test step:Test", platforms, secrets},
|
|
{workdir, "uses-github-empty", "push", "Expected format {org}/{repo}[/path]@ref", platforms, secrets},
|
|
{workdir, "uses-github-noref", "push", "Expected format {org}/{repo}[/path]@ref", platforms, secrets},
|
|
{workdir, "uses-github-root", "push", "", platforms, secrets},
|
|
{workdir, "uses-docker-url", "push", "", platforms, secrets},
|
|
{workdir, "job-nil-step", "push", "invalid Step 0: missing run or uses key", platforms, secrets},
|
|
}
|
|
// These tests are sufficient to only check syntax.
|
|
ctx := common.WithDryrun(t.Context(), true)
|
|
for _, table := range tables {
|
|
t.Run(table.workflowPath, func(t *testing.T) {
|
|
t.Parallel()
|
|
table.runTest(ctx, t, &Config{})
|
|
})
|
|
}
|
|
}
|
|
|
|
type jobInfoMock struct {
|
|
mock.Mock
|
|
}
|
|
|
|
func (jim *jobInfoMock) matrix() map[string]any {
|
|
args := jim.Called()
|
|
return args.Get(0).(map[string]any)
|
|
}
|
|
|
|
func (jim *jobInfoMock) steps() []*model.Step {
|
|
args := jim.Called()
|
|
|
|
return args.Get(0).([]*model.Step)
|
|
}
|
|
|
|
func (jim *jobInfoMock) startContainer() common.Executor {
|
|
args := jim.Called()
|
|
|
|
return args.Get(0).(func(context.Context) error)
|
|
}
|
|
|
|
func (jim *jobInfoMock) stopContainer() common.Executor {
|
|
args := jim.Called()
|
|
|
|
return args.Get(0).(func(context.Context) error)
|
|
}
|
|
|
|
func (jim *jobInfoMock) closeContainer() common.Executor {
|
|
args := jim.Called()
|
|
|
|
return args.Get(0).(func(context.Context) error)
|
|
}
|
|
|
|
func (jim *jobInfoMock) interpolateOutputs() common.Executor {
|
|
args := jim.Called()
|
|
|
|
return args.Get(0).(func(context.Context) error)
|
|
}
|
|
|
|
func (jim *jobInfoMock) result(result string) {
|
|
jim.Called(result)
|
|
}
|
|
|
|
type jobContainerMock struct {
|
|
container.Container
|
|
linuxEnvironment
|
|
}
|
|
|
|
func (jcm *jobContainerMock) ReplaceLogWriter(_, _ io.Writer) (io.Writer, io.Writer) {
|
|
return nil, nil
|
|
}
|
|
|
|
type stepFactoryMock struct {
|
|
mock.Mock
|
|
}
|
|
|
|
func (sfm *stepFactoryMock) newStep(model *model.Step, rc *RunContext) (step, error) {
|
|
args := sfm.Called(model, rc)
|
|
return args.Get(0).(step), args.Error(1)
|
|
}
|
|
|
|
// actionPreparerMock stands in for a step whose action is downloaded before the job's first step.
|
|
type actionPreparerMock struct {
|
|
reference string
|
|
sha string
|
|
prepared int
|
|
}
|
|
|
|
func (apm *actionPreparerMock) prepareActionExecutor() common.Executor {
|
|
return func(context.Context) error {
|
|
apm.prepared++
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func (apm *actionPreparerMock) actionDownloadInfo() (string, string) {
|
|
return apm.reference, apm.sha
|
|
}
|
|
|
|
func TestPrintPrepareActionsFetchesConcurrently(t *testing.T) {
|
|
synctest.Test(t, func(t *testing.T) {
|
|
buf := &bytes.Buffer{}
|
|
ctx := common.WithLogger(t.Context(), logging.New(logging.NewLevelHandler(newJobLogHandler(buf, cyan), logging.LevelInfo)).With("job", "j1"))
|
|
rc := newPrefetchRunContext(t)
|
|
started, release := make(chan string), make(chan struct{})
|
|
var fetched atomic.Int32
|
|
setFetch(t, func(_ctx context.Context, input git.NewGitCloneExecutorInput) (string, string, error) {
|
|
fetched.Add(1)
|
|
started <- input.Ref
|
|
<-release
|
|
return filepath.Join(input.Dir, "commit"), "sha-" + input.Ref, nil
|
|
})
|
|
preparers := append(newRemotePreparers(rc, "org/repo@v1", "org/repo/sub@v1", "org/repo@v2", "org/repo@v3", "org/repo@v4", "org/repo@v5"), &actionPreparerMock{})
|
|
finished := make(chan error)
|
|
go func() { finished <- printPrepareActions(rc, preparers)(ctx) }()
|
|
for range 4 {
|
|
<-started
|
|
}
|
|
synctest.Wait()
|
|
assert.EqualValues(t, 4, fetched.Load())
|
|
close(release)
|
|
<-started
|
|
require.NoError(t, <-finished)
|
|
assert.EqualValues(t, 5, fetched.Load())
|
|
lines := []string{"[j1] | Prepare all required actions"}
|
|
for _, ref := range []string{"v1", "v2", "v3", "v4", "v5"} {
|
|
lines = append(lines, fmt.Sprintf("[j1] | Download action repository 'org/repo@%s' (SHA:sha-%s)", ref, ref))
|
|
}
|
|
assert.Equal(t, strings.Join(append(lines, ""), "\n"), buf.String())
|
|
})
|
|
}
|
|
|
|
func TestPrintPrepareActionsFailsJobAndCancelsLaterDownloads(t *testing.T) {
|
|
for name, panicFetch := range map[string]bool{"error": false, "panic": true} {
|
|
t.Run(name, func(t *testing.T) {
|
|
synctest.Test(t, func(t *testing.T) {
|
|
rc := newPrefetchRunContext(t)
|
|
setFetch(t, func(ctx context.Context, input git.NewGitCloneExecutorInput) (string, string, error) {
|
|
if input.Ref == "v1" {
|
|
if panicFetch {
|
|
panic("broken archive")
|
|
}
|
|
return "", "", assert.AnError
|
|
}
|
|
<-ctx.Done()
|
|
return "", "", ctx.Err()
|
|
})
|
|
ctx := common.WithJobErrorContainer(common.WithLogger(t.Context(), logging.New(logging.DiscardHandler)))
|
|
remaining := &actionPreparerMock{}
|
|
err := printPrepareActions(rc, append(newRemotePreparers(rc, "org/repo@v1", "org/repo@v2"), remaining))(ctx)
|
|
if panicFetch {
|
|
require.ErrorContains(t, err, "panic: broken archive")
|
|
} else {
|
|
require.ErrorIs(t, err, assert.AnError)
|
|
}
|
|
require.ErrorIs(t, err, common.JobError(ctx))
|
|
assert.True(t, rc.jobFailed)
|
|
assert.Zero(t, remaining.prepared)
|
|
})
|
|
})
|
|
}
|
|
}
|
|
|
|
func newPrefetchRunContext(t *testing.T) *RunContext {
|
|
return &RunContext{
|
|
Config: &Config{ActionCacheDir: t.TempDir(), PresetGitHubContext: &model.GithubContext{}},
|
|
Run: &model.Run{JobID: "1", Workflow: &model.Workflow{Jobs: map[string]*model.Job{"1": {}}}},
|
|
}
|
|
}
|
|
|
|
func newRemotePreparers(rc *RunContext, uses ...string) []actionPreparer {
|
|
preparers := make([]actionPreparer, 0, len(uses))
|
|
for _, use := range uses {
|
|
preparers = append(preparers, &stepActionRemote{Step: &model.Step{Uses: use}, RunContext: rc, readAction: func(_ctx context.Context, _step *model.Step, _actionDir, _actionPath string, _readFile actionYamlReader, _writeFile fileWriter) (*model.Action, error) {
|
|
return &model.Action{}, nil
|
|
}})
|
|
}
|
|
return preparers
|
|
}
|
|
|
|
func TestPrintPrepareActionsSkipsWithoutActions(t *testing.T) {
|
|
buf := &bytes.Buffer{}
|
|
logger := logging.New(logging.NewLevelHandler(newJobLogHandler(buf, cyan), logging.LevelInfo))
|
|
ctx := common.WithLogger(t.Context(), logger.With("job", "j1"))
|
|
|
|
require.NoError(t, printPrepareActions(&RunContext{}, nil)(ctx))
|
|
|
|
assert.Empty(t, buf.String())
|
|
}
|
|
|
|
func TestNewStepsExecutorSkipsStepsAfterSetupFailure(t *testing.T) {
|
|
for name, tt := range map[string]struct {
|
|
preErr error
|
|
mainRan bool
|
|
}{
|
|
"setup failure": {preErr: setupError{assert.AnError}},
|
|
"pre step failure": {preErr: assert.AnError, mainRan: true}, // always() steps still run, as on GitHub
|
|
} {
|
|
t.Run(name, func(t *testing.T) {
|
|
ctx := common.WithJobErrorContainer(common.WithLogger(context.Background(), logging.New(logtest.New())))
|
|
mainRan := false
|
|
err := newStepsExecutor(&RunContext{},
|
|
[]common.Executor{func(context.Context) error { return tt.preErr }},
|
|
[]common.Executor{func(context.Context) error { mainRan = true; return nil }},
|
|
)(ctx)
|
|
require.ErrorIs(t, err, assert.AnError)
|
|
assert.Equal(t, tt.mainRan, mainRan)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestPrintCompleteJobName(t *testing.T) {
|
|
for name, tt := range map[string]struct {
|
|
rc *RunContext
|
|
want string
|
|
}{
|
|
"job name": {rc: &RunContext{JobName: "lint", Name: "lint-1"}, want: "lint"},
|
|
"falls back to name": {rc: &RunContext{Name: "lint-1"}, want: "lint-1"},
|
|
"falls back to jobID": {rc: &RunContext{Run: &model.Run{JobID: "lint"}}, want: "lint"},
|
|
} {
|
|
t.Run(name, func(t *testing.T) {
|
|
buf := &bytes.Buffer{}
|
|
logger := logging.New(logging.NewLevelHandler(newJobLogHandler(buf, cyan), logging.LevelInfo))
|
|
ctx := common.WithLogger(t.Context(), logger.With("job", "j1"))
|
|
|
|
require.NoError(t, printCompleteJobName(tt.rc)(ctx))
|
|
|
|
assert.Equal(t, "[j1] | Complete job name: "+tt.want+"\n", buf.String())
|
|
})
|
|
}
|
|
}
|
|
|
|
// actionStepMock is a step whose action has to be downloaded before it can run.
|
|
type actionStepMock struct {
|
|
*stepMock
|
|
*actionPreparerMock
|
|
}
|
|
|
|
// TestNewJobExecutorDownloadsAllActionsBeforeTheFirstStep pins the shape of the setup section:
|
|
// every action is downloaded before any step runs, and the job name closes the section. A pre
|
|
// step that downloaded its own action would leave the log interleaved with the downloads.
|
|
func TestNewJobExecutorDownloadsAllActionsBeforeTheFirstStep(t *testing.T) {
|
|
ctx := common.WithJobErrorContainer(t.Context())
|
|
jim := &jobInfoMock{}
|
|
sfm := &stepFactoryMock{}
|
|
rc := &RunContext{
|
|
JobContainer: &jobContainerMock{},
|
|
Run: &model.Run{
|
|
JobID: "test",
|
|
Workflow: &model.Workflow{
|
|
Jobs: map[string]*model.Job{"test": {}},
|
|
},
|
|
},
|
|
Config: &Config{PresetGitHubContext: &model.GithubContext{}},
|
|
}
|
|
rc.ExprEval = rc.NewExpressionEvaluator(ctx)
|
|
|
|
steps := []*model.Step{{ID: "1"}, {ID: "2"}}
|
|
executorOrder := make([]string, 0)
|
|
|
|
jim.On("steps").Return(steps)
|
|
jim.On("matrix").Return(map[string]any{})
|
|
jim.On("startContainer").Return(func(context.Context) error { return nil })
|
|
jim.On("stopContainer").Return(func(context.Context) error { return nil })
|
|
jim.On("closeContainer").Return(func(context.Context) error { return nil })
|
|
jim.On("interpolateOutputs").Return(func(context.Context) error { return nil })
|
|
jim.On("result", "success")
|
|
|
|
for _, stepModel := range steps {
|
|
sm := &stepMock{}
|
|
apm := &actionPreparerMock{reference: "actions/checkout@v" + stepModel.ID, sha: "sha" + stepModel.ID}
|
|
sfm.On("newStep", stepModel, rc).Return(&actionStepMock{stepMock: sm, actionPreparerMock: apm}, nil)
|
|
|
|
sm.On("pre").Return(func(ctx context.Context) error {
|
|
executorOrder = append(executorOrder, "pre"+stepModel.ID)
|
|
common.Logger(ctx).Infof("Run Pre %s", stepModel.ID)
|
|
return nil
|
|
})
|
|
sm.On("main").Return(func(context.Context) error {
|
|
executorOrder = append(executorOrder, "step"+stepModel.ID)
|
|
return nil
|
|
})
|
|
sm.On("post").Return(func(context.Context) error { return nil })
|
|
|
|
defer sm.AssertExpectations(t)
|
|
}
|
|
|
|
logger, hook := logtest.NewLogger()
|
|
err := newJobExecutor(jim, sfm, rc)(common.WithLogger(ctx, logger.With("job", "test")))
|
|
require.NoError(t, err)
|
|
|
|
assert.Equal(t, []string{"pre1", "pre2", "step1", "step2"}, executorOrder)
|
|
|
|
// the setup is logged ahead of the pre steps and the cleanup is tagged, so the reporter can show both as steps of their own
|
|
setup := make([]string, 0)
|
|
for _, entry := range hook.AllRecords() {
|
|
if strings.HasPrefix(entry.Message, "Prepare all required actions") || strings.HasPrefix(entry.Message, "Download action") ||
|
|
strings.HasPrefix(entry.Message, "Complete job name") || strings.HasPrefix(entry.Message, "Run Pre") {
|
|
setup = append(setup, entry.Message)
|
|
}
|
|
if _, ok := entry.Attr("jobResult"); ok || strings.HasPrefix(entry.Message, "Cleaning up container") {
|
|
stage, _ := entry.Attr("stage")
|
|
assert.Equal(t, StageComplete, stage, entry.Message)
|
|
}
|
|
}
|
|
assert.Equal(t, []string{
|
|
"Prepare all required actions",
|
|
"Download action repository 'actions/checkout@v1' (SHA:sha1)",
|
|
"Download action repository 'actions/checkout@v2' (SHA:sha2)",
|
|
"Complete job name: test",
|
|
"Run Pre 1",
|
|
"Run Pre 2",
|
|
}, setup)
|
|
}
|
|
|
|
func TestNewJobExecutor(t *testing.T) {
|
|
table := []struct {
|
|
name string
|
|
steps []*model.Step
|
|
preSteps []bool
|
|
postSteps []bool
|
|
executedSteps []string
|
|
result string
|
|
hasError bool
|
|
output string
|
|
startError error
|
|
cancelOnStart bool
|
|
timeout bool
|
|
}{
|
|
{
|
|
name: "zeroSteps",
|
|
steps: []*model.Step{},
|
|
preSteps: []bool{},
|
|
postSteps: []bool{},
|
|
executedSteps: []string{},
|
|
result: "success",
|
|
hasError: false,
|
|
},
|
|
{
|
|
name: "stepWithoutPrePost",
|
|
steps: []*model.Step{{
|
|
ID: "1",
|
|
}},
|
|
preSteps: []bool{false},
|
|
postSteps: []bool{false},
|
|
executedSteps: []string{
|
|
"startContainer",
|
|
"step1",
|
|
"interpolateOutputs",
|
|
"stopContainer",
|
|
"closeContainer",
|
|
},
|
|
result: "success",
|
|
hasError: false,
|
|
},
|
|
{
|
|
name: "stepWithFailure",
|
|
steps: []*model.Step{{
|
|
ID: "1",
|
|
}},
|
|
preSteps: []bool{false},
|
|
postSteps: []bool{false},
|
|
executedSteps: []string{
|
|
"startContainer",
|
|
"step1",
|
|
"interpolateOutputs",
|
|
"stopContainer",
|
|
"closeContainer",
|
|
},
|
|
result: "failure",
|
|
hasError: true,
|
|
},
|
|
{
|
|
name: "stepWithPre",
|
|
steps: []*model.Step{{
|
|
ID: "1",
|
|
}},
|
|
preSteps: []bool{true},
|
|
postSteps: []bool{false},
|
|
executedSteps: []string{
|
|
"startContainer",
|
|
"pre1",
|
|
"step1",
|
|
"interpolateOutputs",
|
|
"stopContainer",
|
|
"closeContainer",
|
|
},
|
|
result: "success",
|
|
hasError: false,
|
|
},
|
|
{
|
|
name: "stepWithPost",
|
|
steps: []*model.Step{{
|
|
ID: "1",
|
|
}},
|
|
preSteps: []bool{false},
|
|
postSteps: []bool{true},
|
|
executedSteps: []string{
|
|
"startContainer",
|
|
"step1",
|
|
"post1",
|
|
"interpolateOutputs",
|
|
"stopContainer",
|
|
"closeContainer",
|
|
},
|
|
result: "success",
|
|
hasError: false,
|
|
},
|
|
{
|
|
name: "stepWithPreAndPost",
|
|
steps: []*model.Step{{
|
|
ID: "1",
|
|
}},
|
|
preSteps: []bool{true},
|
|
postSteps: []bool{true},
|
|
executedSteps: []string{
|
|
"startContainer",
|
|
"pre1",
|
|
"step1",
|
|
"post1",
|
|
"interpolateOutputs",
|
|
"stopContainer",
|
|
"closeContainer",
|
|
},
|
|
result: "success",
|
|
hasError: false,
|
|
},
|
|
{
|
|
name: "stepsWithPreAndPost",
|
|
steps: []*model.Step{{
|
|
ID: "1",
|
|
}, {
|
|
ID: "2",
|
|
}, {
|
|
ID: "3",
|
|
}},
|
|
preSteps: []bool{true, false, true},
|
|
postSteps: []bool{false, true, true},
|
|
executedSteps: []string{
|
|
"startContainer",
|
|
"pre1",
|
|
"pre3",
|
|
"step1",
|
|
"step2",
|
|
"step3",
|
|
"post3",
|
|
"post2",
|
|
"interpolateOutputs",
|
|
"stopContainer",
|
|
"closeContainer",
|
|
},
|
|
result: "success",
|
|
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 }}",
|
|
},
|
|
{
|
|
name: "start failure",
|
|
steps: []*model.Step{{ID: "1"}},
|
|
executedSteps: []string{"startContainer", "closeContainer"},
|
|
startError: errors.New("start failed"),
|
|
},
|
|
{
|
|
name: "cancelled at startup boundary",
|
|
steps: []*model.Step{{ID: "1"}},
|
|
preSteps: []bool{false},
|
|
postSteps: []bool{true},
|
|
executedSteps: []string{"startContainer", "step1", "post1", "interpolateOutputs", "stopContainer", "closeContainer"},
|
|
result: "cancelled",
|
|
cancelOnStart: true,
|
|
},
|
|
{
|
|
name: "post steps run after timeout",
|
|
steps: []*model.Step{{ID: "1"}},
|
|
preSteps: []bool{false},
|
|
postSteps: []bool{true},
|
|
executedSteps: []string{"startContainer", "step1", "post1", "interpolateOutputs", "stopContainer", "closeContainer"},
|
|
result: "failure",
|
|
timeout: true,
|
|
},
|
|
}
|
|
|
|
for _, tt := range table {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
ctx := common.WithJobErrorContainer(t.Context())
|
|
cancel := func() {}
|
|
var expire func()
|
|
if tt.timeout {
|
|
deadline := newControllableDeadlineContext(ctx)
|
|
ctx, expire = deadline, deadline.expire
|
|
} else {
|
|
ctx, cancel = context.WithCancel(ctx)
|
|
}
|
|
defer cancel()
|
|
jim := &jobInfoMock{}
|
|
sfm := &stepFactoryMock{}
|
|
rc := &RunContext{
|
|
JobContainer: &jobContainerMock{},
|
|
Run: &model.Run{
|
|
JobID: "test",
|
|
Workflow: &model.Workflow{
|
|
Jobs: map[string]*model.Job{
|
|
"test": {},
|
|
},
|
|
},
|
|
},
|
|
Config: &Config{PresetGitHubContext: &model.GithubContext{}},
|
|
}
|
|
if tt.output != "" {
|
|
require.NoError(t, rc.Run.Job().RawOutputs.Encode(map[string]string{"bad": tt.output}))
|
|
}
|
|
rc.ExprEval = rc.NewExpressionEvaluator(ctx)
|
|
executorOrder := make([]string, 0)
|
|
|
|
jim.On("steps").Return(tt.steps)
|
|
|
|
if len(tt.steps) > 0 {
|
|
jim.On("startContainer").Return(func(_ context.Context) error {
|
|
executorOrder = append(executorOrder, "startContainer")
|
|
if tt.cancelOnStart {
|
|
cancel()
|
|
}
|
|
return tt.startError
|
|
})
|
|
}
|
|
|
|
for i, stepModel := range tt.steps {
|
|
sm := &stepMock{}
|
|
|
|
sfm.On("newStep", stepModel, rc).Return(sm, nil)
|
|
|
|
sm.On("pre").Return(func(_ctx context.Context) error {
|
|
if tt.preSteps[i] {
|
|
executorOrder = append(executorOrder, "pre"+stepModel.ID)
|
|
}
|
|
return nil
|
|
})
|
|
|
|
sm.On("main").Return(func(ctx context.Context) error {
|
|
executorOrder = append(executorOrder, "step"+stepModel.ID)
|
|
if tt.timeout {
|
|
expire()
|
|
return ctx.Err()
|
|
}
|
|
if tt.hasError {
|
|
return errors.New("error")
|
|
}
|
|
return nil
|
|
})
|
|
|
|
sm.On("post").Return(func(ctx context.Context) error {
|
|
if tt.postSteps[i] {
|
|
executorOrder = append(executorOrder, "post"+stepModel.ID)
|
|
}
|
|
if tt.timeout {
|
|
require.NoError(t, ctx.Err(), "post step must run against a fresh, non-expired context")
|
|
}
|
|
return nil
|
|
})
|
|
|
|
defer sm.AssertExpectations(t)
|
|
}
|
|
|
|
if len(tt.steps) > 0 && tt.startError == nil {
|
|
jim.On("matrix").Return(map[string]any{})
|
|
|
|
jim.On("interpolateOutputs").Return(func(ctx context.Context) error {
|
|
executorOrder = append(executorOrder, "interpolateOutputs")
|
|
if tt.output != "" {
|
|
return rc.interpolateOutputs()(ctx)
|
|
}
|
|
return nil
|
|
})
|
|
|
|
if slices.Contains(tt.executedSteps, "stopContainer") {
|
|
jim.On("stopContainer").Return(func(ctx context.Context) error {
|
|
executorOrder = append(executorOrder, "stopContainer")
|
|
require.NoError(t, ctx.Err())
|
|
_, bounded := ctx.Deadline()
|
|
require.True(t, bounded)
|
|
return nil
|
|
})
|
|
}
|
|
|
|
jim.On("result", tt.result)
|
|
}
|
|
|
|
if len(tt.steps) > 0 {
|
|
jim.On("closeContainer").Return(func(_ctx context.Context) error {
|
|
executorOrder = append(executorOrder, "closeContainer")
|
|
return nil
|
|
})
|
|
}
|
|
|
|
executor := newJobExecutor(jim, sfm, rc)
|
|
err := executor(ctx)
|
|
switch {
|
|
case tt.startError != nil:
|
|
require.ErrorIs(t, err, tt.startError)
|
|
case tt.cancelOnStart:
|
|
require.ErrorIs(t, err, context.Canceled)
|
|
default:
|
|
require.NoError(t, err)
|
|
}
|
|
assert.Empty(t, rc.Run.Job().Outputs["bad"])
|
|
assert.Equal(t, tt.executedSteps, executorOrder)
|
|
|
|
jim.AssertExpectations(t)
|
|
sfm.AssertExpectations(t)
|
|
})
|
|
}
|
|
}
|
|
|
|
type controllableDeadlineContext struct {
|
|
context.Context
|
|
done chan struct{}
|
|
}
|
|
|
|
func newControllableDeadlineContext(parent context.Context) *controllableDeadlineContext {
|
|
return &controllableDeadlineContext{Context: parent, done: make(chan struct{})}
|
|
}
|
|
|
|
func (ctx *controllableDeadlineContext) Done() <-chan struct{} {
|
|
return ctx.done
|
|
}
|
|
|
|
func (ctx *controllableDeadlineContext) Err() error {
|
|
select {
|
|
case <-ctx.done:
|
|
return context.DeadlineExceeded
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func (ctx *controllableDeadlineContext) expire() {
|
|
close(ctx.done)
|
|
}
|
|
|
|
func TestSetJobResultMatrixContinueOnError(t *testing.T) {
|
|
ctx := t.Context()
|
|
for name, tolerated := range map[string]bool{
|
|
"one tolerated and one firm failure fails the run": false,
|
|
"all tolerated failures do not fail the run": true,
|
|
} {
|
|
t.Run(name, func(t *testing.T) {
|
|
var job *model.Job
|
|
require.NoError(t, yaml.Unmarshal([]byte("continue-on-error: ${{ matrix.experimental }}\nruns-on: ubuntu-latest"), &job))
|
|
workflow := &model.Workflow{Name: "workflow1", Jobs: map[string]*model.Job{"job1": job}}
|
|
for _, experimental := range []bool{true, tolerated} {
|
|
rc := newTestRC(workflow, map[string]any{"experimental": experimental})
|
|
rc.Config.PresetGitHubContext = &model.GithubContext{}
|
|
setJobResult(ctx, rc, rc, false)
|
|
}
|
|
|
|
assert.Equal(t, "failure", job.Result)
|
|
assert.Equal(t, tolerated, job.ContinueOnError)
|
|
err := handleFailure(&model.Plan{Stages: []*model.Stage{{Runs: []*model.Run{{Workflow: workflow, JobID: "job1"}}}}})(ctx)
|
|
if tolerated {
|
|
require.NoError(t, err)
|
|
} else {
|
|
require.Error(t, err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestHasJobSummaryCapability(t *testing.T) {
|
|
assert.True(t, hasJobSummaryCapability("cache,job-summary artifacts"))
|
|
assert.True(t, hasJobSummaryCapability("cache,\njob-summary\tartifacts"))
|
|
assert.False(t, hasJobSummaryCapability("not-job-summary,job-summary-v2"))
|
|
}
|
|
|
|
// fakeRuntimeToken builds a JWT-shaped string whose middle (claims) segment encodes
|
|
// the given JobID. The header and signature segments are filler — the runner does not
|
|
// verify the signature; the server does.
|
|
func fakeRuntimeToken(jobID int64) string {
|
|
header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"HS256","typ":"JWT"}`))
|
|
claims := base64.RawURLEncoding.EncodeToString(fmt.Appendf(nil, `{"JobID":%d}`, jobID))
|
|
sig := base64.RawURLEncoding.EncodeToString([]byte("sig"))
|
|
return header + "." + claims + "." + sig
|
|
}
|
|
|
|
func newJobSummaryRC(env map[string]string, jobContainer container.ExecutionsEnvironment, stepCount int) *RunContext {
|
|
steps := make([]*model.Step, stepCount)
|
|
for i := range steps {
|
|
steps[i] = &model.Step{ID: strconv.Itoa(i)}
|
|
}
|
|
return &RunContext{
|
|
Config: &Config{},
|
|
JobContainer: jobContainer,
|
|
Env: env,
|
|
Run: &model.Run{
|
|
JobID: "test",
|
|
Workflow: &model.Workflow{
|
|
Jobs: map[string]*model.Job{
|
|
"test": {Steps: steps},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
func TestTryUploadJobSummaryRetriesTransientFailure(t *testing.T) {
|
|
oldDelay := jobSummaryUploadRetryDelay
|
|
jobSummaryUploadRetryDelay = 0
|
|
defer func() {
|
|
jobSummaryUploadRetryDelay = oldDelay
|
|
}()
|
|
|
|
runtimeToken := fakeRuntimeToken(34)
|
|
|
|
requests := 0
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
requests++
|
|
assert.Equal(t, http.MethodPut, r.Method)
|
|
assert.Equal(t, "/_apis/pipelines/workflows/12/jobs/34/steps/0/summary", r.URL.Path)
|
|
assert.Equal(t, "Bearer "+runtimeToken, r.Header.Get("Authorization"))
|
|
assert.Equal(t, "text/markdown; charset=utf-8", r.Header.Get("Content-Type"))
|
|
body, err := io.ReadAll(r.Body)
|
|
assert.NoError(t, err)
|
|
assert.Equal(t, []byte("# summary"), body)
|
|
if requests == 1 {
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}))
|
|
defer server.Close()
|
|
|
|
ctx := t.Context()
|
|
cm := &containerMock{}
|
|
cm.On("GetContainerArchive", mock.Anything, "/var/run/act/workflow/step-summary-0.md").Return(
|
|
io.NopCloser(bytes.NewReader(tarArchive(t, tarEntry{name: "step-summary-0.md", body: "# summary"}))),
|
|
nil,
|
|
).Once()
|
|
|
|
rc := newJobSummaryRC(map[string]string{
|
|
"GITEA_ACTIONS_CAPABILITIES": "cache, job-summary",
|
|
"ACTIONS_RUNTIME_URL": server.URL,
|
|
"ACTIONS_RUNTIME_TOKEN": runtimeToken,
|
|
"GITEA_RUN_ID": "12",
|
|
}, cm, 1)
|
|
|
|
tryUploadJobSummary(ctx, rc)
|
|
|
|
assert.Equal(t, 2, requests)
|
|
cm.AssertExpectations(t)
|
|
}
|
|
|
|
func TestTryUploadJobSummaryStopsAtPhaseTimeout(t *testing.T) {
|
|
oldPhase := jobSummaryUploadPhaseTimeout
|
|
jobSummaryUploadPhaseTimeout = 100 * time.Millisecond
|
|
defer func() {
|
|
jobSummaryUploadPhaseTimeout = oldPhase
|
|
}()
|
|
|
|
runtimeToken := fakeRuntimeToken(34)
|
|
|
|
// The server blocks until either the request context is cancelled (the behaviour
|
|
// under test: the phase timeout aborts the in-flight upload) or the test tears it
|
|
// down. Without the phase timeout the upload would hang until the 30s client
|
|
// timeout instead of releasing the cleanup budget. The release channel guarantees
|
|
// the handler always returns so server.Close() cannot itself hang.
|
|
release := make(chan struct{})
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
select {
|
|
case <-r.Context().Done():
|
|
case <-release:
|
|
}
|
|
}))
|
|
defer server.Close()
|
|
defer close(release)
|
|
|
|
ctx := t.Context()
|
|
cm := &containerMock{}
|
|
cm.On("GetContainerArchive", mock.Anything, "/var/run/act/workflow/step-summary-0.md").Return(
|
|
io.NopCloser(bytes.NewReader(tarArchive(t, tarEntry{name: "step-summary-0.md", body: "# summary"}))),
|
|
nil,
|
|
).Once()
|
|
|
|
rc := newJobSummaryRC(map[string]string{
|
|
"GITEA_ACTIONS_CAPABILITIES": "job-summary",
|
|
"ACTIONS_RUNTIME_URL": server.URL,
|
|
"ACTIONS_RUNTIME_TOKEN": runtimeToken,
|
|
"GITEA_RUN_ID": "12",
|
|
}, cm, 1)
|
|
|
|
done := make(chan struct{})
|
|
go func() {
|
|
defer close(done)
|
|
tryUploadJobSummary(ctx, rc)
|
|
}()
|
|
|
|
select {
|
|
case <-done:
|
|
case <-time.After(5 * time.Second):
|
|
t.Fatal("tryUploadJobSummary did not honour the phase timeout")
|
|
}
|
|
cm.AssertExpectations(t)
|
|
}
|
|
|
|
func TestTryUploadJobSummaryUploadsEachStepIndependentlyWithInstanceTransport(t *testing.T) {
|
|
runtimeToken := fakeRuntimeToken(34)
|
|
|
|
type upload struct {
|
|
path string
|
|
body string
|
|
}
|
|
var got []upload
|
|
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
body, err := io.ReadAll(r.Body)
|
|
assert.NoError(t, err)
|
|
got = append(got, upload{r.URL.Path, string(body)})
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}))
|
|
defer server.Close()
|
|
|
|
ctx := t.Context()
|
|
cm := &containerMock{}
|
|
// Three steps: 0 has content, 1 has empty content (skipped), 2 has content.
|
|
cm.On("GetContainerArchive", mock.Anything, "/var/run/act/workflow/step-summary-0.md").Return(
|
|
io.NopCloser(bytes.NewReader(tarArchive(t, tarEntry{name: "step-summary-0.md", body: "first"}))),
|
|
nil,
|
|
).Once()
|
|
cm.On("GetContainerArchive", mock.Anything, "/var/run/act/workflow/step-summary-1.md").Return(
|
|
io.NopCloser(bytes.NewReader(tarArchive(t, tarEntry{name: "step-summary-1.md", body: ""}))),
|
|
nil,
|
|
).Once()
|
|
cm.On("GetContainerArchive", mock.Anything, "/var/run/act/workflow/step-summary-2.md").Return(
|
|
io.NopCloser(bytes.NewReader(tarArchive(t, tarEntry{name: "step-summary-2.md", body: "third"}))),
|
|
nil,
|
|
).Once()
|
|
|
|
rc := newJobSummaryRC(map[string]string{
|
|
"GITEA_ACTIONS_CAPABILITIES": "job-summary",
|
|
"ACTIONS_RUNTIME_URL": server.URL,
|
|
"ACTIONS_RUNTIME_TOKEN": runtimeToken,
|
|
"GITEA_RUN_ID": "12",
|
|
}, cm, 3)
|
|
rc.Config.GitHubInstance, rc.Config.InstanceTransport = server.URL, server.Client().Transport
|
|
|
|
tryUploadJobSummary(ctx, rc)
|
|
|
|
assert.Equal(t, []upload{
|
|
{"/_apis/pipelines/workflows/12/jobs/34/steps/0/summary", "first"},
|
|
{"/_apis/pipelines/workflows/12/jobs/34/steps/2/summary", "third"},
|
|
}, got)
|
|
cm.AssertExpectations(t)
|
|
}
|
|
|
|
func TestTryUploadJobSummarySkipsUnsupportedUploads(t *testing.T) {
|
|
for name, tt := range map[string]struct {
|
|
capabilities string
|
|
runtimeToken string
|
|
}{
|
|
"requires exact capability": {"not-job-summary,job-summary-v2", fakeRuntimeToken(34)},
|
|
"requires job ID": {"job-summary", "not-a-jwt"},
|
|
} {
|
|
t.Run(name, func(t *testing.T) {
|
|
requests := 0
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _r *http.Request) {
|
|
requests++
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}))
|
|
defer server.Close()
|
|
|
|
rc := newJobSummaryRC(map[string]string{
|
|
"GITEA_ACTIONS_CAPABILITIES": tt.capabilities,
|
|
"ACTIONS_RUNTIME_URL": server.URL,
|
|
"ACTIONS_RUNTIME_TOKEN": tt.runtimeToken,
|
|
"GITEA_RUN_ID": "12",
|
|
}, &containerMock{}, 1)
|
|
|
|
tryUploadJobSummary(t.Context(), rc)
|
|
|
|
assert.Zero(t, requests)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestExtractJobIDFromRuntimeToken(t *testing.T) {
|
|
assert.Equal(t, int64(42), extractJobIDFromRuntimeToken(fakeRuntimeToken(42)))
|
|
assert.Equal(t, int64(0), extractJobIDFromRuntimeToken("not-a-jwt"))
|
|
assert.Equal(t, int64(0), extractJobIDFromRuntimeToken("a.b.c"))
|
|
assert.Equal(t, int64(0), extractJobIDFromRuntimeToken(""))
|
|
}
|
|
|
|
func TestReadSingleFileFromContainerArchiveFindsMatchingRegularFile(t *testing.T) {
|
|
ctx := t.Context()
|
|
cm := &containerMock{}
|
|
cm.On("GetContainerArchive", ctx, "/var/run/act/workflow/SUMMARY.md").Return(
|
|
io.NopCloser(bytes.NewReader(tarArchive(t,
|
|
tarEntry{name: "workflow", typeflag: tar.TypeDir},
|
|
tarEntry{name: "other.md", body: "wrong"},
|
|
tarEntry{name: "SUMMARY.md", body: "right"},
|
|
))),
|
|
nil,
|
|
).Once()
|
|
|
|
body, ok := readSingleFileFromContainerArchive(ctx, cm, "/var/run/act/workflow/SUMMARY.md", 1024)
|
|
|
|
assert.True(t, ok)
|
|
assert.Equal(t, []byte("right"), body)
|
|
cm.AssertExpectations(t)
|
|
}
|
|
|
|
func TestReadSingleFileFromContainerArchiveSizeLimit(t *testing.T) {
|
|
for name, tt := range map[string]struct {
|
|
content string
|
|
maxBytes int64
|
|
want string
|
|
}{
|
|
"truncates oversized summary": {strings.Repeat("a", 300), 200, strings.Repeat("a", 200-len(jobSummaryTruncationMarker)) + jobSummaryTruncationMarker},
|
|
"keeps exact limit": {"abc", 3, "abc"},
|
|
} {
|
|
t.Run(name, func(t *testing.T) {
|
|
logger, hook := logtest.NewLogger()
|
|
ctx := common.WithLogger(t.Context(), logger)
|
|
cm := &containerMock{}
|
|
cm.On("GetContainerArchive", ctx, "/var/run/act/workflow/SUMMARY.md").Return(
|
|
io.NopCloser(bytes.NewReader(tarArchive(t, tarEntry{name: "SUMMARY.md", body: tt.content}))),
|
|
nil,
|
|
).Once()
|
|
|
|
body, ok := readSingleFileFromContainerArchive(ctx, cm, "/var/run/act/workflow/SUMMARY.md", tt.maxBytes)
|
|
|
|
assert.True(t, ok)
|
|
assert.Equal(t, []byte(tt.want), body)
|
|
assert.LessOrEqual(t, int64(len(body)), tt.maxBytes)
|
|
if int64(len(tt.content)) > tt.maxBytes {
|
|
if assert.Len(t, hook.AllRecords(), 1) {
|
|
assert.Contains(t, hook.LastRecord().Message, "job summary truncated")
|
|
}
|
|
} else {
|
|
assert.Empty(t, hook.AllRecords())
|
|
}
|
|
cm.AssertExpectations(t)
|
|
})
|
|
}
|
|
}
|
|
|
|
type tarEntry struct {
|
|
name string
|
|
body string
|
|
typeflag byte
|
|
}
|
|
|
|
func tarArchive(t *testing.T, entries ...tarEntry) []byte {
|
|
t.Helper()
|
|
|
|
buf := &bytes.Buffer{}
|
|
tw := tar.NewWriter(buf)
|
|
for _, entry := range entries {
|
|
typeflag := entry.typeflag
|
|
if typeflag == 0 {
|
|
typeflag = tar.TypeReg
|
|
}
|
|
header := &tar.Header{
|
|
Name: entry.name,
|
|
Typeflag: typeflag,
|
|
Mode: 0o644,
|
|
Size: int64(len(entry.body)),
|
|
}
|
|
if typeflag == tar.TypeDir {
|
|
header.Mode = 0o755
|
|
header.Size = 0
|
|
}
|
|
require.NoError(t, tw.WriteHeader(header))
|
|
if typeflag == tar.TypeReg {
|
|
_, err := tw.Write([]byte(entry.body))
|
|
require.NoError(t, err)
|
|
}
|
|
}
|
|
require.NoError(t, tw.Close())
|
|
return buf.Bytes()
|
|
}
|
|
|
|
func newTestRC(wf *model.Workflow, matrix map[string]any) *RunContext {
|
|
return &RunContext{
|
|
Config: &Config{Workdir: ".", Env: testCheckoutEnv(), PlatformPicker: func([]string) string { return "ubuntu-latest" }},
|
|
StepResults: map[string]*model.StepResult{},
|
|
Env: map[string]string{},
|
|
Matrix: matrix,
|
|
Run: &model.Run{JobID: "job1", Workflow: wf},
|
|
}
|
|
}
|
|
|
|
func makeTestRC(t *testing.T, jobYAML string) *RunContext {
|
|
t.Helper()
|
|
var job *model.Job
|
|
require.NoError(t, yaml.Unmarshal([]byte(jobYAML), &job))
|
|
rc := newTestRC(&model.Workflow{
|
|
Name: "workflow1",
|
|
Jobs: map[string]*model.Job{"job1": job},
|
|
}, nil)
|
|
rc.Config.PresetGitHubContext = &model.GithubContext{}
|
|
rc.ExprEval = rc.NewExpressionEvaluator(t.Context())
|
|
return rc
|
|
}
|
|
|
|
func TestApplyJobTimeout(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
yaml string
|
|
wantTimeout bool
|
|
}{
|
|
{"empty", "runs-on: ubuntu-latest", false},
|
|
{"integer", "timeout-minutes: 5\nruns-on: ubuntu-latest", true},
|
|
{"zero ignored", "timeout-minutes: 0\nruns-on: ubuntu-latest", false},
|
|
{"non-numeric ignored", "timeout-minutes: abc\nruns-on: ubuntu-latest", false},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
rc := makeTestRC(t, tc.yaml)
|
|
ctx := t.Context()
|
|
newCtx, cancel := applyJobTimeout(ctx, rc, rc.Run.Job())
|
|
defer cancel()
|
|
_, hasDeadline := newCtx.Deadline()
|
|
assert.Equal(t, tc.wantTimeout, hasDeadline)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestEvaluateJobContinueOnError(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
yaml string
|
|
want bool
|
|
}{
|
|
{"absent", "runs-on: ubuntu-latest", false},
|
|
{"true", "continue-on-error: true\nruns-on: ubuntu-latest", true},
|
|
{"false", "continue-on-error: false\nruns-on: ubuntu-latest", false},
|
|
{"expression true", "continue-on-error: ${{ 'x' == 'x' }}\nruns-on: ubuntu-latest", true},
|
|
{"expression false", "continue-on-error: ${{ 'x' != 'x' }}\nruns-on: ubuntu-latest", false},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
rc := makeTestRC(t, tc.yaml)
|
|
got := evaluateJobContinueOnError(t.Context(), rc, rc.Run.Job())
|
|
assert.Equal(t, tc.want, got)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestJobSetContinueOnError(t *testing.T) {
|
|
for name, tt := range map[string]struct {
|
|
values []bool
|
|
want bool
|
|
}{
|
|
"first call true": {[]bool{true}, true},
|
|
"first call false": {[]bool{false}, false},
|
|
"true then false locks to false": {[]bool{true, false}, false},
|
|
"false then true stays false": {[]bool{false, true}, false},
|
|
"true then true stays true": {[]bool{true, true}, true},
|
|
} {
|
|
t.Run(name, func(t *testing.T) {
|
|
job := &model.Job{}
|
|
for _, value := range tt.values {
|
|
job.SetContinueOnError(value)
|
|
}
|
|
assert.Equal(t, tt.want, job.ContinueOnError)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestTryUploadJobSummaryMasksSecrets(t *testing.T) {
|
|
var got string
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
body, err := io.ReadAll(r.Body)
|
|
assert.NoError(t, err)
|
|
got = string(body)
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}))
|
|
defer server.Close()
|
|
|
|
cm := &containerMock{}
|
|
cm.On("GetContainerArchive", mock.Anything, "/var/run/act/workflow/step-summary-0.md").Return(
|
|
io.NopCloser(bytes.NewReader(tarArchive(t, tarEntry{
|
|
name: "step-summary-0.md", body: "deployed true with s3cr3t and runtime-added via pr0xypw",
|
|
}))),
|
|
nil,
|
|
).Once()
|
|
|
|
rc := newJobSummaryRC(map[string]string{
|
|
"GITEA_ACTIONS_CAPABILITIES": "job-summary",
|
|
"ACTIONS_RUNTIME_URL": server.URL,
|
|
"ACTIONS_RUNTIME_TOKEN": fakeRuntimeToken(34),
|
|
"GITEA_RUN_ID": "12",
|
|
}, cm, 1)
|
|
rc.Config.Secrets = map[string]string{"TOK": "s3cr3t", "ACTIONS_STEP_DEBUG": "true"}
|
|
rc.Config.ExtraMasks = []string{"pr0xypw"}
|
|
rc.Masks = []string{"runtime-added"}
|
|
|
|
tryUploadJobSummary(t.Context(), rc)
|
|
|
|
assert.Equal(t, "deployed true with *** and *** via ***", got)
|
|
cm.AssertExpectations(t)
|
|
}
|