mirror of
https://gitea.com/gitea/act_runner.git
synced 2026-07-22 20:47:45 +00:00
enhance: report a GitHub-style "Set up job" section (#1089)
Reshapes the job log's "Set up job" section to mirror `actions/runner`: - runner name/version, then `Runner Information` (labels, task, job, repository, event) and `Operating System` groups - every required action downloaded up front under `Prepare all required actions`, each as `Download action repository '<action>@<ref>' (SHA:<sha>)` - `Complete job name` closes the section Downloading up front is the one behavioral change: the same set was already fetched during the pre stage regardless of a step's `if`, now just before the first pre step, so a download failure is reported against the job rather than a step. --------- Co-authored-by: silverwind <me@silverwind.io> Reviewed-on: https://gitea.com/gitea/runner/pulls/1089 Reviewed-by: silverwind <2021+silverwind@noreply.gitea.com>
This commit is contained in:
@@ -261,6 +261,10 @@ type NewGitCloneExecutorInput struct {
|
|||||||
// 0 for full clone.
|
// 0 for full clone.
|
||||||
Depth int
|
Depth int
|
||||||
|
|
||||||
|
// Quiet drops the informational clone line to debug level, for callers that log their own
|
||||||
|
// download summary (the setup section's action report).
|
||||||
|
Quiet bool
|
||||||
|
|
||||||
// For Gitea
|
// For Gitea
|
||||||
InsecureSkipTLS bool
|
InsecureSkipTLS bool
|
||||||
}
|
}
|
||||||
@@ -347,7 +351,11 @@ func gitOptions(token string) (fetchOptions git.FetchOptions, pullOptions git.Pu
|
|||||||
func NewGitCloneExecutor(input NewGitCloneExecutorInput) common.Executor {
|
func NewGitCloneExecutor(input NewGitCloneExecutorInput) common.Executor {
|
||||||
return func(ctx context.Context) error {
|
return func(ctx context.Context) error {
|
||||||
logger := common.Logger(ctx)
|
logger := common.Logger(ctx)
|
||||||
|
if input.Quiet {
|
||||||
|
logger.Debugf("git clone '%s' # ref=%s", input.URL, input.Ref)
|
||||||
|
} else {
|
||||||
logger.Infof("git clone '%s' # ref=%s", input.URL, input.Ref)
|
logger.Infof("git clone '%s' # ref=%s", input.URL, input.Ref)
|
||||||
|
}
|
||||||
logger.Debugf(" cloning %s to %s", input.URL, input.Dir)
|
logger.Debugf(" cloning %s to %s", input.URL, input.Dir)
|
||||||
|
|
||||||
defer AcquireCloneLock(input.Dir)()
|
defer AcquireCloneLock(input.Dir)()
|
||||||
|
|||||||
@@ -17,6 +17,10 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"gitea.com/gitea/runner/act/common"
|
||||||
|
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
logrustest "github.com/sirupsen/logrus/hooks/test"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
@@ -404,6 +408,44 @@ func TestGitCloneExecutorOfflineMode(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestGitCloneExecutorQuietDemotesCloneLine(t *testing.T) {
|
||||||
|
remoteDir := t.TempDir()
|
||||||
|
require.NoError(t, gitCmd("init", "--bare", "--initial-branch=main", remoteDir))
|
||||||
|
workDir := t.TempDir()
|
||||||
|
require.NoError(t, gitCmd("clone", remoteDir, workDir))
|
||||||
|
require.NoError(t, gitCmd("-C", workDir, "checkout", "-b", "main"))
|
||||||
|
require.NoError(t, gitCmd("-C", workDir, "commit", "--allow-empty", "-m", "initial"))
|
||||||
|
require.NoError(t, gitCmd("-C", workDir, "push", "-u", "origin", "main"))
|
||||||
|
|
||||||
|
// Quiet callers report the download themselves, so the clone line must not reach the job log.
|
||||||
|
for name, quiet := range map[string]bool{"quiet": true, "not quiet": false} {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
logger, hook := logrustest.NewNullLogger()
|
||||||
|
logger.SetLevel(log.InfoLevel)
|
||||||
|
ctx := common.WithLogger(context.Background(), logger.WithField("job", "j1"))
|
||||||
|
|
||||||
|
require.NoError(t, NewGitCloneExecutor(NewGitCloneExecutorInput{
|
||||||
|
URL: remoteDir,
|
||||||
|
Ref: "main",
|
||||||
|
Dir: t.TempDir(),
|
||||||
|
Quiet: quiet,
|
||||||
|
})(ctx))
|
||||||
|
|
||||||
|
var cloneLines int
|
||||||
|
for _, entry := range hook.AllEntries() {
|
||||||
|
if strings.HasPrefix(entry.Message, "git clone ") {
|
||||||
|
cloneLines++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if quiet {
|
||||||
|
assert.Zero(t, cloneLines)
|
||||||
|
} else {
|
||||||
|
assert.Equal(t, 1, cloneLines)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestGitCloneExecutorShallow(t *testing.T) {
|
func TestGitCloneExecutorShallow(t *testing.T) {
|
||||||
// Build a local "remote" with several commits on main plus a tag, so a full clone would pull noticeably more history than a shallow one.
|
// Build a local "remote" with several commits on main plus a tag, so a full clone would pull noticeably more history than a shallow one.
|
||||||
remoteDir := t.TempDir()
|
remoteDir := t.TempDir()
|
||||||
|
|||||||
@@ -71,9 +71,67 @@ func reportStepError(ctx context.Context, rc *RunContext, err error) {
|
|||||||
rc.markFailed()
|
rc.markFailed()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// actionPreparer is implemented by steps that download an action before they run, so the job
|
||||||
|
// executor can fetch all of them up front.
|
||||||
|
type actionPreparer interface {
|
||||||
|
prepareActionExecutor() common.Executor
|
||||||
|
actionDownloadInfo() (reference, sha string, ok bool)
|
||||||
|
}
|
||||||
|
|
||||||
|
// printPrepareActions downloads every action the job uses before its first step runs and reports
|
||||||
|
// them as actions/runner's "Prepare all required actions" section does. The steps still call
|
||||||
|
// prepareActionExecutor themselves; it is a no-op once the action is resolved here.
|
||||||
|
func printPrepareActions(rc *RunContext, preparers []actionPreparer) common.Executor {
|
||||||
|
return func(ctx context.Context) error {
|
||||||
|
if len(preparers) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
rawLogger := common.Logger(ctx).WithField(rawOutputField, true)
|
||||||
|
rawLogger.Infof("Prepare all required actions")
|
||||||
|
|
||||||
|
for _, preparer := range preparers {
|
||||||
|
if err := preparer.prepareActionExecutor()(ctx); err != nil {
|
||||||
|
// No step has run yet, so the failure belongs to the job.
|
||||||
|
reportStepError(ctx, rc, err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
reference, sha, ok := preparer.actionDownloadInfo()
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if sha == "" {
|
||||||
|
rawLogger.Infof("Download action repository '%s'", reference)
|
||||||
|
} else {
|
||||||
|
rawLogger.Infof("Download action repository '%s' (SHA:%s)", reference, sha)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// printCompleteJobName closes the setup section the way actions/runner ends its "Set up job" step.
|
||||||
|
func printCompleteJobName(rc *RunContext) common.Executor {
|
||||||
|
return func(ctx context.Context) error {
|
||||||
|
// Name holds a matrix combination; JobName is the shared name GitHub reports.
|
||||||
|
name := rc.JobName
|
||||||
|
if name == "" {
|
||||||
|
name = rc.Name
|
||||||
|
}
|
||||||
|
if name == "" && rc.Run != nil {
|
||||||
|
name = rc.Run.JobID
|
||||||
|
}
|
||||||
|
common.Logger(ctx).WithField(rawOutputField, true).Infof("Complete job name: %s", name)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func newJobExecutor(info jobInfo, sf stepFactory, rc *RunContext) common.Executor {
|
func newJobExecutor(info jobInfo, sf stepFactory, rc *RunContext) common.Executor {
|
||||||
steps := make([]common.Executor, 0)
|
steps := make([]common.Executor, 0)
|
||||||
preSteps := make([]common.Executor, 0)
|
preSteps := make([]common.Executor, 0)
|
||||||
|
// Collected separately: every action is downloaded before the first pre step runs.
|
||||||
|
stepPreSteps := make([]common.Executor, 0)
|
||||||
|
preparers := make([]actionPreparer, 0)
|
||||||
var postExecutor common.Executor
|
var postExecutor common.Executor
|
||||||
|
|
||||||
steps = append(steps, func(ctx context.Context) error {
|
steps = append(steps, func(ctx context.Context) error {
|
||||||
@@ -120,9 +178,13 @@ func newJobExecutor(info jobInfo, sf stepFactory, rc *RunContext) common.Executo
|
|||||||
return common.NewErrorExecutor(err)
|
return common.NewErrorExecutor(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if preparer, ok := step.(actionPreparer); ok {
|
||||||
|
preparers = append(preparers, preparer)
|
||||||
|
}
|
||||||
|
|
||||||
stepIdx := stepModel.Number
|
stepIdx := stepModel.Number
|
||||||
preExec := step.pre()
|
preExec := step.pre()
|
||||||
preSteps = append(preSteps, useStepLogger(rc, stepModel, stepStagePre, func(ctx context.Context) error {
|
stepPreSteps = append(stepPreSteps, useStepLogger(rc, stepModel, stepStagePre, func(ctx context.Context) error {
|
||||||
rc.CurrentStepIndex = stepIdx
|
rc.CurrentStepIndex = stepIdx
|
||||||
preErr := preExec(ctx)
|
preErr := preExec(ctx)
|
||||||
if preErr != nil {
|
if preErr != nil {
|
||||||
@@ -164,6 +226,11 @@ func newJobExecutor(info jobInfo, sf stepFactory, rc *RunContext) common.Executo
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The setup section of the job log: download the actions, run the pre steps, then name the job.
|
||||||
|
preSteps = append(preSteps, printPrepareActions(rc, preparers))
|
||||||
|
preSteps = append(preSteps, stepPreSteps...)
|
||||||
|
preSteps = append(preSteps, printCompleteJobName(rc))
|
||||||
|
|
||||||
postExecutor = postExecutor.Finally(func(ctx context.Context) error {
|
postExecutor = postExecutor.Finally(func(ctx context.Context) error {
|
||||||
jobError := common.JobError(ctx)
|
jobError := common.JobError(ctx)
|
||||||
var err error
|
var err error
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import (
|
|||||||
"gitea.com/gitea/runner/act/container"
|
"gitea.com/gitea/runner/act/container"
|
||||||
"gitea.com/gitea/runner/act/model"
|
"gitea.com/gitea/runner/act/model"
|
||||||
|
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
logrustest "github.com/sirupsen/logrus/hooks/test"
|
logrustest "github.com/sirupsen/logrus/hooks/test"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/mock"
|
"github.com/stretchr/testify/mock"
|
||||||
@@ -111,6 +112,182 @@ func (sfm *stepFactoryMock) newStep(model *model.Step, rc *RunContext) (step, er
|
|||||||
return args.Get(0).(step), args.Error(1)
|
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
|
||||||
|
ok bool
|
||||||
|
err error
|
||||||
|
prepared int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (apm *actionPreparerMock) prepareActionExecutor() common.Executor {
|
||||||
|
return func(context.Context) error {
|
||||||
|
apm.prepared++
|
||||||
|
return apm.err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (apm *actionPreparerMock) actionDownloadInfo() (string, string, bool) {
|
||||||
|
return apm.reference, apm.sha, apm.ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPrintPrepareActionsGolden(t *testing.T) {
|
||||||
|
buf := &bytes.Buffer{}
|
||||||
|
logger := log.New()
|
||||||
|
logger.SetOutput(buf)
|
||||||
|
logger.SetLevel(log.InfoLevel)
|
||||||
|
logger.SetFormatter(&jobLogFormatter{color: cyan})
|
||||||
|
ctx := common.WithLogger(context.Background(), logger.WithFields(log.Fields{"job": "j1"}))
|
||||||
|
|
||||||
|
preparers := []actionPreparer{
|
||||||
|
&actionPreparerMock{reference: "actions/checkout@v7", sha: "9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0", ok: true},
|
||||||
|
// A resolved commit is best effort; the ref alone is reported when it is unknown.
|
||||||
|
&actionPreparerMock{reference: "actions/setup-go@v6", ok: true},
|
||||||
|
// A step that downloads nothing, such as the checkout of the workflow's own repository.
|
||||||
|
&actionPreparerMock{ok: false},
|
||||||
|
}
|
||||||
|
require.NoError(t, printPrepareActions(&RunContext{}, preparers)(ctx))
|
||||||
|
|
||||||
|
want := strings.Join([]string{
|
||||||
|
"[j1] | Prepare all required actions",
|
||||||
|
"[j1] | Download action repository 'actions/checkout@v7' (SHA:9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0)",
|
||||||
|
"[j1] | Download action repository 'actions/setup-go@v6'",
|
||||||
|
"",
|
||||||
|
}, "\n")
|
||||||
|
assert.Equal(t, want, buf.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPrintPrepareActionsSkipsWithoutActions(t *testing.T) {
|
||||||
|
buf := &bytes.Buffer{}
|
||||||
|
logger := log.New()
|
||||||
|
logger.SetOutput(buf)
|
||||||
|
logger.SetFormatter(&jobLogFormatter{color: cyan})
|
||||||
|
ctx := common.WithLogger(context.Background(), logger.WithFields(log.Fields{"job": "j1"}))
|
||||||
|
|
||||||
|
require.NoError(t, printPrepareActions(&RunContext{}, nil)(ctx))
|
||||||
|
|
||||||
|
assert.Empty(t, buf.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPrintPrepareActionsFailsJobOnDownloadError(t *testing.T) {
|
||||||
|
logger, _ := logrustest.NewNullLogger()
|
||||||
|
ctx := common.WithJobErrorContainer(common.WithLogger(context.Background(), logger.WithField("job", "j1")))
|
||||||
|
|
||||||
|
downloadErr := errors.New("failed to fetch \"actions/checkout\"")
|
||||||
|
rc := &RunContext{}
|
||||||
|
remaining := &actionPreparerMock{reference: "actions/setup-go@v6", ok: true}
|
||||||
|
|
||||||
|
err := printPrepareActions(rc, []actionPreparer{
|
||||||
|
&actionPreparerMock{err: downloadErr},
|
||||||
|
remaining,
|
||||||
|
})(ctx)
|
||||||
|
|
||||||
|
require.ErrorIs(t, err, downloadErr)
|
||||||
|
// No step has run yet, so the failure has to be recorded against the job itself.
|
||||||
|
assert.Equal(t, downloadErr, common.JobError(ctx))
|
||||||
|
assert.True(t, rc.jobFailed)
|
||||||
|
assert.Zero(t, remaining.prepared)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 := log.New()
|
||||||
|
logger.SetOutput(buf)
|
||||||
|
logger.SetFormatter(&jobLogFormatter{color: cyan})
|
||||||
|
ctx := common.WithLogger(context.Background(), logger.WithFields(log.Fields{"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(context.Background())
|
||||||
|
jim := &jobInfoMock{}
|
||||||
|
sfm := &stepFactoryMock{}
|
||||||
|
rc := &RunContext{
|
||||||
|
JobContainer: &jobContainerMock{},
|
||||||
|
Run: &model.Run{
|
||||||
|
JobID: "test",
|
||||||
|
Workflow: &model.Workflow{
|
||||||
|
Jobs: map[string]*model.Job{"test": {}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Config: &Config{},
|
||||||
|
}
|
||||||
|
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, ok: true}
|
||||||
|
sfm.On("newStep", stepModel, rc).Return(&actionStepMock{stepMock: sm, actionPreparerMock: apm}, nil)
|
||||||
|
|
||||||
|
sm.On("pre").Return(func(context.Context) error {
|
||||||
|
executorOrder = append(executorOrder, "pre"+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 := logrustest.NewNullLogger()
|
||||||
|
err := newJobExecutor(jim, sfm, rc)(common.WithLogger(ctx, logger.WithField("job", "test")))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, []string{"pre1", "pre2", "step1", "step2"}, executorOrder)
|
||||||
|
|
||||||
|
setup := make([]string, 0)
|
||||||
|
for _, entry := range hook.AllEntries() {
|
||||||
|
if strings.HasPrefix(entry.Message, "Prepare all required actions") || strings.HasPrefix(entry.Message, "Download action") ||
|
||||||
|
strings.HasPrefix(entry.Message, "Complete job name") {
|
||||||
|
setup = append(setup, entry.Message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.Equal(t, []string{
|
||||||
|
"Prepare all required actions",
|
||||||
|
"Download action repository 'actions/checkout@v1'",
|
||||||
|
"Download action repository 'actions/checkout@v2'",
|
||||||
|
"Complete job name: test",
|
||||||
|
}, setup)
|
||||||
|
}
|
||||||
|
|
||||||
func TestNewJobExecutor(t *testing.T) {
|
func TestNewJobExecutor(t *testing.T) {
|
||||||
table := []struct {
|
table := []struct {
|
||||||
name string
|
name string
|
||||||
|
|||||||
@@ -107,7 +107,13 @@ func runStepExecutor(step step, stage stepStage, executor common.Executor) commo
|
|||||||
if strings.Contains(stepString, "::add-mask::") {
|
if strings.Contains(stepString, "::add-mask::") {
|
||||||
stepString = "add-mask command"
|
stepString = "add-mask command"
|
||||||
}
|
}
|
||||||
|
if stage == stepStageMain {
|
||||||
|
// Main steps print their own raw "Run <title>" header, so this line is redundant and
|
||||||
|
// only leaks into the "Set up job" section for the first step; keep it as a debug trace.
|
||||||
|
logger.Debugf("Run %s %s", stage, stepString)
|
||||||
|
} else {
|
||||||
logger.Infof("Run %s %s", stage, stepString)
|
logger.Infof("Run %s %s", stage, stepString)
|
||||||
|
}
|
||||||
|
|
||||||
// Prepare and clean Runner File Commands
|
// Prepare and clean Runner File Commands
|
||||||
actPath := rc.JobContainer.GetActPath()
|
actPath := rc.JobContainer.GetActPath()
|
||||||
|
|||||||
@@ -131,6 +131,8 @@ func (sar *stepActionRemote) prepareActionExecutor() common.Executor {
|
|||||||
Token: token,
|
Token: token,
|
||||||
OfflineMode: sar.RunContext.Config.ActionOfflineMode,
|
OfflineMode: sar.RunContext.Config.ActionOfflineMode,
|
||||||
Depth: sar.RunContext.Config.ActionCloneDepth,
|
Depth: sar.RunContext.Config.ActionCloneDepth,
|
||||||
|
// printPrepareActions reports the download with its resolved commit.
|
||||||
|
Quiet: true,
|
||||||
|
|
||||||
InsecureSkipTLS: sar.cloneSkipTLS(), // For Gitea
|
InsecureSkipTLS: sar.cloneSkipTLS(), // For Gitea
|
||||||
})
|
})
|
||||||
@@ -146,6 +148,13 @@ func (sar *stepActionRemote) prepareActionExecutor() common.Executor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Best effort: the download report falls back to the ref alone when the commit is unknown.
|
||||||
|
if _, sha, err := git.FindGitRevision(ctx, actionDir); err != nil {
|
||||||
|
common.Logger(ctx).Debugf("unable to resolve the commit of %s: %v", sar.remoteAction.Reference(), err)
|
||||||
|
} else {
|
||||||
|
sar.resolvedSha = sha
|
||||||
|
}
|
||||||
|
|
||||||
remoteReader := func(ctx context.Context) actionYamlReader { //nolint:unparam // pre-existing issue from nektos/act
|
remoteReader := func(ctx context.Context) actionYamlReader { //nolint:unparam // pre-existing issue from nektos/act
|
||||||
return func(filename string) (io.Reader, io.Closer, error) {
|
return func(filename string) (io.Reader, io.Closer, error) {
|
||||||
f, err := os.Open(filepath.Join(actionDir, sar.remoteAction.Path, filename))
|
f, err := os.Open(filepath.Join(actionDir, sar.remoteAction.Path, filename))
|
||||||
@@ -165,6 +174,15 @@ func (sar *stepActionRemote) prepareActionExecutor() common.Executor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// actionDownloadInfo reports the action this step downloaded and the commit it resolved to. ok is
|
||||||
|
// false when nothing was fetched, as for the local checkout of the workflow's own repository.
|
||||||
|
func (sar *stepActionRemote) actionDownloadInfo() (reference, sha string, ok bool) {
|
||||||
|
if sar.remoteAction == nil || sar.action == nil {
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
return sar.remoteAction.Reference(), sar.resolvedSha, true
|
||||||
|
}
|
||||||
|
|
||||||
func (sar *stepActionRemote) pre() common.Executor {
|
func (sar *stepActionRemote) pre() common.Executor {
|
||||||
sar.env = map[string]string{}
|
sar.env = map[string]string{}
|
||||||
|
|
||||||
@@ -313,6 +331,16 @@ func (ra *remoteAction) CloneURL(u string) string {
|
|||||||
return fmt.Sprintf("%s/%s/%s", u, ra.Org, ra.Repo)
|
return fmt.Sprintf("%s/%s/%s", u, ra.Org, ra.Repo)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reference renders the action as {org}/{repo}[/path]@{ref}, omitting the download source, which
|
||||||
|
// can be interpolated from a secret.
|
||||||
|
func (ra *remoteAction) Reference() string {
|
||||||
|
repo := fmt.Sprintf("%s/%s", ra.Org, ra.Repo)
|
||||||
|
if ra.Path != "" {
|
||||||
|
repo = fmt.Sprintf("%s/%s", repo, ra.Path)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s@%s", repo, ra.Ref)
|
||||||
|
}
|
||||||
|
|
||||||
func (ra *remoteAction) IsCheckout() bool {
|
func (ra *remoteAction) IsCheckout() bool {
|
||||||
if ra.Org == "actions" && ra.Repo == "checkout" {
|
if ra.Org == "actions" && ra.Repo == "checkout" {
|
||||||
return true
|
return true
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -818,6 +821,97 @@ func Test_newRemoteAction(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func Test_remoteActionReference(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
uses string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{uses: "actions/checkout@v7", want: "actions/checkout@v7"},
|
||||||
|
{uses: "actions/aws/ec2@main", want: "actions/aws/ec2@main"},
|
||||||
|
// The download source can be interpolated from a secret and must stay out of the log.
|
||||||
|
{uses: "https://gitea.example.com/actions/checkout@v7", want: "actions/checkout@v7"},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.uses, func(t *testing.T) {
|
||||||
|
assert.Equal(t, tt.want, newRemoteAction(tt.uses).Reference())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestStepActionRemotePreResolvesDownloadedCommit runs the real download path against a local
|
||||||
|
// git repository standing in for the actions instance, so the reported commit is the one the
|
||||||
|
// clone actually checked out.
|
||||||
|
func TestStepActionRemotePreResolvesDownloadedCommit(t *testing.T) {
|
||||||
|
instance := t.TempDir()
|
||||||
|
actionDir := filepath.Join(instance, "actions", "setup-go")
|
||||||
|
require.NoError(t, os.MkdirAll(actionDir, 0o755))
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(actionDir, "action.yml"),
|
||||||
|
[]byte("name: setup-go\nruns:\n using: node20\n main: index.js\n"), 0o600))
|
||||||
|
|
||||||
|
// Supply an identity on the commit so the test does not depend on a
|
||||||
|
// git identity being configured in the environment; a CI runner without
|
||||||
|
// user.name/user.email would otherwise fail "commit" with exit code 128.
|
||||||
|
for _, args := range [][]string{
|
||||||
|
{"init", "--initial-branch=main", actionDir},
|
||||||
|
{"-C", actionDir, "add", "action.yml"},
|
||||||
|
{"-C", actionDir, "-c", "user.name=runner", "-c", "user.email=runner@example.com", "-c", "commit.gpgsign=false", "commit", "-m", "action"},
|
||||||
|
} {
|
||||||
|
cmd := exec.Command("git", args...)
|
||||||
|
require.NoError(t, cmd.Run(), "git %v", args)
|
||||||
|
}
|
||||||
|
out, err := exec.Command("git", "-C", actionDir, "rev-parse", "HEAD").Output()
|
||||||
|
require.NoError(t, err)
|
||||||
|
wantSha := strings.TrimSpace(string(out))
|
||||||
|
|
||||||
|
sar := &stepActionRemote{
|
||||||
|
Step: &model.Step{Uses: "actions/setup-go@main"},
|
||||||
|
RunContext: &RunContext{
|
||||||
|
Config: &Config{
|
||||||
|
GitHubInstance: "https://gitea.example.com",
|
||||||
|
DefaultActionInstance: instance,
|
||||||
|
ActionCacheDir: t.TempDir(),
|
||||||
|
},
|
||||||
|
Run: &model.Run{
|
||||||
|
JobID: "1",
|
||||||
|
Workflow: &model.Workflow{Jobs: map[string]*model.Job{"1": {}}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
readAction: readActionImpl,
|
||||||
|
}
|
||||||
|
|
||||||
|
require.NoError(t, sar.prepareActionExecutor()(context.Background()))
|
||||||
|
|
||||||
|
reference, sha, ok := sar.actionDownloadInfo()
|
||||||
|
assert.True(t, ok)
|
||||||
|
assert.Equal(t, "actions/setup-go@main", reference)
|
||||||
|
assert.Equal(t, wantSha, sha)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStepActionRemoteActionDownloadInfo(t *testing.T) {
|
||||||
|
t.Run("reports the action and its resolved commit", func(t *testing.T) {
|
||||||
|
sar := &stepActionRemote{
|
||||||
|
remoteAction: newRemoteAction("actions/checkout@v7"),
|
||||||
|
action: &model.Action{},
|
||||||
|
resolvedSha: "9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0",
|
||||||
|
}
|
||||||
|
|
||||||
|
reference, sha, ok := sar.actionDownloadInfo()
|
||||||
|
|
||||||
|
assert.True(t, ok)
|
||||||
|
assert.Equal(t, "actions/checkout@v7", reference)
|
||||||
|
assert.Equal(t, "9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0", sha)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("reports nothing when no action was downloaded", func(t *testing.T) {
|
||||||
|
// The local checkout of the workflow's own repository resolves no action.
|
||||||
|
sar := &stepActionRemote{remoteAction: newRemoteAction("actions/checkout@v7")}
|
||||||
|
|
||||||
|
_, _, ok := sar.actionDownloadInfo()
|
||||||
|
|
||||||
|
assert.False(t, ok)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func Test_safeFilename(t *testing.T) {
|
func Test_safeFilename(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
s string
|
s string
|
||||||
|
|||||||
@@ -315,7 +315,7 @@ func (r *Runner) run(ctx context.Context, task *runnerv1.Task, reporter *report.
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
reporter.Logf("%s(version:%s) received task %v of job %v, be triggered by event: %s", r.name, ver.Version(), task.Id, task.Context.Fields["job"].GetStringValue(), task.Context.Fields["event_name"].GetStringValue())
|
r.reportSetup(reporter, task)
|
||||||
|
|
||||||
workflow, jobID, err := generateWorkflow(task)
|
workflow, jobID, err := generateWorkflow(task)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
83
internal/app/run/setup.go
Normal file
83
internal/app/run/setup.go
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package run
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"runtime"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gitea.com/gitea/runner/internal/pkg/report"
|
||||||
|
"gitea.com/gitea/runner/internal/pkg/ver"
|
||||||
|
|
||||||
|
runnerv1 "gitea.dev/actions-proto-go/runner/v1"
|
||||||
|
)
|
||||||
|
|
||||||
|
// osReleasePath describes the host distribution on Linux; absent elsewhere, where the platform
|
||||||
|
// falls back to the Go runtime alone. A var so tests can point it at a fixture.
|
||||||
|
var osReleasePath = "/etc/os-release"
|
||||||
|
|
||||||
|
// reportSetup opens the job log the way actions/runner opens its "Set up job" step. The action
|
||||||
|
// downloads and the closing job name are written later, as the job starts.
|
||||||
|
func (r *Runner) reportSetup(reporter *report.Reporter, task *runnerv1.Task) {
|
||||||
|
for _, line := range r.setupLines(task) {
|
||||||
|
reporter.Logf("%s", line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// setupLines names the runner, then reports what it was asked to run and the host it runs on, each
|
||||||
|
// in its own group.
|
||||||
|
func (r *Runner) setupLines(task *runnerv1.Task) []string {
|
||||||
|
fields := task.Context.Fields
|
||||||
|
lines := []string{
|
||||||
|
fmt.Sprintf("%s(version:%s)", r.name, ver.Version()),
|
||||||
|
"::group::Runner Information",
|
||||||
|
}
|
||||||
|
if names := r.labels.Names(); len(names) > 0 {
|
||||||
|
lines = append(lines, "Runner labels: "+strings.Join(names, ", "))
|
||||||
|
}
|
||||||
|
lines = append(lines,
|
||||||
|
// The task id correlates the job log with the runner's log and the server's task list.
|
||||||
|
fmt.Sprintf("Task: %d", task.Id),
|
||||||
|
"Job: "+fields["job"].GetStringValue(),
|
||||||
|
"Repository: "+fields["repository"].GetStringValue(),
|
||||||
|
"Triggered by event: "+fields["event_name"].GetStringValue(),
|
||||||
|
"::endgroup::",
|
||||||
|
"::group::Operating System",
|
||||||
|
)
|
||||||
|
lines = append(lines, osInfo()...)
|
||||||
|
return append(lines, "::endgroup::")
|
||||||
|
}
|
||||||
|
|
||||||
|
// osInfo describes the host the runner executes on.
|
||||||
|
func osInfo() []string {
|
||||||
|
lines := make([]string, 0, 2)
|
||||||
|
if name := prettyOSName(); name != "" {
|
||||||
|
lines = append(lines, name)
|
||||||
|
}
|
||||||
|
return append(lines, fmt.Sprintf("%s/%s", runtime.GOOS, runtime.GOARCH))
|
||||||
|
}
|
||||||
|
|
||||||
|
// prettyOSName reads PRETTY_NAME (e.g. "Ubuntu 24.04.4 LTS") from os-release, or "" when absent.
|
||||||
|
func prettyOSName() string {
|
||||||
|
data, err := os.ReadFile(osReleasePath)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
for line := range strings.SplitSeq(string(data), "\n") {
|
||||||
|
key, value, ok := strings.Cut(strings.TrimSpace(line), "=")
|
||||||
|
if !ok || key != "PRETTY_NAME" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Values are shell-quoted, but the quotes are optional.
|
||||||
|
if unquoted, err := strconv.Unquote(value); err == nil {
|
||||||
|
return unquoted
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
103
internal/app/run/setup_test.go
Normal file
103
internal/app/run/setup_test.go
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package run
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.com/gitea/runner/internal/pkg/labels"
|
||||||
|
"gitea.com/gitea/runner/internal/pkg/ver"
|
||||||
|
|
||||||
|
runnerv1 "gitea.dev/actions-proto-go/runner/v1"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"google.golang.org/protobuf/types/known/structpb"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSetupLines(t *testing.T) {
|
||||||
|
original := osReleasePath
|
||||||
|
path := filepath.Join(t.TempDir(), "os-release")
|
||||||
|
require.NoError(t, os.WriteFile(path, []byte("PRETTY_NAME=\"Ubuntu 24.04.4 LTS\"\n"), 0o600))
|
||||||
|
osReleasePath = path
|
||||||
|
defer func() { osReleasePath = original }()
|
||||||
|
|
||||||
|
r := &Runner{
|
||||||
|
name: "gitea-com-gitea-0003",
|
||||||
|
labels: labels.Labels{
|
||||||
|
{Name: "ubuntu-latest", Schema: labels.SchemeDocker, Arg: "//node:20"},
|
||||||
|
{Name: "ubuntu-22.04", Schema: labels.SchemeDocker, Arg: "//node:20"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
taskCtx, err := structpb.NewStruct(map[string]any{
|
||||||
|
"job": "lint",
|
||||||
|
"repository": "gitea/runner",
|
||||||
|
"event_name": "pull_request",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, []string{
|
||||||
|
"gitea-com-gitea-0003(version:" + ver.Version() + ")",
|
||||||
|
"::group::Runner Information",
|
||||||
|
"Runner labels: ubuntu-latest, ubuntu-22.04",
|
||||||
|
"Task: 268506",
|
||||||
|
"Job: lint",
|
||||||
|
"Repository: gitea/runner",
|
||||||
|
"Triggered by event: pull_request",
|
||||||
|
"::endgroup::",
|
||||||
|
"::group::Operating System",
|
||||||
|
"Ubuntu 24.04.4 LTS",
|
||||||
|
fmt.Sprintf("%s/%s", runtime.GOOS, runtime.GOARCH),
|
||||||
|
"::endgroup::",
|
||||||
|
}, r.setupLines(&runnerv1.Task{Id: 268506, Context: taskCtx}))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPrettyOSName(t *testing.T) {
|
||||||
|
tests := map[string]struct {
|
||||||
|
osRelease string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
"quoted value": {
|
||||||
|
osRelease: "NAME=\"Ubuntu\"\nVERSION_ID=\"24.04\"\nPRETTY_NAME=\"Ubuntu 24.04.4 LTS\"\n",
|
||||||
|
want: "Ubuntu 24.04.4 LTS",
|
||||||
|
},
|
||||||
|
"unquoted value": {
|
||||||
|
osRelease: "PRETTY_NAME=Alpine Linux v3.21\n",
|
||||||
|
want: "Alpine Linux v3.21",
|
||||||
|
},
|
||||||
|
"no pretty name": {
|
||||||
|
osRelease: "NAME=\"Ubuntu\"\nVERSION_ID=\"24.04\"\n",
|
||||||
|
want: "",
|
||||||
|
},
|
||||||
|
// A key that merely ends in PRETTY_NAME must not be mistaken for it.
|
||||||
|
"similar key": {
|
||||||
|
osRelease: "IMAGE_PRETTY_NAME=\"Ubuntu Core 24\"\n",
|
||||||
|
want: "",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for name, tt := range tests {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "os-release")
|
||||||
|
require.NoError(t, os.WriteFile(path, []byte(tt.osRelease), 0o600))
|
||||||
|
|
||||||
|
original := osReleasePath
|
||||||
|
osReleasePath = path
|
||||||
|
defer func() { osReleasePath = original }()
|
||||||
|
|
||||||
|
assert.Equal(t, tt.want, prettyOSName())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("missing file", func(t *testing.T) {
|
||||||
|
original := osReleasePath
|
||||||
|
osReleasePath = filepath.Join(t.TempDir(), "absent")
|
||||||
|
defer func() { osReleasePath = original }()
|
||||||
|
|
||||||
|
assert.Empty(t, prettyOSName())
|
||||||
|
})
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user