mirror of
https://gitea.com/gitea/act_runner.git
synced 2026-07-26 17:30:41 +08:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
94ab020204 | ||
|
|
b4a64b97dd | ||
|
|
26f9fb12af | ||
|
|
c9c4957e38 | ||
|
|
b1a02cdd5d | ||
|
|
0fd8602ac3 |
@@ -20,7 +20,19 @@ jobs:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-go@v6
|
||||
# Custom publishers (the R2 mirror below) run as the very last
|
||||
# step of goreleaser's publish pipeline, after the Gitea release
|
||||
# has already been created and every artifact already uploaded
|
||||
# to S3. Fail here instead, before anything is built or
|
||||
# published, if the R2 secrets are missing.
|
||||
- name: check R2 configuration
|
||||
run: sh scripts/upload-r2.sh --check-config
|
||||
env:
|
||||
R2_ENDPOINT: ${{ secrets.R2_ENDPOINT }}
|
||||
R2_BUCKET: ${{ secrets.R2_BUCKET }}
|
||||
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
||||
- uses: actions/setup-go@v7
|
||||
with:
|
||||
go-version-file: "go.mod"
|
||||
- name: goreleaser
|
||||
@@ -35,6 +47,10 @@ jobs:
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
S3_REGION: ${{ secrets.AWS_REGION }}
|
||||
S3_BUCKET: ${{ secrets.AWS_BUCKET }}
|
||||
R2_ENDPOINT: ${{ secrets.R2_ENDPOINT }}
|
||||
R2_BUCKET: ${{ secrets.R2_BUCKET }}
|
||||
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
||||
GORELEASER_FORCE_TOKEN: "gitea"
|
||||
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
|
||||
@@ -12,7 +12,19 @@ jobs:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0 # all history for all branches and tags
|
||||
- uses: actions/setup-go@v6
|
||||
# Custom publishers (the R2 mirror below) run as the very last
|
||||
# step of goreleaser's publish pipeline, after the Gitea release
|
||||
# has already been created and every artifact already uploaded
|
||||
# to S3. Fail here instead, before anything is built or
|
||||
# published, if the R2 secrets are missing.
|
||||
- name: check R2 configuration
|
||||
run: sh scripts/upload-r2.sh --check-config
|
||||
env:
|
||||
R2_ENDPOINT: ${{ secrets.R2_ENDPOINT }}
|
||||
R2_BUCKET: ${{ secrets.R2_BUCKET }}
|
||||
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
||||
- uses: actions/setup-go@v7
|
||||
with:
|
||||
go-version-file: "go.mod"
|
||||
- name: Import GPG key
|
||||
@@ -34,6 +46,10 @@ jobs:
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
S3_REGION: ${{ secrets.AWS_REGION }}
|
||||
S3_BUCKET: ${{ secrets.AWS_BUCKET }}
|
||||
R2_ENDPOINT: ${{ secrets.R2_ENDPOINT }}
|
||||
R2_BUCKET: ${{ secrets.R2_BUCKET }}
|
||||
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
||||
GORELEASER_FORCE_TOKEN: "gitea"
|
||||
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GPG_FINGERPRINT: ${{ steps.import_gpg.outputs.fingerprint }}
|
||||
|
||||
@@ -18,7 +18,7 @@ jobs:
|
||||
DOCKER_CONFIG: /tmp/docker-noauth
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-go@v6
|
||||
- uses: actions/setup-go@v7
|
||||
with:
|
||||
go-version-file: 'go.mod'
|
||||
- name: prepare anonymous docker config
|
||||
|
||||
@@ -93,6 +93,37 @@ blobs:
|
||||
- glob: ./**.xz
|
||||
- glob: ./**.sha256
|
||||
|
||||
# Mirrors the S3 `blobs:` upload above into Cloudflare R2 during the
|
||||
# parallel S3+R2 period (S3 will be removed once migration completes).
|
||||
# A second `blobs:` entry is impossible here since the blob pipe
|
||||
# authenticates from the global AWS_* env with no per-entry
|
||||
# credentials; `publishers:` supports per-entry `env:` instead, so
|
||||
# it's used to invoke scripts/upload-r2.sh once per artifact. Custom
|
||||
# publishers inherit almost nothing from the environment, hence the
|
||||
# explicit R2_* forwarding below.
|
||||
#
|
||||
# This publisher fires 109 times for 73 distinct keys because
|
||||
# goreleaser's release pipe already registers `release.extra_files`
|
||||
# as UploadableFile artifacts, and `internal/exec`'s filterArtifacts
|
||||
# appends this block's own extra_files with no de-duplication. It
|
||||
# can't be globbed away, since gobwas/glob (via goreleaser/fileglob)
|
||||
# has no substring-exclusion matcher. It's harmless: PUT is
|
||||
# idempotent, and the `./**.xz` glob below is kept deliberately so
|
||||
# this publisher declares its own complete file set rather than
|
||||
# implicitly depending on the `release:` block's globs.
|
||||
publishers:
|
||||
- name: cloudflare-r2
|
||||
checksum: true
|
||||
extra_files:
|
||||
- glob: ./**.xz
|
||||
- glob: ./**.sha256
|
||||
cmd: sh scripts/upload-r2.sh {{ abs .ArtifactPath }} gitea-runner/{{ .Version }}/{{ .ArtifactName }}
|
||||
env:
|
||||
- R2_ENDPOINT={{ index .Env "R2_ENDPOINT" }}
|
||||
- R2_BUCKET={{ index .Env "R2_BUCKET" }}
|
||||
- R2_ACCESS_KEY_ID={{ index .Env "R2_ACCESS_KEY_ID" }}
|
||||
- R2_SECRET_ACCESS_KEY={{ index .Env "R2_SECRET_ACCESS_KEY" }}
|
||||
|
||||
archives:
|
||||
- format: binary
|
||||
name_template: "{{ .Binary }}"
|
||||
|
||||
@@ -17,7 +17,7 @@ RUN make clean && make build
|
||||
### DIND VARIANT
|
||||
#
|
||||
#
|
||||
FROM docker:29.6.1-dind AS dind
|
||||
FROM docker:29.6.2-dind AS dind
|
||||
|
||||
ARG VERSION=dev
|
||||
|
||||
@@ -37,7 +37,7 @@ ENTRYPOINT ["s6-svscan","/etc/s6"]
|
||||
### DIND-ROOTLESS VARIANT
|
||||
#
|
||||
#
|
||||
FROM docker:29.6.1-dind-rootless AS dind-rootless
|
||||
FROM docker:29.6.2-dind-rootless AS dind-rootless
|
||||
|
||||
ARG VERSION=dev
|
||||
|
||||
|
||||
@@ -224,6 +224,7 @@ Run one dedicated `gitea-runner cache-server` that all runners point at.
|
||||
dir: /data/actcache
|
||||
port: 8088
|
||||
external_secret: "replace-with-a-strong-random-secret"
|
||||
# external_secret_file: /path/to/secret # secret can also be passed via a file
|
||||
```
|
||||
|
||||
2. Start the server:
|
||||
@@ -238,6 +239,7 @@ Run one dedicated `gitea-runner cache-server` that all runners point at.
|
||||
cache:
|
||||
external_server: "http://<cache-server-host>:8088/"
|
||||
external_secret: "replace-with-a-strong-random-secret" # must match the server
|
||||
# external_secret_file: /path/to/secret # secret can also be passed via a file
|
||||
```
|
||||
|
||||
Alternatively, mount the same NFS/CIFS share on every runner and point `cache.dir` at it — simpler, but with weaker isolation between repositories.
|
||||
|
||||
86
act/container/docker_create_flags.go
Normal file
86
act/container/docker_create_flags.go
Normal file
@@ -0,0 +1,86 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build !(WITHOUT_DOCKER || !(linux || darwin || windows || netbsd))
|
||||
|
||||
package container
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"slices"
|
||||
|
||||
"github.com/kballard/go-shellquote"
|
||||
"github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
const (
|
||||
pullPolicyAlways = "always"
|
||||
pullPolicyMissing = "missing"
|
||||
pullPolicyNever = "never"
|
||||
)
|
||||
|
||||
var pullPolicies = []string{pullPolicyAlways, pullPolicyMissing, pullPolicyNever}
|
||||
|
||||
// createFlags are the flags docker/cli registers on the `create` and `run` commands
|
||||
// instead of in addFlags, so they are not part of containerOptions.
|
||||
type createFlags struct {
|
||||
platform string
|
||||
pull string
|
||||
name string
|
||||
useAPISocket bool
|
||||
}
|
||||
|
||||
func registerCreateFlags(flags *pflag.FlagSet) *createFlags {
|
||||
cf := new(createFlags)
|
||||
flags.StringVar(&cf.platform, "platform", "", "Set platform if server is multi-platform capable")
|
||||
flags.StringVar(&cf.pull, "pull", pullPolicyMissing, `Pull image before creating ("always", "missing", "never")`)
|
||||
flags.StringVar(&cf.name, "name", "", "Assign a name to the container")
|
||||
flags.BoolVar(&cf.useAPISocket, "use-api-socket", false, "Bind mount Docker API socket and required auth")
|
||||
// Accepted without effect: pull progress is only logged at debug level, and docker
|
||||
// no longer implements content trust.
|
||||
flags.BoolP("quiet", "q", false, "Suppress the pull output")
|
||||
flags.Bool("disable-content-trust", true, "Skip image verification (deprecated)")
|
||||
return cf
|
||||
}
|
||||
|
||||
// parseContainerOptions parses a container options string. The flags are returned even
|
||||
// on error, holding whatever was read before the failure.
|
||||
func parseContainerOptions(options string) (*pflag.FlagSet, *containerOptions, *createFlags, error) {
|
||||
flags := pflag.NewFlagSet("container_flags", pflag.ContinueOnError)
|
||||
flags.SetOutput(io.Discard)
|
||||
copts := addFlags(flags)
|
||||
cf := registerCreateFlags(flags)
|
||||
|
||||
args, err := shellquote.Split(options)
|
||||
if err != nil {
|
||||
return flags, copts, cf, fmt.Errorf("Cannot split container options: '%s': '%w'", options, err)
|
||||
}
|
||||
|
||||
if err := flags.Parse(args); err != nil {
|
||||
return flags, copts, cf, fmt.Errorf("Cannot parse container options: '%s': '%w'", options, err)
|
||||
}
|
||||
|
||||
return flags, copts, cf, nil
|
||||
}
|
||||
|
||||
// createFlagsFromOptions reads the create-level flags that have to be known before the
|
||||
// container is created. Malformed options keep the defaults here and are reported by
|
||||
// mergeContainerConfigs at create time.
|
||||
func createFlagsFromOptions(options string) *createFlags {
|
||||
_, _, cf, _ := parseContainerOptions(options)
|
||||
return cf
|
||||
}
|
||||
|
||||
func (cf *createFlags) validate() error {
|
||||
if !slices.Contains(pullPolicies, cf.pull) {
|
||||
return fmt.Errorf("invalid --pull option %q: must be one of %q", cf.pull, pullPolicies)
|
||||
}
|
||||
|
||||
if cf.useAPISocket {
|
||||
return errors.New("--use-api-socket is not supported, use the runner's container.docker_host setting to expose a docker socket")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
61
act/container/docker_create_flags_test.go
Normal file
61
act/container/docker_create_flags_test.go
Normal file
@@ -0,0 +1,61 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package container
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestCreateFlagsFromOptions(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
options string
|
||||
platform string
|
||||
pull string
|
||||
}{
|
||||
{"", "", pullPolicyMissing},
|
||||
{"-v /a:/b --platform=linux/arm64 --pull always", "linux/arm64", pullPolicyAlways},
|
||||
{"--platform linux/arm/v7 --pull never", "linux/arm/v7", pullPolicyNever},
|
||||
{`--platform "linux/amd64`, "", pullPolicyMissing}, // malformed, defaults kept
|
||||
} {
|
||||
t.Run(tc.options, func(t *testing.T) {
|
||||
cf := createFlagsFromOptions(tc.options)
|
||||
assert.Equal(t, tc.platform, cf.platform)
|
||||
assert.Equal(t, tc.pull, cf.pull)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateFlagsValidate(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
options string
|
||||
wantErr string
|
||||
}{
|
||||
{"--quiet --disable-content-trust --name mine", ""},
|
||||
{"--pull sometimes", `invalid --pull option "sometimes"`},
|
||||
{"--use-api-socket", "--use-api-socket is not supported"},
|
||||
} {
|
||||
t.Run(tc.options, func(t *testing.T) {
|
||||
err := createFlagsFromOptions(tc.options).validate()
|
||||
if tc.wantErr == "" {
|
||||
require.NoError(t, err)
|
||||
return
|
||||
}
|
||||
require.ErrorContains(t, err, tc.wantErr)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewContainerAppliesCreateFlags(t *testing.T) {
|
||||
input := &NewContainerInput{Platform: "linux/amd64", Options: "--platform linux/arm64 --pull never"}
|
||||
cr := NewContainer(input).(*containerReference)
|
||||
assert.Equal(t, "linux/arm64", input.Platform)
|
||||
assert.Equal(t, pullPolicyNever, cr.pullPolicy)
|
||||
|
||||
kept := &NewContainerInput{Platform: "linux/amd64", Options: "--privileged"}
|
||||
NewContainer(kept)
|
||||
assert.Equal(t, "linux/amd64", kept.Platform)
|
||||
}
|
||||
@@ -35,7 +35,6 @@ import (
|
||||
"github.com/go-git/go-git/v5/plumbing/format/gitignore"
|
||||
"github.com/gobwas/glob"
|
||||
"github.com/joho/godotenv"
|
||||
"github.com/kballard/go-shellquote"
|
||||
"github.com/moby/moby/api/pkg/stdcopy"
|
||||
"github.com/moby/moby/api/types/container"
|
||||
"github.com/moby/moby/api/types/mount"
|
||||
@@ -43,7 +42,6 @@ import (
|
||||
"github.com/moby/moby/api/types/system"
|
||||
"github.com/moby/moby/client"
|
||||
specs "github.com/opencontainers/image-spec/specs-go/v1"
|
||||
"github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
// drainGracePeriod bounds how long we wait for an output-copy goroutine to
|
||||
@@ -57,6 +55,12 @@ const drainGracePeriod = 2 * time.Second
|
||||
func NewContainer(input *NewContainerInput) ExecutionsEnvironment {
|
||||
cr := new(containerReference)
|
||||
cr.input = input
|
||||
// Resolved up front because the image pull runs before the container is created.
|
||||
cf := createFlagsFromOptions(input.Options)
|
||||
if cf.platform != "" {
|
||||
cr.input.Platform = cf.platform
|
||||
}
|
||||
cr.pullPolicy = cf.pull
|
||||
return cr
|
||||
}
|
||||
|
||||
@@ -137,6 +141,11 @@ func (cr *containerReference) Start(attach bool) common.Executor {
|
||||
}
|
||||
|
||||
func (cr *containerReference) Pull(forcePull bool) common.Executor {
|
||||
if cr.pullPolicy == pullPolicyNever {
|
||||
return common.NewInfoExecutor("docker pull skipped image=%s, --pull=never in the options", cr.input.Image)
|
||||
}
|
||||
forcePull = forcePull || cr.pullPolicy == pullPolicyAlways
|
||||
|
||||
return common.
|
||||
NewInfoExecutor("docker pull image=%s platform=%s username=%s forcePull=%t", cr.input.Image, cr.input.Platform, cr.input.Username, forcePull).
|
||||
Then(
|
||||
@@ -232,11 +241,12 @@ func (cr *containerReference) ReplaceLogWriter(stdout, stderr io.Writer) (io.Wri
|
||||
}
|
||||
|
||||
type containerReference struct {
|
||||
cli client.APIClient
|
||||
id string
|
||||
input *NewContainerInput
|
||||
UID int
|
||||
GID int
|
||||
cli client.APIClient
|
||||
id string
|
||||
input *NewContainerInput
|
||||
pullPolicy string
|
||||
UID int
|
||||
GID int
|
||||
// attachDone is closed by the attach() streaming goroutine once it has
|
||||
// drained and flushed the container's output. wait() blocks on it so the
|
||||
// tail of the log lands before the step proceeds.
|
||||
@@ -411,17 +421,13 @@ func (cr *containerReference) mergeContainerConfigs(ctx context.Context, config
|
||||
}
|
||||
|
||||
// parse configuration from CLI container.options
|
||||
flags := pflag.NewFlagSet("container_flags", pflag.ContinueOnError)
|
||||
copts := addFlags(flags)
|
||||
|
||||
optionsArgs, err := shellquote.Split(input.Options)
|
||||
flags, copts, cf, err := parseContainerOptions(input.Options)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("Cannot split container options: '%s': '%w'", input.Options, err)
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
err = flags.Parse(optionsArgs)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("Cannot parse container options: '%s': '%w'", input.Options, err)
|
||||
if err := cf.validate(); err != nil {
|
||||
return nil, nil, fmt.Errorf("Cannot process container options: '%s': '%w'", input.Options, err)
|
||||
}
|
||||
|
||||
// FIXME: If everything is fine after gitea/act v0.260.0, remove the following comment.
|
||||
@@ -476,6 +482,9 @@ func (cr *containerReference) mergeContainerConfigs(ctx context.Context, config
|
||||
}
|
||||
hostConfig.Binds = binds
|
||||
hostConfig.Mounts = mounts
|
||||
if cf.name != "" {
|
||||
logger.Warn("--name in the options will be ignored.")
|
||||
}
|
||||
if len(copts.netMode.Value()) > 0 {
|
||||
logger.Warn("--network and --net in the options will be ignored.")
|
||||
}
|
||||
|
||||
@@ -454,37 +454,7 @@ func (rc *RunContext) startJobContainer() common.Executor {
|
||||
rc.ServiceContainers = append(rc.ServiceContainers, c)
|
||||
}
|
||||
|
||||
rc.cleanUpJobContainer = func(ctx context.Context) error {
|
||||
reuseJobContainer := func(ctx context.Context) bool {
|
||||
return rc.Config.ReuseContainers
|
||||
}
|
||||
|
||||
if rc.JobContainer != nil {
|
||||
return rc.JobContainer.Remove().IfNot(reuseJobContainer).
|
||||
Then(container.NewDockerVolumeRemoveExecutor(rc.jobContainerName(), false)).IfNot(reuseJobContainer).
|
||||
Then(container.NewDockerVolumeRemoveExecutor(rc.jobContainerName()+"-env", false)).IfNot(reuseJobContainer).
|
||||
Then(func(ctx context.Context) error {
|
||||
if len(rc.ServiceContainers) > 0 {
|
||||
logger.Infof("Cleaning up services for job %s", rc.JobName)
|
||||
if err := rc.stopServiceContainers()(ctx); err != nil {
|
||||
logger.Errorf("Error while cleaning services: %v", err)
|
||||
}
|
||||
}
|
||||
if createAndDeleteNetwork {
|
||||
// clean network if it has been created by act
|
||||
// if using service containers
|
||||
// it means that the network to which containers are connecting is created by `runner`,
|
||||
// so, we should remove the network at last.
|
||||
logger.Infof("Cleaning up network for job %s, and network name is: %s", rc.JobName, networkName)
|
||||
if err := container.NewDockerNetworkRemoveExecutor(networkName)(ctx); err != nil {
|
||||
logger.Errorf("Error while cleaning network: %v", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})(ctx)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
rc.cleanUpJobContainer = rc.cleanupJobResources(networkName, createAndDeleteNetwork)
|
||||
|
||||
// For Gitea, `jobContainerNetwork` should be the same as `networkName`
|
||||
jobContainerNetwork := networkName
|
||||
@@ -539,6 +509,41 @@ func (rc *RunContext) startJobContainer() common.Executor {
|
||||
}
|
||||
}
|
||||
|
||||
// cleanupJobResources removes everything the job created, continuing past failures.
|
||||
// Only job container and volume errors are returned, the rest are logged.
|
||||
func (rc *RunContext) cleanupJobResources(networkName string, createAndDeleteNetwork bool) common.Executor {
|
||||
return func(ctx context.Context) error {
|
||||
logger := common.Logger(ctx)
|
||||
removeJobContainer := rc.JobContainer != nil && !rc.Config.ReuseContainers
|
||||
|
||||
var errs []error
|
||||
if removeJobContainer {
|
||||
errs = append(errs, rc.JobContainer.Remove()(ctx))
|
||||
}
|
||||
if len(rc.ServiceContainers) > 0 {
|
||||
logger.Infof("Cleaning up services for job %s", rc.JobName)
|
||||
if err := rc.stopServiceContainers()(ctx); err != nil {
|
||||
logger.Errorf("Error while cleaning services: %v", err)
|
||||
}
|
||||
}
|
||||
if removeJobContainer {
|
||||
// after the containers using them, services can hold these via `--volumes-from`
|
||||
name := rc.jobContainerName()
|
||||
errs = append(errs,
|
||||
container.NewDockerVolumeRemoveExecutor(name, false)(ctx),
|
||||
container.NewDockerVolumeRemoveExecutor(name+"-env", false)(ctx))
|
||||
}
|
||||
if createAndDeleteNetwork {
|
||||
// last, once every container has detached
|
||||
logger.Infof("Cleaning up network for job %s, and network name is: %s", rc.JobName, networkName)
|
||||
if err := container.NewDockerNetworkRemoveExecutor(networkName)(ctx); err != nil {
|
||||
logger.Errorf("Error while cleaning network: %v", err)
|
||||
}
|
||||
}
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
}
|
||||
|
||||
func (rc *RunContext) execJobContainer(cmd []string, env map[string]string, user, workdir string) common.Executor { //nolint:unparam // pre-existing issue from nektos/act
|
||||
return func(ctx context.Context) error {
|
||||
return rc.JobContainer.Exec(cmd, env, user, workdir)(ctx)
|
||||
|
||||
@@ -7,6 +7,7 @@ package runner
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"runtime"
|
||||
@@ -451,6 +452,46 @@ func TestRunContextValidVolumes(t *testing.T) {
|
||||
assert.Len(t, rc.validVolumes(), len(got), "repeated calls must be stable, not accumulate")
|
||||
}
|
||||
|
||||
func TestCleanupJobResourcesCleansServicesWithoutJobContainer(t *testing.T) {
|
||||
service := &containerMock{}
|
||||
service.On("Remove").Return(func(context.Context) error { return nil }).Once()
|
||||
service.On("Close").Return(func(context.Context) error { return nil }).Once()
|
||||
|
||||
rc := &RunContext{
|
||||
Config: &Config{},
|
||||
ServiceContainers: []container.ExecutionsEnvironment{service},
|
||||
}
|
||||
|
||||
err := rc.cleanupJobResources("external-network", false)(context.Background())
|
||||
require.NoError(t, err)
|
||||
service.AssertExpectations(t)
|
||||
}
|
||||
|
||||
// cleanup used to bail out on a previous step's error and on a cancelled context
|
||||
func TestCleanupJobResourcesContinuesAfterFailure(t *testing.T) {
|
||||
t.Setenv("DOCKER_HOST", "unix:///nonexistent.sock")
|
||||
|
||||
jobContainer := &containerMock{}
|
||||
jobContainer.On("Remove").Return(func(context.Context) error { return errors.New("removal failed") }).Once()
|
||||
service := &containerMock{}
|
||||
service.On("Remove").Return(func(context.Context) error { return nil }).Once()
|
||||
service.On("Close").Return(func(context.Context) error { return nil }).Once()
|
||||
|
||||
rc := &RunContext{
|
||||
Name: "job",
|
||||
Config: &Config{},
|
||||
Run: &model.Run{Workflow: &model.Workflow{Name: "wf"}, JobID: "job"},
|
||||
JobContainer: jobContainer,
|
||||
ServiceContainers: []container.ExecutionsEnvironment{service},
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
require.Error(t, rc.cleanupJobResources("job-network", true)(ctx))
|
||||
jobContainer.AssertExpectations(t)
|
||||
service.AssertExpectations(t)
|
||||
}
|
||||
|
||||
// TestInterpolateOutputsIsPerMatrixCombo guards the matrix-output fix: combinations share one
|
||||
// *model.Job, so each must interpolate from its own pristine snapshot. Otherwise the first
|
||||
// combo's resolved value freezes the shared template and later combos can't resolve their own.
|
||||
|
||||
@@ -50,7 +50,7 @@ func runCacheServer(configFile *string, cacheArgs *cacheServerArgs) func(cmd *co
|
||||
|
||||
secret := cfg.Cache.ExternalSecret
|
||||
if secret == "" {
|
||||
return errors.New("cache.external_secret must be set for cache-server; configure the same value on each runner that points at this server via cache.external_server")
|
||||
return errors.New("cache.external_secret (or cache.external_secret_file) must be set for cache-server; configure the same value on each runner that points at this server via cache.external_server")
|
||||
}
|
||||
cacheHandler, err := artifactcache.StartHandler(
|
||||
dir,
|
||||
|
||||
@@ -89,6 +89,7 @@ func NewRunner(cfg *config.Config, reg *config.Registration, cli client.Client)
|
||||
if cfg.Cache.ExternalServer != "" {
|
||||
envs["ACTIONS_CACHE_URL"] = cfg.Cache.ExternalServer
|
||||
} else {
|
||||
warnIgnoredCacheSecret(cfg)
|
||||
handler, err := artifactcache.StartHandler(
|
||||
cfg.Cache.Dir,
|
||||
cfg.Cache.Host,
|
||||
@@ -512,14 +513,11 @@ func (r *Runner) run(ctx context.Context, task *runnerv1.Task, reporter *report.
|
||||
// function the caller must invoke (typically via defer) to revoke the
|
||||
// credential when the task finishes.
|
||||
//
|
||||
// Three modes:
|
||||
// Two modes:
|
||||
// - Embedded handler: register in-process via RegisterJob.
|
||||
// - external_server + external_secret: POST to the remote server's
|
||||
// /_internal/register, defer a POST to /_internal/revoke. This is what
|
||||
// enables full per-job auth and repo scoping over the network.
|
||||
// - external_server alone (no secret): no-op revoker. The remote server is
|
||||
// in legacy openMode and ignores the runtime token; trust is at the
|
||||
// network layer.
|
||||
// - external_server: POST to the remote server's /_internal/register, defer a
|
||||
// POST to /_internal/revoke. This is what enables full per-job auth and
|
||||
// repo scoping over the network.
|
||||
//
|
||||
// Safe with an empty token (older Gitea did not issue one).
|
||||
func (r *Runner) registerCacheForTask(token, repo string, reporter *report.Reporter) func() {
|
||||
@@ -532,6 +530,7 @@ func (r *Runner) registerCacheForTask(token, repo string, reporter *report.Repor
|
||||
if r.cfg.Cache.ExternalServer != "" && r.cfg.Cache.ExternalSecret != "" {
|
||||
return r.registerExternalCacheJob(token, repo, reporter)
|
||||
}
|
||||
// No cache server to register against: caching is disabled, or the built-in server failed to start.
|
||||
return func() {}
|
||||
}
|
||||
|
||||
@@ -655,3 +654,20 @@ func (r *Runner) Declare(ctx context.Context, labels []string) (*connect.Respons
|
||||
Capabilities: RunnerCapabilities(),
|
||||
}))
|
||||
}
|
||||
|
||||
// warnIgnoredCacheSecret flags an external cache server secret configured on a runner that uses the built-in cache server.
|
||||
func warnIgnoredCacheSecret(cfg *config.Config) {
|
||||
if cfg.Cache.ExternalServer != "" {
|
||||
return
|
||||
}
|
||||
// Not using an external cache server, so any configured secret is ignored.
|
||||
if cfg.Cache.ExternalSecret == "" {
|
||||
return
|
||||
}
|
||||
// LoadDefault resolves external_secret_file into ExternalSecret, so report whichever key the operator actually wrote.
|
||||
key := "cache.external_secret"
|
||||
if cfg.Cache.ExternalSecretFile != "" {
|
||||
key = "cache.external_secret_file"
|
||||
}
|
||||
log.Warnf("%s is set but cache.external_server is not; the built-in cache server does not use a shared secret, so the value is ignored", key)
|
||||
}
|
||||
|
||||
@@ -132,6 +132,11 @@ cache:
|
||||
# Required when external_server is set. Must be identical on every runner and the cache-server.
|
||||
# Generate with: openssl rand -hex 32
|
||||
external_secret: ""
|
||||
# Path to a file containing the shared secret, as an alternative to external_secret.
|
||||
# Use this to keep the secret out of this file.
|
||||
# Surrounding whitespace is trimmed, so a trailing newline in the file is fine.
|
||||
# Setting both external_secret and external_secret_file is an error.
|
||||
external_secret_file: ""
|
||||
# When true, reuse a cached action instead of fetching from the remote on every job.
|
||||
# A moved tag (e.g. a re-tagged "v6") or an updated branch stays at the cached commit
|
||||
# until its cache entry expires or is manually removed.
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"maps"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
@@ -57,13 +58,14 @@ type Runner struct {
|
||||
|
||||
// Cache represents the configuration for caching.
|
||||
type Cache struct {
|
||||
Enabled *bool `yaml:"enabled"` // Enabled indicates whether caching is enabled. It is a pointer to distinguish between false and not set. If not set, it will be true.
|
||||
Dir string `yaml:"dir"` // Dir specifies the directory path for caching.
|
||||
Host string `yaml:"host"` // Host specifies the caching host.
|
||||
Port uint16 `yaml:"port"` // Port specifies the caching port.
|
||||
ExternalServer string `yaml:"external_server"` // ExternalServer specifies the URL of external cache server
|
||||
ExternalSecret string `yaml:"external_secret"` // ExternalSecret is a shared secret between this runner and an external gitea-runner cache-server, enabling per-job ACTIONS_RUNTIME_TOKEN authentication and repo scoping over the network. Leave empty to keep the legacy unauthenticated behavior.
|
||||
OfflineMode bool `yaml:"offline_mode"` // OfflineMode reuses a cached action without fetching from the remote; a moved tag or branch stays at the cached commit until the cache entry is removed.
|
||||
Enabled *bool `yaml:"enabled"` // Enabled indicates whether caching is enabled. It is a pointer to distinguish between false and not set. If not set, it will be true.
|
||||
Dir string `yaml:"dir"` // Dir specifies the directory path for caching.
|
||||
Host string `yaml:"host"` // Host specifies the caching host.
|
||||
Port uint16 `yaml:"port"` // Port specifies the caching port.
|
||||
ExternalServer string `yaml:"external_server"` // ExternalServer specifies the URL of external cache server
|
||||
ExternalSecret string `yaml:"external_secret"` // ExternalSecret is a shared secret between this runner and an external gitea-runner cache-server, enabling per-job ACTIONS_RUNTIME_TOKEN authentication and repo scoping over the network. Required whenever ExternalServer is set; ExternalSecretFile is the alternative way to provide it.
|
||||
ExternalSecretFile string `yaml:"external_secret_file"` // ExternalSecretFile is the path to a file holding the ExternalSecret value, so the secret can be mounted instead of stored in the config file. LoadDefault reads it into ExternalSecret; setting both is an error.
|
||||
OfflineMode bool `yaml:"offline_mode"` // OfflineMode reuses a cached action without fetching from the remote; a moved tag or branch stays at the cached commit until the cache entry is removed.
|
||||
}
|
||||
|
||||
// Container represents the configuration for the container.
|
||||
@@ -177,6 +179,10 @@ func LoadDefault(file string) (*Config, error) {
|
||||
b := true
|
||||
cfg.Cache.Enabled = &b
|
||||
}
|
||||
// Resolved regardless of cache.enabled, because the `cache-server` command reads the secret from the same key without checking cache.enabled.
|
||||
if err := resolveCacheExternalSecret(cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if *cfg.Cache.Enabled {
|
||||
if cfg.Cache.Dir == "" {
|
||||
home, err := os.UserHomeDir()
|
||||
@@ -186,7 +192,7 @@ func LoadDefault(file string) (*Config, error) {
|
||||
cfg.Cache.Dir = filepath.Join(home, ".cache", "actcache")
|
||||
}
|
||||
if cfg.Cache.ExternalServer != "" && cfg.Cache.ExternalSecret == "" {
|
||||
return nil, errors.New("cache.external_server is set but cache.external_secret is empty; configure the same external_secret on this runner and the gitea-runner cache-server")
|
||||
return nil, errors.New("cache.external_server is set but no shared secret is configured; set cache.external_secret (or cache.external_secret_file) to the same value used by the gitea-runner cache-server")
|
||||
}
|
||||
}
|
||||
if cfg.Container.WorkdirParent == "" {
|
||||
@@ -301,3 +307,24 @@ func definedRunnerConfigKeys(content []byte) (map[string]bool, error) {
|
||||
|
||||
return defined, nil
|
||||
}
|
||||
|
||||
// resolveCacheExternalSecret loads cache.external_secret from the file named by cache.external_secret_file,
|
||||
// so deployments can mount the secret instead of committing it to the config file.
|
||||
func resolveCacheExternalSecret(cfg *Config) error {
|
||||
if cfg.Cache.ExternalSecretFile == "" {
|
||||
return nil
|
||||
}
|
||||
if cfg.Cache.ExternalSecret != "" {
|
||||
return errors.New("cache.external_secret and cache.external_secret_file are both set; configure only one of them")
|
||||
}
|
||||
content, err := os.ReadFile(cfg.Cache.ExternalSecretFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read cache.external_secret_file %q: %w", cfg.Cache.ExternalSecretFile, err)
|
||||
}
|
||||
secret := strings.TrimSpace(string(content))
|
||||
if secret == "" {
|
||||
return fmt.Errorf("cache.external_secret_file %q contains no secret", cfg.Cache.ExternalSecretFile)
|
||||
}
|
||||
cfg.Cache.ExternalSecret = secret
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -227,3 +227,91 @@ func TestContainerNetworkCreateOptions(t *testing.T) {
|
||||
assert.Nil(t, opts.EnableIPv6)
|
||||
})
|
||||
}
|
||||
|
||||
func TestLoadDefault_ReadsExternalSecretFromFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
secretPath := filepath.Join(dir, "cache.secret")
|
||||
require.NoError(t, os.WriteFile(secretPath, []byte(" s3cr3t\n"), 0o600))
|
||||
|
||||
path := filepath.Join(dir, "config.yaml")
|
||||
require.NoError(t, os.WriteFile(path, []byte(`
|
||||
cache:
|
||||
enabled: true
|
||||
external_server: "http://cache.invalid/"
|
||||
external_secret_file: "`+secretPath+`"
|
||||
`), 0o600))
|
||||
|
||||
cfg, err := LoadDefault(path)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "s3cr3t", cfg.Cache.ExternalSecret)
|
||||
}
|
||||
|
||||
func TestLoadDefault_ReadsExternalSecretFromFileWhenCacheDisabled(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
secretPath := filepath.Join(dir, "cache.secret")
|
||||
require.NoError(t, os.WriteFile(secretPath, []byte("s3cr3t"), 0o600))
|
||||
|
||||
// the file has to be resolved even when cache is disabled
|
||||
path := filepath.Join(dir, "config.yaml")
|
||||
require.NoError(t, os.WriteFile(path, []byte(`
|
||||
cache:
|
||||
enabled: false
|
||||
external_secret_file: "`+secretPath+`"
|
||||
`), 0o600))
|
||||
|
||||
cfg, err := LoadDefault(path)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "s3cr3t", cfg.Cache.ExternalSecret)
|
||||
}
|
||||
|
||||
func TestLoadDefault_RejectsBothExternalSecretAndFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
secretPath := filepath.Join(dir, "cache.secret")
|
||||
require.NoError(t, os.WriteFile(secretPath, []byte("s3cr3t"), 0o600))
|
||||
|
||||
path := filepath.Join(dir, "config.yaml")
|
||||
require.NoError(t, os.WriteFile(path, []byte(`
|
||||
cache:
|
||||
enabled: true
|
||||
external_server: "http://cache.invalid/"
|
||||
external_secret: "inline"
|
||||
external_secret_file: "`+secretPath+`"
|
||||
`), 0o600))
|
||||
|
||||
_, err := LoadDefault(path)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "both set")
|
||||
}
|
||||
|
||||
func TestLoadDefault_RejectsMissingExternalSecretFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "config.yaml")
|
||||
require.NoError(t, os.WriteFile(path, []byte(`
|
||||
cache:
|
||||
enabled: true
|
||||
external_server: "http://cache.invalid/"
|
||||
external_secret_file: "`+filepath.Join(dir, "absent.secret")+`"
|
||||
`), 0o600))
|
||||
|
||||
_, err := LoadDefault(path)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "read cache.external_secret_file")
|
||||
}
|
||||
|
||||
func TestLoadDefault_RejectsEmptyExternalSecretFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
secretPath := filepath.Join(dir, "cache.secret")
|
||||
require.NoError(t, os.WriteFile(secretPath, []byte("\n \n"), 0o600))
|
||||
|
||||
path := filepath.Join(dir, "config.yaml")
|
||||
require.NoError(t, os.WriteFile(path, []byte(`
|
||||
cache:
|
||||
enabled: true
|
||||
external_server: "http://cache.invalid/"
|
||||
external_secret_file: "`+secretPath+`"
|
||||
`), 0o600))
|
||||
|
||||
_, err := LoadDefault(path)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "contains no secret")
|
||||
}
|
||||
|
||||
112
scripts/upload-r2.sh
Executable file
112
scripts/upload-r2.sh
Executable file
@@ -0,0 +1,112 @@
|
||||
#!/bin/sh
|
||||
# Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
# SPDX-License-Identifier: MIT
|
||||
#
|
||||
# upload-r2.sh uploads a single local file to a single object key in a
|
||||
# Cloudflare R2 bucket, using curl's built-in AWS SigV4 signer (R2 is
|
||||
# S3-API compatible).
|
||||
#
|
||||
# This is the R2 half of the release process's parallel S3+R2 upload
|
||||
# period: goreleaser's `blobs:` pipe still uploads every release
|
||||
# artifact to AWS S3, and this script is invoked once per artifact
|
||||
# (via a goreleaser `publishers:` entry) to mirror the same artifact
|
||||
# into R2. Once the migration away from S3 is complete, the `blobs:`
|
||||
# block and the AWS_* secrets can be dropped without touching this
|
||||
# script.
|
||||
#
|
||||
# Usage:
|
||||
# upload-r2.sh <local-file> <remote-key>
|
||||
# upload-r2.sh --check-config
|
||||
#
|
||||
# The second form only validates that the required environment
|
||||
# variables below are set (it does not touch the network or the
|
||||
# filesystem beyond that), and is meant to be run as an early
|
||||
# preflight step in CI: goreleaser custom publishers run as the very
|
||||
# last step of the publish pipeline, so without a preflight check a
|
||||
# missing R2_* secret would only be discovered after the Gitea release
|
||||
# has already been created and every artifact already uploaded to S3.
|
||||
#
|
||||
# Required environment variables:
|
||||
# R2_ENDPOINT Base URL of the R2 endpoint, e.g.
|
||||
# https://<account>.r2.cloudflarestorage.com
|
||||
# R2_BUCKET Destination bucket name.
|
||||
# R2_ACCESS_KEY_ID R2 access key id.
|
||||
# R2_SECRET_ACCESS_KEY R2 secret access key.
|
||||
|
||||
set -eu
|
||||
|
||||
# check_env validates that all required R2_* environment variables are
|
||||
# set and non-empty, printing a single "missing required environment
|
||||
# variable(s): ..." message and exiting non-zero otherwise. Used by
|
||||
# both the normal upload mode and --check-config, so the validation
|
||||
# logic only exists in one place.
|
||||
check_env() {
|
||||
missing=""
|
||||
|
||||
if [ -z "${R2_ENDPOINT:-}" ]; then
|
||||
missing="$missing R2_ENDPOINT"
|
||||
fi
|
||||
if [ -z "${R2_BUCKET:-}" ]; then
|
||||
missing="$missing R2_BUCKET"
|
||||
fi
|
||||
if [ -z "${R2_ACCESS_KEY_ID:-}" ]; then
|
||||
missing="$missing R2_ACCESS_KEY_ID"
|
||||
fi
|
||||
if [ -z "${R2_SECRET_ACCESS_KEY:-}" ]; then
|
||||
missing="$missing R2_SECRET_ACCESS_KEY"
|
||||
fi
|
||||
|
||||
if [ -n "$missing" ]; then
|
||||
echo "upload-r2.sh: missing required environment variable(s):$missing" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
if [ "$#" -eq 1 ] && [ "$1" = "--check-config" ]; then
|
||||
check_env
|
||||
echo "upload-r2.sh: R2 configuration OK"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ "$#" -ne 2 ]; then
|
||||
echo "usage: upload-r2.sh <local-file> <remote-key>" >&2
|
||||
echo " upload-r2.sh --check-config" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
local_file="$1"
|
||||
remote_key="$2"
|
||||
|
||||
if [ ! -f "$local_file" ]; then
|
||||
echo "upload-r2.sh: local file not found: $local_file" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
check_env
|
||||
|
||||
# Strip a single trailing slash from the endpoint, if present, so that
|
||||
# building the path-style URL below never produces a double slash.
|
||||
endpoint="${R2_ENDPOINT%/}"
|
||||
url="$endpoint/$R2_BUCKET/$remote_key"
|
||||
|
||||
# Credentials are passed to curl through a config file read from
|
||||
# stdin rather than as a command-line argument, so they never show up
|
||||
# in `ps` output.
|
||||
#
|
||||
# --fail-with-body (instead of plain --fail) still exits non-zero on
|
||||
# HTTP errors, but also prints R2's XML error body, which is where the
|
||||
# actual error code lives (SignatureDoesNotMatch, NoSuchBucket,
|
||||
# AccessDenied, ...); with plain --fail that body is discarded and the
|
||||
# failure is silent. --retry 3 (without --retry-all-errors) still
|
||||
# retries the transient cases (5xx, 408, 429, connection failures);
|
||||
# --retry-all-errors would additionally retry permanent 4xx responses
|
||||
# three times with backoff, which only delays an inevitable failure.
|
||||
printf 'user = "%s:%s"\n' "$R2_ACCESS_KEY_ID" "$R2_SECRET_ACCESS_KEY" | curl \
|
||||
--config - \
|
||||
--fail-with-body \
|
||||
--silent \
|
||||
--show-error \
|
||||
--retry 3 \
|
||||
--aws-sigv4 "aws:amz:auto:s3" \
|
||||
--upload-file "$local_file" \
|
||||
"$url"
|
||||
Reference in New Issue
Block a user