diff --git a/act/common/git/git.go b/act/common/git/git.go index 243aa5db..0ef1eab5 100644 --- a/act/common/git/git.go +++ b/act/common/git/git.go @@ -261,6 +261,10 @@ type NewGitCloneExecutorInput struct { // 0 for full clone. 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 InsecureSkipTLS bool } @@ -347,7 +351,11 @@ func gitOptions(token string) (fetchOptions git.FetchOptions, pullOptions git.Pu func NewGitCloneExecutor(input NewGitCloneExecutorInput) common.Executor { return func(ctx context.Context) error { logger := common.Logger(ctx) - logger.Infof("git clone '%s' # ref=%s", input.URL, input.Ref) + 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.Debugf(" cloning %s to %s", input.URL, input.Dir) defer AcquireCloneLock(input.Dir)() diff --git a/act/common/git/git_test.go b/act/common/git/git_test.go index 271080b7..02811acd 100644 --- a/act/common/git/git_test.go +++ b/act/common/git/git_test.go @@ -17,6 +17,10 @@ import ( "testing" "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/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) { // 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() diff --git a/act/runner/job_executor.go b/act/runner/job_executor.go index ed27f1c5..099362fb 100644 --- a/act/runner/job_executor.go +++ b/act/runner/job_executor.go @@ -71,9 +71,67 @@ func reportStepError(ctx context.Context, rc *RunContext, err error) { 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 { steps := 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 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) } + if preparer, ok := step.(actionPreparer); ok { + preparers = append(preparers, preparer) + } + stepIdx := stepModel.Number 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 preErr := preExec(ctx) 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 { jobError := common.JobError(ctx) var err error diff --git a/act/runner/job_executor_test.go b/act/runner/job_executor_test.go index 4c1cf7f0..aa723721 100644 --- a/act/runner/job_executor_test.go +++ b/act/runner/job_executor_test.go @@ -24,6 +24,7 @@ import ( "gitea.com/gitea/runner/act/container" "gitea.com/gitea/runner/act/model" + log "github.com/sirupsen/logrus" logrustest "github.com/sirupsen/logrus/hooks/test" "github.com/stretchr/testify/assert" "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) } +// 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) { table := []struct { name string diff --git a/act/runner/step.go b/act/runner/step.go index c67f5ba7..01a44108 100644 --- a/act/runner/step.go +++ b/act/runner/step.go @@ -107,7 +107,13 @@ func runStepExecutor(step step, stage stepStage, executor common.Executor) commo if strings.Contains(stepString, "::add-mask::") { stepString = "add-mask command" } - logger.Infof("Run %s %s", stage, stepString) + if stage == stepStageMain { + // Main steps print their own raw "Run " 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) + } // Prepare and clean Runner File Commands actPath := rc.JobContainer.GetActPath() diff --git a/act/runner/step_action_remote.go b/act/runner/step_action_remote.go index b61276a0..0cccb729 100644 --- a/act/runner/step_action_remote.go +++ b/act/runner/step_action_remote.go @@ -131,6 +131,8 @@ func (sar *stepActionRemote) prepareActionExecutor() common.Executor { Token: token, OfflineMode: sar.RunContext.Config.ActionOfflineMode, Depth: sar.RunContext.Config.ActionCloneDepth, + // printPrepareActions reports the download with its resolved commit. + Quiet: true, 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 return func(filename string) (io.Reader, io.Closer, error) { 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 { 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) } +// 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 { if ra.Org == "actions" && ra.Repo == "checkout" { return true diff --git a/act/runner/step_action_remote_test.go b/act/runner/step_action_remote_test.go index 6759b78a..473cbb62 100644 --- a/act/runner/step_action_remote_test.go +++ b/act/runner/step_action_remote_test.go @@ -10,6 +10,9 @@ import ( "errors" "fmt" "io" + "os" + "os/exec" + "path/filepath" "strings" "testing" "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) { tests := []struct { s string diff --git a/internal/app/run/runner.go b/internal/app/run/runner.go index d041aff0..f2ed4f7a 100644 --- a/internal/app/run/runner.go +++ b/internal/app/run/runner.go @@ -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) if err != nil { diff --git a/internal/app/run/setup.go b/internal/app/run/setup.go new file mode 100644 index 00000000..d4806d5c --- /dev/null +++ b/internal/app/run/setup.go @@ -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 "" +} diff --git a/internal/app/run/setup_test.go b/internal/app/run/setup_test.go new file mode 100644 index 00000000..9ef00d83 --- /dev/null +++ b/internal/app/run/setup_test.go @@ -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()) + }) +}