8 Commits
v1.0.8 ... main

Author SHA1 Message Date
Renovate Bot
e583b0706b fix(deps): update module golang.org/x/sys to v0.46.0 (#1019)
Reviewed-on: https://gitea.com/gitea/runner/pulls/1019
Co-authored-by: Renovate Bot <renovate-bot@gitea.com>
Co-committed-by: Renovate Bot <renovate-bot@gitea.com>
2026-06-09 16:02:06 +00:00
Renovate Bot
8ad84cd96a fix(deps): update module github.com/docker/cli to v29.5.3+incompatible (#1018)
Reviewed-on: https://gitea.com/gitea/runner/pulls/1018
Co-authored-by: Renovate Bot <renovate-bot@gitea.com>
Co-committed-by: Renovate Bot <renovate-bot@gitea.com>
2026-06-09 16:01:45 +00:00
Zettat123
0a2f28244d fix!: stop implicitly using DOCKER_USERNAME/DOCKER_PASSWORD secrets for image pulls (#1007)
## Background

`DOCKER_USERNAME` and `DOCKER_PASSWORD` are commonly used by workflows as ordinary secrets for logging in to a private registry and pushing images. However, the runner also treated these secret names as implicit Docker pull credentials.

These credentials carry no registry information, but they were attached to every pull unconditionally. As a result, a user who configured `DOCKER_USERNAME` / `DOCKER_PASSWORD` secrets for their private registry (e.g. to push images) would have those same credentials sent to Docker Hub when pulling a public image, causing the pull to fail with authentication failure.

## Changes

- Stop using `DOCKER_USERNAME` and `DOCKER_PASSWORD` as implicit pull credentials for job containers.
- Stop injecting `DOCKER_USERNAME` and `DOCKER_PASSWORD` as pull credentials for step containers.

## ⚠️ BREAKING ⚠️

This is a breaking change.

Workflows or runner setups that previously relied on `DOCKER_USERNAME` and `DOCKER_PASSWORD` being implicitly used for Docker image pulls must migrate to an explicit authentication mechanism.

Migration options:

- For private job container images, use `container.credentials`:

```yaml
  jobs:
    build:
      container:
        image: registry.example.com/image:tag
        credentials:
          username: ${{ secrets.REGISTRY_USERNAME }}
          password: ${{ secrets.REGISTRY_PASSWORD }}
```

- For private service container images, use service `credentials`.

- For private `uses: docker://...` or private Docker actions, configure Docker authentication in the runner environment before the job starts. For example, run `docker login` on the runner host.

`DOCKER_USERNAME` and `DOCKER_PASSWORD` can still be used as ordinary workflow secrets, for example with `docker/login-action` before pushing images.

---

Related:

- Fixes #386

---------

Co-authored-by: Nicolas <bircni@icloud.com>
Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1007
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: Zettat123 <39446+zettat123@noreply.gitea.com>
Co-committed-by: Zettat123 <39446+zettat123@noreply.gitea.com>
2026-06-09 08:10:45 +00:00
Renovate Bot
443b0e336c fix(deps): update module github.com/opencontainers/selinux to v1.15.1 (#1017)
This PR contains the following updates:

| Package | Change | [Age](https://docs.renovatebot.com/merge-confidence/) | [Confidence](https://docs.renovatebot.com/merge-confidence/) |
|---|---|---|---|
| [github.com/opencontainers/selinux](https://github.com/opencontainers/selinux) | `v1.15.0` → `v1.15.1` | ![age](https://developer.mend.io/api/mc/badges/age/go/github.com%2fopencontainers%2fselinux/v1.15.1?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/go/github.com%2fopencontainers%2fselinux/v1.15.0/v1.15.1?slim=true) |

---

### Release Notes

<details>
<summary>opencontainers/selinux (github.com/opencontainers/selinux)</summary>

### [`v1.15.1`](https://github.com/opencontainers/selinux/releases/tag/v1.15.1)

[Compare Source](https://github.com/opencontainers/selinux/compare/v1.15.0...v1.15.1)

#### What's Changed

- ReserveLabelV2: ignore labels without MCS by [@&#8203;kolyshkin](https://github.com/kolyshkin) in [#&#8203;272](https://github.com/opencontainers/selinux/pull/272)

**Full Changelog**: <https://github.com/opencontainers/selinux/compare/v1.15.0...v1.15.1>

</details>

---

### Configuration

📅 **Schedule**: (UTC)

- Branch creation
  - At any time (no schedule defined)
- Automerge
  - At any time (no schedule defined)

🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update again.

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box

---

This PR has been generated by [Mend Renovate](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My4xOTEuMiIsInVwZGF0ZWRJblZlciI6IjQzLjE5MS4yIiwidGFyZ2V0QnJhbmNoIjoibWFpbiIsImxhYmVscyI6W119-->

---------

Co-authored-by: Nicolas <bircni@icloud.com>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1017
Reviewed-by: Nicolas <bircni@icloud.com>
Co-authored-by: Renovate Bot <renovate-bot@gitea.com>
Co-committed-by: Renovate Bot <renovate-bot@gitea.com>
2026-06-08 17:31:32 +00:00
Nicolas
53c4db6a4b feat: upload job summary when supported (#917)
- Add GitHub-style Actions **job summaries** support (writes to `GITHUB_STEP_SUMMARY` / `workflow/SUMMARY.md`) and render them in the run UI.
- Gitea stores summaries internally (DB) and serves them in the run view payload.
- `act_runner` uploads the summary **only when Gitea advertises support** (`X-Gitea-Actions-Capabilities: job-summary`), and warns on upload failures without failing the job.

## Compatibility
- New Gitea + old runner: no upload → no summary shown (no behavior change)
- New runner + old Gitea: capability not advertised → runner skips upload (no behavior change)

## Issue
- Fixes go-gitea/gitea#23721

Reviewed-on: https://gitea.com/gitea/runner/pulls/917
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
Reviewed-by: Zettat123 <39446+zettat123@noreply.gitea.com>
2026-06-08 17:24:03 +00:00
Zettat123
1073c8bfec fix: do not update cached actions with stale origin URL (#1014)
## Background

Remote action cache directories can be keyed by the raw `uses` string. When Gitea's `DEFAULT_ACTIONS_URL` changes, the raw `uses` value may stay the same while the resolved clone URL changes.

In that case, an existing cached clone can still point to the old `origin` URL. Reusing it may fetch from the wrong remote with credentials for the new resolved URL, causing action clone failures until the user manually clears `~/.cache/act`.

## Changes

- Verify the cached clone's `origin` URL before reusing it in `CloneIfRequired`.
- Remove the cached clone and re-clone when the existing `origin` is different from the requested URL.

## Related

- Fixes #1010

Reviewed-on: https://gitea.com/gitea/runner/pulls/1014
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: Zettat123 <39446+zettat123@noreply.gitea.com>
Co-committed-by: Zettat123 <39446+zettat123@noreply.gitea.com>
2026-06-05 09:21:33 +00:00
Renovate Bot
ff7d9ca8d0 fix(deps): update module golang.org/x/sys to v0.45.0 (#1012)
Reviewed-on: https://gitea.com/gitea/runner/pulls/1012
Co-authored-by: Renovate Bot <renovate-bot@gitea.com>
Co-committed-by: Renovate Bot <renovate-bot@gitea.com>
2026-06-03 00:20:24 +00:00
Renovate Bot
984b47c716 fix(deps): update module code.gitea.io/actions-proto-go to gitea.dev/actions-proto-go v0.5.0 (#1009)
This PR contains the following updates:

| Package | Change | [Age](https://docs.renovatebot.com/merge-confidence/) | [Confidence](https://docs.renovatebot.com/merge-confidence/) |
|---|---|---|---|
| code.gitea.io/actions-proto-go | `v0.4.1` → `v0.5.0` | ![age](https://developer.mend.io/api/mc/badges/age/go/code.gitea.io%2factions-proto-go/v0.5.0?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/go/code.gitea.io%2factions-proto-go/v0.4.1/v0.5.0?slim=true) |

---

### Configuration

📅 **Schedule**: (UTC)

- Branch creation
  - At any time (no schedule defined)
- Automerge
  - At any time (no schedule defined)

🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update again.

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box

---

This PR has been generated by [Mend Renovate](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My4xOTEuMiIsInVwZGF0ZWRJblZlciI6IjQzLjE5MS4yIiwidGFyZ2V0QnJhbmNoIjoibWFpbiIsImxhYmVscyI6W119-->

---------

Co-authored-by: Nicolas <bircni@icloud.com>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1009
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: Renovate Bot <renovate-bot@gitea.com>
Co-committed-by: Renovate Bot <renovate-bot@gitea.com>
2026-06-02 17:32:36 +00:00
26 changed files with 810 additions and 65 deletions

View File

@@ -265,8 +265,23 @@ type NewGitCloneExecutorInput struct {
func CloneIfRequired(ctx context.Context, refName plumbing.ReferenceName, input NewGitCloneExecutorInput, logger log.FieldLogger) (*git.Repository, bool, error) {
r, err := git.PlainOpen(input.Dir)
if err == nil {
// Reuse existing clone
return r, true, nil
// Verify the cached clone still points to the resolved URL before reusing it.
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

View File

@@ -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) {
// 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,

View File

@@ -436,13 +436,11 @@ func newStepContainer(ctx context.Context, step step, image string, cmd, entrypo
if rc.IsHostEnv(ctx) {
networkMode = "default"
}
stepContainer := container.NewContainer(&container.NewContainerInput{
stepContainer := ContainerNewContainer(&container.NewContainerInput{
Cmd: cmd,
Entrypoint: entrypoint,
WorkingDir: rc.JobContainer.ToContainerPath(rc.Config.Workdir),
Image: image,
Username: rc.Config.Secrets["DOCKER_USERNAME"],
Password: rc.Config.Secrets["DOCKER_PASSWORD"],
Name: createContainerName(rc.jobContainerName(), "STEP-"+stepModel.ID),
Env: envList,
Mounts: mounts,

View File

@@ -258,6 +258,54 @@ func TestActionRunner(t *testing.T) {
}
}
func TestNewStepContainerDoesNotUseDockerSecrets(t *testing.T) {
cm := &containerMock{}
var captured *container.NewContainerInput
origContainerNewContainer := ContainerNewContainer
ContainerNewContainer = func(input *container.NewContainerInput) container.ExecutionsEnvironment {
captured = input
return cm
}
defer func() {
ContainerNewContainer = origContainerNewContainer
}()
ctx := context.Background()
rc := &RunContext{
Name: "job",
Config: &Config{
Secrets: map[string]string{
"DOCKER_USERNAME": "docker-user",
"DOCKER_PASSWORD": "docker-password",
},
},
Run: &model.Run{
JobID: "job",
Workflow: &model.Workflow{
Name: "test",
Jobs: map[string]*model.Job{
"job": {},
},
},
},
JobContainer: cm,
StepResults: map[string]*model.StepResult{},
}
env := map[string]string{}
step := &stepMock{}
step.On("getRunContext").Return(rc)
step.On("getStepModel").Return(&model.Step{ID: "action"})
step.On("getEnv").Return(&env)
_ = newStepContainer(ctx, step, "registry.example.com/action:tag", nil, nil)
// DOCKER_USERNAME/DOCKER_PASSWORD should not be injected as pull credentials for docker action containers.
assert.Empty(t, captured.Username)
assert.Empty(t, captured.Password)
step.AssertExpectations(t)
}
func TestMaybeCopyToActionDirHoldsCloneLock(t *testing.T) {
ctx := context.Background()

View File

@@ -5,15 +5,46 @@
package runner
import (
"archive/tar"
"bytes"
"context"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"path"
"slices"
"strconv"
"strings"
"time"
"unicode"
"gitea.com/gitea/runner/act/common"
"gitea.com/gitea/runner/act/container"
"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 {
matrix() map[string]any
steps() []*model.Step
@@ -80,8 +111,10 @@ func newJobExecutor(info jobInfo, sf stepFactory, rc *RunContext) common.Executo
return common.NewErrorExecutor(err)
}
stepIdx := stepModel.Number
preExec := step.pre()
preSteps = append(preSteps, useStepLogger(rc, stepModel, stepStagePre, func(ctx context.Context) error {
rc.CurrentStepIndex = stepIdx
preErr := preExec(ctx)
if preErr != nil {
reportStepError(ctx, preErr)
@@ -93,6 +126,7 @@ func newJobExecutor(info jobInfo, sf stepFactory, rc *RunContext) common.Executo
stepExec := step.main()
steps = append(steps, useStepLogger(rc, stepModel, stepStageMain, func(ctx context.Context) error {
rc.CurrentStepIndex = stepIdx
err := stepExec(ctx)
if err != nil {
reportStepError(ctx, err)
@@ -104,6 +138,7 @@ func newJobExecutor(info jobInfo, sf stepFactory, rc *RunContext) common.Executo
postFn := step.post()
postExec := useStepLogger(rc, stepModel, stepStagePost, func(ctx context.Context) error {
rc.CurrentStepIndex = stepIdx
err := postFn(ctx)
if err != nil {
reportStepError(ctx, err)
@@ -129,6 +164,7 @@ func newJobExecutor(info jobInfo, sf stepFactory, rc *RunContext) common.Executo
defer cancel()
logger := common.Logger(ctx)
tryUploadJobSummary(ctx, rc)
// For Gitea
// 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)
@@ -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 {
return func(ctx context.Context) error {
ctx = withStepLogger(ctx, stepModel.Number, stepModel.ID, rc.ExprEval.Interpolate(ctx, stepModel.String()), stage.String())

View File

@@ -5,19 +5,29 @@
package runner
import (
"archive/tar"
"bytes"
"context"
"encoding/base64"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"slices"
"strconv"
"strings"
"testing"
"time"
"gitea.com/gitea/runner/act/common"
"gitea.com/gitea/runner/act/container"
"gitea.com/gitea/runner/act/model"
logrustest "github.com/sirupsen/logrus/hooks/test"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
)
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()
}

View File

@@ -36,15 +36,19 @@ import (
// RunContext contains info about current job
type RunContext struct {
Name string
Config *Config
Matrix map[string]any
Run *model.Run
EventJSON 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
ExtraPath []string
CurrentStep string
Name string
Config *Config
Matrix map[string]any
Run *model.Run
EventJSON 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
ExtraPath []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
IntraActionState map[string]map[string]string
ExprEval ExpressionEvaluator
@@ -57,6 +61,14 @@ type RunContext struct {
Masks []string
cleanUpJobContainer common.Executor
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,
// 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.
@@ -704,6 +716,17 @@ func (rc *RunContext) steps() []*model.Step {
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
func (rc *RunContext) Executor() (common.Executor, error) {
var executor common.Executor
@@ -1152,21 +1175,18 @@ func setActionRuntimeVars(rc *RunContext, env map[string]string) {
}
func (rc *RunContext) handleCredentials(ctx context.Context) (string, string, error) {
// TODO: remove below 2 lines when we can release act with breaking changes
username := rc.Config.Secrets["DOCKER_USERNAME"]
password := rc.Config.Secrets["DOCKER_PASSWORD"]
container := rc.Run.Job().Container()
if container == nil || container.Credentials == nil {
return username, password, nil
return "", "", nil
}
if container.Credentials != nil && len(container.Credentials) != 2 {
if len(container.Credentials) != 2 {
err := errors.New("invalid property count for key 'credentials:'")
return "", "", err
}
ee := rc.NewExpressionEvaluator(ctx)
var username, password string
if username = ee.Interpolate(ctx, container.Credentials["username"]); username == "" {
err := errors.New("failed to interpolate container.credentials.username")
return "", "", err

View File

@@ -170,6 +170,38 @@ func TestRunContext_EvalBool(t *testing.T) {
}
}
func TestRunContextHandleCredentialsDoesNotUseDockerSecrets(t *testing.T) {
workflow, err := model.ReadWorkflow(strings.NewReader(`
name: test
on: push
jobs:
job:
runs-on: ubuntu-latest
steps: []
`))
require.NoError(t, err)
rc := &RunContext{
Config: &Config{
Secrets: map[string]string{
"DOCKER_USERNAME": "docker-user",
"DOCKER_PASSWORD": "docker-password",
},
Env: map[string]string{},
},
Run: &model.Run{
JobID: "job",
Workflow: workflow,
},
}
// DOCKER_USERNAME/DOCKER_PASSWORD secrets should not be used as implicit job container pull credentials.
username, password, err := rc.handleCredentials(t.Context())
require.NoError(t, err)
assert.Empty(t, username)
assert.Empty(t, password)
}
func TestRunContext_GetBindsAndMounts(t *testing.T) {
rctemplate := &RunContext{
Name: "TestRCName",

View File

@@ -124,7 +124,12 @@ func runStepExecutor(step step, stage stepStage, executor common.Executor) commo
envFileCommand := path.Join("workflow", "envs.txt")
(*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)
{
@@ -136,22 +141,23 @@ func runStepExecutor(step step, stage stepStage, executor common.Executor) commo
(*step.getEnv())["GITEA_STEP_SUMMARY"] = (*step.getEnv())["GITHUB_STEP_SUMMARY"]
}
_ = rc.JobContainer.Copy(actPath, &container.FileEntry{
Name: outputFileCommand,
Mode: 0o666,
}, &container.FileEntry{
Name: stateFileCommand,
Mode: 0o666,
}, &container.FileEntry{
Name: pathFileCommand,
Mode: 0o666,
}, &container.FileEntry{
Name: envFileCommand,
Mode: 0o666,
}, &container.FileEntry{
Name: summaryFileCommand,
Mode: 0o666,
})(ctx)
// Reset the per-phase file-command files. GITHUB_STEP_SUMMARY is intentionally
// excluded here and initialized below at most once per step so writes from later
// phases and from composite sub-steps accumulate instead of being truncated.
files := []*container.FileEntry{
{Name: outputFileCommand, Mode: 0o666},
{Name: stateFileCommand, Mode: 0o666},
{Name: pathFileCommand, Mode: 0o666},
{Name: envFileCommand, Mode: 0o666},
}
if topRC.summaryFileInitialized == nil {
topRC.summaryFileInitialized = map[int]bool{}
}
if !topRC.summaryFileInitialized[stepSummaryIndex] {
files = append(files, &container.FileEntry{Name: summaryFileCommand, Mode: 0o666})
topRC.summaryFileInitialized[stepSummaryIndex] = true
}
_ = rc.JobContainer.Copy(actPath, files...)(ctx)
timeoutctx, cancelTimeOut := evaluateStepTimeout(ctx, rc.ExprEval, stepModel)
defer cancelTimeOut()

View File

@@ -125,8 +125,6 @@ func (sd *stepDocker) newStepContainer(ctx context.Context, image string, cmd, e
Entrypoint: entrypoint,
WorkingDir: rc.JobContainer.ToContainerPath(rc.Config.Workdir),
Image: image,
Username: rc.Config.Secrets["DOCKER_USERNAME"],
Password: rc.Config.Secrets["DOCKER_PASSWORD"],
Name: createContainerName(rc.jobContainerName(), "STEP-"+step.ID),
Env: envList,
Mounts: mounts,

View File

@@ -38,7 +38,12 @@ func TestStepDockerMain(t *testing.T) {
sd := &stepDocker{
RunContext: &RunContext{
StepResults: map[string]*model.StepResult{},
Config: &Config{},
Config: &Config{
Secrets: map[string]string{
"DOCKER_USERNAME": "docker-user",
"DOCKER_PASSWORD": "docker-password",
},
},
Run: &model.Run{
JobID: "1",
Workflow: &model.Workflow{
@@ -106,6 +111,10 @@ func TestStepDockerMain(t *testing.T) {
assert.Equal(t, "node:14", input.Image)
// DOCKER_USERNAME/DOCKER_PASSWORD secrets should not be used as implicit pull credentials for docker:// action containers.
assert.Empty(t, input.Username)
assert.Empty(t, input.Password)
cm.AssertExpectations(t)
}

8
go.mod
View File

@@ -3,15 +3,15 @@ module gitea.com/gitea/runner
go 1.26.0
require (
code.gitea.io/actions-proto-go v0.4.1
connectrpc.com/connect v1.20.0
dario.cat/mergo v1.0.2
gitea.dev/actions-proto-go v0.5.0
github.com/Masterminds/semver v1.5.0
github.com/avast/retry-go/v5 v5.0.0
github.com/containerd/errdefs v1.0.0
github.com/creack/pty v1.1.24
github.com/distribution/reference v0.6.0
github.com/docker/cli v29.5.2+incompatible
github.com/docker/cli v29.5.3+incompatible
github.com/docker/go-connections v0.7.0
github.com/go-git/go-billy/v5 v5.9.0
github.com/go-git/go-git/v5 v5.19.1
@@ -26,7 +26,7 @@ require (
github.com/moby/moby/client v0.4.1
github.com/moby/patternmatcher v0.6.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/prometheus/client_golang v1.23.2
github.com/rhysd/actionlint v1.7.12
@@ -37,7 +37,7 @@ require (
github.com/timshannon/bolthold v0.0.0-20240314194003-30aac6950928
go.etcd.io/bbolt v1.4.3
go.yaml.in/yaml/v4 v4.0.0-rc.3
golang.org/x/sys v0.44.0
golang.org/x/sys v0.46.0
golang.org/x/term v0.43.0
google.golang.org/protobuf v1.36.11
gotest.tools/v3 v3.5.2

12
go.sum
View File

@@ -1,11 +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.20.0 h1:6TNDAB+WeNd2uolWNlYczB5E0KNNaVMNUEx8JEUsPmQ=
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/go.mod h1:y8f1EMG7r+hCuFf/rXsKqMJrJAUoADZGNh5/vZPKcGc=
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
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/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8=
github.com/Masterminds/semver v1.5.0 h1:H65muMkzWKEuNDnfl9d70GUjFniHKHRbFPGBuZ3QEww=
@@ -49,6 +49,8 @@ github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5Qvfr
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/docker/cli v29.5.2+incompatible h1:ubykJ1Y8LmNRGJ2BuMQ0kHOt/RO1YzGNswqWMJgivuQ=
github.com/docker/cli v29.5.2+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
github.com/docker/cli v29.5.3+incompatible h1:nbEFfz774vBwQ5KRYv7c/AghjReqnGISvrRhzjV0evs=
github.com/docker/cli v29.5.3+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
github.com/docker/docker-credential-helpers v0.9.6 h1:cT2PbRPSlnMmNTfT2TDMXRyQ1KMWHG7xoTLBcn1ZNv0=
github.com/docker/docker-credential-helpers v0.9.6/go.mod h1:v1S+hepowrQXITkEfw6o4+BMbGot02wiKpzWhGUZK6c=
github.com/docker/go-connections v0.7.0 h1:6SsRfJddP22WMrCkj19x9WKjEDTB+ahsdiGYf0mN39c=
@@ -149,6 +151,8 @@ github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJw
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
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.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/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
@@ -252,6 +256,10 @@ 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.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
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/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
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/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=

View File

@@ -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",
resp.Msg.Runner.Name, resp.Msg.Runner.Version, resp.Msg.Runner.Labels)
}
runner.SetCapabilitiesFromDeclare(resp)
if cfg.Metrics.Enabled {
metrics.Init()

View File

@@ -19,9 +19,9 @@ import (
"gitea.com/gitea/runner/internal/pkg/labels"
"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"
pingv1 "gitea.dev/actions-proto-go/ping/v1"
runnerv1 "gitea.dev/actions-proto-go/runner/v1"
"github.com/mattn/go-isatty"
log "github.com/sirupsen/logrus"
"github.com/spf13/cobra"

View File

@@ -16,8 +16,8 @@ import (
"gitea.com/gitea/runner/internal/pkg/config"
"gitea.com/gitea/runner/internal/pkg/metrics"
runnerv1 "code.gitea.io/actions-proto-go/runner/v1"
"connectrpc.com/connect"
runnerv1 "gitea.dev/actions-proto-go/runner/v1"
log "github.com/sirupsen/logrus"
)

View File

@@ -14,8 +14,8 @@ import (
"gitea.com/gitea/runner/internal/pkg/client/mocks"
"gitea.com/gitea/runner/internal/pkg/config"
runnerv1 "code.gitea.io/actions-proto-go/runner/v1"
connect_go "connectrpc.com/connect"
runnerv1 "gitea.dev/actions-proto-go/runner/v1"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"

View File

@@ -31,8 +31,8 @@ import (
"gitea.com/gitea/runner/internal/pkg/report"
"gitea.com/gitea/runner/internal/pkg/ver"
runnerv1 "code.gitea.io/actions-proto-go/runner/v1"
"connectrpc.com/connect"
runnerv1 "gitea.dev/actions-proto-go/runner/v1"
"github.com/moby/moby/api/types/container"
log "github.com/sirupsen/logrus"
)
@@ -47,6 +47,7 @@ type Runner struct {
labels labels.Labels
envs map[string]string
cacheHandler *artifactcache.Handler
capabilities string
runningTasks sync.Map
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 {
if _, ok := r.runningTasks.Load(task.Id); ok {
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 {
// +3 reserves space for the per-task keys injected by run():
// ACTIONS_ID_TOKEN_REQUEST_URL, ACTIONS_ID_TOKEN_REQUEST_TOKEN, ACTIONS_RUNTIME_TOKEN.
envs := make(map[string]string, len(r.envs)+3)
// Reserve space for the per-task keys injected by run():
// ACTIONS_ID_TOKEN_REQUEST_URL, ACTIONS_ID_TOKEN_REQUEST_TOKEN, ACTIONS_RUNTIME_TOKEN,
// GITEA_ACTIONS_CAPABILITIES, GITEA_RUN_ID.
envs := make(map[string]string, len(r.envs)+5)
maps.Copy(envs, r.envs)
return envs
}
@@ -261,6 +271,13 @@ func (r *Runner) run(ctx context.Context, task *runnerv1.Task, reporter *report.
taskContext := task.Context.Fields
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(),
r.getDefaultActionsURL(task),
r.client.Address())

View File

@@ -11,7 +11,7 @@ import (
"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"
)

View File

@@ -8,7 +8,7 @@ import (
"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"
"go.yaml.in/yaml/v4"
"gotest.tools/v3/assert"

View File

@@ -4,8 +4,8 @@
package client
import (
"code.gitea.io/actions-proto-go/ping/v1/pingv1connect"
"code.gitea.io/actions-proto-go/runner/v1/runnerv1connect"
"gitea.dev/actions-proto-go/ping/v1/pingv1connect"
"gitea.dev/actions-proto-go/runner/v1/runnerv1connect"
)
// A Client manages communication with the runner.

View File

@@ -10,9 +10,9 @@ import (
"strings"
"time"
"code.gitea.io/actions-proto-go/ping/v1/pingv1connect"
"code.gitea.io/actions-proto-go/runner/v1/runnerv1connect"
"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 {

View File

@@ -9,9 +9,9 @@ import (
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

View File

@@ -7,7 +7,7 @@ import (
"sync"
"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/collectors"
)

View File

@@ -18,8 +18,8 @@ import (
"gitea.com/gitea/runner/internal/pkg/config"
"gitea.com/gitea/runner/internal/pkg/metrics"
runnerv1 "code.gitea.io/actions-proto-go/runner/v1"
"connectrpc.com/connect"
runnerv1 "gitea.dev/actions-proto-go/runner/v1"
"github.com/avast/retry-go/v5"
log "github.com/sirupsen/logrus"
"google.golang.org/protobuf/proto"

View File

@@ -15,8 +15,8 @@ import (
"gitea.com/gitea/runner/internal/pkg/client/mocks"
"gitea.com/gitea/runner/internal/pkg/config"
runnerv1 "code.gitea.io/actions-proto-go/runner/v1"
connect_go "connectrpc.com/connect"
runnerv1 "gitea.dev/actions-proto-go/runner/v1"
log "github.com/sirupsen/logrus"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"