mirror of
https://gitea.com/gitea/act_runner.git
synced 2026-06-22 01:34:25 +02:00
Compare commits
6 Commits
v1.0.7
...
443b0e336c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
443b0e336c | ||
|
|
53c4db6a4b | ||
|
|
1073c8bfec | ||
|
|
ff7d9ca8d0 | ||
|
|
984b47c716 | ||
|
|
c749e52bb7 |
@@ -265,8 +265,23 @@ type NewGitCloneExecutorInput struct {
|
|||||||
func CloneIfRequired(ctx context.Context, refName plumbing.ReferenceName, input NewGitCloneExecutorInput, logger log.FieldLogger) (*git.Repository, bool, error) {
|
func CloneIfRequired(ctx context.Context, refName plumbing.ReferenceName, input NewGitCloneExecutorInput, logger log.FieldLogger) (*git.Repository, bool, error) {
|
||||||
r, err := git.PlainOpen(input.Dir)
|
r, err := git.PlainOpen(input.Dir)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
// Reuse existing clone
|
// Verify the cached clone still points to the resolved URL before reusing it.
|
||||||
return r, true, nil
|
remote, err := r.Remote("origin")
|
||||||
|
if err == nil && len(remote.Config().URLs) > 0 && remote.Config().URLs[0] == input.URL {
|
||||||
|
// Reuse existing clone
|
||||||
|
return r, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
logger.Debugf("Removing cached clone at %s because origin cannot be read: %v", input.Dir, err)
|
||||||
|
} else if len(remote.Config().URLs) == 0 {
|
||||||
|
logger.Debugf("Removing cached clone at %s because origin has no URL", input.Dir)
|
||||||
|
} else {
|
||||||
|
logger.Debugf("Removing cached clone at %s because origin URL changed from %s to %s", input.Dir, remote.Config().URLs[0], input.URL)
|
||||||
|
}
|
||||||
|
if err := os.RemoveAll(input.Dir); err != nil {
|
||||||
|
return nil, false, fmt.Errorf("remove cached clone %s: %w", input.Dir, err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var progressWriter io.Writer
|
var progressWriter io.Writer
|
||||||
|
|||||||
@@ -235,6 +235,51 @@ func TestGitCloneExecutor(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestGitCloneExecutorReclonesWhenOriginURLChanges(t *testing.T) {
|
||||||
|
createRemote := func(message string) string {
|
||||||
|
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", message))
|
||||||
|
require.NoError(t, gitCmd("-C", workDir, "push", "-u", "origin", "main"))
|
||||||
|
|
||||||
|
return remoteDir
|
||||||
|
}
|
||||||
|
|
||||||
|
oldRemoteDir := createRemote("old-action")
|
||||||
|
newRemoteDir := createRemote("new-action")
|
||||||
|
cacheDir := t.TempDir()
|
||||||
|
|
||||||
|
require.NoError(t, NewGitCloneExecutor(NewGitCloneExecutorInput{
|
||||||
|
URL: oldRemoteDir,
|
||||||
|
Ref: "main",
|
||||||
|
Dir: cacheDir,
|
||||||
|
})(t.Context()))
|
||||||
|
|
||||||
|
markerPath := filepath.Join(cacheDir, "stale-marker")
|
||||||
|
require.NoError(t, os.WriteFile(markerPath, []byte("stale"), 0o644))
|
||||||
|
|
||||||
|
require.NoError(t, NewGitCloneExecutor(NewGitCloneExecutorInput{
|
||||||
|
URL: newRemoteDir,
|
||||||
|
Ref: "main",
|
||||||
|
Dir: cacheDir,
|
||||||
|
})(t.Context()))
|
||||||
|
|
||||||
|
originURL, err := findGitRemoteURL(t.Context(), cacheDir, "origin")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, newRemoteDir, originURL)
|
||||||
|
|
||||||
|
out, err := exec.Command("git", "-C", cacheDir, "log", "--oneline", "-1", "--format=%s").Output()
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "new-action", strings.TrimSpace(string(out)))
|
||||||
|
|
||||||
|
_, err = os.Stat(markerPath)
|
||||||
|
require.True(t, os.IsNotExist(err), "stale cached directory should be removed before recloning")
|
||||||
|
}
|
||||||
|
|
||||||
func TestGitCloneExecutorNonFastForwardRef(t *testing.T) {
|
func TestGitCloneExecutorNonFastForwardRef(t *testing.T) {
|
||||||
// Simulate the scenario where a remote ref (e.g. a GitHub PR head ref) changes
|
// Simulate the scenario where a remote ref (e.g. a GitHub PR head ref) changes
|
||||||
// non-fast-forward between two fetches. Before the fix, the fetch used Force=false,
|
// non-fast-forward between two fetches. Before the fix, the fetch used Force=false,
|
||||||
|
|||||||
@@ -322,6 +322,30 @@ func (e *HostEnvironment) exec(ctx context.Context, command []string, cmdline st
|
|||||||
cmd.Stderr = e.StdOut
|
cmd.Stderr = e.StdOut
|
||||||
cmd.Dir = wd
|
cmd.Dir = wd
|
||||||
cmd.SysProcAttr = getSysProcAttr(cmdline, false)
|
cmd.SysProcAttr = getSysProcAttr(cmdline, false)
|
||||||
|
|
||||||
|
// On Windows a step often launches a process tree (a shell that starts a
|
||||||
|
// child which spawns further GUI or background processes). The default
|
||||||
|
// context cancellation only kills the direct child, leaving the rest of the
|
||||||
|
// tree running; and because the orphans inherit cmd's stdout/stderr pipe,
|
||||||
|
// cmd.Wait() would block forever, hanging the runner. Kill the whole tree
|
||||||
|
// via a Job Object on cancellation, and bound the wait so a leftover pipe
|
||||||
|
// writer can never hang Wait indefinitely.
|
||||||
|
var killer atomic.Pointer[processKiller]
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
cmd.Cancel = func() error {
|
||||||
|
if k := killer.Load(); k != nil {
|
||||||
|
return k.Kill()
|
||||||
|
}
|
||||||
|
if cmd.Process != nil {
|
||||||
|
return cmd.Process.Kill()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// Once the step process has exited, give its I/O pipes at most this long
|
||||||
|
// to drain before Wait force-closes them and returns (Go's WaitDelay).
|
||||||
|
cmd.WaitDelay = 10 * time.Second
|
||||||
|
}
|
||||||
|
|
||||||
var ppty *os.File
|
var ppty *os.File
|
||||||
var tty *os.File
|
var tty *os.File
|
||||||
defer func() {
|
defer func() {
|
||||||
@@ -351,6 +375,18 @@ func (e *HostEnvironment) exec(ctx context.Context, command []string, cmdline st
|
|||||||
if err := cmd.Start(); err != nil {
|
if err := cmd.Start(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
// Assign the started process to a Job Object so cmd.Cancel can kill the
|
||||||
|
// whole descendant tree. Children spawned afterwards are auto-included.
|
||||||
|
// On failure (e.g. nested-job restrictions) we fall back to the default
|
||||||
|
// single-process kill; WaitDelay + end-of-job cleanup still apply.
|
||||||
|
if k, kerr := newProcessKiller(cmd.Process); kerr != nil {
|
||||||
|
common.Logger(ctx).Warnf("process tree kill setup failed, falling back to single-process kill: %v", kerr)
|
||||||
|
} else {
|
||||||
|
killer.Store(k)
|
||||||
|
defer k.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
err = cmd.Wait()
|
err = cmd.Wait()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
var exitErr *exec.ExitError
|
var exitErr *exec.ExitError
|
||||||
|
|||||||
19
act/container/process_other.go
Normal file
19
act/container/process_other.go
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
//go:build !windows
|
||||||
|
|
||||||
|
package container
|
||||||
|
|
||||||
|
import "os"
|
||||||
|
|
||||||
|
// processKiller is a no-op on non-Windows platforms. The Job Object based
|
||||||
|
// tree-kill is only wired in on Windows (see exec()); elsewhere the default
|
||||||
|
// exec.CommandContext cancellation and Setpgid handling apply.
|
||||||
|
type processKiller struct{}
|
||||||
|
|
||||||
|
func newProcessKiller(_ *os.Process) (*processKiller, error) { return &processKiller{}, nil }
|
||||||
|
|
||||||
|
func (k *processKiller) Kill() error { return nil }
|
||||||
|
|
||||||
|
func (k *processKiller) Close() error { return nil }
|
||||||
71
act/container/process_windows.go
Normal file
71
act/container/process_windows.go
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package container
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"golang.org/x/sys/windows"
|
||||||
|
)
|
||||||
|
|
||||||
|
// processKiller terminates a step process together with its entire descendant
|
||||||
|
// tree via a Windows Job Object.
|
||||||
|
//
|
||||||
|
// Background: a step often launches a process tree (a shell that starts a
|
||||||
|
// child which in turn spawns further GUI or background processes). The default
|
||||||
|
// exec.CommandContext cancellation only kills the direct child, so cancelling a
|
||||||
|
// job left the rest of the tree running. Because those orphans inherited the
|
||||||
|
// step's stdout/stderr pipe, cmd.Wait() also blocked forever and the runner hung.
|
||||||
|
//
|
||||||
|
// Assigning the step process to a Job Object lets us kill the whole tree
|
||||||
|
// atomically on cancellation (TerminateJobObject), which also closes the
|
||||||
|
// inherited pipe handles so cmd.Wait() can return.
|
||||||
|
type processKiller struct {
|
||||||
|
job windows.Handle
|
||||||
|
}
|
||||||
|
|
||||||
|
// newProcessKiller creates a Job Object and assigns p (an already-started
|
||||||
|
// process) to it. Children spawned by p afterwards are automatically part of
|
||||||
|
// the job. The job does NOT use JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, so closing
|
||||||
|
// the handle on normal completion does not kill legitimate background
|
||||||
|
// processes; the tree is only torn down by an explicit Kill (cancellation).
|
||||||
|
func newProcessKiller(p *os.Process) (*processKiller, error) {
|
||||||
|
job, err := windows.CreateJobObject(nil, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
h, err := windows.OpenProcess(windows.PROCESS_SET_QUOTA|windows.PROCESS_TERMINATE, false, uint32(p.Pid))
|
||||||
|
if err != nil {
|
||||||
|
windows.CloseHandle(job)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer windows.CloseHandle(h)
|
||||||
|
|
||||||
|
if err := windows.AssignProcessToJobObject(job, h); err != nil {
|
||||||
|
windows.CloseHandle(job)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &processKiller{job: job}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kill terminates every process currently assigned to the job (the step process
|
||||||
|
// and all of its descendants).
|
||||||
|
func (k *processKiller) Kill() error {
|
||||||
|
if k == nil || k.job == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return windows.TerminateJobObject(k.job, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close releases the job handle. It does not terminate the processes.
|
||||||
|
func (k *processKiller) Close() error {
|
||||||
|
if k == nil || k.job == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
h := k.job
|
||||||
|
k.job = 0
|
||||||
|
return windows.CloseHandle(h)
|
||||||
|
}
|
||||||
78
act/container/process_windows_test.go
Normal file
78
act/container/process_windows_test.go
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package container
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"golang.org/x/sys/windows"
|
||||||
|
)
|
||||||
|
|
||||||
|
// processAlive reports whether pid refers to a still-running process.
|
||||||
|
func processAlive(pid int) bool {
|
||||||
|
h, err := windows.OpenProcess(windows.PROCESS_QUERY_LIMITED_INFORMATION, false, uint32(pid))
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
defer windows.CloseHandle(h)
|
||||||
|
var code uint32
|
||||||
|
if err := windows.GetExitCodeProcess(h, &code); err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
const stillActive = 259 // STILL_ACTIVE
|
||||||
|
return code == stillActive
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestProcessKillerKillsTree verifies that a process assigned to the Job Object
|
||||||
|
// is terminated together with a child it spawns afterwards. This mirrors a step
|
||||||
|
// that launches a child which spawns further processes, where cancelling the
|
||||||
|
// job must take down the whole tree, not just the direct child.
|
||||||
|
func TestProcessKillerKillsTree(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
pidFile := filepath.Join(dir, "child.pid")
|
||||||
|
|
||||||
|
// Parent powershell spawns a detached, long-lived child powershell (writing
|
||||||
|
// its PID to a file) and then sleeps. The child is launched AFTER the parent
|
||||||
|
// has been assigned to the job, so it must be captured by the job too.
|
||||||
|
script := fmt.Sprintf(
|
||||||
|
`$c = Start-Process powershell -PassThru -ArgumentList '-NoProfile','-Command','Start-Sleep -Seconds 600'; `+
|
||||||
|
`Set-Content -LiteralPath %q -Value $c.Id; Start-Sleep -Seconds 600`, pidFile)
|
||||||
|
cmd := exec.Command("powershell.exe", "-NoProfile", "-Command", script)
|
||||||
|
require.NoError(t, cmd.Start())
|
||||||
|
t.Cleanup(func() { _ = cmd.Process.Kill() })
|
||||||
|
|
||||||
|
killer, err := newProcessKiller(cmd.Process)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer killer.Close()
|
||||||
|
|
||||||
|
// Wait for the child PID to be reported.
|
||||||
|
var childPID int
|
||||||
|
require.Eventually(t, func() bool {
|
||||||
|
b, e := os.ReadFile(pidFile)
|
||||||
|
if e != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
s := strings.TrimSpace(string(b))
|
||||||
|
if s == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
childPID, _ = strconv.Atoi(s)
|
||||||
|
return childPID > 0 && processAlive(childPID)
|
||||||
|
}, 20*time.Second, 200*time.Millisecond, "child process should start")
|
||||||
|
|
||||||
|
// Killing the job must terminate both the parent and the detached child.
|
||||||
|
require.NoError(t, killer.Kill())
|
||||||
|
|
||||||
|
require.Eventually(t, func() bool {
|
||||||
|
return !processAlive(cmd.Process.Pid) && !processAlive(childPID)
|
||||||
|
}, 20*time.Second, 200*time.Millisecond, "parent and child should both be terminated")
|
||||||
|
}
|
||||||
@@ -5,15 +5,46 @@
|
|||||||
package runner
|
package runner
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"archive/tar"
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"path"
|
||||||
|
"slices"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
"unicode"
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
|
"gitea.com/gitea/runner/act/container"
|
||||||
"gitea.com/gitea/runner/act/model"
|
"gitea.com/gitea/runner/act/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const maxJobSummaryBytes = 1024 * 1024
|
||||||
|
|
||||||
|
// jobSummaryTruncationMarker is appended to a summary that exceeded the size limit
|
||||||
|
// so the rendered output makes the truncation visible instead of silently cutting off.
|
||||||
|
const jobSummaryTruncationMarker = "\n\n---\n\n*Job summary truncated: it exceeded the maximum allowed size.*\n"
|
||||||
|
|
||||||
|
var (
|
||||||
|
jobSummaryUploadRetryDelay = time.Second
|
||||||
|
// jobSummaryUploadRequestTimeout bounds a single step upload request. It is kept
|
||||||
|
// below jobSummaryUploadPhaseTimeout so one slow or unreachable request times out
|
||||||
|
// and lets the remaining steps still upload within the phase budget, instead of a
|
||||||
|
// single stuck request consuming the whole phase.
|
||||||
|
jobSummaryUploadRequestTimeout = 5 * time.Second
|
||||||
|
// jobSummaryUploadPhaseTimeout bounds the total time spent uploading all step
|
||||||
|
// summaries. The uploads run inside the job cleanup budget that is also used to
|
||||||
|
// stop and remove the container, so a slow or unreachable endpoint must not be
|
||||||
|
// allowed to consume it; this keeps the remaining budget available for teardown.
|
||||||
|
jobSummaryUploadPhaseTimeout = 15 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
type jobInfo interface {
|
type jobInfo interface {
|
||||||
matrix() map[string]any
|
matrix() map[string]any
|
||||||
steps() []*model.Step
|
steps() []*model.Step
|
||||||
@@ -80,8 +111,10 @@ func newJobExecutor(info jobInfo, sf stepFactory, rc *RunContext) common.Executo
|
|||||||
return common.NewErrorExecutor(err)
|
return common.NewErrorExecutor(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
stepIdx := stepModel.Number
|
||||||
preExec := step.pre()
|
preExec := step.pre()
|
||||||
preSteps = append(preSteps, useStepLogger(rc, stepModel, stepStagePre, func(ctx context.Context) error {
|
preSteps = append(preSteps, useStepLogger(rc, stepModel, stepStagePre, func(ctx context.Context) error {
|
||||||
|
rc.CurrentStepIndex = stepIdx
|
||||||
preErr := preExec(ctx)
|
preErr := preExec(ctx)
|
||||||
if preErr != nil {
|
if preErr != nil {
|
||||||
reportStepError(ctx, preErr)
|
reportStepError(ctx, preErr)
|
||||||
@@ -93,6 +126,7 @@ func newJobExecutor(info jobInfo, sf stepFactory, rc *RunContext) common.Executo
|
|||||||
|
|
||||||
stepExec := step.main()
|
stepExec := step.main()
|
||||||
steps = append(steps, useStepLogger(rc, stepModel, stepStageMain, func(ctx context.Context) error {
|
steps = append(steps, useStepLogger(rc, stepModel, stepStageMain, func(ctx context.Context) error {
|
||||||
|
rc.CurrentStepIndex = stepIdx
|
||||||
err := stepExec(ctx)
|
err := stepExec(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
reportStepError(ctx, err)
|
reportStepError(ctx, err)
|
||||||
@@ -104,6 +138,7 @@ func newJobExecutor(info jobInfo, sf stepFactory, rc *RunContext) common.Executo
|
|||||||
|
|
||||||
postFn := step.post()
|
postFn := step.post()
|
||||||
postExec := useStepLogger(rc, stepModel, stepStagePost, func(ctx context.Context) error {
|
postExec := useStepLogger(rc, stepModel, stepStagePost, func(ctx context.Context) error {
|
||||||
|
rc.CurrentStepIndex = stepIdx
|
||||||
err := postFn(ctx)
|
err := postFn(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
reportStepError(ctx, err)
|
reportStepError(ctx, err)
|
||||||
@@ -129,6 +164,7 @@ func newJobExecutor(info jobInfo, sf stepFactory, rc *RunContext) common.Executo
|
|||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
logger := common.Logger(ctx)
|
logger := common.Logger(ctx)
|
||||||
|
tryUploadJobSummary(ctx, rc)
|
||||||
// For Gitea
|
// For Gitea
|
||||||
// We don't need to call `stopServiceContainers` here since it will be called by following `info.stopContainer`
|
// We don't need to call `stopServiceContainers` here since it will be called by following `info.stopContainer`
|
||||||
// logger.Infof("Cleaning up services for job %s", rc.JobName)
|
// logger.Infof("Cleaning up services for job %s", rc.JobName)
|
||||||
@@ -235,6 +271,180 @@ func setJobOutputs(ctx context.Context, rc *RunContext) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func tryUploadJobSummary(ctx context.Context, rc *RunContext) {
|
||||||
|
if rc == nil || rc.JobContainer == nil || rc.Config == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Bound the whole upload phase so a slow or unreachable endpoint cannot consume
|
||||||
|
// the job cleanup budget reserved for stopping and removing the container.
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, jobSummaryUploadPhaseTimeout)
|
||||||
|
defer cancel()
|
||||||
|
env := rc.GetEnv()
|
||||||
|
caps := strings.TrimSpace(env["GITEA_ACTIONS_CAPABILITIES"])
|
||||||
|
if !hasJobSummaryCapability(caps) {
|
||||||
|
// Server did not advertise support. Do not attempt upload.
|
||||||
|
return
|
||||||
|
}
|
||||||
|
runtimeURL := strings.TrimSpace(env["ACTIONS_RUNTIME_URL"])
|
||||||
|
runtimeToken := strings.TrimSpace(env["ACTIONS_RUNTIME_TOKEN"])
|
||||||
|
runID := strings.TrimSpace(env["GITEA_RUN_ID"])
|
||||||
|
if runtimeURL == "" || runtimeToken == "" || runID == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if rc.Run == nil || rc.Run.Job() == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// The numeric ActionRunJob ID is not exposed in the proto Task message or task context,
|
||||||
|
// but the server signs it into the ACTIONS_RUNTIME_TOKEN JWT claims. We decode the
|
||||||
|
// unverified claims to retrieve it; the server re-verifies the token on the request.
|
||||||
|
jobID := extractJobIDFromRuntimeToken(runtimeToken)
|
||||||
|
if jobID <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
base := strings.TrimRight(runtimeURL, "/") + "/_apis/pipelines/workflows/" + runID +
|
||||||
|
"/jobs/" + strconv.FormatInt(jobID, 10) + "/steps/"
|
||||||
|
actPath := rc.JobContainer.GetActPath()
|
||||||
|
// Reuse a single client across all step uploads so connections can be pooled.
|
||||||
|
client := &http.Client{Timeout: jobSummaryUploadRequestTimeout}
|
||||||
|
for i := range rc.Run.Job().Steps {
|
||||||
|
summaryPath := path.Join(actPath, "workflow", "step-summary-"+strconv.Itoa(i)+".md")
|
||||||
|
body, ok := readSingleFileFromContainerArchive(ctx, rc.JobContainer, summaryPath, maxJobSummaryBytes)
|
||||||
|
if !ok || len(body) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
uploadJobSummary(ctx, client, base+strconv.Itoa(i)+"/summary", runtimeToken, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractJobIDFromRuntimeToken returns the JobID claim from an ACTIONS_RUNTIME_TOKEN JWT
|
||||||
|
// without verifying its signature. Returns 0 if the token is unparseable or has no JobID.
|
||||||
|
func extractJobIDFromRuntimeToken(token string) int64 {
|
||||||
|
parts := strings.Split(token, ".")
|
||||||
|
if len(parts) != 3 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
var claims struct {
|
||||||
|
JobID int64 `json:"JobID"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(payload, &claims); err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return claims.JobID
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasJobSummaryCapability(caps string) bool {
|
||||||
|
return slices.Contains(strings.FieldsFunc(caps, func(r rune) bool {
|
||||||
|
return r == ',' || unicode.IsSpace(r)
|
||||||
|
}), "job-summary")
|
||||||
|
}
|
||||||
|
|
||||||
|
func uploadJobSummary(ctx context.Context, client *http.Client, url, runtimeToken string, body []byte) {
|
||||||
|
logger := common.Logger(ctx)
|
||||||
|
|
||||||
|
var lastStatus int
|
||||||
|
var lastErr error
|
||||||
|
for attempt := 0; attempt < 2; attempt++ {
|
||||||
|
status, err := putJobSummary(ctx, client, url, runtimeToken, body)
|
||||||
|
if err == nil && status/100 == 2 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
lastStatus = status
|
||||||
|
lastErr = err
|
||||||
|
if attempt == 1 || !isTransientJobSummaryUploadFailure(status, err) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
timer := time.NewTimer(jobSummaryUploadRetryDelay)
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
timer.Stop()
|
||||||
|
lastErr = ctx.Err()
|
||||||
|
attempt = 1
|
||||||
|
case <-timer.C:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Best-effort only; do not fail job, but log because capability was advertised.
|
||||||
|
if lastErr != nil {
|
||||||
|
logger.WithError(lastErr).Warn("job summary upload failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
logger.Warnf("job summary upload failed: status=%d", lastStatus)
|
||||||
|
}
|
||||||
|
|
||||||
|
func putJobSummary(ctx context.Context, client *http.Client, url, runtimeToken string, body []byte) (int, error) {
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPut, url, bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", "Bearer "+runtimeToken)
|
||||||
|
req.Header.Set("Content-Type", "text/markdown; charset=utf-8")
|
||||||
|
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
_, _ = io.Copy(io.Discard, resp.Body)
|
||||||
|
return resp.StatusCode, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func isTransientJobSummaryUploadFailure(status int, err error) bool {
|
||||||
|
return err != nil || status == http.StatusRequestTimeout || status == http.StatusTooManyRequests || status/100 == 5
|
||||||
|
}
|
||||||
|
|
||||||
|
func readSingleFileFromContainerArchive(ctx context.Context, env container.ExecutionsEnvironment, p string, maxBytes int64) ([]byte, bool) {
|
||||||
|
rc, err := env.GetContainerArchive(ctx, p)
|
||||||
|
if err != nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
defer rc.Close()
|
||||||
|
|
||||||
|
tr := tar.NewReader(rc)
|
||||||
|
for {
|
||||||
|
header, err := tr.Next()
|
||||||
|
if err == io.EOF {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
if header.Typeflag != tar.TypeReg {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !archiveEntryMatchesPath(header.Name, p) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Summaries larger than the limit are truncated rather than dropped, so the
|
||||||
|
// user still gets the leading content (mirroring how GitHub caps oversized
|
||||||
|
// step summaries instead of discarding them). Read one extra byte so an
|
||||||
|
// over-limit file is detected from the actual stream rather than trusting
|
||||||
|
// header.Size, then cap the returned content at maxBytes.
|
||||||
|
b, err := io.ReadAll(io.LimitReader(tr, maxBytes+1))
|
||||||
|
if err != nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
if int64(len(b)) > maxBytes {
|
||||||
|
// Reserve room for the marker so the marked-up result still fits in maxBytes.
|
||||||
|
marker := []byte(jobSummaryTruncationMarker)
|
||||||
|
keep := max(maxBytes-int64(len(marker)), 0)
|
||||||
|
b = append(b[:keep], marker...)
|
||||||
|
common.Logger(ctx).Warnf("job summary truncated: path=%s max=%d", p, maxBytes)
|
||||||
|
}
|
||||||
|
return b, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func archiveEntryMatchesPath(entryName, requestedPath string) bool {
|
||||||
|
entryName = path.Clean(strings.TrimPrefix(entryName, "/"))
|
||||||
|
requestedPath = path.Clean(strings.TrimPrefix(requestedPath, "/"))
|
||||||
|
return entryName == requestedPath || entryName == path.Base(requestedPath)
|
||||||
|
}
|
||||||
|
|
||||||
func useStepLogger(rc *RunContext, stepModel *model.Step, stage stepStage, executor common.Executor) common.Executor {
|
func useStepLogger(rc *RunContext, stepModel *model.Step, stage stepStage, executor common.Executor) common.Executor {
|
||||||
return func(ctx context.Context) error {
|
return func(ctx context.Context) error {
|
||||||
ctx = withStepLogger(ctx, stepModel.Number, stepModel.ID, rc.ExprEval.Interpolate(ctx, stepModel.String()), stage.String())
|
ctx = withStepLogger(ctx, stepModel.Number, stepModel.ID, rc.ExprEval.Interpolate(ctx, stepModel.String()), stage.String())
|
||||||
|
|||||||
@@ -5,19 +5,29 @@
|
|||||||
package runner
|
package runner
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"archive/tar"
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
"slices"
|
"slices"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
"gitea.com/gitea/runner/act/container"
|
"gitea.com/gitea/runner/act/container"
|
||||||
"gitea.com/gitea/runner/act/model"
|
"gitea.com/gitea/runner/act/model"
|
||||||
|
|
||||||
|
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"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestJobExecutor(t *testing.T) {
|
func TestJobExecutor(t *testing.T) {
|
||||||
@@ -336,3 +346,331 @@ func TestNewJobExecutor(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 := context.Background()
|
||||||
|
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 := context.Background()
|
||||||
|
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 TestTryUploadJobSummaryUploadsEachStepIndependently(t *testing.T) {
|
||||||
|
runtimeToken := fakeRuntimeToken(34)
|
||||||
|
|
||||||
|
type upload struct {
|
||||||
|
path string
|
||||||
|
body string
|
||||||
|
}
|
||||||
|
var got []upload
|
||||||
|
server := httptest.NewServer(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 := context.Background()
|
||||||
|
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)
|
||||||
|
|
||||||
|
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 TestTryUploadJobSummaryRequiresExactCapability(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": "not-job-summary,job-summary-v2",
|
||||||
|
"ACTIONS_RUNTIME_URL": server.URL,
|
||||||
|
"ACTIONS_RUNTIME_TOKEN": fakeRuntimeToken(34),
|
||||||
|
"GITEA_RUN_ID": "12",
|
||||||
|
}, &containerMock{}, 1)
|
||||||
|
|
||||||
|
tryUploadJobSummary(context.Background(), rc)
|
||||||
|
|
||||||
|
assert.Equal(t, 0, requests)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTryUploadJobSummarySkipsWhenJobIDMissingFromToken(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": "job-summary",
|
||||||
|
"ACTIONS_RUNTIME_URL": server.URL,
|
||||||
|
"ACTIONS_RUNTIME_TOKEN": "not-a-jwt",
|
||||||
|
"GITEA_RUN_ID": "12",
|
||||||
|
}, &containerMock{}, 1)
|
||||||
|
|
||||||
|
tryUploadJobSummary(context.Background(), rc)
|
||||||
|
|
||||||
|
assert.Equal(t, 0, 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 := context.Background()
|
||||||
|
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 TestReadSingleFileFromContainerArchiveTruncatesWhenTooLarge(t *testing.T) {
|
||||||
|
logger, hook := logrustest.NewNullLogger()
|
||||||
|
ctx := common.WithLogger(context.Background(), logger)
|
||||||
|
cm := &containerMock{}
|
||||||
|
content := strings.Repeat("a", 300)
|
||||||
|
cm.On("GetContainerArchive", ctx, "/var/run/act/workflow/SUMMARY.md").Return(
|
||||||
|
io.NopCloser(bytes.NewReader(tarArchive(t, tarEntry{name: "SUMMARY.md", body: content}))),
|
||||||
|
nil,
|
||||||
|
).Once()
|
||||||
|
|
||||||
|
const maxBytes = 200
|
||||||
|
body, ok := readSingleFileFromContainerArchive(ctx, cm, "/var/run/act/workflow/SUMMARY.md", maxBytes)
|
||||||
|
|
||||||
|
// Oversized summaries are truncated to the limit (reserving room for the marker)
|
||||||
|
// rather than dropped entirely, and the truncation marker is appended.
|
||||||
|
assert.True(t, ok)
|
||||||
|
assert.LessOrEqual(t, len(body), maxBytes)
|
||||||
|
keep := maxBytes - len(jobSummaryTruncationMarker)
|
||||||
|
assert.Equal(t, []byte(content[:keep]+jobSummaryTruncationMarker), body)
|
||||||
|
if assert.Len(t, hook.Entries, 1) {
|
||||||
|
assert.Contains(t, hook.Entries[0].Message, "job summary truncated")
|
||||||
|
}
|
||||||
|
cm.AssertExpectations(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReadSingleFileFromContainerArchiveKeepsExactLimitWithoutWarning(t *testing.T) {
|
||||||
|
logger, hook := logrustest.NewNullLogger()
|
||||||
|
ctx := common.WithLogger(context.Background(), 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: "abc"}))),
|
||||||
|
nil,
|
||||||
|
).Once()
|
||||||
|
|
||||||
|
body, ok := readSingleFileFromContainerArchive(ctx, cm, "/var/run/act/workflow/SUMMARY.md", 3)
|
||||||
|
|
||||||
|
// A summary that is exactly at the limit is kept whole and not flagged as truncated.
|
||||||
|
assert.True(t, ok)
|
||||||
|
assert.Equal(t, []byte("abc"), body)
|
||||||
|
assert.Empty(t, hook.Entries)
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
|||||||
@@ -36,15 +36,19 @@ import (
|
|||||||
|
|
||||||
// RunContext contains info about current job
|
// RunContext contains info about current job
|
||||||
type RunContext struct {
|
type RunContext struct {
|
||||||
Name string
|
Name string
|
||||||
Config *Config
|
Config *Config
|
||||||
Matrix map[string]any
|
Matrix map[string]any
|
||||||
Run *model.Run
|
Run *model.Run
|
||||||
EventJSON string
|
EventJSON string
|
||||||
Env map[string]string
|
Env map[string]string
|
||||||
GlobalEnv map[string]string // to pass env changes of GITHUB_ENV and set-env correctly, due to dirty Env field
|
GlobalEnv map[string]string // to pass env changes of GITHUB_ENV and set-env correctly, due to dirty Env field
|
||||||
ExtraPath []string
|
ExtraPath []string
|
||||||
CurrentStep string
|
CurrentStep string
|
||||||
|
// CurrentStepIndex is the index of the top-level job step currently executing
|
||||||
|
// (model.Step.Number). Composite sub-steps inherit the outer step's index by
|
||||||
|
// walking the Parent chain; see topLevelRunContext.
|
||||||
|
CurrentStepIndex int
|
||||||
StepResults map[string]*model.StepResult
|
StepResults map[string]*model.StepResult
|
||||||
IntraActionState map[string]map[string]string
|
IntraActionState map[string]map[string]string
|
||||||
ExprEval ExpressionEvaluator
|
ExprEval ExpressionEvaluator
|
||||||
@@ -57,6 +61,14 @@ type RunContext struct {
|
|||||||
Masks []string
|
Masks []string
|
||||||
cleanUpJobContainer common.Executor
|
cleanUpJobContainer common.Executor
|
||||||
caller *caller // job calling this RunContext (reusable workflows)
|
caller *caller // job calling this RunContext (reusable workflows)
|
||||||
|
// summaryFileInitialized tracks which per-step summary files (workflow/step-summary-N.md)
|
||||||
|
// have already been created on the JobContainer. The runner sets up file-command files
|
||||||
|
// via JobContainer.Copy at the start of every phase, which truncates them — fine for
|
||||||
|
// GITHUB_ENV/OUTPUT/STATE/PATH (consumed per phase) but wrong for GITHUB_STEP_SUMMARY,
|
||||||
|
// which has accumulating semantics. We initialize each step's summary file exactly once
|
||||||
|
// so writes from later phases and from composite sub-steps append to the same file.
|
||||||
|
// Only populated on the top-level RunContext; child RCs walk Parent via topLevelRunContext.
|
||||||
|
summaryFileInitialized map[int]bool
|
||||||
// outputTemplate is this combination's pristine snapshot of the job's output expressions,
|
// outputTemplate is this combination's pristine snapshot of the job's output expressions,
|
||||||
// captured before execution so each matrix combo interpolates from the originals rather
|
// captured before execution so each matrix combo interpolates from the originals rather
|
||||||
// than from a sibling's already-resolved values written into the shared Job.Outputs.
|
// than from a sibling's already-resolved values written into the shared Job.Outputs.
|
||||||
@@ -704,6 +716,17 @@ func (rc *RunContext) steps() []*model.Step {
|
|||||||
return steps
|
return steps
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// topLevelRunContext walks the Parent chain to the outermost RunContext. Composite
|
||||||
|
// actions create child RunContexts whose sub-steps need to share the outer job step's
|
||||||
|
// summary file path so that nested writes accumulate under the right step_index.
|
||||||
|
func (rc *RunContext) topLevelRunContext() *RunContext {
|
||||||
|
top := rc
|
||||||
|
for top.Parent != nil {
|
||||||
|
top = top.Parent
|
||||||
|
}
|
||||||
|
return top
|
||||||
|
}
|
||||||
|
|
||||||
// Executor returns a pipeline executor for all the steps in the job
|
// Executor returns a pipeline executor for all the steps in the job
|
||||||
func (rc *RunContext) Executor() (common.Executor, error) {
|
func (rc *RunContext) Executor() (common.Executor, error) {
|
||||||
var executor common.Executor
|
var executor common.Executor
|
||||||
|
|||||||
@@ -124,7 +124,12 @@ func runStepExecutor(step step, stage stepStage, executor common.Executor) commo
|
|||||||
envFileCommand := path.Join("workflow", "envs.txt")
|
envFileCommand := path.Join("workflow", "envs.txt")
|
||||||
(*step.getEnv())["GITHUB_ENV"] = path.Join(actPath, envFileCommand)
|
(*step.getEnv())["GITHUB_ENV"] = path.Join(actPath, envFileCommand)
|
||||||
|
|
||||||
summaryFileCommand := path.Join("workflow", "SUMMARY.md")
|
// Per-step summary file. Composite sub-steps share the outer job step's index
|
||||||
|
// via the Parent chain so all writes from within a composite action accumulate
|
||||||
|
// in the same file and upload under the outer step_index.
|
||||||
|
topRC := rc.topLevelRunContext()
|
||||||
|
stepSummaryIndex := topRC.CurrentStepIndex
|
||||||
|
summaryFileCommand := path.Join("workflow", "step-summary-"+strconv.Itoa(stepSummaryIndex)+".md")
|
||||||
(*step.getEnv())["GITHUB_STEP_SUMMARY"] = path.Join(actPath, summaryFileCommand)
|
(*step.getEnv())["GITHUB_STEP_SUMMARY"] = path.Join(actPath, summaryFileCommand)
|
||||||
|
|
||||||
{
|
{
|
||||||
@@ -136,22 +141,23 @@ func runStepExecutor(step step, stage stepStage, executor common.Executor) commo
|
|||||||
(*step.getEnv())["GITEA_STEP_SUMMARY"] = (*step.getEnv())["GITHUB_STEP_SUMMARY"]
|
(*step.getEnv())["GITEA_STEP_SUMMARY"] = (*step.getEnv())["GITHUB_STEP_SUMMARY"]
|
||||||
}
|
}
|
||||||
|
|
||||||
_ = rc.JobContainer.Copy(actPath, &container.FileEntry{
|
// Reset the per-phase file-command files. GITHUB_STEP_SUMMARY is intentionally
|
||||||
Name: outputFileCommand,
|
// excluded here and initialized below at most once per step so writes from later
|
||||||
Mode: 0o666,
|
// phases and from composite sub-steps accumulate instead of being truncated.
|
||||||
}, &container.FileEntry{
|
files := []*container.FileEntry{
|
||||||
Name: stateFileCommand,
|
{Name: outputFileCommand, Mode: 0o666},
|
||||||
Mode: 0o666,
|
{Name: stateFileCommand, Mode: 0o666},
|
||||||
}, &container.FileEntry{
|
{Name: pathFileCommand, Mode: 0o666},
|
||||||
Name: pathFileCommand,
|
{Name: envFileCommand, Mode: 0o666},
|
||||||
Mode: 0o666,
|
}
|
||||||
}, &container.FileEntry{
|
if topRC.summaryFileInitialized == nil {
|
||||||
Name: envFileCommand,
|
topRC.summaryFileInitialized = map[int]bool{}
|
||||||
Mode: 0o666,
|
}
|
||||||
}, &container.FileEntry{
|
if !topRC.summaryFileInitialized[stepSummaryIndex] {
|
||||||
Name: summaryFileCommand,
|
files = append(files, &container.FileEntry{Name: summaryFileCommand, Mode: 0o666})
|
||||||
Mode: 0o666,
|
topRC.summaryFileInitialized[stepSummaryIndex] = true
|
||||||
})(ctx)
|
}
|
||||||
|
_ = rc.JobContainer.Copy(actPath, files...)(ctx)
|
||||||
|
|
||||||
timeoutctx, cancelTimeOut := evaluateStepTimeout(ctx, rc.ExprEval, stepModel)
|
timeoutctx, cancelTimeOut := evaluateStepTimeout(ctx, rc.ExprEval, stepModel)
|
||||||
defer cancelTimeOut()
|
defer cancelTimeOut()
|
||||||
|
|||||||
6
go.mod
6
go.mod
@@ -3,9 +3,9 @@ module gitea.com/gitea/runner
|
|||||||
go 1.26.0
|
go 1.26.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
code.gitea.io/actions-proto-go v0.4.1
|
|
||||||
connectrpc.com/connect v1.20.0
|
connectrpc.com/connect v1.20.0
|
||||||
dario.cat/mergo v1.0.2
|
dario.cat/mergo v1.0.2
|
||||||
|
gitea.dev/actions-proto-go v0.5.0
|
||||||
github.com/Masterminds/semver v1.5.0
|
github.com/Masterminds/semver v1.5.0
|
||||||
github.com/avast/retry-go/v5 v5.0.0
|
github.com/avast/retry-go/v5 v5.0.0
|
||||||
github.com/containerd/errdefs v1.0.0
|
github.com/containerd/errdefs v1.0.0
|
||||||
@@ -26,7 +26,7 @@ require (
|
|||||||
github.com/moby/moby/client v0.4.1
|
github.com/moby/moby/client v0.4.1
|
||||||
github.com/moby/patternmatcher v0.6.1
|
github.com/moby/patternmatcher v0.6.1
|
||||||
github.com/opencontainers/image-spec v1.1.1
|
github.com/opencontainers/image-spec v1.1.1
|
||||||
github.com/opencontainers/selinux v1.15.0
|
github.com/opencontainers/selinux v1.15.1
|
||||||
github.com/pkg/errors v0.9.1
|
github.com/pkg/errors v0.9.1
|
||||||
github.com/prometheus/client_golang v1.23.2
|
github.com/prometheus/client_golang v1.23.2
|
||||||
github.com/rhysd/actionlint v1.7.12
|
github.com/rhysd/actionlint v1.7.12
|
||||||
@@ -37,6 +37,7 @@ require (
|
|||||||
github.com/timshannon/bolthold v0.0.0-20240314194003-30aac6950928
|
github.com/timshannon/bolthold v0.0.0-20240314194003-30aac6950928
|
||||||
go.etcd.io/bbolt v1.4.3
|
go.etcd.io/bbolt v1.4.3
|
||||||
go.yaml.in/yaml/v4 v4.0.0-rc.3
|
go.yaml.in/yaml/v4 v4.0.0-rc.3
|
||||||
|
golang.org/x/sys v0.45.0
|
||||||
golang.org/x/term v0.43.0
|
golang.org/x/term v0.43.0
|
||||||
google.golang.org/protobuf v1.36.11
|
google.golang.org/protobuf v1.36.11
|
||||||
gotest.tools/v3 v3.5.2
|
gotest.tools/v3 v3.5.2
|
||||||
@@ -106,7 +107,6 @@ require (
|
|||||||
golang.org/x/crypto v0.50.0 // indirect
|
golang.org/x/crypto v0.50.0 // indirect
|
||||||
golang.org/x/net v0.53.0 // indirect
|
golang.org/x/net v0.53.0 // indirect
|
||||||
golang.org/x/sync v0.20.0 // indirect
|
golang.org/x/sync v0.20.0 // indirect
|
||||||
golang.org/x/sys v0.44.0 // indirect
|
|
||||||
gopkg.in/warnings.v0 v0.1.2 // indirect
|
gopkg.in/warnings.v0 v0.1.2 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
12
go.sum
12
go.sum
@@ -1,13 +1,11 @@
|
|||||||
code.gitea.io/actions-proto-go v0.4.1 h1:l0EYhjsgpUe/1VABo2eK7zcoNX2W44WOnb0MSLrKfls=
|
|
||||||
code.gitea.io/actions-proto-go v0.4.1/go.mod h1:mn7Wkqz6JbnTOHQpot3yDeHx+O5C9EGhMEE+htvHBas=
|
|
||||||
connectrpc.com/connect v1.19.2 h1:McQ83FGdzL+t60peksi0gXC7MQ/iLKgLduAnThbM0mo=
|
|
||||||
connectrpc.com/connect v1.19.2/go.mod h1:tN20fjdGlewnSFeZxLKb0xwIZ6ozc3OQs2hTXy4du9w=
|
|
||||||
connectrpc.com/connect v1.20.0 h1:6TNDAB+WeNd2uolWNlYczB5E0KNNaVMNUEx8JEUsPmQ=
|
connectrpc.com/connect v1.20.0 h1:6TNDAB+WeNd2uolWNlYczB5E0KNNaVMNUEx8JEUsPmQ=
|
||||||
connectrpc.com/connect v1.20.0/go.mod h1:A2ygJrukXwWy32vkCAAHNVguZrqZ+jeZ9rGRnGR4dN4=
|
connectrpc.com/connect v1.20.0/go.mod h1:A2ygJrukXwWy32vkCAAHNVguZrqZ+jeZ9rGRnGR4dN4=
|
||||||
cyphar.com/go-pathrs v0.2.3 h1:0pH8gep37wB0BgaXrEaN1OtZhUMeS7VvaejSr6i822o=
|
cyphar.com/go-pathrs v0.2.3 h1:0pH8gep37wB0BgaXrEaN1OtZhUMeS7VvaejSr6i822o=
|
||||||
cyphar.com/go-pathrs v0.2.3/go.mod h1:y8f1EMG7r+hCuFf/rXsKqMJrJAUoADZGNh5/vZPKcGc=
|
cyphar.com/go-pathrs v0.2.3/go.mod h1:y8f1EMG7r+hCuFf/rXsKqMJrJAUoADZGNh5/vZPKcGc=
|
||||||
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
|
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
|
||||||
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
|
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
|
||||||
|
gitea.dev/actions-proto-go v0.5.0 h1:Fc3DI4Fm3B3JBRXFUjegql+usoNAjjAw1cxMansfA2I=
|
||||||
|
gitea.dev/actions-proto-go v0.5.0/go.mod h1:p4RX+D9oqiEEzzkPMXscw2CmaGuYFPWFc6xIOmDNDqs=
|
||||||
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk=
|
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk=
|
||||||
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8=
|
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8=
|
||||||
github.com/Masterminds/semver v1.5.0 h1:H65muMkzWKEuNDnfl9d70GUjFniHKHRbFPGBuZ3QEww=
|
github.com/Masterminds/semver v1.5.0 h1:H65muMkzWKEuNDnfl9d70GUjFniHKHRbFPGBuZ3QEww=
|
||||||
@@ -149,10 +147,10 @@ github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8
|
|||||||
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
|
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
|
||||||
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
|
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
|
||||||
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
|
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
|
||||||
github.com/opencontainers/selinux v1.14.1 h1:a7XlXV/nN/l5zFP1FWZYoExpClu1QOPMfWUV2CZ8kEQ=
|
|
||||||
github.com/opencontainers/selinux v1.14.1/go.mod h1:LenyElirjUHszfxrjuFqC85HIeXZKumHcKMQtnaDlQQ=
|
|
||||||
github.com/opencontainers/selinux v1.15.0 h1:4Gs40e/R2FvM8PC1HPaPncLLaDor8Y2WDfk5gjU9o5M=
|
github.com/opencontainers/selinux v1.15.0 h1:4Gs40e/R2FvM8PC1HPaPncLLaDor8Y2WDfk5gjU9o5M=
|
||||||
github.com/opencontainers/selinux v1.15.0/go.mod h1:LenyElirjUHszfxrjuFqC85HIeXZKumHcKMQtnaDlQQ=
|
github.com/opencontainers/selinux v1.15.0/go.mod h1:LenyElirjUHszfxrjuFqC85HIeXZKumHcKMQtnaDlQQ=
|
||||||
|
github.com/opencontainers/selinux v1.15.1 h1:ERxeh5caJvCzNAKdI8WQbJmB1LDTn4BuaAg8wihLBpA=
|
||||||
|
github.com/opencontainers/selinux v1.15.1/go.mod h1:LenyElirjUHszfxrjuFqC85HIeXZKumHcKMQtnaDlQQ=
|
||||||
github.com/pjbgf/sha1cd v0.6.0 h1:3WJ8Wz8gvDz29quX1OcEmkAlUg9diU4GxJHqs0/XiwU=
|
github.com/pjbgf/sha1cd v0.6.0 h1:3WJ8Wz8gvDz29quX1OcEmkAlUg9diU4GxJHqs0/XiwU=
|
||||||
github.com/pjbgf/sha1cd v0.6.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM=
|
github.com/pjbgf/sha1cd v0.6.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM=
|
||||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||||
@@ -256,6 +254,8 @@ golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|||||||
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
|
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
|
||||||
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
|
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
|
||||||
|
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
|
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
|
||||||
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
|
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
|
||||||
|
|||||||
@@ -148,6 +148,7 @@ func runDaemon(ctx context.Context, daemArgs *daemonArgs, configFile *string) fu
|
|||||||
log.Infof("runner: %s, with version: %s, with labels: %v, declare successfully",
|
log.Infof("runner: %s, with version: %s, with labels: %v, declare successfully",
|
||||||
resp.Msg.Runner.Name, resp.Msg.Runner.Version, resp.Msg.Runner.Labels)
|
resp.Msg.Runner.Name, resp.Msg.Runner.Version, resp.Msg.Runner.Labels)
|
||||||
}
|
}
|
||||||
|
runner.SetCapabilitiesFromDeclare(resp)
|
||||||
|
|
||||||
if cfg.Metrics.Enabled {
|
if cfg.Metrics.Enabled {
|
||||||
metrics.Init()
|
metrics.Init()
|
||||||
|
|||||||
@@ -19,9 +19,9 @@ import (
|
|||||||
"gitea.com/gitea/runner/internal/pkg/labels"
|
"gitea.com/gitea/runner/internal/pkg/labels"
|
||||||
"gitea.com/gitea/runner/internal/pkg/ver"
|
"gitea.com/gitea/runner/internal/pkg/ver"
|
||||||
|
|
||||||
pingv1 "code.gitea.io/actions-proto-go/ping/v1"
|
|
||||||
runnerv1 "code.gitea.io/actions-proto-go/runner/v1"
|
|
||||||
"connectrpc.com/connect"
|
"connectrpc.com/connect"
|
||||||
|
pingv1 "gitea.dev/actions-proto-go/ping/v1"
|
||||||
|
runnerv1 "gitea.dev/actions-proto-go/runner/v1"
|
||||||
"github.com/mattn/go-isatty"
|
"github.com/mattn/go-isatty"
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
|||||||
@@ -16,8 +16,8 @@ import (
|
|||||||
"gitea.com/gitea/runner/internal/pkg/config"
|
"gitea.com/gitea/runner/internal/pkg/config"
|
||||||
"gitea.com/gitea/runner/internal/pkg/metrics"
|
"gitea.com/gitea/runner/internal/pkg/metrics"
|
||||||
|
|
||||||
runnerv1 "code.gitea.io/actions-proto-go/runner/v1"
|
|
||||||
"connectrpc.com/connect"
|
"connectrpc.com/connect"
|
||||||
|
runnerv1 "gitea.dev/actions-proto-go/runner/v1"
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ import (
|
|||||||
"gitea.com/gitea/runner/internal/pkg/client/mocks"
|
"gitea.com/gitea/runner/internal/pkg/client/mocks"
|
||||||
"gitea.com/gitea/runner/internal/pkg/config"
|
"gitea.com/gitea/runner/internal/pkg/config"
|
||||||
|
|
||||||
runnerv1 "code.gitea.io/actions-proto-go/runner/v1"
|
|
||||||
connect_go "connectrpc.com/connect"
|
connect_go "connectrpc.com/connect"
|
||||||
|
runnerv1 "gitea.dev/actions-proto-go/runner/v1"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/mock"
|
"github.com/stretchr/testify/mock"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
|||||||
@@ -31,8 +31,8 @@ import (
|
|||||||
"gitea.com/gitea/runner/internal/pkg/report"
|
"gitea.com/gitea/runner/internal/pkg/report"
|
||||||
"gitea.com/gitea/runner/internal/pkg/ver"
|
"gitea.com/gitea/runner/internal/pkg/ver"
|
||||||
|
|
||||||
runnerv1 "code.gitea.io/actions-proto-go/runner/v1"
|
|
||||||
"connectrpc.com/connect"
|
"connectrpc.com/connect"
|
||||||
|
runnerv1 "gitea.dev/actions-proto-go/runner/v1"
|
||||||
"github.com/moby/moby/api/types/container"
|
"github.com/moby/moby/api/types/container"
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
)
|
)
|
||||||
@@ -47,6 +47,7 @@ type Runner struct {
|
|||||||
labels labels.Labels
|
labels labels.Labels
|
||||||
envs map[string]string
|
envs map[string]string
|
||||||
cacheHandler *artifactcache.Handler
|
cacheHandler *artifactcache.Handler
|
||||||
|
capabilities string
|
||||||
|
|
||||||
runningTasks sync.Map
|
runningTasks sync.Map
|
||||||
runningCount atomic.Int64
|
runningCount atomic.Int64
|
||||||
@@ -185,6 +186,14 @@ func (r *Runner) cleanupStaleTaskDirs(ctx context.Context, workdirRoot string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *Runner) SetCapabilitiesFromDeclare(resp *connect.Response[runnerv1.DeclareResponse]) {
|
||||||
|
if resp == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Capability negotiation is done via response headers to avoid a hard proto bump.
|
||||||
|
r.capabilities = strings.TrimSpace(resp.Header().Get("X-Gitea-Actions-Capabilities"))
|
||||||
|
}
|
||||||
|
|
||||||
func (r *Runner) Run(ctx context.Context, task *runnerv1.Task) error {
|
func (r *Runner) Run(ctx context.Context, task *runnerv1.Task) error {
|
||||||
if _, ok := r.runningTasks.Load(task.Id); ok {
|
if _, ok := r.runningTasks.Load(task.Id); ok {
|
||||||
return fmt.Errorf("task %d is already running", task.Id)
|
return fmt.Errorf("task %d is already running", task.Id)
|
||||||
@@ -219,9 +228,10 @@ func (r *Runner) Run(ctx context.Context, task *runnerv1.Task) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (r *Runner) cloneEnvs() map[string]string {
|
func (r *Runner) cloneEnvs() map[string]string {
|
||||||
// +3 reserves space for the per-task keys injected by run():
|
// Reserve space for the per-task keys injected by run():
|
||||||
// ACTIONS_ID_TOKEN_REQUEST_URL, ACTIONS_ID_TOKEN_REQUEST_TOKEN, ACTIONS_RUNTIME_TOKEN.
|
// ACTIONS_ID_TOKEN_REQUEST_URL, ACTIONS_ID_TOKEN_REQUEST_TOKEN, ACTIONS_RUNTIME_TOKEN,
|
||||||
envs := make(map[string]string, len(r.envs)+3)
|
// GITEA_ACTIONS_CAPABILITIES, GITEA_RUN_ID.
|
||||||
|
envs := make(map[string]string, len(r.envs)+5)
|
||||||
maps.Copy(envs, r.envs)
|
maps.Copy(envs, r.envs)
|
||||||
return envs
|
return envs
|
||||||
}
|
}
|
||||||
@@ -261,6 +271,13 @@ func (r *Runner) run(ctx context.Context, task *runnerv1.Task, reporter *report.
|
|||||||
taskContext := task.Context.Fields
|
taskContext := task.Context.Fields
|
||||||
envs := r.cloneEnvs()
|
envs := r.cloneEnvs()
|
||||||
|
|
||||||
|
if r.capabilities != "" {
|
||||||
|
envs["GITEA_ACTIONS_CAPABILITIES"] = r.capabilities
|
||||||
|
}
|
||||||
|
if v := taskContext["run_id"].GetStringValue(); v != "" {
|
||||||
|
envs["GITEA_RUN_ID"] = v
|
||||||
|
}
|
||||||
|
|
||||||
log.Infof("task %v repo is %v %v %v", task.Id, taskContext["repository"].GetStringValue(),
|
log.Infof("task %v repo is %v %v %v", task.Id, taskContext["repository"].GetStringValue(),
|
||||||
r.getDefaultActionsURL(task),
|
r.getDefaultActionsURL(task),
|
||||||
r.client.Address())
|
r.client.Address())
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import (
|
|||||||
|
|
||||||
"gitea.com/gitea/runner/act/model"
|
"gitea.com/gitea/runner/act/model"
|
||||||
|
|
||||||
runnerv1 "code.gitea.io/actions-proto-go/runner/v1"
|
runnerv1 "gitea.dev/actions-proto-go/runner/v1"
|
||||||
"go.yaml.in/yaml/v4"
|
"go.yaml.in/yaml/v4"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import (
|
|||||||
|
|
||||||
"gitea.com/gitea/runner/act/model"
|
"gitea.com/gitea/runner/act/model"
|
||||||
|
|
||||||
runnerv1 "code.gitea.io/actions-proto-go/runner/v1"
|
runnerv1 "gitea.dev/actions-proto-go/runner/v1"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"go.yaml.in/yaml/v4"
|
"go.yaml.in/yaml/v4"
|
||||||
"gotest.tools/v3/assert"
|
"gotest.tools/v3/assert"
|
||||||
|
|||||||
@@ -4,8 +4,8 @@
|
|||||||
package client
|
package client
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"code.gitea.io/actions-proto-go/ping/v1/pingv1connect"
|
"gitea.dev/actions-proto-go/ping/v1/pingv1connect"
|
||||||
"code.gitea.io/actions-proto-go/runner/v1/runnerv1connect"
|
"gitea.dev/actions-proto-go/runner/v1/runnerv1connect"
|
||||||
)
|
)
|
||||||
|
|
||||||
// A Client manages communication with the runner.
|
// A Client manages communication with the runner.
|
||||||
|
|||||||
@@ -10,9 +10,9 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"code.gitea.io/actions-proto-go/ping/v1/pingv1connect"
|
|
||||||
"code.gitea.io/actions-proto-go/runner/v1/runnerv1connect"
|
|
||||||
"connectrpc.com/connect"
|
"connectrpc.com/connect"
|
||||||
|
"gitea.dev/actions-proto-go/ping/v1/pingv1connect"
|
||||||
|
"gitea.dev/actions-proto-go/runner/v1/runnerv1connect"
|
||||||
)
|
)
|
||||||
|
|
||||||
func getHTTPClient(endpoint string, insecure bool) *http.Client {
|
func getHTTPClient(endpoint string, insecure bool) *http.Client {
|
||||||
|
|||||||
@@ -9,9 +9,9 @@ import (
|
|||||||
|
|
||||||
mock "github.com/stretchr/testify/mock"
|
mock "github.com/stretchr/testify/mock"
|
||||||
|
|
||||||
pingv1 "code.gitea.io/actions-proto-go/ping/v1"
|
pingv1 "gitea.dev/actions-proto-go/ping/v1"
|
||||||
|
|
||||||
runnerv1 "code.gitea.io/actions-proto-go/runner/v1"
|
runnerv1 "gitea.dev/actions-proto-go/runner/v1"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Client is an autogenerated mock type for the Client type
|
// Client is an autogenerated mock type for the Client type
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
runnerv1 "code.gitea.io/actions-proto-go/runner/v1"
|
runnerv1 "gitea.dev/actions-proto-go/runner/v1"
|
||||||
"github.com/prometheus/client_golang/prometheus"
|
"github.com/prometheus/client_golang/prometheus"
|
||||||
"github.com/prometheus/client_golang/prometheus/collectors"
|
"github.com/prometheus/client_golang/prometheus/collectors"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -18,8 +18,8 @@ import (
|
|||||||
"gitea.com/gitea/runner/internal/pkg/config"
|
"gitea.com/gitea/runner/internal/pkg/config"
|
||||||
"gitea.com/gitea/runner/internal/pkg/metrics"
|
"gitea.com/gitea/runner/internal/pkg/metrics"
|
||||||
|
|
||||||
runnerv1 "code.gitea.io/actions-proto-go/runner/v1"
|
|
||||||
"connectrpc.com/connect"
|
"connectrpc.com/connect"
|
||||||
|
runnerv1 "gitea.dev/actions-proto-go/runner/v1"
|
||||||
"github.com/avast/retry-go/v5"
|
"github.com/avast/retry-go/v5"
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
"google.golang.org/protobuf/proto"
|
"google.golang.org/protobuf/proto"
|
||||||
|
|||||||
@@ -15,8 +15,8 @@ import (
|
|||||||
"gitea.com/gitea/runner/internal/pkg/client/mocks"
|
"gitea.com/gitea/runner/internal/pkg/client/mocks"
|
||||||
"gitea.com/gitea/runner/internal/pkg/config"
|
"gitea.com/gitea/runner/internal/pkg/config"
|
||||||
|
|
||||||
runnerv1 "code.gitea.io/actions-proto-go/runner/v1"
|
|
||||||
connect_go "connectrpc.com/connect"
|
connect_go "connectrpc.com/connect"
|
||||||
|
runnerv1 "gitea.dev/actions-proto-go/runner/v1"
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/mock"
|
"github.com/stretchr/testify/mock"
|
||||||
|
|||||||
Reference in New Issue
Block a user