mirror of
https://gitea.com/gitea/act_runner.git
synced 2026-11-01 04:48:49 +08:00
Follow-up to https://gitea.com/gitea/runner/pulls/1362, whose crash e2e missed because it only drove the daemon on Docker. - `dispatch`, `failure_paths` and `reusable_workflow` run through the daemon and `exec`, each on Docker and one shared k3s cluster. - Fixed sleeps become gates the test releases, the job timeout uses a 15s runner deadline, k3s boots alongside Gitea, Gitea's data is on tmpfs. - Outside a container, `make test-e2e` runs `go test` in a Linux container on its own network like CI, so local runs take the same paths. Local wall time: | | Before | After | |---|---|---| | Suite | 84s | 27–35s | | `job_timeout` | 64s | 17s | | `reconnect` | 22s | 4.5s | | `capacity/serial` | 20s | 2s | | `cli_shutdown_drains` | 14s | 2.3s | | `kubernetes` | 31s | 21–23s | _Written by Claude (Opus 5.5)._ --------- Co-authored-by: bircni <bircni@icloud.com> Reviewed-on: https://gitea.com/gitea/runner/pulls/1363 Reviewed-by: bircni <bircni@icloud.com> Co-authored-by: silverwind <me@silverwind.io>
1148 lines
34 KiB
Go
1148 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
|
|
want time.Duration
|
|
}{
|
|
{"empty", "runs-on: ubuntu-latest", 0},
|
|
{"integer", "timeout-minutes: 5\nruns-on: ubuntu-latest", 5 * time.Minute},
|
|
{"zero ignored", "timeout-minutes: 0\nruns-on: ubuntu-latest", 0},
|
|
{"non-numeric ignored", "timeout-minutes: abc\nruns-on: ubuntu-latest", 0},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
rc := makeTestRC(t, tc.yaml)
|
|
newCtx, cancel := applyJobTimeout(t.Context(), rc, rc.Run.Job())
|
|
defer cancel()
|
|
deadline, hasDeadline := newCtx.Deadline()
|
|
assert.Equal(t, tc.want != 0, hasDeadline)
|
|
if hasDeadline {
|
|
assert.WithinDuration(t, time.Now().Add(tc.want), deadline, time.Minute)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|