mirror of
https://gitea.com/gitea/runner.git
synced 2026-11-08 00:00:28 +00:00
fix: handle workflow commands like GitHub's runner (#1333)
Commands the runner applies itself, like the `::set-output::` older actions still print, showed up raw in the job log. The reporter also re-parsed every line with one job-wide stop-commands and echo state, while act keeps one command handler per step, stage, composite child and hook, as GitHub does. A `::stop-commands::` left active in one of them kept the reporter suppressed for the others, so their `::add-mask::` values were never registered, and post steps logged command lines twice. - act's command handler decides what reaches the log, as GitHub's runner does: applied commands only under `ACTIONS_STEP_DEBUG` or `::echo::on`, `::stop-commands::` and its resume line shown with long tokens masked - Masks go straight to the reporter, which no longer parses command lines - One parser follows GitHub's rules: leading whitespace, `##[...]` anywhere in a line, registered commands only - Service logs honour only `::add-mask::` --------- Co-authored-by: bircni <[email protected]> Reviewed-on: https://gitea.com/gitea/runner/pulls/1333 Reviewed-by: bircni <[email protected]> Co-authored-by: silverwind <[email protected]>
This commit is contained in:
1 parent
befb1b4bb9
commit
430f7bb2cf
15 files changed
+374
-729
No files matched your search
+16
-151
@@ -9,7 +9,6 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/rand/v2"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -101,9 +100,6 @@ type Reporter struct {
|
||||
logNotify chan struct{} // signal: new log rows arrived
|
||||
stateNotify chan struct{} // signal: step transition (start/stop)
|
||||
|
||||
debugOutputEnabled bool
|
||||
stopCommandEndToken string
|
||||
|
||||
jobLog *jobLog // this task's rows on the runner's own disk, nil when log.job.dir is unset
|
||||
}
|
||||
|
||||
@@ -156,10 +152,6 @@ func NewReporter(ctx context.Context, cancel context.CancelFunc, client client.C
|
||||
|
||||
registerGlobalMasks(rv)
|
||||
|
||||
if task.Secrets["ACTIONS_STEP_DEBUG"] == "true" {
|
||||
rv.debugOutputEnabled = true
|
||||
}
|
||||
|
||||
return rv
|
||||
}
|
||||
|
||||
@@ -345,11 +337,8 @@ func (h *reporterHandler) WithAttrs(attrs []logging.Attr) logging.Handler {
|
||||
}
|
||||
|
||||
// appendLogRow masks a row before buffering it for Gitea and the local job.log, the one point
|
||||
// feeding both. A nil row is one the command handler dropped. Caller holds stateMu.
|
||||
// feeding both. Caller holds stateMu.
|
||||
func (r *Reporter) appendLogRow(row *runnerv1.LogRow) {
|
||||
if row == nil {
|
||||
return
|
||||
}
|
||||
row.Content = r.mask(row.Content)
|
||||
if step := r.currentReportedStep; step != nil {
|
||||
if step.LogLength == 0 {
|
||||
@@ -461,7 +450,7 @@ func (r *Reporter) Handle(_ context.Context, entry logging.Record) error {
|
||||
}
|
||||
}
|
||||
if r.shouldAppendLogRow(entry) {
|
||||
r.appendLogRow(r.parseLogRow(entry))
|
||||
r.appendLogRow(logRow(entry))
|
||||
}
|
||||
r.unlockAndNotify(urgentState)
|
||||
return nil
|
||||
@@ -475,7 +464,7 @@ func (r *Reporter) Handle(_ context.Context, entry logging.Record) error {
|
||||
}
|
||||
if step == nil {
|
||||
if r.shouldAppendLogRow(entry) {
|
||||
r.appendLogRow(r.parseLogRow(entry))
|
||||
r.appendLogRow(logRow(entry))
|
||||
}
|
||||
r.unlockAndNotify(false)
|
||||
return nil
|
||||
@@ -484,9 +473,6 @@ func (r *Reporter) Handle(_ context.Context, entry logging.Record) error {
|
||||
if step.StartedAt == nil {
|
||||
step.StartedAt = timestamppb.New(timestamp)
|
||||
urgentState = true
|
||||
// The runner's own handler is per step, so an unresumed ::stop-commands:: must not
|
||||
// leave the reporter suppressed, and no longer registering masks, for the whole job.
|
||||
r.stopCommandEndToken = ""
|
||||
}
|
||||
|
||||
// Force reporting log errors as raw output to prevent silent failures
|
||||
@@ -497,16 +483,14 @@ func (r *Reporter) Handle(_ context.Context, entry logging.Record) error {
|
||||
|
||||
if ok {
|
||||
if rawOutput, ok := v.(bool); ok && rawOutput {
|
||||
if row := r.parseLogRow(entry); row != nil {
|
||||
if step.LogLength == 0 {
|
||||
step.LogIndex = int64(r.logOffset + len(r.logRows))
|
||||
}
|
||||
step.LogLength++
|
||||
r.appendLogRow(row)
|
||||
if step.LogLength == 0 {
|
||||
step.LogIndex = int64(r.logOffset + len(r.logRows))
|
||||
}
|
||||
step.LogLength++
|
||||
r.appendLogRow(logRow(entry))
|
||||
}
|
||||
} else if r.shouldAppendLogRow(entry) {
|
||||
r.appendLogRow(r.parseLogRow(entry))
|
||||
r.appendLogRow(logRow(entry))
|
||||
}
|
||||
if v, ok := entry.Attr("stepResult"); ok && isJobStepEntry(entry) {
|
||||
if stepResult, ok := r.parseResult(v); ok {
|
||||
@@ -654,8 +638,8 @@ func (r *Reporter) Logf(format string, a ...any) {
|
||||
|
||||
func (r *Reporter) logf(format string, a ...any) {
|
||||
if !r.duringSteps() {
|
||||
// Masked like any other row: these bypass parseLogRow, but a caller can still
|
||||
// interpolate a secret, such as a configured URL carrying credentials.
|
||||
// Masked like any other row, as a caller can interpolate a secret such as a configured URL
|
||||
// carrying credentials.
|
||||
r.appendLogRow(&runnerv1.LogRow{Time: timestamppb.Now(), Content: fmt.Sprintf(format, a...)})
|
||||
}
|
||||
}
|
||||
@@ -1000,129 +984,8 @@ func (r *Reporter) parseResult(result any) (runnerv1.Result, bool) {
|
||||
return ret, ok
|
||||
}
|
||||
|
||||
// A property value never contains a raw ':' (GitHub escapes it as %3A), so excluding ':' ends
|
||||
// the property list at the first '::' as GitHub does; greedily would swallow a '::' message.
|
||||
var cmdRegex = regexp.MustCompile(`^::([^ :]+)( [^:]*)?::(.*)$`)
|
||||
|
||||
// handleCommand takes value still escaped, so that the web UI decodes it exactly once. Only
|
||||
// the branches that consume the payload here decode it.
|
||||
func (r *Reporter) handleCommand(originalContent, command, properties, value string) *string {
|
||||
command = strings.ToLower(command) // GitHub matches command names case-insensitively
|
||||
if r.stopCommandEndToken != "" {
|
||||
if !strings.EqualFold(command, r.stopCommandEndToken) {
|
||||
return &originalContent
|
||||
}
|
||||
// Resumed here rather than from the switch, because the end token is arbitrary and a
|
||||
// token naming a real command would otherwise never resume.
|
||||
r.stopCommandEndToken = ""
|
||||
return nil
|
||||
}
|
||||
|
||||
switch command {
|
||||
case "add-mask":
|
||||
r.addMask(runner.UnescapeCommandData(value))
|
||||
return nil
|
||||
case "debug":
|
||||
if r.debugOutputEnabled {
|
||||
return &originalContent // kept as ::debug::, so the web UI labels and decodes it
|
||||
}
|
||||
return nil
|
||||
|
||||
case "notice", "warning", "error":
|
||||
// Gitea has no annotation store, so the annotation is rendered into the log with
|
||||
// its source location instead of being dropped: that location is the whole point
|
||||
// of the command for compiler and linter output.
|
||||
annotation := formatAnnotation(command, properties, value)
|
||||
return &annotation
|
||||
case "group", "endgroup":
|
||||
// Passed through: the web UI folds the log on these and decodes the payload itself.
|
||||
return &originalContent
|
||||
case "stop-commands":
|
||||
r.stopCommandEndToken = runner.UnescapeCommandData(value)
|
||||
return nil
|
||||
}
|
||||
return &originalContent
|
||||
}
|
||||
|
||||
// formatAnnotation folds the file, line, column and title the command carries into its message,
|
||||
// which the web UI otherwise drops along with the rest of the properties:
|
||||
//
|
||||
// ::error file=main.go,line=12,col=5,title=vet::undefined: x
|
||||
// ::error::main.go:12:5: vet: undefined: x
|
||||
//
|
||||
// The ::-form prefix is deliberate, and value is not escaped here because it arrived escaped
|
||||
// and must stay that way.
|
||||
func formatAnnotation(level, properties, value string) string {
|
||||
props := parseCommandProperties(properties)
|
||||
|
||||
prefix := props["file"]
|
||||
if prefix != "" {
|
||||
if props["line"] != "" {
|
||||
prefix += ":" + props["line"]
|
||||
if props["col"] != "" {
|
||||
prefix += ":" + props["col"]
|
||||
}
|
||||
}
|
||||
prefix += ": "
|
||||
}
|
||||
if props["title"] != "" {
|
||||
prefix += props["title"] + ": "
|
||||
}
|
||||
return "::" + level + "::" + prefix + value
|
||||
}
|
||||
|
||||
// parseCommandProperties parses the `file=main.go,line=12` part of a workflow command.
|
||||
func parseCommandProperties(properties string) map[string]string {
|
||||
properties = strings.TrimSpace(properties)
|
||||
if properties == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
props := map[string]string{}
|
||||
for pair := range strings.SplitSeq(properties, ",") {
|
||||
key, value, ok := strings.Cut(pair, "=")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
// Only the property-list separators are decoded, the web UI decodes the rest.
|
||||
value = strings.ReplaceAll(strings.ReplaceAll(value, "%3A", ":"), "%2C", ",")
|
||||
// GitHub keys its property dictionary case-insensitively, so `File=` works there too.
|
||||
props[strings.ToLower(strings.TrimSpace(key))] = value
|
||||
}
|
||||
// GitHub's toolkit emits `col`; accept `column` as well, which some tools write instead.
|
||||
if props["col"] == "" {
|
||||
props["col"] = props["column"]
|
||||
}
|
||||
return props
|
||||
}
|
||||
|
||||
func cutPrefixFold(s, prefix string) (string, bool) {
|
||||
if len(s) < len(prefix) || !strings.EqualFold(s[:len(prefix)], prefix) {
|
||||
return s, false
|
||||
}
|
||||
return s[len(prefix):], true
|
||||
}
|
||||
|
||||
func (r *Reporter) parseLogRow(entry logging.Record) *runnerv1.LogRow {
|
||||
content := strings.TrimRight(entry.Message, "\r\n")
|
||||
|
||||
// cmdRegex only covers the ::cmd:: form, so the ##[add-mask] one would otherwise reach
|
||||
// the log carrying its own secret. Registered and dropped like its ::add-mask:: twin.
|
||||
if arg, ok := cutPrefixFold(content, "##[add-mask]"); ok {
|
||||
r.addMask(runner.UnescapeLegacyCommand(arg))
|
||||
return nil
|
||||
}
|
||||
|
||||
matches := cmdRegex.FindStringSubmatch(content)
|
||||
if matches != nil {
|
||||
if output := r.handleCommand(content, matches[1], matches[2], matches[3]); output != nil {
|
||||
content = *output
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
return &runnerv1.LogRow{Time: timestamppb.New(entry.Time), Content: content}
|
||||
func logRow(entry logging.Record) *runnerv1.LogRow {
|
||||
return &runnerv1.LogRow{Time: timestamppb.New(entry.Time), Content: strings.TrimRight(entry.Message, "\r\n")}
|
||||
}
|
||||
|
||||
// mask repairs the content first, so a secret the repair itself spells out is still caught.
|
||||
@@ -1130,10 +993,12 @@ func (r *Reporter) mask(content string) string {
|
||||
return r.logReplacer.Replace(strings.ToValidUTF8(content, "?"))
|
||||
}
|
||||
|
||||
// addMask deliberately leaves the process-wide masker alone. Its entries come from every live
|
||||
// AddMask deliberately leaves the process-wide masker alone. Its entries come from every live
|
||||
// task at once, so a workflow could otherwise mask "error" there and rewrite the runner's own
|
||||
// log, and every other task's, for the rest of the job.
|
||||
func (r *Reporter) addMask(msg string) {
|
||||
func (r *Reporter) AddMask(msg string) {
|
||||
r.stateMu.Lock()
|
||||
defer r.stateMu.Unlock()
|
||||
r.oldnew = runner.AppendSecretMasker(r.oldnew, msg)
|
||||
r.logReplacer = runner.NewSecretReplacer(r.oldnew)
|
||||
}
|
||||
Reference in new issue
Block a user