Compare commits

...
9 Commits
Author SHA1 Message Date
silverwindandbircni 9e3647395a feat: add GITEA_DOCKER_WORKSPACE and container cleanup (#1204)
1. Add `GITEA_DOCKER_WORKSPACE` which holds the workspace path as the daemon sees it, enabling `${GITEA_DOCKER_WORKSPACE:-.}/data:/app/data` in a compose file without having to resort to `bind_workdir` (which causes much more problems like breaking `actions/cache` because of unstable workspace paths).
2. Add container/network/volume cleanup for containers started within jobs, for example via `docker compose` inside a job. It works by running a lightweight docker socket proxy and injecting a `com.gitea.runner.job` label into every container creation and that label is used to remove containers started by that job at the end. Perf impact of this is near-zero.

Docs: https://gitea.com/gitea/docs/pulls/535

Assisted by Claude (Fable 5.1).
Co-authored-by: bircni <[email protected]>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1204
Reviewed-by: bircni <[email protected]>
Co-authored-by: silverwind <[email protected]>
2026-09-05 08:58:23 +00:00
silverwind c158ac5472 perf: speed up action downloads (#1209)
An action pinned to a full commit hash cloned the whole repository, and a cached one hit the network on every run. Now only the pinned commit is fetched at depth 1, a cached commit is reused with no network at all, and the action's `.git` directory no longer ships into the job container, matching GitHub.

```
repo                  cold ms         warm ms       cache KiB
actions/checkout      5807 → 787    1064 → 12    11492 → 2349
actions/setup-node   15257 → 1013   1365 → 21    64303 → 9924
actions/cache        18782 → 760    1589 → 16    60890 → 12507
actions/setup-go      5513 → 764     589 → 20    16600 → 9104
docker/login-action  25809 → 1225   1288 → 10    81709 → 12503
```

Reviewed-on: https://gitea.com/gitea/runner/pulls/1209
Reviewed-by: bircni <[email protected]>
Co-authored-by: silverwind <[email protected]>
2026-09-05 08:52:10 +00:00
1745c7c841 fix: fail the step or job whose expression cannot be interpolated (#1199)
An expression that failed to evaluate was logged and replaced by an empty string, so a `run:` step executed an empty script and reported success. The same swallow covered `shell:`, `working-directory:`, step `env:`, `with:`, `uses:`, the job's `env:`, `container:`, `services:`, `runs-on:` and outputs, and a called workflow's `with:` and `secrets:`. Every interpolation now propagates its error as actions/runner does: step-level values fail the step, job-level values fail the job at setup, `timeout-minutes` logs the error and runs unbounded, and a job or step name keeps its source text.

`defaults.run` and a called workflow's inputs and secrets are resolved once at job setup with the job context rather than per step, and the job's image is resolved once, so host mode and `ImageOS` derive from the image the job started with.

Closes https://gitea.com/gitea/runner/issues/392
Closes https://gitea.com/gitea/runner/issues/555

---------

Co-authored-by: silverwind <[email protected]>
Co-authored-by: silverwind <[email protected]>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1199
Reviewed-by: silverwind <[email protected]>
Co-authored-by: bircni <[email protected]>
2026-09-03 10:43:04 +00:00
Renovate Bot 7f53eca716 fix(deps): update module golang.org/x/crypto to v0.56.0 [security] (#1205)
This PR contains the following updates:

| Package | Change | [Age](https://docs.renovatebot.com/merge-confidence/) | [Confidence](https://docs.renovatebot.com/merge-confidence/) |
|---|---|---|---|
| [golang.org/x/crypto](https://pkg.go.dev/golang.org/x/crypto) | [`v0.55.0` → `v0.56.0`](https://cs.opensource.google/go/x/crypto/+/refs/tags/v0.55.0...refs/tags/v0.56.0) | ![age](https://developer.mend.io/api/mc/badges/age/go/golang.org%2fx%2fcrypto/v0.56.0?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/go/golang.org%2fx%2fcrypto/v0.55.0/v0.56.0?slim=true) |

---

### Prevent DoS on deadlocked undecided channel in golang.org/x/crypto/ssh
[CVE-2026-78662](https://nvd.nist.gov/vuln/detail/CVE-2026-78662) / [GO-2026-6354](https://pkg.go.dev/vuln/GO-2026-6354)

<details>
<summary>More information</summary>

#### Details
Previously, a channel registered in the mux's chanList is not usable until it is established. A malicious peer was able flood the channel's incomingRequests, deadlocking the entire connection.

Now, we add an atomic established state, set when a channel becomes usable. Until such a time, handlePacket drops every packet other than the open confirmation/failure, without blocking and without tearing down the connection.

#### Severity
Unknown

#### References
- [https://go.dev/issue/81316](https://go.dev/issue/81316)
- [https://go.dev/cl/826504](https://go.dev/cl/826504)
- [https://groups.google.com/g/golang-announce/c/1y3fb2np35U](https://groups.google.com/g/golang-announce/c/1y3fb2np35U)

This data is provided by [OSV](https://osv.dev/vulnerability/GO-2026-6354) and the [Go Vulnerability Database](https://github.com/golang/vulndb) ([CC-BY 4.0](https://github.com/golang/vulndb#license)).
</details>

---

### Prevent DoS on deadlocked established channel in golang.org/x/crypto/ssh
[CVE-2026-56855](https://nvd.nist.gov/vuln/detail/CVE-2026-56855) / [GO-2026-6355](https://pkg.go.dev/vuln/GO-2026-6355)

<details>
<summary>More information</summary>

#### Details
Previously, after a channel has been established, a malicious peer could send crafted messages that would deadlock the entire connection.

Now, we handle all RFC 4254 channel messages; global requests are handled explicitly. Then, treat all other messages as a protocol error and tear the connection down instead of buffering and blocking.

#### Severity
Unknown

#### References
- [https://go.dev/issue/81317](https://go.dev/issue/81317)
- [https://go.dev/cl/826524](https://go.dev/cl/826524)
- [https://groups.google.com/g/golang-announce/c/1y3fb2np35U](https://groups.google.com/g/golang-announce/c/1y3fb2np35U)

This data is provided by [OSV](https://osv.dev/vulnerability/GO-2026-6355) and the [Go Vulnerability Database](https://github.com/golang/vulndb) ([CC-BY 4.0](https://github.com/golang/vulndb#license)).
</details>

---

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

Reviewed-on: https://gitea.com/gitea/runner/pulls/1205
Reviewed-by: Lunny Xiao <[email protected]>
Co-authored-by: Renovate Bot <[email protected]>
2026-09-03 06:40:31 +00:00
silverwind fca6b44c60 fix: automatically add the workspace mount to allowed volumes (#1203)
Automatically allow workspace mounts in `valid_volumes` for the mounts done via `bind_workdir`, this obsoletes the need for `/workspace/**` or other insecure configurations which would expose workspaces between tasks.

Reviewed-on: https://gitea.com/gitea/runner/pulls/1203
Reviewed-by: bircni <[email protected]>
Co-authored-by: silverwind <[email protected]>
2026-09-02 20:32:41 +00:00
bircniandsilverwind b9018aca31 fix: preserve symlinked Node action entrypoints (#1202)
Node resolves an ESM main to its realpath but leaves `process.argv[1]` as passed. `/var/run/act` reaches the action through the `/var/run` -> `/run` symlink most images ship, so the two disagree and actions comparing them skip their own `run()`. Passing `--preserve-symlinks-main` makes them match.

Trade-off: an action whose `runs.main` is a symlink now resolves dependencies from the link's directory rather than the target's.

Fixes https://gitea.com/gitea/runner/issues/1201
Co-authored-by: silverwind <[email protected]>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1202
Reviewed-by: silverwind <[email protected]>
Co-authored-by: bircni <[email protected]>
2026-08-31 13:06:49 +00:00
Renovate Bot 6c77065295 chore(deps): update dependencies (#1198)
This PR contains the following updates:

| Package | Update | Change |
|---|---|---|
| [node](https://github.com/nodejs/node) | digest | `934240a` → `be23f54` |
| [node](https://github.com/nodejs/node) | digest | `3638d9a` → `ba849c6` |

---

### Configuration

📅 **Schedule**: (UTC)

- Branch creation
  - Between 12:00 AM and 03:59 AM, only on Monday (`* 0-3 * * 1`)
- 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.

👻 **Immortal**: This PR will be recreated if closed unmerged. Get [config help](https://github.com/renovatebot/renovate/discussions) if that's undesired.

---

 - [ ] <!-- 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-->

Reviewed-on: https://gitea.com/gitea/runner/pulls/1198
Reviewed-by: silverwind <[email protected]>
Co-authored-by: Renovate Bot <[email protected]>
2026-08-29 02:00:21 +00:00
235c035003 feat: add input and input-file flags for exec command (#1173)
Add `input` and `input-file` flags for exec command and refactor common code from LoadVars and LoadEnvs.
Closes (https://gitea.com/gitea/runner/issues/1022)

---------

Co-authored-by: Lunny Xiao <[email protected]>
Co-authored-by: silverwind <[email protected]>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1173
Reviewed-by: Lunny Xiao <[email protected]>
Reviewed-by: silverwind <[email protected]>
Co-authored-by: Minjie Fang <[email protected]>
2026-08-27 16:38:01 +00:00
silverwind 12dc9d26a2 fix: improve behaviour across masking, commands and status (#1194)
Fixes 51 bugs discovered via comparison with `actions/runner`. Every fix has test coverage.

### Secrets

- A short secret registered no shifted-base64 form, so `base64("user:$TOKEN")` printed in the clear
- Encoded forms came only from the whole trimmed value, missing padded and per-line spellings
- Masks split only on `\n`, so `::add-mask::a%0Db` registered neither half
- Adds XML, expression-string and quote-trimming encoders

### Workflow commands

- Split at the last `::` or `]` rather than the first, so `::add-mask::a::b` registered no mask
- A command on the last line without a newline was ignored, and `::ADD-MASK::` did nothing
- `##[...]` did not decode `%3B`/`%5D`, properties lost anything after a second `=`
- `$GITHUB_ENV` and `::set-env::` now refuse `NODE_OPTIONS`

### Status

- `continue-on-error` reported failed, a cancelled job reported success, an `if:` error reported cancelled
- File commands ran after `continue-on-error`, failing the job while the step stayed green
- A bad job output aborted the whole run instead of that job

### Steps and actions

- `${{ matrix.* }}` and `${{ strategy.* }}` were empty inside composite actions
- Composite inputs leaked into nested actions as `INPUT_*`, `with:` matched case-sensitively, `pre` failures were dropped
- Docker actions dropped `runs.env` when the caller passed `with: args:`, and caller `args`/`entrypoint` beat the manifest
- An implicit shell ran with `pipefail`, a `shell:` without `{0}` passed without running
- `container.env` overrode job env and every `$GITHUB_ENV` write, heredocs lost leading blank lines, `$GITHUB_PATH` was not BOM-decoded

Written by Claude Opus 5.

Reviewed-on: https://gitea.com/gitea/runner/pulls/1194
Reviewed-by: bircni <[email protected]>
Co-authored-by: silverwind <[email protected]>
2026-08-27 09:36:27 +00:00
54 changed files with 2404 additions and 615 deletions

No files matched your search

+1 -1
View File
@@ -30,7 +30,7 @@ jobs:
# daemon retains them between runs, so this is usually a fast manifest re-check. # daemon retains them between runs, so this is usually a fast manifest re-check.
- name: pre-pull test images - name: pre-pull test images
env: env:
TEST_JOB_IMAGE: node:24-bookworm-slim@sha256:3638d9a6fe4030bd716be989438248074489337ba3275657f93595428be4fc03 # renovate: datasource=docker TEST_JOB_IMAGE: node:24-bookworm-slim@sha256:ba849c60be29959425b8734d57b8b4b7d56f98edd9504c9af091d5281095a71e # renovate: datasource=docker
TEST_SERVICE_IMAGE: nginx:alpine@sha256:db35bfc6b2951e7f8a72db5db120288c127ffaeeb4a6d4b95a26fead017d5913 # renovate: datasource=docker TEST_SERVICE_IMAGE: nginx:alpine@sha256:db35bfc6b2951e7f8a72db5db120288c127ffaeeb4a6d4b95a26fead017d5913 # renovate: datasource=docker
run: | run: |
for image in "$TEST_JOB_IMAGE" "$TEST_SERVICE_IMAGE"; do for image in "$TEST_JOB_IMAGE" "$TEST_SERVICE_IMAGE"; do
+1 -1
View File
@@ -171,7 +171,7 @@ coverage-report: ## turn coverage.txt from `make test` into .tmp/coverage.md
test-dind: ## run the daemon-facing tests against the built dind image (TARGET=dind|dind-rootless) test-dind: ## run the daemon-facing tests against the built dind image (TARGET=dind|dind-rootless)
@./scripts/test-dind.sh $(TARGET) @./scripts/test-dind.sh $(TARGET)
E2E_JOB_IMAGE ?= node:24-bookworm@sha256:934240a162082fd8b8a2f90cd5114446443f1eba1c5378f6687167ca405e6584 # renovate: datasource=docker E2E_JOB_IMAGE ?= node:24-bookworm@sha256:be23f54a88d34e8824c741b19b91064094f92c1c97b194144bfc8b50d67258e2 # renovate: datasource=docker
SERVICE_IMAGE ?= nginx:1.31.4-alpine@sha256:db35bfc6b2951e7f8a72db5db120288c127ffaeeb4a6d4b95a26fead017d5913 # renovate: datasource=docker SERVICE_IMAGE ?= nginx:1.31.4-alpine@sha256:db35bfc6b2951e7f8a72db5db120288c127ffaeeb4a6d4b95a26fead017d5913 # renovate: datasource=docker
E2E_GITEA_IMAGE ?= gitea/gitea:main-nightly E2E_GITEA_IMAGE ?= gitea/gitea:main-nightly
E2E_CONCURRENCY ?= 8 E2E_CONCURRENCY ?= 8
+11
View File
@@ -266,6 +266,17 @@ A job in a container reaches a service by its id on the job network, on the port
Unlike GitHub, a job whose steps run on the host (a `host` label without `container:`) starts no service containers, so `job.services` and `job.container` stay empty. Give such a job a `container:` when it needs services. Unlike GitHub, a job whose steps run on the host (a `host` label without `container:`) starts no service containers, so `job.services` and `job.container` stay empty. Give such a job a `container:` when it needs services.
#### Docker from a job (`GITEA_DOCKER_WORKSPACE`)
A container a job starts through the Docker socket cannot bind-mount the workspace by the job's own path, the daemon does not have it. `GITEA_DOCKER_WORKSPACE` holds the path the daemon sees. Use it as the prefix of workspace binds, with `.` as the fallback for local use, here in a `docker-compose.yaml`:
```yaml
volumes:
- ${GITEA_DOCKER_WORKSPACE:-.}/data:/app/data
```
Containers, networks and volumes a job creates through the socket are removed when the job ends. Both apply to jobs in containers, on the host `.` already works.
#### Proxy #### Proxy
Set these variables in the runner's environment, with systemd `Environment=`, `docker run -e`, or Kubernetes `env:`: Set these variables in the runner's environment, with systemd `Environment=`, `docker run -e`, or Kubernetes `env:`:
+90 -37
View File
@@ -391,7 +391,11 @@ func NewGitCloneExecutor(input NewGitCloneExecutorInput) common.Executor {
} }
} }
if !isOfflineMode { // A just-cloned ref is as current as a fetch would make it, and a commit hash never moves.
_, _, present := refRevision(r, input.Ref)
refresh := !isOfflineMode && (!present || (reused && !plumbing.IsHash(input.Ref)))
if refresh {
err = r.FetchContext(ctx, &fetchOptions) err = r.FetchContext(ctx, &fetchOptions)
if err != nil && !errors.Is(err, git.NoErrAlreadyUpToDate) { if err != nil && !errors.Is(err, git.NoErrAlreadyUpToDate) {
return err return err
@@ -411,23 +415,7 @@ func NewGitCloneExecutor(input NewGitCloneExecutorInput) common.Executor {
} }
} }
// At this point we need to know if it's a tag or a branch rev, refType, _ := refRevision(r, input.Ref)
// And the easiest way to do it is duck typing
//
// If err is nil, it's a tag so let's proceed with that hash like we would if
// it was a sha
refType := "tag"
rev = plumbing.Revision(path.Join("refs", "tags", input.Ref))
if _, err := r.Tag(input.Ref); errors.Is(err, git.ErrTagNotFound) {
rName := plumbing.ReferenceName(path.Join("refs", "remotes", "origin", input.Ref))
if _, err := r.Reference(rName, false); errors.Is(err, plumbing.ErrReferenceNotFound) {
refType = "sha"
rev = plumbing.Revision(input.Ref)
} else {
refType = "branch"
rev = plumbing.Revision(rName)
}
}
if hash, err = r.ResolveRevision(rev); err != nil { if hash, err = r.ResolveRevision(rev); err != nil {
logger.Errorf("Unable to resolve %s: %v", input.Ref, err) logger.Errorf("Unable to resolve %s: %v", input.Ref, err)
@@ -459,7 +447,7 @@ func NewGitCloneExecutor(input NewGitCloneExecutorInput) common.Executor {
reusedMsg := "" reusedMsg := ""
switch { switch {
case !isOfflineMode && !shallow: case refresh && !shallow:
// In shallow mode the depth-limited fetch above already advanced the ref. // In shallow mode the depth-limited fetch above already advanced the ref.
if err = w.PullContext(ctx, &pullOptions); err != nil && !errors.Is(err, git.NoErrAlreadyUpToDate) { if err = w.PullContext(ctx, &pullOptions); err != nil && !errors.Is(err, git.NoErrAlreadyUpToDate) {
logger.Debugf("Unable to pull %s: %v", refName, err) logger.Debugf("Unable to pull %s: %v", refName, err)
@@ -501,27 +489,37 @@ func NewGitCloneExecutor(input NewGitCloneExecutorInput) common.Executor {
} }
} }
// cloneAtDepth clones input.URL into input.Dir using opts. // cloneAtDepth falls back to a full clone when the shallow attempt fails.
// With input.Depth > 0 it first tries a shallow, single-branch clone of input.Ref, falling back when error.
func cloneAtDepth(ctx context.Context, input NewGitCloneExecutorInput, opts git.CloneOptions, logger log.FieldLogger) (*git.Repository, error) { func cloneAtDepth(ctx context.Context, input NewGitCloneExecutorInput, opts git.CloneOptions, logger log.FieldLogger) (*git.Repository, error) {
if input.Depth > 0 { if input.Depth > 0 {
for _, refName := range []plumbing.ReferenceName{ if plumbing.IsHash(input.Ref) {
plumbing.NewBranchReferenceName(input.Ref), r, err := fetchPinnedSHA(ctx, input, opts)
plumbing.NewTagReferenceName(input.Ref),
} {
shallowOpts := opts
shallowOpts.Depth = input.Depth
shallowOpts.SingleBranch = true
shallowOpts.ReferenceName = refName
shallowOpts.Tags = git.NoTags
r, err := git.PlainCloneContext(ctx, input.Dir, false, &shallowOpts)
if err == nil { if err == nil {
return r, nil return r, nil
} }
logger.Debugf("Shallow clone of %s as %s failed: %v", input.URL, refName, err) logger.Debugf("Shallow fetch of %s at %s failed: %v", input.URL, input.Ref, err)
if rmErr := os.RemoveAll(input.Dir); rmErr != nil { if err := removePartialClone(input.Dir); err != nil {
return nil, fmt.Errorf("remove partial clone %s: %w", input.Dir, rmErr) return nil, err
}
} else {
for _, refName := range []plumbing.ReferenceName{
plumbing.NewBranchReferenceName(input.Ref),
plumbing.NewTagReferenceName(input.Ref),
} {
shallowOpts := opts
shallowOpts.Depth = input.Depth
shallowOpts.SingleBranch = true
shallowOpts.ReferenceName = refName
shallowOpts.Tags = git.NoTags
r, err := git.PlainCloneContext(ctx, input.Dir, false, &shallowOpts)
if err == nil {
return r, nil
}
logger.Debugf("Shallow clone of %s as %s failed: %v", input.URL, refName, err)
if err := removePartialClone(input.Dir); err != nil {
return nil, err
}
} }
} }
logger.Debugf("Falling back to a full clone of %s for ref %q", input.URL, input.Ref) logger.Debugf("Falling back to a full clone of %s for ref %q", input.URL, input.Ref)
@@ -530,14 +528,65 @@ func cloneAtDepth(ctx context.Context, input NewGitCloneExecutorInput, opts git.
return git.PlainCloneContext(ctx, input.Dir, false, &opts) return git.PlainCloneContext(ctx, input.Dir, false, &opts)
} }
func removePartialClone(dir string) error {
if err := os.RemoveAll(dir); err != nil {
return fmt.Errorf("remove partial clone %s: %w", dir, err)
}
return nil
}
func fetchPinnedSHA(ctx context.Context, input NewGitCloneExecutorInput, opts git.CloneOptions) (*git.Repository, error) {
r, err := git.PlainInit(input.Dir, false)
if err != nil {
return nil, err
}
if _, err := r.CreateRemote(&config.RemoteConfig{Name: "origin", URLs: []string{input.URL}}); err != nil {
return nil, err
}
if err := r.FetchContext(ctx, &git.FetchOptions{
RefSpecs: []config.RefSpec{pinnedRefSpec(input.Ref)},
Depth: input.Depth,
Tags: git.NoTags,
Auth: opts.Auth,
Progress: opts.Progress,
InsecureSkipTLS: opts.InsecureSkipTLS,
}); err != nil {
return nil, err
}
return r, nil
}
// pinnedRefSpec keeps a hash-shaped name out of refs/heads and refs/tags.
func pinnedRefSpec(sha string) config.RefSpec {
return config.RefSpec(fmt.Sprintf("+%s:refs/pinned/%s", sha, sha))
}
// refRevision picks the revision to check out and reports whether it resolves locally.
func refRevision(r *git.Repository, ref string) (plumbing.Revision, string, bool) {
if plumbing.IsHash(ref) {
// git ignores a ref named as 40 hex digits, so a full hash always denotes the commit itself.
_, err := r.CommitObject(plumbing.NewHash(ref))
return plumbing.Revision(ref), "sha", err == nil
}
if _, err := r.Tag(ref); err == nil {
return plumbing.Revision(path.Join("refs", "tags", ref)), "tag", true
}
remoteRef := plumbing.ReferenceName(path.Join("refs", "remotes", "origin", ref))
if _, err := r.Reference(remoteRef, false); err == nil {
return plumbing.Revision(remoteRef), "branch", true
}
rev := plumbing.Revision(ref)
_, err := r.ResolveRevision(rev)
return rev, "sha", err == nil
}
// isShallow reports whether the local repository was cloned with a limited depth. // isShallow reports whether the local repository was cloned with a limited depth.
func isShallow(r *git.Repository) bool { func isShallow(r *git.Repository) bool {
shallows, err := r.Storer.Shallow() shallows, err := r.Storer.Shallow()
return err == nil && len(shallows) > 0 return err == nil && len(shallows) > 0
} }
// shallowFetchRefSpec returns the single refspec that updates only input.Ref, keeping a shallow clone from re-downloading every branch's history. // shallowFetchRefSpec limits a shallow update to the requested ref, falling back to the broad refspec when it is not present locally.
// ok is false when the ref is not present locally as a tag or remote-tracking branch, in which case the broad default refspec is used.
func shallowFetchRefSpec(r *git.Repository, ref string) (config.RefSpec, bool) { func shallowFetchRefSpec(r *git.Repository, ref string) (config.RefSpec, bool) {
tagRef := plumbing.NewTagReferenceName(ref) tagRef := plumbing.NewTagReferenceName(ref)
if _, err := r.Reference(tagRef, false); err == nil { if _, err := r.Reference(tagRef, false); err == nil {
@@ -548,5 +597,9 @@ func shallowFetchRefSpec(r *git.Repository, ref string) (config.RefSpec, bool) {
branchRef := plumbing.NewBranchReferenceName(ref) branchRef := plumbing.NewBranchReferenceName(ref)
return config.RefSpec(fmt.Sprintf("+%s:%s", branchRef, remoteRef)), true return config.RefSpec(fmt.Sprintf("+%s:%s", branchRef, remoteRef)), true
} }
if plumbing.IsHash(ref) {
// The broad refspec carries only advertised tips, never a pinned commit.
return pinnedRefSpec(ref), true
}
return "", false return "", false
} }
+110 -6
View File
@@ -16,6 +16,7 @@ import (
"strconv" "strconv"
"strings" "strings"
"sync" "sync"
"sync/atomic"
"syscall" "syscall"
"testing" "testing"
"time" "time"
@@ -24,6 +25,9 @@ import (
gogit "github.com/go-git/go-git/v5" gogit "github.com/go-git/go-git/v5"
gogitconfig "github.com/go-git/go-git/v5/config" gogitconfig "github.com/go-git/go-git/v5/config"
"github.com/go-git/go-git/v5/plumbing/transport"
gogitclient "github.com/go-git/go-git/v5/plumbing/transport/client"
gogitfile "github.com/go-git/go-git/v5/plumbing/transport/file"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
logrustest "github.com/sirupsen/logrus/hooks/test" logrustest "github.com/sirupsen/logrus/hooks/test"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
@@ -435,7 +439,7 @@ func TestGitCloneExecutorShallow(t *testing.T) {
require.NoError(t, gitCmd("-C", workDir, "commit", "--allow-empty", "-m", m)) require.NoError(t, gitCmd("-C", workDir, "commit", "--allow-empty", "-m", m))
} }
require.NoError(t, gitCmd("-C", workDir, "tag", "v1")) require.NoError(t, gitCmd("-C", workDir, "tag", "v1"))
sha := gitRevParse(t, workDir, "HEAD~1") // c2, a SHA that go-git cannot shallow-clone unadvertisedSHA := gitRevParse(t, workDir, "HEAD~1")
require.NoError(t, gitCmd("-C", workDir, "push", "-u", "origin", "main")) require.NoError(t, gitCmd("-C", workDir, "push", "-u", "origin", "main"))
require.NoError(t, gitCmd("-C", workDir, "push", "origin", "v1")) require.NoError(t, gitCmd("-C", workDir, "push", "origin", "v1"))
@@ -461,14 +465,43 @@ func TestGitCloneExecutorShallow(t *testing.T) {
assert.Equal(t, "c3", gitHeadSubject(t, dir)) assert.Equal(t, "c3", gitHeadSubject(t, dir))
}) })
t.Run("SHA falls back to a full clone", func(t *testing.T) { unadvertisedRemote := func(t *testing.T, allowUnadvertised bool) string {
remote := filepath.Join(t.TempDir(), "remote.git")
require.NoError(t, gitCmd("clone", "--bare", remoteDir, remote))
require.NoError(t, gitCmd("-C", remote, "config", "uploadpack.allowAnySHA1InWant", strconv.FormatBool(allowUnadvertised)))
return remote
}
t.Run("commit hash falls back to a full clone when the remote refuses unadvertised objects", func(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
require.NoError(t, NewGitCloneExecutor(NewGitCloneExecutorInput{ require.NoError(t, NewGitCloneExecutor(NewGitCloneExecutorInput{
URL: remoteDir, Ref: sha, Dir: dir, Depth: 1, URL: unadvertisedRemote(t, false), Ref: unadvertisedSHA, Dir: dir, Depth: 1,
})(t.Context())) })(t.Context()))
// go-git cannot shallow-clone a raw SHA, so it falls back to a full clone; the absence of a shallow marker proves the fallback happened. assert.NoFileExists(t, shallowMarker(dir))
assert.NoFileExists(t, shallowMarker(dir), "a SHA ref must not produce a shallow clone") assert.Equal(t, unadvertisedSHA, gitRevParse(t, dir, "HEAD"))
assert.Equal(t, sha, gitRevParse(t, dir, "HEAD")) })
t.Run("commit hash is fetched shallowly, moved to another hash, then reused without reaching the remote", func(t *testing.T) {
remote := unadvertisedRemote(t, true)
dir := t.TempDir()
cloneAt := func(ref string) error {
return NewGitCloneExecutor(NewGitCloneExecutorInput{
URL: remote, Ref: ref, Dir: dir, Depth: 1,
})(t.Context())
}
require.NoError(t, cloneAt(unadvertisedSHA))
assert.FileExists(t, shallowMarker(dir))
assert.Equal(t, 1, gitRevCount(t, dir))
assert.Equal(t, unadvertisedSHA, gitRevParse(t, dir, "HEAD"))
olderSHA := gitRevParse(t, workDir, "HEAD~2")
require.NoError(t, cloneAt(olderSHA))
assert.Equal(t, olderSHA, gitRevParse(t, dir, "HEAD"))
require.NoError(t, os.RemoveAll(remote))
require.NoError(t, cloneAt(olderSHA))
assert.Equal(t, olderSHA, gitRevParse(t, dir, "HEAD"))
}) })
t.Run("moving branch updates while staying shallow", func(t *testing.T) { t.Run("moving branch updates while staying shallow", func(t *testing.T) {
@@ -491,6 +524,77 @@ func TestGitCloneExecutorShallow(t *testing.T) {
}) })
} }
func TestGitCloneExecutorColdCloneSkipsRefresh(t *testing.T) {
remoteDir := t.TempDir()
require.NoError(t, gitCmd("init", "--bare", "--initial-branch=main", remoteDir))
workDir := t.TempDir()
require.NoError(t, gitCmd("clone", remoteDir, workDir))
require.NoError(t, gitCmd("-C", workDir, "checkout", "-b", "main"))
require.NoError(t, gitCmd("-C", workDir, "commit", "--allow-empty", "-m", "c1"))
require.NoError(t, gitCmd("-C", workDir, "tag", "v1"))
require.NoError(t, gitCmd("-C", workDir, "push", "-u", "origin", "main"))
require.NoError(t, gitCmd("-C", workDir, "push", "origin", "v1"))
for name, tt := range map[string]struct {
Ref string
Depth int
}{
"shallow branch": {"main", 1},
"full clone tag": {"v1", 0},
} {
t.Run(name, func(t *testing.T) {
counter := installCountingTransport(t)
require.NoError(t, NewGitCloneExecutor(NewGitCloneExecutorInput{
URL: remoteDir, Ref: tt.Ref, Dir: t.TempDir(), Depth: tt.Depth,
})(t.Context()))
assert.Equal(t, int64(1), counter.sessions.Load())
})
}
}
func TestGitCloneExecutorPinnedHashIgnoresShadowingRef(t *testing.T) {
remoteDir := t.TempDir()
require.NoError(t, gitCmd("init", "--bare", "--initial-branch=main", remoteDir))
workDir := t.TempDir()
require.NoError(t, gitCmd("clone", remoteDir, workDir))
require.NoError(t, gitCmd("-C", workDir, "checkout", "-b", "main"))
require.NoError(t, gitCmd("-C", workDir, "commit", "--allow-empty", "-m", "pinned"))
pinned := gitRevParse(t, workDir, "HEAD")
require.NoError(t, gitCmd("-C", workDir, "commit", "--allow-empty", "-m", "decoy"))
require.NoError(t, gitCmd("-C", workDir, "tag", pinned))
require.NoError(t, gitCmd("-C", workDir, "push", "-u", "origin", "main"))
require.NoError(t, gitCmd("-C", workDir, "push", "origin", pinned))
for name, depth := range map[string]int{"shallow": 1, "full clone": 0} {
t.Run(name, func(t *testing.T) {
dir := t.TempDir()
require.NoError(t, NewGitCloneExecutor(NewGitCloneExecutorInput{
URL: remoteDir, Ref: pinned, Dir: dir, Depth: depth,
})(t.Context()))
assert.Equal(t, pinned, gitRevParse(t, dir, "HEAD"))
})
}
}
type countingTransport struct {
transport.Transport
sessions atomic.Int64
}
func (c *countingTransport) NewUploadPackSession(ep *transport.Endpoint, auth transport.AuthMethod) (transport.UploadPackSession, error) {
c.sessions.Add(1)
return c.Transport.NewUploadPackSession(ep, auth)
}
func installCountingTransport(t *testing.T) *countingTransport {
t.Helper()
counter := &countingTransport{Transport: gogitfile.DefaultClient}
gogitclient.InstallProtocol("file", counter)
t.Cleanup(func() { gogitclient.InstallProtocol("file", gogitfile.DefaultClient) })
return counter
}
func gitRevParse(t *testing.T, dir, rev string) string { func gitRevParse(t *testing.T, dir, rev string) string {
t.Helper() t.Helper()
out, err := exec.Command("git", "-C", dir, "rev-parse", rev).Output() out, err := exec.Command("git", "-C", dir, "rev-parse", rev).Output()
+13 -1
View File
@@ -75,6 +75,17 @@ const (
// fragment, missingContainerError composes it into the message every operation shares. // fragment, missingContainerError composes it into the message every operation shares.
var ErrContainerNotFound = errors.New("does not exist") var ErrContainerNotFound = errors.New("does not exist")
// DockerProxy is a job's docker socket, fronting the daemon's for the job's lifetime.
type DockerProxy struct {
Socket string
close func(context.Context) error
}
// Close removes what the job created through the socket, then stops serving it.
func (p *DockerProxy) Close(ctx context.Context) error {
return p.close(ctx)
}
// Info is a snapshot of a container, as of one inspect. // Info is a snapshot of a container, as of one inspect.
type Info struct { type Info struct {
ID string ID string
@@ -84,13 +95,14 @@ type Info struct {
// HealthOutput is the last healthcheck probe's output. // HealthOutput is the last healthcheck probe's output.
HealthOutput string HealthOutput string
Ports map[string]string // container port ("5432") to the host port it is published on Ports map[string]string // container port ("5432") to the host port it is published on
Mounts map[string]string // container path to its source on the daemon
} }
// Container for managing docker run containers // Container for managing docker run containers
type Container interface { type Container interface {
Create(capAdd, capDrop []string) common.Executor Create(capAdd, capDrop []string) common.Executor
Copy(destPath string, files ...*FileEntry) common.Executor Copy(destPath string, files ...*FileEntry) common.Executor
CopyDir(destPath, srcPath string, useGitIgnore bool) common.Executor CopyDir(destPath, srcPath string, useGitIgnore, skipGitDir bool) common.Executor
GetContainerArchive(ctx context.Context, srcPath string) (io.ReadCloser, error) GetContainerArchive(ctx context.Context, srcPath string) (io.ReadCloser, error)
Inspect(ctx context.Context) (*Info, error) Inspect(ctx context.Context) (*Info, error)
DumpLogs(ctx context.Context) error DumpLogs(ctx context.Context) error
+276
View File
@@ -0,0 +1,276 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
//go:build !(WITHOUT_DOCKER || !(linux || darwin || windows || netbsd))
package container
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/http/httputil"
"os"
"path/filepath"
"regexp"
"strings"
"sync"
"time"
"gitea.com/gitea/runner/act/common"
cerrdefs "github.com/containerd/errdefs"
"github.com/moby/moby/api/types/container"
"github.com/moby/moby/api/types/mount"
"github.com/moby/moby/client"
)
const (
jobLabel = "com.gitea.runner.job"
maxCreateBody = 8 << 20
)
var (
createPath = regexp.MustCompile(`^(/v[0-9.]+)?/(containers|networks|volumes)/create$`)
rawStreamPath = regexp.MustCompile(`^(/v[0-9.]+)?/(containers/[^/]+/attach|exec/[^/]+/start)$`)
proxyProbe struct {
sync.Mutex
decided bool
dir string
}
)
// DockerProxyDir returns where job proxy sockets live, "" while undecided or when the daemon cannot open the runner's files.
func DockerProxyDir(ctx context.Context) string {
proxyProbe.Lock()
defer proxyProbe.Unlock()
if proxyProbe.decided {
return proxyProbe.dir
}
dir := filepath.Join(os.TempDir(), "gitea-runner-docker")
ok, err := daemonSeesDir(ctx, dir)
if err != nil {
common.Logger(ctx).Debugf("docker proxy probe postponed: %v", err)
return ""
}
proxyProbe.decided = true
if ok {
proxyProbe.dir = dir
} else {
common.Logger(ctx).Infof("the docker daemon cannot reach the runner's filesystem, jobs get the daemon socket directly")
}
return proxyProbe.dir
}
func daemonSeesDir(ctx context.Context, dir string) (bool, error) {
if err := os.MkdirAll(dir, 0o700); err != nil {
return false, err
}
probe := filepath.Join(dir, "probe")
if err := os.WriteFile(probe, nil, 0o600); err != nil {
return false, err
}
cli, err := GetDockerClient(ctx)
if err != nil {
return false, err
}
defer cli.Close()
images, err := cli.ImageList(ctx, client.ImageListOptions{})
if err != nil {
return false, err
}
if len(images.Items) == 0 {
return false, errors.New("no image to probe with yet")
}
// creating validates that a bind source exists on the daemon's side, nothing is started
created, err := cli.ContainerCreate(ctx, client.ContainerCreateOptions{
Config: &container.Config{Image: images.Items[0].ID, Cmd: []string{"true"}},
HostConfig: &container.HostConfig{Mounts: []mount.Mount{{Type: mount.TypeBind, Source: probe, Target: "/gitea-runner-probe"}}},
})
if cerrdefs.IsInvalidArgument(err) {
return false, nil
}
if err != nil {
return false, err
}
_, err = cli.ContainerRemove(ctx, created.ID, client.ContainerRemoveOptions{Force: true})
return true, err
}
// StartDockerProxy serves a job's docker socket in dir, labelling what the job creates through it.
func StartDockerProxy(daemonSocket, dir, job string) (*DockerProxy, error) {
if err := os.MkdirAll(dir, 0o700); err != nil {
return nil, err
}
digest := sha256.Sum256([]byte(job))
socket := filepath.Join(dir, hex.EncodeToString(digest[:8])+".sock")
_ = os.Remove(socket)
listener, err := net.Listen("unix", socket)
if err != nil {
return nil, err
}
if info, err := os.Stat(daemonSocket); err == nil {
_ = os.Chmod(socket, info.Mode().Perm())
}
dial := func(ctx context.Context, _, _ string) (net.Conn, error) {
return (&net.Dialer{}).DialContext(ctx, "unix", daemonSocket)
}
transport := &http.Transport{DialContext: dial}
forward := &httputil.ReverseProxy{
Rewrite: func(r *httputil.ProxyRequest) {
r.Out.URL.Scheme = "http"
r.Out.URL.Host = "docker"
},
Transport: transport,
}
server := &http.Server{ReadHeaderTimeout: 30 * time.Second, Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method != http.MethodPost:
case createPath.MatchString(r.URL.Path):
if err := addLabel(r, job); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
case rawStreamPath.MatchString(r.URL.Path):
tunnel(w, r, dial)
return
}
forward.ServeHTTP(w, r)
})}
go func() { _ = server.Serve(listener) }()
return &DockerProxy{Socket: socket, close: func(ctx context.Context) error {
err := removeJobResources(ctx, job)
_ = server.Close()
transport.CloseIdleConnections()
_ = os.Remove(socket)
return err
}}, nil
}
func addLabel(r *http.Request, job string) error {
body, err := io.ReadAll(io.LimitReader(r.Body, maxCreateBody+1))
if err != nil {
return err
}
if len(body) > maxCreateBody {
return errors.New("create request too large")
}
if len(bytes.TrimSpace(body)) == 0 {
r.Body = io.NopCloser(bytes.NewReader(body))
return nil
}
var fields map[string]json.RawMessage
if err := json.Unmarshal(body, &fields); err != nil {
return fmt.Errorf("invalid create request: %w", err)
}
key := "Labels"
for name := range fields {
if strings.EqualFold(name, key) {
key = name
break
}
}
labels := map[string]string{}
if raw := fields[key]; len(raw) > 0 && string(raw) != "null" {
if err := json.Unmarshal(raw, &labels); err != nil {
return fmt.Errorf("invalid create request: %w", err)
}
}
labels[jobLabel] = job
if fields[key], err = json.Marshal(labels); err != nil {
return err
}
if body, err = json.Marshal(fields); err != nil {
return err
}
r.Body = io.NopCloser(bytes.NewReader(body))
r.ContentLength = int64(len(body))
r.TransferEncoding = nil
return nil
}
// tunnel splices attach and exec streams, which the daemon hijacks with or without an HTTP upgrade
func tunnel(w http.ResponseWriter, r *http.Request, dial func(context.Context, string, string) (net.Conn, error)) {
hijacker, ok := w.(http.Hijacker)
if !ok {
http.Error(w, "connection cannot be hijacked", http.StatusInternalServerError)
return
}
upstream, err := dial(r.Context(), "", "")
if err != nil {
http.Error(w, err.Error(), http.StatusBadGateway)
return
}
defer upstream.Close()
if err := r.Write(upstream); err != nil {
http.Error(w, err.Error(), http.StatusBadGateway)
return
}
downstream, buffered, err := hijacker.Hijack()
if err != nil {
return
}
defer downstream.Close()
if _, err := io.CopyN(upstream, buffered, int64(buffered.Reader.Buffered())); err != nil {
return
}
done := make(chan struct{}, 2)
go func() {
_, _ = io.Copy(upstream, downstream)
done <- struct{}{}
}()
go func() {
_, _ = io.Copy(downstream, upstream)
done <- struct{}{}
}()
<-done
}
func removeJobResources(ctx context.Context, job string) error {
cli, err := GetDockerClient(ctx)
if err != nil {
return err
}
defer cli.Close()
return removeLabelled(ctx, cli, job)
}
func removeLabelled(ctx context.Context, cli client.APIClient, job string) error {
logger := common.Logger(ctx)
filters := make(client.Filters).Add("label", jobLabel+"="+job)
containers, err := cli.ContainerList(ctx, client.ContainerListOptions{All: true, Filters: filters})
if err != nil {
return err
}
var errs []error
for _, c := range containers.Items {
logger.Infof("removing container %s the job left behind", strings.TrimPrefix(strings.Join(c.Names, ","), "/"))
errs = append(errs, (&containerReference{cli: cli, id: c.ID}).remove()(ctx))
}
networks, err := cli.NetworkList(ctx, client.NetworkListOptions{Filters: filters})
if err != nil {
return errors.Join(append(errs, err)...)
}
for _, n := range networks.Items {
if _, err := cli.NetworkRemove(ctx, n.ID, client.NetworkRemoveOptions{}); err != nil && !cerrdefs.IsNotFound(err) {
errs = append(errs, fmt.Errorf("failed to remove network %s: %w", n.Name, err))
}
}
volumes, err := cli.VolumeList(ctx, client.VolumeListOptions{Filters: filters})
if err != nil {
return errors.Join(append(errs, err)...)
}
for _, v := range volumes.Items {
if _, err := cli.VolumeRemove(ctx, v.Name, client.VolumeRemoveOptions{}); err != nil && !cerrdefs.IsNotFound(err) {
errs = append(errs, fmt.Errorf("failed to remove volume %s: %w", v.Name, err))
}
}
return errors.Join(errs...)
}
+270
View File
@@ -0,0 +1,270 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
//go:build !(WITHOUT_DOCKER || !(linux || darwin || windows || netbsd))
package container
import (
"archive/tar"
"bufio"
"bytes"
"context"
"encoding/json"
"io"
"net"
"net/http"
"path/filepath"
"strings"
"testing"
"time"
cerrdefs "github.com/containerd/errdefs"
"github.com/moby/moby/api/types/container"
"github.com/moby/moby/api/types/network"
"github.com/moby/moby/api/types/volume"
mobyclient "github.com/moby/moby/client"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
)
func (m *mockDockerClient) VolumeList(ctx context.Context, opts mobyclient.VolumeListOptions) (mobyclient.VolumeListResult, error) {
args := m.Called(ctx, opts)
return args.Get(0).(mobyclient.VolumeListResult), args.Error(1)
}
func (m *mockDockerClient) VolumeRemove(ctx context.Context, id string, opts mobyclient.VolumeRemoveOptions) (mobyclient.VolumeRemoveResult, error) {
args := m.Called(ctx, id, opts)
return args.Get(0).(mobyclient.VolumeRemoveResult), args.Error(1)
}
// unix socket paths are limited to about a hundred bytes, which the default macOS TMPDIR exceeds
func shortTempDir(t testing.TB) string {
t.Helper()
t.Setenv("TMPDIR", "/tmp")
return t.TempDir()
}
func daemonSocketPath(t testing.TB, cli mobyclient.APIClient) string {
t.Helper()
host := cli.DaemonHost()
if !strings.HasPrefix(host, "unix://") {
t.Skipf("skipping: daemon at %s is not a unix socket", host)
}
return strings.TrimPrefix(host, "unix://")
}
func TestDockerProxy(t *testing.T) {
bodies := make(chan []byte, 8)
daemonSocket := filepath.Join(shortTempDir(t), "d.sock")
listener, err := net.Listen("unix", daemonSocket)
require.NoError(t, err)
daemon := &http.Server{ReadHeaderTimeout: time.Second, Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case strings.HasSuffix(r.URL.Path, "/create"):
body, _ := io.ReadAll(r.Body)
bodies <- body
w.WriteHeader(http.StatusCreated)
case strings.HasSuffix(r.URL.Path, "/start"):
conn, buffered, err := w.(http.Hijacker).Hijack()
if err != nil {
return
}
defer conn.Close()
_, _ = conn.Write([]byte("HTTP/1.1 200 OK\r\nContent-Type: application/vnd.docker.raw-stream\r\n\r\n"))
_, _ = io.Copy(conn, buffered)
default:
w.Header().Set("Api-Version", "1.47")
_, _ = w.Write([]byte("OK " + r.Method + " " + r.URL.Path))
}
})}
go func() { _ = daemon.Serve(listener) }()
t.Cleanup(func() { _ = daemon.Close() })
proxy, err := StartDockerProxy(daemonSocket, shortTempDir(t), "job-1")
require.NoError(t, err)
client := &http.Client{Transport: &http.Transport{DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
return (&net.Dialer{}).DialContext(ctx, "unix", proxy.Socket)
}}}
t.Run("labels creates", func(t *testing.T) {
for path, body := range map[string]string{
"/v1.47/containers/create": `{"Image":"alpine","Labels":{"own":"1"}}`,
"/networks/create": `{"Name":"n"}`,
"/volumes/create": `{"Name":"v","labels":null}`,
} {
resp, err := client.Post("http://docker"+path, "application/json", strings.NewReader(body))
require.NoError(t, err)
resp.Body.Close()
assert.Equal(t, http.StatusCreated, resp.StatusCode)
var got map[string]json.RawMessage
require.NoError(t, json.Unmarshal(<-bodies, &got))
key := "Labels"
if _, ok := got["labels"]; ok {
key = "labels"
}
var labels map[string]string
require.NoError(t, json.Unmarshal(got[key], &labels))
assert.Equal(t, "job-1", labels[jobLabel], path)
if strings.Contains(body, "own") {
assert.Equal(t, "1", labels["own"])
assert.JSONEq(t, `"alpine"`, string(got["Image"]))
}
}
})
t.Run("passes other requests through", func(t *testing.T) {
resp, err := client.Get("http://docker/v1.47/_ping")
require.NoError(t, err)
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
assert.Equal(t, "1.47", resp.Header.Get("Api-Version"))
assert.Equal(t, "OK GET /v1.47/_ping", string(body))
})
t.Run("tunnels raw streams", func(t *testing.T) {
conn, err := net.Dial("unix", proxy.Socket)
require.NoError(t, err)
defer conn.Close()
_, err = conn.Write([]byte("POST /v1.47/exec/abc/start HTTP/1.1\r\nHost: docker\r\nContent-Length: 2\r\n\r\n{}ping\n"))
require.NoError(t, err)
reader := bufio.NewReader(conn)
resp, err := http.ReadResponse(reader, nil)
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, "application/vnd.docker.raw-stream", resp.Header.Get("Content-Type"))
echoed, err := reader.ReadString('\n')
require.NoError(t, err)
assert.Equal(t, "{}ping\n", echoed)
})
}
func TestRemoveLabelledRemovesContainersNetworksAndVolumes(t *testing.T) {
ctx := context.Background()
filters := make(mobyclient.Filters).Add("label", jobLabel+"=job-1")
cli := &mockDockerClient{}
cli.On("ContainerList", ctx, mobyclient.ContainerListOptions{All: true, Filters: filters}).
Return(mobyclient.ContainerListResult{Items: []container.Summary{{ID: "c1", Names: []string{"/app"}}}}, nil)
cli.On("ContainerKill", ctx, "c1", mock.Anything).Return(mobyclient.ContainerKillResult{}, nil)
cli.On("ContainerRemove", ctx, "c1", mobyclient.ContainerRemoveOptions{RemoveVolumes: true, Force: true}).
Return(mobyclient.ContainerRemoveResult{}, nil)
cli.On("NetworkList", ctx, mobyclient.NetworkListOptions{Filters: filters}).
Return(mobyclient.NetworkListResult{Items: []network.Summary{{ID: "n1", Name: "app_default"}}}, nil)
cli.On("NetworkRemove", ctx, "n1", mock.Anything).Return(mobyclient.NetworkRemoveResult{}, nil)
cli.On("VolumeList", ctx, mobyclient.VolumeListOptions{Filters: filters}).
Return(mobyclient.VolumeListResult{Items: []volume.Volume{{Name: "app_data"}}}, nil)
cli.On("VolumeRemove", ctx, "app_data", mobyclient.VolumeRemoveOptions{}).Return(mobyclient.VolumeRemoveResult{}, nil)
require.NoError(t, removeLabelled(ctx, cli, "job-1"))
cli.AssertExpectations(t)
}
func TestDockerProxyWithDaemon(t *testing.T) {
requireDocker(t)
ctx := context.Background()
require.NoError(t, NewDockerPullExecutor(NewDockerPullExecutorInput{Image: "alpine"})(ctx))
direct, err := GetDockerClient(ctx)
require.NoError(t, err)
defer direct.Close()
dir := shortTempDir(t)
seen, err := daemonSeesDir(ctx, dir)
require.NoError(t, err)
t.Logf("daemon sees the runner's filesystem: %v", seen)
job := "proxy-test-" + t.Name()
proxy, err := StartDockerProxy(daemonSocketPath(t, direct), dir, job)
require.NoError(t, err)
viaProxy, err := mobyclient.New(mobyclient.WithHost("unix://" + proxy.Socket))
require.NoError(t, err)
defer viaProxy.Close()
created, err := viaProxy.ContainerCreate(ctx, mobyclient.ContainerCreateOptions{
Config: &container.Config{Image: "alpine", Cmd: []string{"sleep", "300"}, Labels: map[string]string{"own": "1"}},
})
require.NoError(t, err)
t.Cleanup(func() { _, _ = direct.ContainerRemove(ctx, created.ID, mobyclient.ContainerRemoveOptions{Force: true}) })
_, err = viaProxy.ContainerStart(ctx, created.ID, mobyclient.ContainerStartOptions{})
require.NoError(t, err)
net, err := viaProxy.NetworkCreate(ctx, job, mobyclient.NetworkCreateOptions{})
require.NoError(t, err)
t.Cleanup(func() { _, _ = direct.NetworkRemove(ctx, net.ID, mobyclient.NetworkRemoveOptions{}) })
_, err = viaProxy.VolumeCreate(ctx, mobyclient.VolumeCreateOptions{Name: job})
require.NoError(t, err)
t.Cleanup(func() { _, _ = direct.VolumeRemove(ctx, job, mobyclient.VolumeRemoveOptions{Force: true}) })
inspected, err := direct.ContainerInspect(ctx, created.ID, mobyclient.ContainerInspectOptions{})
require.NoError(t, err)
assert.Equal(t, map[string]string{"own": "1", jobLabel: job}, inspected.Container.Config.Labels)
vol, err := direct.VolumeInspect(ctx, job, mobyclient.VolumeInspectOptions{})
require.NoError(t, err)
assert.Equal(t, job, vol.Volume.Labels[jobLabel])
exec, err := viaProxy.ExecCreate(ctx, created.ID, mobyclient.ExecCreateOptions{Cmd: []string{"cat"}, AttachStdin: true, AttachStdout: true, TTY: true})
require.NoError(t, err)
attached, err := viaProxy.ExecAttach(ctx, exec.ID, mobyclient.ExecAttachOptions{TTY: true})
require.NoError(t, err)
_, err = attached.Conn.Write([]byte("hello\n"))
require.NoError(t, err)
echoed, err := attached.Reader.ReadString('\n')
require.NoError(t, err)
assert.Equal(t, "hello\r\n", echoed)
attached.Close()
require.NoError(t, proxy.Close(ctx))
_, err = direct.ContainerInspect(ctx, created.ID, mobyclient.ContainerInspectOptions{})
assert.True(t, cerrdefs.IsNotFound(err))
_, err = direct.NetworkInspect(ctx, net.ID, mobyclient.NetworkInspectOptions{})
assert.True(t, cerrdefs.IsNotFound(err))
_, err = direct.VolumeInspect(ctx, job, mobyclient.VolumeInspectOptions{})
assert.True(t, cerrdefs.IsNotFound(err))
}
func BenchmarkDockerProxy(b *testing.B) {
ctx := context.Background()
direct, err := GetDockerClient(ctx)
require.NoError(b, err)
defer direct.Close()
if _, err := direct.Ping(ctx, mobyclient.PingOptions{}); err != nil {
b.Skipf("docker daemon unreachable: %v", err)
}
proxy, err := StartDockerProxy(daemonSocketPath(b, direct), shortTempDir(b), "bench")
require.NoError(b, err)
defer func() { _ = proxy.Close(ctx) }()
viaProxy, err := mobyclient.New(mobyclient.WithHost("unix://" + proxy.Socket))
require.NoError(b, err)
defer viaProxy.Close()
require.NoError(b, NewDockerPullExecutor(NewDockerPullExecutorInput{Image: "alpine"})(ctx))
created, err := direct.ContainerCreate(ctx, mobyclient.ContainerCreateOptions{Config: &container.Config{Image: "alpine", Cmd: []string{"sleep", "600"}}})
require.NoError(b, err)
defer func() { _, _ = direct.ContainerRemove(ctx, created.ID, mobyclient.ContainerRemoveOptions{Force: true}) }()
_, err = direct.ContainerStart(ctx, created.ID, mobyclient.ContainerStartOptions{})
require.NoError(b, err)
var archive bytes.Buffer
writer := tar.NewWriter(&archive)
payload := make([]byte, 64<<20)
require.NoError(b, writer.WriteHeader(&tar.Header{Name: "blob", Mode: 0o600, Size: int64(len(payload))}))
_, _ = writer.Write(payload)
require.NoError(b, writer.Close())
for name, cli := range map[string]mobyclient.APIClient{"direct": direct, "proxy": viaProxy} {
b.Run("ping/"+name, func(b *testing.B) {
for b.Loop() {
if _, err := cli.Ping(ctx, mobyclient.PingOptions{}); err != nil {
b.Fatal(err)
}
}
})
b.Run("copy64MiB/"+name, func(b *testing.B) {
b.SetBytes(int64(archive.Len()))
for b.Loop() {
_, err := cli.CopyToContainer(ctx, created.ID, mobyclient.CopyToContainerOptions{DestinationPath: "/tmp", Content: bytes.NewReader(archive.Bytes())})
if err != nil {
b.Fatal(err)
}
}
})
}
}
+19 -6
View File
@@ -138,12 +138,12 @@ func (cr *containerReference) Copy(destPath string, files ...*FileEntry) common.
).IfNot(common.Dryrun) ).IfNot(common.Dryrun)
} }
func (cr *containerReference) CopyDir(destPath, srcPath string, useGitIgnore bool) common.Executor { func (cr *containerReference) CopyDir(destPath, srcPath string, useGitIgnore, skipGitDir bool) common.Executor {
return common.NewPipelineExecutor( return common.NewPipelineExecutor(
common.NewInfoExecutor("docker cp src=%s dst=%s", srcPath, destPath), common.NewInfoExecutor("docker cp src=%s dst=%s", srcPath, destPath),
cr.connect(), cr.connect(),
cr.find(), cr.find(),
cr.copyDir(destPath, srcPath, useGitIgnore), cr.copyDir(destPath, srcPath, useGitIgnore, skipGitDir),
func(ctx context.Context) error { func(ctx context.Context) error {
// If this fails, then folders have wrong permissions on non root container // If this fails, then folders have wrong permissions on non root container
if cr.UID != 0 || cr.GID != 0 { if cr.UID != 0 || cr.GID != 0 {
@@ -252,6 +252,10 @@ func containerInfoFromInspect(inspect container.InspectResponse) *Info {
ID: inspect.ID, ID: inspect.ID,
Health: HealthNone, Health: HealthNone,
Ports: map[string]string{}, // an empty map, never null, in the expression context Ports: map[string]string{}, // an empty map, never null, in the expression context
Mounts: make(map[string]string, len(inspect.Mounts)),
}
for _, mountPoint := range inspect.Mounts {
info.Mounts[mountPoint.Destination] = mountPoint.Source
} }
if state := inspect.State; state != nil { if state := inspect.State; state != nil {
@@ -583,6 +587,14 @@ func (cr *containerReference) mergeContainerConfigs(ctx context.Context, config
if err != nil { if err != nil {
return nil, nil, fmt.Errorf("cannot process container options: '%s': '%w'", options, err) return nil, nil, fmt.Errorf("cannot process container options: '%s': '%w'", options, err)
} }
// workflow aliases join the runner's own, deduped so a re-create cannot grow the input
for _, endpoint := range containerConfig.NetworkingConfig.EndpointsConfig {
for _, alias := range endpoint.Aliases {
if !slices.Contains(cr.input.NetworkAliases, alias) {
cr.input.NetworkAliases = append(cr.input.NetworkAliases, alias)
}
}
}
// For Gitea, forcing --privileged off is not enough, other options reach the host too // For Gitea, forcing --privileged off is not enough, other options reach the host too
if !hostConfig.Privileged { if !hostConfig.Privileged {
@@ -932,7 +944,7 @@ func (cr *containerReference) waitForCommand(ctx context.Context, resp client.Hi
} }
} }
func (cr *containerReference) copyDir(dstPath, srcPath string, useGitIgnore bool) common.Executor { func (cr *containerReference) copyDir(dstPath, srcPath string, useGitIgnore, skipGitDir bool) common.Executor {
return func(ctx context.Context) error { return func(ctx context.Context) error {
if cr.id == "" { if cr.id == "" {
return cr.missingContainerError("copy directory to %s", dstPath) return cr.missingContainerError("copy directory to %s", dstPath)
@@ -973,9 +985,10 @@ func (cr *containerReference) copyDir(dstPath, srcPath string, useGitIgnore bool
} }
fc := &filecollector.FileCollector{ fc := &filecollector.FileCollector{
Ignorer: ignorer, Ignorer: ignorer,
SrcPath: srcPath, SrcPath: srcPath,
SrcPrefix: srcPrefix, SrcPrefix: srcPrefix,
SkipGitDir: skipGitDir,
Handler: &filecollector.TarCollector{ Handler: &filecollector.TarCollector{
TarWriter: tw, TarWriter: tw,
UID: cr.UID, UID: cr.UID,
+26 -1
View File
@@ -471,7 +471,7 @@ func TestRejectsMissingContainer(t *testing.T) {
assert.Contains(t, err.Error(), `container "job-1" does not exist`, op) assert.Contains(t, err.Error(), `container "job-1" does not exist`, op)
} }
check("copyContent", cr.copyContent("/var/run/act", &FileEntry{Name: "x", Mode: 0o644})(ctx)) check("copyContent", cr.copyContent("/var/run/act", &FileEntry{Name: "x", Mode: 0o644})(ctx))
check("copyDir", cr.copyDir("/var/run/act", "/src", false)(ctx)) check("copyDir", cr.copyDir("/var/run/act", "/src", false, false)(ctx))
check("exec", cr.exec([]string{"echo"}, nil, "", "")(ctx)) check("exec", cr.exec([]string{"echo"}, nil, "", "")(ctx))
_, err := cr.GetContainerArchive(ctx, "/var/run/act/x") _, err := cr.GetContainerArchive(ctx, "/var/run/act/x")
check("GetContainerArchive", err) check("GetContainerArchive", err)
@@ -801,6 +801,20 @@ func TestContainerInfoFromInspect(t *testing.T) {
assert.Equal(t, "abc123", info.ID) assert.Equal(t, "abc123", info.ID)
assert.Equal(t, HealthNone, info.Health) assert.Equal(t, HealthNone, info.Health)
}) })
t.Run("reports the mount sources by container path", func(t *testing.T) {
info := containerInfoFromInspect(container.InspectResponse{
Mounts: []container.MountPoint{
{Type: mount.TypeVolume, Name: "job", Source: "/var/lib/docker/volumes/job/_data", Destination: "/workspace/owner/repo"},
{Type: mount.TypeBind, Source: "/var/run/docker.sock", Destination: "/var/run/docker.sock"},
},
})
assert.Equal(t, map[string]string{
"/workspace/owner/repo": "/var/lib/docker/volumes/job/_data",
"/var/run/docker.sock": "/var/run/docker.sock",
}, info.Mounts)
})
} }
func TestMergeContainerConfigsVolumesReplaceRunnerMounts(t *testing.T) { func TestMergeContainerConfigsVolumesReplaceRunnerMounts(t *testing.T) {
@@ -833,6 +847,17 @@ func TestMergeContainerConfigsWarnsOnlyAboutOptionsThatWereGiven(t *testing.T) {
assert.Equal(t, 1, warnings("--network host", "")) assert.Equal(t, 1, warnings("--network host", ""))
} }
func TestMergeContainerConfigsKeepsNetworkAliasesFromOptions(t *testing.T) {
logger, _ := test.NewNullLogger()
cr := &containerReference{input: &NewContainerInput{
NetworkMode: "job-network", NetworkAliases: []string{"redis"}, WorkflowOptions: "--network-alias redis-primary",
}}
_, _, err := cr.mergeContainerConfigs(common.WithLogger(t.Context(), logger), &container.Config{}, &container.HostConfig{})
require.NoError(t, err)
assert.Equal(t, []string{"redis", "redis-primary"}, cr.input.NetworkAliases)
}
// A dead daemon must fail the job, not panic through logrus and not silently // A dead daemon must fail the job, not panic through logrus and not silently
// drop the requested platform. // drop the requested platform.
func TestSupportsContainerImagePlatformDaemonError(t *testing.T) { func TestSupportsContainerImagePlatformDaemonError(t *testing.T) {
+8
View File
@@ -29,6 +29,14 @@ func RemoveImage(ctx context.Context, imageName string, force, pruneChildren boo
return false, errors.New("Unsupported Operation") return false, errors.New("Unsupported Operation")
} }
func DockerProxyDir(ctx context.Context) string {
return ""
}
func StartDockerProxy(daemonSocket, dir, job string) (*DockerProxy, error) {
return nil, errors.New("Unsupported Operation")
}
// NewDockerBuildExecutor function to create a run executor for the container // NewDockerBuildExecutor function to create a run executor for the container
func NewDockerBuildExecutor(input NewDockerBuildExecutorInput) common.Executor { func NewDockerBuildExecutor(input NewDockerBuildExecutorInput) common.Executor {
return func(ctx context.Context) error { return func(ctx context.Context) error {
+11 -5
View File
@@ -92,7 +92,7 @@ func (e *HostEnvironment) Copy(destPath string, files ...*FileEntry) common.Exec
} }
} }
func (e *HostEnvironment) CopyDir(destPath, srcPath string, useGitIgnore bool) common.Executor { func (e *HostEnvironment) CopyDir(destPath, srcPath string, useGitIgnore, skipGitDir bool) common.Executor {
return func(ctx context.Context) error { return func(ctx context.Context) error {
logger := common.Logger(ctx) logger := common.Logger(ctx)
srcPrefix := filepath.Dir(srcPath) srcPrefix := filepath.Dir(srcPath)
@@ -110,9 +110,10 @@ func (e *HostEnvironment) CopyDir(destPath, srcPath string, useGitIgnore bool) c
ignorer = gitignore.NewMatcher(ps) ignorer = gitignore.NewMatcher(ps)
} }
fc := &filecollector.FileCollector{ fc := &filecollector.FileCollector{
Ignorer: ignorer, Ignorer: ignorer,
SrcPath: srcPath, SrcPath: srcPath,
SrcPrefix: srcPrefix, SrcPrefix: srcPrefix,
SkipGitDir: skipGitDir,
Handler: &filecollector.CopyCollector{ Handler: &filecollector.CopyCollector{
DstDir: destPath, DstDir: destPath,
}, },
@@ -629,8 +630,11 @@ func (*HostEnvironment) JoinPathVariable(paths ...string) string {
func goArchToActionArch(arch string) string { func goArchToActionArch(arch string) string {
archMapper := map[string]string{ archMapper := map[string]string{
"x86_64": "X64", "x86_64": "X64",
"amd64": "X64",
"386": "X86", "386": "X86",
"arm": "ARM",
"aarch64": "ARM64", "aarch64": "ARM64",
"arm64": "ARM64",
} }
if arch, ok := archMapper[arch]; ok { if arch, ok := archMapper[arch]; ok {
return arch return arch
@@ -640,7 +644,9 @@ func goArchToActionArch(arch string) string {
func goOsToActionOs(os string) string { func goOsToActionOs(os string) string {
osMapper := map[string]string{ osMapper := map[string]string{
"darwin": "macOS", "linux": "Linux",
"darwin": "macOS",
"windows": "Windows",
} }
if os, ok := osMapper[os]; ok { if os, ok := osMapper[os]; ok {
return os return os
+28 -1
View File
@@ -27,6 +27,11 @@ import (
// Type assert HostEnvironment implements ExecutionsEnvironment // Type assert HostEnvironment implements ExecutionsEnvironment
var _ ExecutionsEnvironment = &HostEnvironment{} var _ ExecutionsEnvironment = &HostEnvironment{}
func TestActionPlatformNames(t *testing.T) {
assert.Equal(t, []string{"Linux", "macOS", "Windows"}, []string{goOsToActionOs("linux"), goOsToActionOs("darwin"), goOsToActionOs("windows")})
assert.Equal(t, []string{"X86", "X64", "ARM", "ARM64"}, []string{goArchToActionArch("386"), goArchToActionArch("amd64"), goArchToActionArch("arm"), goArchToActionArch("arm64")})
}
func TestCopyDir(t *testing.T) { func TestCopyDir(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
ctx := context.Background() ctx := context.Background()
@@ -42,10 +47,32 @@ func TestCopyDir(t *testing.T) {
_ = os.MkdirAll(e.TmpDir, 0o700) _ = os.MkdirAll(e.TmpDir, 0o700)
_ = os.MkdirAll(e.ToolCache, 0o700) _ = os.MkdirAll(e.ToolCache, 0o700)
_ = os.MkdirAll(e.ActPath, 0o700) _ = os.MkdirAll(e.ActPath, 0o700)
err := e.CopyDir(e.Workdir, e.Path, true)(ctx) err := e.CopyDir(e.Workdir, e.Path, true, false)(ctx)
assert.NoError(t, err) assert.NoError(t, err)
} }
func TestCopyDirSkipGitDir(t *testing.T) {
for name, skipGitDir := range map[string]bool{"kept for a workspace copy": false, "skipped for an action copy": true} {
t.Run(name, func(t *testing.T) {
src := filepath.Join(t.TempDir(), "action")
require.NoError(t, os.MkdirAll(filepath.Join(src, ".git", "objects"), 0o700))
require.NoError(t, os.WriteFile(filepath.Join(src, ".git", "objects", "pack"), []byte("x"), 0o600))
require.NoError(t, os.WriteFile(filepath.Join(src, "action.yml"), []byte("name: x\n"), 0o600))
dest := filepath.Join(t.TempDir(), "dest")
e := &HostEnvironment{StdOut: os.Stdout}
require.NoError(t, e.CopyDir(dest, src+string(filepath.Separator), false, skipGitDir)(context.Background()))
assert.FileExists(t, filepath.Join(dest, "action.yml"))
if skipGitDir {
assert.NoDirExists(t, filepath.Join(dest, ".git"))
} else {
assert.FileExists(t, filepath.Join(dest, ".git", "objects", "pack"))
}
})
}
}
func TestGetContainerArchive(t *testing.T) { func TestGetContainerArchive(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
ctx := context.Background() ctx := context.Background()
+3 -6
View File
@@ -50,7 +50,7 @@ func parseEnvFile(e Container, srcPath string, env *map[string]string) common.Ex
case singleLineEnv != -1 && (multiLineEnv == -1 || singleLineEnv < multiLineEnv): case singleLineEnv != -1 && (multiLineEnv == -1 || singleLineEnv < multiLineEnv):
localEnv[line[:singleLineEnv]] = line[singleLineEnv+1:] localEnv[line[:singleLineEnv]] = line[singleLineEnv+1:]
case multiLineEnv != -1: case multiLineEnv != -1:
multiLineEnvContent := "" var multiLineEnvContent []string
multiLineEnvDelimiter := line[multiLineEnv+2:] multiLineEnvDelimiter := line[multiLineEnv+2:]
delimiterFound := false delimiterFound := false
for s.Scan() { for s.Scan() {
@@ -59,10 +59,7 @@ func parseEnvFile(e Container, srcPath string, env *map[string]string) common.Ex
delimiterFound = true delimiterFound = true
break break
} }
if multiLineEnvContent != "" { multiLineEnvContent = append(multiLineEnvContent, content)
multiLineEnvContent += "\n"
}
multiLineEnvContent += content
} }
if err := s.Err(); err != nil { if err := s.Err(); err != nil {
return fmt.Errorf("reading env file: %w", err) return fmt.Errorf("reading env file: %w", err)
@@ -70,7 +67,7 @@ func parseEnvFile(e Container, srcPath string, env *map[string]string) common.Ex
if !delimiterFound { if !delimiterFound {
return fmt.Errorf("invalid format delimiter '%v' not found before end of file", multiLineEnvDelimiter) return fmt.Errorf("invalid format delimiter '%v' not found before end of file", multiLineEnvDelimiter)
} }
localEnv[line[:multiLineEnv]] = multiLineEnvContent localEnv[line[:multiLineEnv]] = strings.Join(multiLineEnvContent, "\n")
default: default:
return fmt.Errorf("invalid format '%v', expected a line with '=' or '<<'", line) return fmt.Errorf("invalid format '%v', expected a line with '=' or '<<'", line)
} }
+5
View File
@@ -86,6 +86,11 @@ func TestParseEnvFileMultiLineKeepsBlankLines(t *testing.T) {
env := map[string]string{} env := map[string]string{}
require.NoError(t, parseEnvFile(e, envPath, &env)(context.Background())) require.NoError(t, parseEnvFile(e, envPath, &env)(context.Background()))
assert.Equal(t, "line1\n\nline2", env["FOO"]) assert.Equal(t, "line1\n\nline2", env["FOO"])
require.NoError(t, os.WriteFile(envPath, []byte("FOO<<EOF\n\nline2\nEOF\n"), 0o600))
env = map[string]string{}
require.NoError(t, parseEnvFile(e, envPath, &env)(context.Background()))
assert.Equal(t, "\nline2", env["FOO"])
} }
func TestParseEnvFileUTF8BOM(t *testing.T) { func TestParseEnvFileUTF8BOM(t *testing.T) {
+11 -4
View File
@@ -94,10 +94,11 @@ func (cc *CopyCollector) WriteFile(fpath string, fi fs.FileInfo, linkName string
} }
type FileCollector struct { type FileCollector struct {
Ignorer gitignore.Matcher Ignorer gitignore.Matcher
SrcPath string SrcPath string
SrcPrefix string SrcPrefix string
Handler Handler SkipGitDir bool
Handler Handler
} }
func openGitIndex(path string) (*index.Index, error) { func openGitIndex(path string) (*index.Index, error) {
@@ -124,6 +125,12 @@ func (fc *FileCollector) CollectFiles(ctx context.Context, submodulePath []strin
if fi.IsDir() && len(split) > 0 && split[len(split)-1] == "." { if fi.IsDir() && len(split) > 0 && split[len(split)-1] == "." {
return nil return nil
} }
if fc.SkipGitDir && split[len(split)-1] == ".git" {
if fi.IsDir() {
return filepath.SkipDir
}
return nil
}
var entry *index.Entry var entry *index.Entry
if i != nil { if i != nil {
entry, err = i.Entry(strings.Join(split[len(submodulePath):], "/")) entry, err = i.Entry(strings.Join(split[len(submodulePath):], "/"))
+81 -41
View File
@@ -32,7 +32,7 @@ type actionStep interface {
step step
getActionModel() *model.Action getActionModel() *model.Action
getCompositeRunContext(context.Context) *RunContext getCompositeRunContext(context.Context) (*RunContext, error)
getCompositeSteps() *compositeSteps getCompositeSteps() *compositeSteps
} }
@@ -154,7 +154,7 @@ func maybeCopyToActionDir(ctx context.Context, step actionStep, actionDir, actio
return err return err
} }
return rc.JobContainer.CopyDir(containerActionDirCopy, actionDir+"/", rc.Config.UseGitIgnore)(ctx) return rc.JobContainer.CopyDir(containerActionDirCopy, actionDir+"/", rc.Config.UseGitIgnore, true)(ctx)
} }
func runActionImpl(step actionStep, actionDir string, remoteAction *remoteAction) common.Executor { func runActionImpl(step actionStep, actionDir string, remoteAction *remoteAction) common.Executor {
@@ -172,7 +172,9 @@ func runActionImpl(step actionStep, actionDir string, remoteAction *remoteAction
rc.withGithubEnv(ctx, step.getGithubContext(ctx), *step.getEnv()) rc.withGithubEnv(ctx, step.getGithubContext(ctx), *step.getEnv())
populateEnvsFromSavedState(step.getEnv(), step, rc) populateEnvsFromSavedState(step.getEnv(), step, rc)
populateEnvsFromInput(ctx, step.getEnv(), action, rc) if err := populateEnvsFromInput(ctx, step.getEnv(), action, rc); err != nil {
return err
}
actionLocation := path.Join(actionDir, actionPath) actionLocation := path.Join(actionDir, actionPath)
actionName, containerActionDir := getContainerActionPaths(stepModel, actionLocation, rc) actionName, containerActionDir := getContainerActionPaths(stepModel, actionLocation, rc)
@@ -185,7 +187,7 @@ func runActionImpl(step actionStep, actionDir string, remoteAction *remoteAction
if err := maybeCopyToActionDir(ctx, step, actionDir, actionPath, containerActionDir); err != nil { if err := maybeCopyToActionDir(ctx, step, actionDir, actionPath, containerActionDir); err != nil {
return err return err
} }
containerArgs := []string{"node", path.Join(containerActionDir, action.Runs.Main)} containerArgs := nodeActionCommand(path.Join(containerActionDir, action.Runs.Main))
logger.Debugf("executing remote job container: %s", containerArgs) logger.Debugf("executing remote job container: %s", containerArgs)
rc.ApplyExtraPath(ctx, step.getEnv()) rc.ApplyExtraPath(ctx, step.getEnv())
@@ -232,6 +234,11 @@ func runActionImpl(step actionStep, actionDir string, remoteAction *remoteAction
} }
} }
// /var/run is a symlink, so without the flag node's import.meta.url differs from argv[1], which ESM actions compare.
func nodeActionCommand(script string) []string {
return []string{"node", "--preserve-symlinks-main", script}
}
// https://github.com/nektos/act/issues/228#issuecomment-629709055 // https://github.com/nektos/act/issues/228#issuecomment-629709055
// files in .gitignore are not copied in a Docker container // files in .gitignore are not copied in a Docker container
// this causes issues with actions that ignore other important resources // this causes issues with actions that ignore other important resources
@@ -347,19 +354,35 @@ func execAsDocker(ctx context.Context, step actionStep, actionName, actionDir, b
} }
} }
eval := rc.NewActionInputsExpressionEvaluator(ctx, step) eval := rc.NewActionInputsExpressionEvaluator(ctx, step)
cmd, err := shellquote.Split(eval.Interpolate(ctx, step.getStepModel().With["args"])) args, err := eval.Interpolate(ctx, step.getStepModel().With["args"])
if err != nil {
return fmt.Errorf("unable to interpolate with.args: %w", err)
}
cmd, err := shellquote.Split(args)
if err != nil { if err != nil {
return err return err
} }
if len(cmd) == 0 { ee, err := evalDockerEnv(ctx, step, action)
cmd = action.Runs.Args if err != nil {
evalDockerArgs(ctx, step, action, &cmd) return err
}
if action.Runs.Args != nil {
// a fresh slice, the manifest is evaluated again for every stage
cmd = make([]string, len(action.Runs.Args))
for i, v := range action.Runs.Args {
if cmd[i], err = ee.Interpolate(ctx, v); err != nil {
return fmt.Errorf("unable to interpolate runs.args: %w", err)
}
}
} }
entrypoint, err := dockerEntrypoint(ctx, step, eval, stage) entrypoint, err := dockerEntrypoint(ctx, step, eval, stage)
if err != nil { if err != nil {
return err return err
} }
stepContainer := newStepContainer(ctx, step, image, cmd, entrypoint, rc.Config.ContainerOptions) stepContainer, err := newStepContainer(ctx, step, image, cmd, entrypoint, rc.Config.ContainerOptions)
if err != nil {
return err
}
return common.NewPipelineExecutor( return common.NewPipelineExecutor(
prepImage, prepImage,
stepContainer.Pull(forcePull), stepContainer.Pull(forcePull),
@@ -381,10 +404,16 @@ func dockerEntrypoint(ctx context.Context, step actionStep, eval *expressionEval
case stepStagePost: case stepStagePost:
entrypoint = runs.PostEntrypoint entrypoint = runs.PostEntrypoint
default: default:
if fields := strings.Fields(eval.Interpolate(ctx, step.getStepModel().With["entrypoint"])); len(fields) > 0 {
return fields, nil
}
entrypoint = runs.Entrypoint entrypoint = runs.Entrypoint
if entrypoint == "" {
withEntrypoint, err := eval.Interpolate(ctx, step.getStepModel().With["entrypoint"])
if err != nil {
return nil, fmt.Errorf("unable to interpolate with.entrypoint: %w", err)
}
if fields := strings.Fields(withEntrypoint); len(fields) > 0 {
return fields, nil
}
}
} }
if entrypoint == "" { if entrypoint == "" {
@@ -393,36 +422,41 @@ func dockerEntrypoint(ctx context.Context, step actionStep, eval *expressionEval
return shellquote.Split(entrypoint) return shellquote.Split(entrypoint)
} }
func evalDockerArgs(ctx context.Context, step step, action *model.Action, cmd *[]string) { // evalDockerEnv returns an evaluator bound to the environment it installed.
func evalDockerEnv(ctx context.Context, step step, action *model.Action) (*expressionEvaluator, error) {
rc := step.getRunContext() rc := step.getRunContext()
stepModel := step.getStepModel() stepModel := step.getStepModel()
var err error
inputs := make(map[string]string) inputs := make(map[string]string)
eval := rc.NewExpressionEvaluator(ctx) eval := rc.NewExpressionEvaluator(ctx)
// Set Defaults // Set Defaults
for k, input := range action.Inputs { for k, input := range action.Inputs {
inputs[k] = eval.Interpolate(ctx, input.Default) if inputs[k], err = eval.Interpolate(ctx, input.Default); err != nil {
return nil, fmt.Errorf("unable to interpolate the default of input %s: %w", k, err)
}
} }
if stepModel.With != nil { for k, v := range stepModel.With {
for k, v := range stepModel.With { if inputs[k], err = eval.Interpolate(ctx, v); err != nil {
inputs[k] = eval.Interpolate(ctx, v) return nil, fmt.Errorf("unable to interpolate with.%s: %w", k, err)
} }
} }
mergeIntoMap(step, step.getEnv(), inputs) mergeIntoMap(step, step.getEnv(), inputs)
stepEE := rc.NewActionInputsExpressionEvaluator(ctx, step) env := make(map[string]string, len(action.Runs.Env)+len(*step.getEnv()))
for i, v := range *cmd { mergeIntoMap(step, &env, action.Runs.Env, *step.getEnv())
(*cmd)[i] = stepEE.Interpolate(ctx, v) *step.getEnv() = env
}
mergeIntoMap(step, step.getEnv(), action.Runs.Env)
ee := rc.NewActionInputsExpressionEvaluator(ctx, step) ee := rc.NewActionInputsExpressionEvaluator(ctx, step)
for k, v := range *step.getEnv() { for k, v := range *step.getEnv() {
(*step.getEnv())[k] = ee.Interpolate(ctx, v) if (*step.getEnv())[k], err = ee.Interpolate(ctx, v); err != nil {
return nil, fmt.Errorf("unable to interpolate env %s: %w", k, err)
}
} }
return ee, nil
} }
func newStepContainer(ctx context.Context, step step, image string, cmd, entrypoint []string, runnerOptions string) container.Container { func newStepContainer(ctx context.Context, step step, image string, cmd, entrypoint []string, runnerOptions string) (container.Container, error) {
rc := step.getRunContext() rc := step.getRunContext()
logWriter := rc.commandLogWriter(ctx) logWriter := rc.commandLogWriter(ctx)
envList := make([]string, 0) envList := make([]string, 0)
@@ -432,9 +466,12 @@ func newStepContainer(ctx context.Context, step step, image string, cmd, entrypo
envList = append(envList, rc.runnerEnv(ctx)...) envList = append(envList, rc.runnerEnv(ctx)...)
binds, mounts := rc.GetBindsAndMounts() binds, mounts, err := rc.GetBindsAndMounts()
if err != nil {
return nil, err
}
networkMode := "container:" + rc.jobContainerName() networkMode := "container:" + rc.jobContainerName()
if rc.IsHostEnv(ctx) { if rc.IsHostEnv() {
networkMode = "default" networkMode = "default"
} }
return ContainerNewContainer(&container.NewContainerInput{ return ContainerNewContainer(&container.NewContainerInput{
@@ -456,7 +493,7 @@ func newStepContainer(ctx context.Context, step step, image string, cmd, entrypo
AutoRemove: true, AutoRemove: true,
ValidVolumes: rc.validVolumes(), ValidVolumes: rc.validVolumes(),
AllocatePTY: rc.Config.AllocatePTY, AllocatePTY: rc.Config.AllocatePTY,
}) }), nil
} }
func populateEnvsFromSavedState(env *map[string]string, step actionStep, rc *RunContext) { func populateEnvsFromSavedState(env *map[string]string, step actionStep, rc *RunContext) {
@@ -469,15 +506,19 @@ func populateEnvsFromSavedState(env *map[string]string, step actionStep, rc *Run
} }
} }
func populateEnvsFromInput(ctx context.Context, env *map[string]string, action *model.Action, rc *RunContext) { func populateEnvsFromInput(ctx context.Context, env *map[string]string, action *model.Action, rc *RunContext) error {
eval := rc.NewExpressionEvaluator(ctx) eval := rc.NewExpressionEvaluator(ctx)
for inputID, input := range action.Inputs { for inputID, input := range action.Inputs {
envKey := regexp.MustCompile("[^A-Z0-9-]").ReplaceAllString(strings.ToUpper(inputID), "_") envKey := regexp.MustCompile("[^A-Z0-9-]").ReplaceAllString(strings.ToUpper(inputID), "_")
envKey = "INPUT_" + envKey envKey = "INPUT_" + envKey
if _, ok := (*env)[envKey]; !ok { if _, ok := (*env)[envKey]; !ok {
(*env)[envKey] = eval.Interpolate(ctx, input.Default) var err error
if (*env)[envKey], err = eval.Interpolate(ctx, input.Default); err != nil {
return fmt.Errorf("unable to interpolate the default of input %s: %w", inputID, err)
}
} }
} }
return nil
} }
func getContainerActionPaths(step *model.Step, actionDir string, rc *RunContext) (string, string) { func getContainerActionPaths(step *model.Step, actionDir string, rc *RunContext) (string, string) {
@@ -577,16 +618,19 @@ func runPreStep(step actionStep) common.Executor {
actionDir, actionPath, _, containerActionDir := actionStagePaths(step) actionDir, actionPath, _, containerActionDir := actionStagePaths(step)
x := action.Runs.Using x := action.Runs.Using
if !x.IsComposite() {
// defaults in pre steps were missing, however provided inputs are available
if err := populateEnvsFromInput(ctx, step.getEnv(), action, rc); err != nil {
return err
}
}
switch { switch {
case x.IsNode(): case x.IsNode():
// defaults in pre steps were missing, however provided inputs are available
populateEnvsFromInput(ctx, step.getEnv(), action, rc)
if err := maybeCopyToActionDir(ctx, step, actionDir, actionPath, containerActionDir); err != nil { if err := maybeCopyToActionDir(ctx, step, actionDir, actionPath, containerActionDir); err != nil {
return err return err
} }
containerArgs := []string{"node", path.Join(containerActionDir, action.Runs.Pre)} containerArgs := nodeActionCommand(path.Join(containerActionDir, action.Runs.Pre))
logger.Debugf("executing remote job container: %s", containerArgs) logger.Debugf("executing remote job container: %s", containerArgs)
rc.ApplyExtraPath(ctx, step.getEnv()) rc.ApplyExtraPath(ctx, step.getEnv())
@@ -594,14 +638,13 @@ func runPreStep(step actionStep) common.Executor {
return rc.JobContainer.Exec(containerArgs, *step.getEnv(), "", "")(ctx) return rc.JobContainer.Exec(containerArgs, *step.getEnv(), "", "")(ctx)
case x.IsDocker(): case x.IsDocker():
// defaults in pre steps were missing, however provided inputs are available
populateEnvsFromInput(ctx, step.getEnv(), action, rc)
return execDockerActionStage(ctx, step, stepStagePre) return execDockerActionStage(ctx, step, stepStagePre)
case x.IsComposite(): case x.IsComposite():
if step.getCompositeSteps() == nil { if step.getCompositeSteps() == nil {
step.getCompositeRunContext(ctx) if _, err := step.getCompositeRunContext(ctx); err != nil {
return err
}
} }
if steps := step.getCompositeSteps(); steps != nil && steps.pre != nil { if steps := step.getCompositeSteps(); steps != nil && steps.pre != nil {
@@ -610,9 +653,6 @@ func runPreStep(step actionStep) common.Executor {
return errors.New("missing steps in composite action") return errors.New("missing steps in composite action")
case x == model.ActionRunsUsingGo: case x == model.ActionRunsUsingGo:
// defaults in pre steps were missing, however provided inputs are available
populateEnvsFromInput(ctx, step.getEnv(), action, rc)
if err := maybeCopyToActionDir(ctx, step, actionDir, actionPath, containerActionDir); err != nil { if err := maybeCopyToActionDir(ctx, step, actionDir, actionPath, containerActionDir); err != nil {
return err return err
} }
@@ -687,7 +727,7 @@ func runPostStep(step actionStep) common.Executor {
populateEnvsFromSavedState(step.getEnv(), step, rc) populateEnvsFromSavedState(step.getEnv(), step, rc)
containerArgs := []string{"node", path.Join(containerActionDir, action.Runs.Post)} containerArgs := nodeActionCommand(path.Join(containerActionDir, action.Runs.Post))
logger.Debugf("executing remote job container: %s", containerArgs) logger.Debugf("executing remote job container: %s", containerArgs)
rc.ApplyExtraPath(ctx, step.getEnv()) rc.ApplyExtraPath(ctx, step.getEnv())
+55 -24
View File
@@ -7,6 +7,7 @@ package runner
import ( import (
"context" "context"
"errors" "errors"
"fmt"
"regexp" "regexp"
"slices" "slices"
"strconv" "strconv"
@@ -17,7 +18,7 @@ import (
"gitea.dev/actionslib/pkg/model" "gitea.dev/actionslib/pkg/model"
) )
func evaluateCompositeInputAndEnv(ctx context.Context, parent *RunContext, step actionStep) map[string]string { func evaluateCompositeInputAndEnv(ctx context.Context, parent *RunContext, step actionStep) (map[string]string, error) {
env := make(map[string]string) env := make(map[string]string)
stepEnv := *step.getEnv() stepEnv := *step.getEnv()
for k, v := range stepEnv { for k, v := range stepEnv {
@@ -36,23 +37,44 @@ func evaluateCompositeInputAndEnv(ctx context.Context, parent *RunContext, step
// lookup if key is defined in the step but the already // lookup if key is defined in the step but the already
// evaluated value from the environment // evaluated value from the environment
_, defined := step.getStepModel().With[inputID] defined := false
for key := range step.getStepModel().With {
if strings.EqualFold(key, inputID) {
defined = true
break
}
}
if value, ok := stepEnv[envKey]; defined && ok { if value, ok := stepEnv[envKey]; defined && ok {
env[envKey] = value env[envKey] = value
} else { } else {
// defaults could contain expressions // defaults could contain expressions
env[envKey] = ee.Interpolate(ctx, input.Default) var err error
if env[envKey], err = ee.Interpolate(ctx, input.Default); err != nil {
return nil, fmt.Errorf("unable to interpolate the default of input %s: %w", inputID, err)
}
} }
} }
gh := step.getGithubContext(ctx) gh := step.getGithubContext(ctx)
env["GITHUB_ACTION_REPOSITORY"] = gh.ActionRepository env["GITHUB_ACTION_REPOSITORY"] = gh.ActionRepository
env["GITHUB_ACTION_REF"] = gh.ActionRef env["GITHUB_ACTION_REF"] = gh.ActionRef
return env return env, nil
} }
func newCompositeRunContext(ctx context.Context, parent *RunContext, step actionStep, actionPath string) *RunContext { func (rc *RunContext) setCompositeActionEnv(env map[string]string) {
env := evaluateCompositeInputAndEnv(ctx, parent, step) rc.setActionEnv(env)
for key := range rc.Env {
if strings.HasPrefix(key, "INPUT_") {
delete(rc.Env, key)
}
}
}
func newCompositeRunContext(ctx context.Context, parent *RunContext, step actionStep, actionPath string) (*RunContext, error) {
env, err := evaluateCompositeInputAndEnv(ctx, parent, step)
if err != nil {
return nil, err
}
// run with the global config but without secrets // run with the global config but without secrets
configCopy := *parent.Config configCopy := *parent.Config
@@ -62,29 +84,31 @@ func newCompositeRunContext(ctx context.Context, parent *RunContext, step action
compositerc := &RunContext{ compositerc := &RunContext{
Name: parent.Name, Name: parent.Name,
JobName: parent.JobName, JobName: parent.JobName,
Matrix: parent.Matrix,
Run: &model.Run{ Run: &model.Run{
JobID: parent.Run.JobID, JobID: parent.Run.JobID,
Workflow: &model.Workflow{ Workflow: &model.Workflow{
Name: parent.Run.Workflow.Name, Name: parent.Run.Workflow.Name,
Jobs: map[string]*model.Job{ Jobs: map[string]*model.Job{
parent.Run.JobID: {}, parent.Run.JobID: {Strategy: parent.Run.Job().Strategy},
}, },
}, },
}, },
Config: &configCopy, Config: &configCopy,
StepResults: map[string]*model.StepResult{}, StepResults: map[string]*model.StepResult{},
JobContainer: parent.JobContainer, JobContainer: parent.JobContainer,
ActionPath: actionPath, ActionPath: actionPath,
GlobalEnv: parent.GlobalEnv, GlobalEnv: parent.GlobalEnv,
Masks: parent.Masks, Masks: parent.Masks,
ExtraPath: parent.ExtraPath, ExtraPath: parent.ExtraPath,
Parent: parent, Parent: parent,
EventJSON: parent.EventJSON, EventJSON: parent.EventJSON,
platformImage: parent.platformImage,
} }
compositerc.setActionEnv(env) compositerc.setCompositeActionEnv(env)
compositerc.ExprEval = compositerc.NewExpressionEvaluator(ctx) compositerc.ExprEval = compositerc.NewExpressionEvaluator(ctx)
return compositerc return compositerc, nil
} }
// appendUniqueMasks appends the masks from src to dst, skipping any mask that // appendUniqueMasks appends the masks from src to dst, skipping any mask that
@@ -105,7 +129,10 @@ func execAsComposite(step actionStep) common.Executor {
action := step.getActionModel() action := step.getActionModel()
return func(ctx context.Context) error { return func(ctx context.Context) error {
compositeRC := step.getCompositeRunContext(ctx) compositeRC, err := step.getCompositeRunContext(ctx)
if err != nil {
return err
}
steps := step.getCompositeSteps() steps := step.getCompositeSteps()
@@ -115,14 +142,17 @@ func execAsComposite(step actionStep) common.Executor {
ctx = WithCompositeLogger(ctx, &compositeRC.Masks) ctx = WithCompositeLogger(ctx, &compositeRC.Masks)
err := steps.main(ctx) err = steps.main(ctx)
// Map outputs from composite RunContext to job RunContext // Map outputs from composite RunContext to job RunContext
eval := compositeRC.NewExpressionEvaluator(ctx) eval := compositeRC.NewExpressionEvaluator(ctx)
for outputName, output := range action.Outputs { for outputName, output := range action.Outputs {
rc.setOutput(ctx, map[string]string{ value, outputErr := eval.Interpolate(ctx, output.Value)
"name": outputName, if outputErr != nil {
}, eval.Interpolate(ctx, output.Value)) err = errors.Join(err, fmt.Errorf("unable to interpolate output %s: %w", outputName, outputErr))
continue
}
rc.setOutput(ctx, map[string]string{"name": outputName}, value)
} }
// compositeRC.Masks is seeded with rc.Masks (see newCompositeRunContext) // compositeRC.Masks is seeded with rc.Masks (see newCompositeRunContext)
@@ -131,7 +161,7 @@ func execAsComposite(step actionStep) common.Executor {
// repeated composite actions grow rc.Masks exponentially. // repeated composite actions grow rc.Masks exponentially.
rc.Masks = appendUniqueMasks(rc.Masks, compositeRC.Masks) rc.Masks = appendUniqueMasks(rc.Masks, compositeRC.Masks)
rc.ExtraPath = compositeRC.ExtraPath rc.ExtraPath = compositeRC.ExtraPath
// compositeRC.Env is dirty, contains INPUT_ and merged step env, only rely on compositeRC.GlobalEnv // Propagate GlobalEnv only, so composite inputs and step-local values do not escape.
mergeIntoMap := mergeIntoMapCaseSensitive mergeIntoMap := mergeIntoMapCaseSensitive
if rc.JobContainer.IsEnvironmentCaseInsensitive() { if rc.JobContainer.IsEnvironmentCaseInsensitive() {
mergeIntoMap = mergeIntoMapCaseInsensitive mergeIntoMap = mergeIntoMapCaseInsensitive
@@ -194,6 +224,7 @@ func (rc *RunContext) compositeExecutor(action *model.Action) *compositeSteps {
} }
steps = append(steps, common.JobError) steps = append(steps, common.JobError)
preSteps = append(preSteps, common.JobError)
return &compositeSteps{ return &compositeSteps{
pre: func(ctx context.Context) error { pre: func(ctx context.Context) error {
return common.NewPipelineExecutor(preSteps...)(common.WithJobErrorContainer(ctx)) return common.NewPipelineExecutor(preSteps...)(common.WithJobErrorContainer(ctx))
+49
View File
@@ -6,9 +6,58 @@ package runner
import ( import (
"testing" "testing"
"gitea.com/gitea/runner/act/common"
"gitea.com/gitea/runner/act/common/git"
"gitea.dev/actionslib/pkg/model"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
) )
func TestCompositeActionParity(t *testing.T) {
t.Run("inherits contexts without leaking inputs", func(t *testing.T) {
ctx := t.Context()
strategy := &model.Strategy{MaxParallel: 3}
parent := &RunContext{
Config: &Config{},
Matrix: map[string]any{"os": "linux"},
Run: &model.Run{JobID: "job", Workflow: &model.Workflow{Name: "workflow", Jobs: map[string]*model.Job{"job": {Strategy: strategy}}}},
JobContainer: &jobContainerMock{},
platformImage: "-self-hosted",
}
composite, err := newCompositeRunContext(ctx, parent, &stepActionRemote{
Step: &model.Step{With: map[string]string{"SHARED": "outer"}},
RunContext: parent,
action: &model.Action{Inputs: map[string]model.Input{"shared": {Default: "outer-default"}}},
env: map[string]string{"INPUT_SHARED": "outer"},
}, "/action")
require.NoError(t, err)
assert.Same(t, strategy, composite.Run.Job().Strategy)
assert.True(t, composite.IsHostEnv())
interpolated, err := composite.NewExpressionEvaluator(ctx).Interpolate(ctx,
"${{ matrix.os }}|${{ strategy.max-parallel }}|${{ inputs.shared }}")
require.NoError(t, err)
assert.Equal(t, "linux|3|outer", interpolated)
assert.NotContains(t, composite.Env, "INPUT_SHARED")
nestedEnv := composite.GetEnv()
require.NoError(t, populateEnvsFromInput(ctx, &nestedEnv, &model.Action{Inputs: map[string]model.Input{"shared": {Default: "inner-default"}}}, composite))
assert.Equal(t, "inner-default", nestedEnv["INPUT_SHARED"])
})
t.Run("propagates pre failures", func(t *testing.T) {
setCloneExecutor(t, func(git.NewGitCloneExecutorInput) common.Executor { return common.NewErrorExecutor(assert.AnError) })
rc := &RunContext{
Config: &Config{GitHubInstance: "github.com", ActionCacheDir: t.TempDir()},
Run: &model.Run{JobID: "job", Workflow: &model.Workflow{Jobs: map[string]*model.Job{"job": {}}}},
JobContainer: &jobContainerMock{},
}
require.ErrorIs(t, rc.compositeExecutor(&model.Action{Runs: model.ActionRuns{Using: "composite", Steps: []model.Step{{ID: "nested", Uses: "org/action@v1"}}}}).pre(t.Context()), assert.AnError)
})
}
func TestAppendUniqueMasks(t *testing.T) { func TestAppendUniqueMasks(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
+84 -25
View File
@@ -8,6 +8,10 @@ import (
"context" "context"
"io" "io"
"io/fs" "io/fs"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings" "strings"
"sync" "sync"
"testing" "testing"
@@ -223,7 +227,7 @@ func TestActionRunner(t *testing.T) {
ctx := context.Background() ctx := context.Background()
cm := &containerMock{} cm := &containerMock{}
cm.On("CopyDir", "/var/run/act/actions/dir/", "dir/", false).Return(func(ctx context.Context) error { return nil }) cm.On("CopyDir", "/var/run/act/actions/dir/", "dir/", false, true).Return(func(ctx context.Context) error { return nil })
envMatcher := mock.MatchedBy(func(env map[string]string) bool { envMatcher := mock.MatchedBy(func(env map[string]string) bool {
for k, v := range tt.expectedEnv { for k, v := range tt.expectedEnv {
@@ -234,7 +238,7 @@ func TestActionRunner(t *testing.T) {
return true return true
}) })
cm.On("Exec", []string{"node", "/var/run/act/actions/dir/path"}, envMatcher, "", "").Return(func(ctx context.Context) error { return nil }) cm.On("Exec", []string{"node", "--preserve-symlinks-main", "/var/run/act/actions/dir/path"}, envMatcher, "", "").Return(func(ctx context.Context) error { return nil })
tt.step.getRunContext().JobContainer = cm tt.step.getRunContext().JobContainer = cm
@@ -246,6 +250,32 @@ func TestActionRunner(t *testing.T) {
} }
} }
func TestNodeActionCommandPreservesSymlinkedEntrypoint(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("symlink creation requires privileges on Windows")
}
requireHostTools(t, "node")
actionDir := t.TempDir()
entrypoint := filepath.Join(actionDir, "index.mjs")
require.NoError(t, os.WriteFile(entrypoint, []byte(`
import {fileURLToPath} from "node:url";
const self = fileURLToPath(import.meta.url);
if (process.argv[1] !== self) {
console.log("argv[1]:", process.argv[1], "import.meta.url:", self);
process.exitCode = 1;
}
`), 0o600))
symlinkedActionDir := filepath.Join(t.TempDir(), "action")
require.NoError(t, os.Symlink(actionDir, symlinkedActionDir))
symlinkedEntrypoint := filepath.Join(symlinkedActionDir, "index.mjs")
args := nodeActionCommand(symlinkedEntrypoint)
output, err := exec.CommandContext(t.Context(), args[0], args[1:]...).CombinedOutput()
require.NoError(t, err, "%s", output)
}
func TestNewStepContainerDoesNotUseDockerSecrets(t *testing.T) { func TestNewStepContainerDoesNotUseDockerSecrets(t *testing.T) {
cm := &containerMock{} cm := &containerMock{}
@@ -286,7 +316,7 @@ func TestNewStepContainerDoesNotUseDockerSecrets(t *testing.T) {
step.On("getStepModel").Return(&model.Step{ID: "action"}) step.On("getStepModel").Return(&model.Step{ID: "action"})
step.On("getEnv").Return(&env) step.On("getEnv").Return(&env)
_ = newStepContainer(ctx, step, "registry.example.com/action:tag", nil, nil, "") _, _ = 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. // DOCKER_USERNAME/DOCKER_PASSWORD should not be injected as pull credentials for docker action containers.
assert.Empty(t, captured.Username) assert.Empty(t, captured.Username)
@@ -307,7 +337,7 @@ func TestMaybeCopyToActionDirHoldsCloneLock(t *testing.T) {
copyEntered := make(chan struct{}) copyEntered := make(chan struct{})
cm := &containerMock{} cm := &containerMock{}
cm.On("CopyDir", "/var/run/act/actions/", actionDir+"/", false).Return(func(ctx context.Context) error { cm.On("CopyDir", "/var/run/act/actions/", actionDir+"/", false, true).Return(func(ctx context.Context) error {
close(copyEntered) close(copyEntered)
<-releaseCopy <-releaseCopy
return nil return nil
@@ -492,8 +522,6 @@ func TestDockerActionImageTag(t *testing.T) {
) )
} }
// Only the entrypoint is stage specific: every stage of a docker action receives runs.args
// and runs.env, and the `entrypoint` input applies to the main stage alone.
func TestExecAsDockerStageEntrypoint(t *testing.T) { func TestExecAsDockerStageEntrypoint(t *testing.T) {
orig := ContainerNewContainer orig := ContainerNewContainer
defer func() { ContainerNewContainer = orig }() defer func() { ContainerNewContainer = orig }()
@@ -501,25 +529,62 @@ func TestExecAsDockerStageEntrypoint(t *testing.T) {
for _, tc := range []struct { for _, tc := range []struct {
name string name string
stage stepStage stage stepStage
with map[string]string
runs model.ActionRuns
env map[string]string
wantCmd []string
wantEntrypoint []string wantEntrypoint []string
}{ }{
{ {
name: "main stage prefers the entrypoint input", name: "main stage prefers manifest values",
stage: stepStageMain, stage: stepStageMain,
wantEntrypoint: []string{"input.sh"}, with: map[string]string{"args": "caller", "entrypoint": "input.sh"},
runs: model.ActionRuns{
Entrypoint: "main.sh",
Args: []string{"manifest"},
Env: map[string]string{"ACTION_ONLY": "manifest"},
},
wantCmd: []string{"manifest"},
wantEntrypoint: []string{"main.sh"},
}, },
{ {
name: "pre stage uses runs.pre-entrypoint", name: "main stage uses caller fallbacks",
stage: stepStagePre, stage: stepStageMain,
with: map[string]string{"args": "caller --flag", "entrypoint": "input.sh --verbose"},
runs: model.ActionRuns{Env: map[string]string{"ACTION_ONLY": "manifest"}},
wantCmd: []string{"caller", "--flag"},
wantEntrypoint: []string{"input.sh", "--verbose"},
},
{
name: "explicit empty manifest args suppress caller args",
stage: stepStageMain,
with: map[string]string{"args": "caller"},
runs: model.ActionRuns{Args: []string{}},
wantCmd: []string{},
},
{
name: "pre stage keeps step environment",
stage: stepStagePre,
with: map[string]string{"entrypoint": "input.sh"},
runs: model.ActionRuns{
PreEntrypoint: "pre.sh --verbose",
Args: []string{"hello"},
Env: map[string]string{"SHARED": "manifest"},
},
env: map[string]string{"SHARED": "step"},
wantCmd: []string{"hello"},
wantEntrypoint: []string{"pre.sh", "--verbose"}, wantEntrypoint: []string{"pre.sh", "--verbose"},
}, },
{ {
name: "post stage uses runs.post-entrypoint", name: "post stage uses manifest entrypoint",
stage: stepStagePost, stage: stepStagePost,
runs: model.ActionRuns{PostEntrypoint: "post.sh", Args: []string{"hello"}},
wantCmd: []string{"hello"},
wantEntrypoint: []string{"post.sh"}, wantEntrypoint: []string{"post.sh"},
}, },
} { } {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
tc.runs.Using, tc.runs.Image = "docker", "docker://node:14"
cm := &containerMock{} cm := &containerMock{}
var input *container.NewContainerInput var input *container.NewContainerInput
ContainerNewContainer = func(in *container.NewContainerInput) container.ExecutionsEnvironment { ContainerNewContainer = func(in *container.NewContainerInput) container.ExecutionsEnvironment {
@@ -528,22 +593,14 @@ func TestExecAsDockerStageEntrypoint(t *testing.T) {
} }
step := &stepActionRemote{ step := &stepActionRemote{
Step: &model.Step{ID: "1", Uses: "org/action@v1", With: map[string]string{"entrypoint": "input.sh"}}, Step: &model.Step{ID: "1", Uses: "org/action@v1", With: tc.with},
RunContext: &RunContext{ RunContext: &RunContext{
Config: &Config{}, Config: &Config{},
Run: &model.Run{JobID: "1", Workflow: &model.Workflow{Jobs: map[string]*model.Job{"1": {}}}}, Run: &model.Run{JobID: "1", Workflow: &model.Workflow{Jobs: map[string]*model.Job{"1": {}}}},
JobContainer: cm, JobContainer: cm,
}, },
action: &model.Action{Runs: model.ActionRuns{ action: &model.Action{Runs: tc.runs},
Using: "docker", env: mergeMaps(tc.env),
Image: "docker://node:14",
PreEntrypoint: "pre.sh --verbose",
Entrypoint: "main.sh",
PostEntrypoint: "post.sh",
Args: []string{"hello"},
Env: map[string]string{"MY_VAR": "world"},
}},
env: map[string]string{},
} }
cm.On("Pull", false).Return(func(context.Context) error { return nil }) cm.On("Pull", false).Return(func(context.Context) error { return nil })
@@ -554,9 +611,11 @@ func TestExecAsDockerStageEntrypoint(t *testing.T) {
require.NoError(t, execAsDocker(context.Background(), step, "action", t.TempDir(), t.TempDir(), false, tc.stage)) require.NoError(t, execAsDocker(context.Background(), step, "action", t.TempDir(), t.TempDir(), false, tc.stage))
require.NotNil(t, input) require.NotNil(t, input)
assert.Equal(t, tc.wantCmd, input.Cmd)
assert.Equal(t, tc.wantEntrypoint, input.Entrypoint) assert.Equal(t, tc.wantEntrypoint, input.Entrypoint)
assert.Equal(t, []string{"hello"}, input.Cmd) for key, value := range mergeMaps(tc.runs.Env, tc.env) {
assert.Contains(t, input.Env, "MY_VAR=world") assert.Contains(t, input.Env, key+"="+value)
}
}) })
} }
} }
+2
View File
@@ -61,6 +61,8 @@ func TestCancelledJobStatusEnablesAlwaysAndCancelledSteps(t *testing.T) {
enabled, err := interp.Evaluate("always()", exprparser.DefaultStatusCheckSuccess) enabled, err := interp.Evaluate("always()", exprparser.DefaultStatusCheckSuccess)
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, true, enabled, "`if: always()` step must run on a cancelled job") assert.Equal(t, true, enabled, "`if: always()` step must run on a cancelled job")
setJobResult(context.Background(), rc, rc, true)
assert.Equal(t, "cancelled", rc.Run.Job().Result)
} }
// TestMainStepsExecutorRunsAlwaysStepsAfterCancel verifies that newMainStepsExecutor does // TestMainStepsExecutorRunsAlwaysStepsAfterCancel verifies that newMainStepsExecutor does
+43 -12
View File
@@ -18,11 +18,17 @@ var commandPatternGA *regexp.Regexp
var commandPatternADO *regexp.Regexp var commandPatternADO *regexp.Regexp
func init() { func init() {
commandPatternGA = regexp.MustCompile("^::([^ ]+)( (.+))?::([^\r\n]*)[\r\n]+$") commandPatternGA = regexp.MustCompile("^::([^ ]+?)( (.+?))?::([^\r\n]*)[\r\n]*$")
commandPatternADO = regexp.MustCompile("^##\\[([^ ]+)( (.+))?]([^\r\n]*)[\r\n]+$") // excluding ']' ends the command info at the first bracket, as GitHub does
commandPatternADO = regexp.MustCompile("^##\\[([^ \\]]+)( ([^\\]]*))?]([^\r\n]*)[\r\n]*$")
} }
func tryParseRawActionCommand(line string) (command string, kvPairs map[string]string, arg string, ok bool) { func tryParseRawActionCommand(line string) (command string, kvPairs map[string]string, arg string, ok bool) {
command, kvPairs, arg, _, ok = tryParseActionCommand(line)
return command, kvPairs, arg, ok
}
func tryParseActionCommand(line string) (command string, kvPairs map[string]string, arg string, legacy, ok bool) {
if m := commandPatternGA.FindStringSubmatch(line); m != nil { if m := commandPatternGA.FindStringSubmatch(line); m != nil {
command = m[1] command = m[1]
kvPairs = parseKeyValuePairs(m[3], ",") kvPairs = parseKeyValuePairs(m[3], ",")
@@ -32,19 +38,21 @@ func tryParseRawActionCommand(line string) (command string, kvPairs map[string]s
command = m[1] command = m[1]
kvPairs = parseKeyValuePairs(m[3], ";") kvPairs = parseKeyValuePairs(m[3], ";")
arg = m[4] arg = m[4]
legacy = true
ok = true ok = true
} }
return command, kvPairs, arg, ok return command, kvPairs, arg, legacy, ok
} }
func (rc *RunContext) commandHandler(ctx context.Context) common.LineHandler { func (rc *RunContext) commandHandler(ctx context.Context) common.LineHandler {
logger := common.Logger(ctx) logger := common.Logger(ctx)
resumeCommand := "" resumeCommand := ""
return func(line string) bool { return func(line string) bool {
command, kvPairs, arg, ok := tryParseRawActionCommand(line) command, kvPairs, arg, legacy, ok := tryParseActionCommand(line)
if !ok { if !ok {
return true return true
} }
command = strings.ToLower(command)
if resumeCommand != "" { if resumeCommand != "" {
// There should not be any emojis in the log output for Gitea. // There should not be any emojis in the log output for Gitea.
@@ -54,19 +62,24 @@ func (rc *RunContext) commandHandler(ctx context.Context) common.LineHandler {
logger.Infof("%s", line) logger.Infof("%s", line)
// Resumed here rather than from the switch, because the end token is arbitrary // Resumed here rather than from the switch, because the end token is arbitrary
// and a token naming a real command would otherwise never resume. // and a token naming a real command would otherwise never resume.
if command == resumeCommand { if strings.EqualFold(command, resumeCommand) {
resumeCommand = "" resumeCommand = ""
} }
return true return true
} }
arg = UnescapeCommandData(arg) if legacy {
kvPairs = unescapeKvPairs(kvPairs) arg = UnescapeLegacyCommand(arg)
kvPairs = unescapeKvPairs(kvPairs, UnescapeLegacyCommand)
} else {
arg = UnescapeCommandData(arg)
kvPairs = unescapeKvPairs(kvPairs, unescapeCommandProperty)
}
if (command == "set-env" || command == "add-path") && rc.refuseUnsecureCommand(ctx, command) { if (command == "set-env" || command == "add-path") && rc.refuseUnsecureCommand(ctx, command) {
return true return true
} }
switch command { switch command {
case "set-env": case "set-env":
rc.setEnv(ctx, kvPairs, arg) rc.setEnv(ctx, kvPairs, arg, true)
case "set-output": case "set-output":
rc.setOutput(ctx, kvPairs, arg) rc.setOutput(ctx, kvPairs, arg)
case "add-path": case "add-path":
@@ -139,8 +152,16 @@ func (rc *RunContext) takeUnsecureCommandError() error {
return err return err
} }
func (rc *RunContext) setEnv(ctx context.Context, kvPairs map[string]string, arg string) { func (rc *RunContext) setEnv(ctx context.Context, kvPairs map[string]string, arg string, fromCommand bool) {
name := kvPairs["name"] name := kvPairs["name"]
if strings.EqualFold(name, "NODE_OPTIONS") {
message := "Can't store NODE_OPTIONS output parameter using '$GITHUB_ENV' command."
if fromCommand {
message = "Can't update NODE_OPTIONS environment variable using ::set-env:: command."
}
common.Logger(ctx).WithField(rawOutputField, true).Errorf("##[error]%s", EscapeCommandData(message))
return
}
common.Logger(ctx).Infof("::set-env:: %s=%s", name, arg) common.Logger(ctx).Infof("::set-env:: %s=%s", name, arg)
if rc.Env == nil { if rc.Env == nil {
rc.Env = make(map[string]string) rc.Env = make(map[string]string)
@@ -159,6 +180,10 @@ func (rc *RunContext) setEnv(ctx context.Context, kvPairs map[string]string, arg
mergeIntoMap(rc.GlobalEnv, newenv) mergeIntoMap(rc.GlobalEnv, newenv)
} }
func (rc *RunContext) setEnvFile(ctx context.Context, kvPairs map[string]string, arg string) {
rc.setEnv(ctx, kvPairs, arg, false)
}
func (rc *RunContext) setOutput(ctx context.Context, kvPairs map[string]string, arg string) { func (rc *RunContext) setOutput(ctx context.Context, kvPairs map[string]string, arg string) {
logger := common.Logger(ctx) logger := common.Logger(ctx)
stepID := rc.CurrentStep stepID := rc.CurrentStep
@@ -189,7 +214,7 @@ func parseKeyValuePairs(kvPairs, separator string) map[string]string {
rtn := make(map[string]string) rtn := make(map[string]string)
kvPairList := strings.SplitSeq(kvPairs, separator) kvPairList := strings.SplitSeq(kvPairs, separator)
for kvPair := range kvPairList { for kvPair := range kvPairList {
kv := strings.Split(kvPair, "=") kv := strings.SplitN(kvPair, "=", 2)
if len(kv) == 2 { if len(kv) == 2 {
rtn[kv[0]] = kv[1] rtn[kv[0]] = kv[1]
} }
@@ -202,6 +227,7 @@ var (
commandDataEscaper = strings.NewReplacer("%", "%25", "\r", "%0D", "\n", "%0A") commandDataEscaper = strings.NewReplacer("%", "%25", "\r", "%0D", "\n", "%0A")
commandDataUnescaper = strings.NewReplacer("%25", "%", "%0D", "\r", "%0A", "\n") commandDataUnescaper = strings.NewReplacer("%25", "%", "%0D", "\r", "%0A", "\n")
commandPropertyUnescaper = strings.NewReplacer("%25", "%", "%0D", "\r", "%0A", "\n", "%3A", ":", "%2C", ",") commandPropertyUnescaper = strings.NewReplacer("%25", "%", "%0D", "\r", "%0A", "\n", "%3A", ":", "%2C", ",")
legacyCommandUnescaper = strings.NewReplacer("%3B", ";", "%0D", "\r", "%0A", "\n", "%5D", "]", "%25", "%")
) )
// EscapeCommandData encodes the data part of a "::cmd::" or "##[cmd]" line the runner writes itself, // EscapeCommandData encodes the data part of a "::cmd::" or "##[cmd]" line the runner writes itself,
@@ -218,9 +244,14 @@ func unescapeCommandProperty(arg string) string {
return commandPropertyUnescaper.Replace(arg) return commandPropertyUnescaper.Replace(arg)
} }
func unescapeKvPairs(kvPairs map[string]string) map[string]string { // UnescapeLegacyCommand decodes a "##[cmd]" line, which also spells ";" and "]" escaped.
func UnescapeLegacyCommand(arg string) string {
return legacyCommandUnescaper.Replace(arg)
}
func unescapeKvPairs(kvPairs map[string]string, unescape func(string) string) map[string]string {
for k, v := range kvPairs { for k, v := range kvPairs {
kvPairs[k] = unescapeCommandProperty(v) kvPairs[k] = unescape(v)
} }
return kvPairs return kvPairs
} }
+23 -4
View File
@@ -26,12 +26,21 @@ func unsecureRC() *RunContext {
func TestSetEnv(t *testing.T) { func TestSetEnv(t *testing.T) {
a := assert.New(t) a := assert.New(t)
ctx := context.Background() logger, hook := test.NewNullLogger()
ctx := common.WithLogger(context.Background(), logger)
rc := unsecureRC() rc := unsecureRC()
handler := rc.commandHandler(ctx) handler := rc.commandHandler(ctx)
handler("::set-env name=x::valz\n") handler("::set-env name=x::valz\n")
a.Equal("valz", rc.Env["x"]) a.Equal("valz", rc.Env["x"])
handler("::SET-ENV name=NODE_OPTIONS::--require command.js\n")
rc.setEnvFile(ctx, map[string]string{"name": "node_options"}, "--require env.js")
a.NotContains(rc.Env, "NODE_OPTIONS")
a.NotContains(rc.Env, "node_options")
entries := hook.AllEntries()
require.Len(t, entries, 3)
a.Equal("##[error]Can't update NODE_OPTIONS environment variable using ::set-env:: command.", entries[1].Message)
a.Equal("##[error]Can't store NODE_OPTIONS output parameter using '$GITHUB_ENV' command.", entries[2].Message)
} }
func TestStopCommandsKeepsSuppressedLinesInLog(t *testing.T) { func TestStopCommandsKeepsSuppressedLinesInLog(t *testing.T) {
@@ -85,6 +94,16 @@ func TestSetOutput(t *testing.T) {
handler("::set-output name=x%3A%2C%0A%25%0D%3A::percent2%25%0Atest\n") handler("::set-output name=x%3A%2C%0A%25%0D%3A::percent2%25%0Atest\n")
a.Equal("percent2%\ntest", rc.StepResults["my-step"].Outputs["x:,\n%\r:"]) a.Equal("percent2%\ntest", rc.StepResults["my-step"].Outputs["x:,\n%\r:"])
handler("::set-output name=symbol::std::vector")
a.Equal("std::vector", rc.StepResults["my-step"].Outputs["symbol"])
handler("::set-output name=a=b::value\n")
a.Equal("value", rc.StepResults["my-step"].Outputs["a=b"])
handler("##[set-output name=legacy%3B%5D]value%3B%5D")
a.Equal("value;]", rc.StepResults["my-step"].Outputs["legacy;]"])
handler("##[set-output name=bracket]value]tail")
a.Equal("value]tail", rc.StepResults["my-step"].Outputs["bracket"])
handler("::set-output name=modern%3B%5D::value%3B%5D\n")
a.Equal("value%3B%5D", rc.StepResults["my-step"].Outputs["modern%3B%5D"])
} }
func TestAddpath(t *testing.T) { func TestAddpath(t *testing.T) {
@@ -110,7 +129,7 @@ func TestStopCommands(t *testing.T) {
handler("::set-env name=x::valz\n") handler("::set-env name=x::valz\n")
a.Equal("valz", rc.Env["x"]) a.Equal("valz", rc.Env["x"])
handler("::stop-commands::my-end-token\n") handler("::stop-commands::MY-END-TOKEN\n")
handler("::set-env name=x::abcd\n") handler("::set-env name=x::abcd\n")
a.Equal("valz", rc.Env["x"]) a.Equal("valz", rc.Env["x"])
handler("::my-end-token::\n") handler("::my-end-token::\n")
@@ -163,10 +182,10 @@ func TestAddmask(t *testing.T) {
rc := new(RunContext) rc := new(RunContext)
handler := rc.commandHandler(loggerCtx) handler := rc.commandHandler(loggerCtx)
handler("::add-mask::my-secret-value\n") handler("::ADD-MASK::my::secret")
a.Equal("***", hook.LastEntry().Message) a.Equal("***", hook.LastEntry().Message)
a.NotEqual("*my-secret-value", hook.LastEntry().Message) a.Equal([]string{"my::secret"}, rc.Masks)
} }
// based on https://stackoverflow.com/a/10476304 // based on https://stackoverflow.com/a/10476304
+2 -2
View File
@@ -57,8 +57,8 @@ func (cm *containerMock) Copy(destPath string, files ...*container.FileEntry) co
return args.Get(0).(func(context.Context) error) return args.Get(0).(func(context.Context) error)
} }
func (cm *containerMock) CopyDir(destPath, srcPath string, useGitIgnore bool) common.Executor { func (cm *containerMock) CopyDir(destPath, srcPath string, useGitIgnore, skipGitDir bool) common.Executor {
args := cm.Called(destPath, srcPath, useGitIgnore) args := cm.Called(destPath, srcPath, useGitIgnore, skipGitDir)
return args.Get(0).(func(context.Context) error) return args.Get(0).(func(context.Context) error)
} }
+51 -54
View File
@@ -13,6 +13,7 @@ import (
"reflect" "reflect"
"regexp" "regexp"
"strings" "strings"
"sync"
"time" "time"
"gitea.com/gitea/runner/act/common" "gitea.com/gitea/runner/act/common"
@@ -71,7 +72,7 @@ func (rc *RunContext) NewExpressionEvaluatorWithEnv(ctx context.Context, env map
} }
ghc := rc.getGithubContext(ctx) ghc := rc.getGithubContext(ctx)
inputs := getEvaluatorInputs(ctx, rc, rc.actionInputs, ghc) inputs := getEvaluatorInputs(rc, rc.actionInputs, ghc)
ee := &exprparser.EvaluationEnvironment{ ee := &exprparser.EvaluationEnvironment{
Github: ghc, Github: ghc,
@@ -81,7 +82,7 @@ func (rc *RunContext) NewExpressionEvaluatorWithEnv(ctx context.Context, env map
// todo: should be unavailable // todo: should be unavailable
// but required to interpolate/evaluate the step outputs on the job // but required to interpolate/evaluate the step outputs on the job
Steps: rc.getStepsContext(), Steps: rc.getStepsContext(),
Secrets: getWorkflowSecrets(ctx, rc), Secrets: getWorkflowSecrets(rc),
Vars: getWorkflowVars(ctx, rc), Vars: getWorkflowVars(ctx, rc),
Strategy: strategy, Strategy: strategy,
Matrix: rc.Matrix, Matrix: rc.Matrix,
@@ -137,14 +138,14 @@ func (rc *RunContext) newStepExpressionEvaluator(ctx context.Context, step step,
Env: *step.getEnv(), Env: *step.getEnv(),
Job: rc.getJobContext(), Job: rc.getJobContext(),
Steps: rc.getStepsContext(), Steps: rc.getStepsContext(),
Secrets: getWorkflowSecrets(ctx, rc), Secrets: getWorkflowSecrets(rc),
Vars: getWorkflowVars(ctx, rc), Vars: getWorkflowVars(ctx, rc),
Strategy: strategy, Strategy: strategy,
Matrix: rc.Matrix, Matrix: rc.Matrix,
Needs: using, Needs: using,
// todo: should be unavailable // todo: should be unavailable
// but required to interpolate/evaluate the inputs in actions/composite // but required to interpolate/evaluate the inputs in actions/composite
Inputs: getEvaluatorInputs(ctx, rc, stepInputs, rc.getGithubContext(ctx)), Inputs: getEvaluatorInputs(rc, stepInputs, rc.getGithubContext(ctx)),
HashFiles: getHashFilesFunction(ctx, rc), HashFiles: getHashFilesFunction(ctx, rc),
} }
ee.Runner = rc.getRunnerContext(ctx) ee.Runner = rc.getRunnerContext(ctx)
@@ -246,17 +247,18 @@ func (ee expressionEvaluator) EvaluateYamlNode(ctx context.Context, node *yaml.N
return ee.shared(ctx).EvaluateYamlNode(node) return ee.shared(ctx).EvaluateYamlNode(node)
} }
func (ee expressionEvaluator) Interpolate(ctx context.Context, in string) string { func (ee expressionEvaluator) Interpolate(ctx context.Context, in string) (string, error) {
out, err := ee.interpolate(ctx, in) return ee.shared(ctx).Interpolate(in)
if err != nil {
common.Logger(ctx).Errorf("Unable to interpolate expression '%s': %s", in, err)
return ""
}
return out
} }
func (ee expressionEvaluator) interpolate(ctx context.Context, in string) (string, error) { // InterpolateName keeps the source text of a job or step name that cannot be evaluated, as GitHub does.
return ee.shared(ctx).Interpolate(in) func (ee expressionEvaluator) InterpolateName(ctx context.Context, in string) string {
out, err := ee.Interpolate(ctx, in)
if err != nil {
common.Logger(ctx).Warnf("Unable to evaluate the display name '%s': %s", in, err)
return in
}
return out
} }
// EvalBool evaluates an expression against given evaluator. An `if:` is an expression even without // EvalBool evaluates an expression against given evaluator. An `if:` is an expression even without
@@ -277,10 +279,10 @@ func inputsFromEnv(env map[string]string) map[string]any {
return inputs return inputs
} }
func getEvaluatorInputs(ctx context.Context, rc *RunContext, stepInputs map[string]any, ghc *model.GithubContext) map[string]any { func getEvaluatorInputs(rc *RunContext, stepInputs map[string]any, ghc *model.GithubContext) map[string]any {
inputs := map[string]any{} inputs := map[string]any{}
setupWorkflowInputs(ctx, &inputs, rc) maps.Copy(inputs, rc.workflowCallInputs)
maps.Copy(inputs, stepInputs) maps.Copy(inputs, stepInputs)
if ghc.EventName == "workflow_dispatch" { if ghc.EventName == "workflow_dispatch" {
@@ -324,54 +326,49 @@ func coerceInputValue(value any, inputType string) any {
return value == "true" return value == "true"
} }
func setupWorkflowInputs(ctx context.Context, inputs *map[string]any, rc *RunContext) { // resolveWorkflowCall evaluates the caller's with: and secrets: once, as the server does before dispatching a called workflow.
if rc.caller != nil { func (rc *RunContext) resolveWorkflowCall(ctx context.Context) error {
config := rc.Run.Workflow.WorkflowCallConfig() if rc.caller == nil {
return nil
}
callerJob := rc.caller.runContext.Run.Job()
callerEval := rc.caller.runContext.ExprEval
calleeEval := sync.OnceValue(func() *expressionEvaluator { return rc.NewExpressionEvaluator(ctx) })
config := rc.Run.Workflow.WorkflowCallConfig()
for name, input := range config.Inputs { rc.workflowCallInputs = make(map[string]any, len(config.Inputs))
value := rc.caller.runContext.Run.Job().With[name] for name, input := range config.Inputs {
if value != nil { value, eval, label := callerJob.With[name], callerEval, "input"
if str, ok := value.(string); ok { if value == nil {
// evaluate using the calling RunContext (outside) value, eval, label = input.Default, calleeEval(), "the default of input"
value = rc.caller.runContext.ExprEval.Interpolate(ctx, str) }
} if str, ok := value.(string); ok {
var err error
if value, err = eval.Interpolate(ctx, str); err != nil {
return fmt.Errorf("unable to interpolate %s %s: %w", label, name, err)
} }
}
rc.workflowCallInputs[name] = coerceInputValue(value, input.Type)
}
if value == nil && config != nil && config.Inputs != nil { secrets := callerJob.Secrets()
value = input.Default if secrets == nil && callerJob.InheritSecrets() {
if rc.ExprEval != nil { secrets = rc.caller.runContext.Config.Secrets
if str, ok := value.(string); ok { }
// evaluate using the called RunContext (inside) rc.workflowCallSecrets = make(map[string]string, len(secrets))
value = rc.ExprEval.Interpolate(ctx, str) for k, v := range secrets {
} var err error
} if rc.workflowCallSecrets[k], err = callerEval.Interpolate(ctx, v); err != nil {
} return fmt.Errorf("unable to interpolate secret %s: %w", k, err)
(*inputs)[name] = coerceInputValue(value, input.Type)
} }
} }
return nil
} }
func getWorkflowSecrets(ctx context.Context, rc *RunContext) map[string]string { func getWorkflowSecrets(rc *RunContext) map[string]string {
if rc.caller != nil { if rc.caller != nil {
job := rc.caller.runContext.Run.Job() return rc.workflowCallSecrets
secrets := job.Secrets()
if secrets == nil && job.InheritSecrets() {
secrets = rc.caller.runContext.Config.Secrets
}
// Interpolate into a new map. secrets may be the shared Config.Secrets (or the job's
// map), which other parallel jobs read concurrently (e.g. log masking), so mutating it
// in place is a data race.
interpolated := make(map[string]string, len(secrets))
for k, v := range secrets {
interpolated[k] = rc.caller.runContext.ExprEval.Interpolate(ctx, v)
}
return interpolated
} }
return rc.Config.Secrets return rc.Config.Secrets
} }
+11 -8
View File
@@ -266,19 +266,21 @@ func TestInterpolate(t *testing.T) {
{"${{ null }}", ""}, {"${{ null }}", ""},
{"${{ fromJSON('[1,2]') }}", "Array"}, {"${{ fromJSON('[1,2]') }}", "Array"},
{"${{ fromJSON('{\"a\":1}') }}", "Object"}, {"${{ fromJSON('{\"a\":1}') }}", "Object"},
// a malformed part must not restructure its neighbours, and it interpolates to nothing
{"${{ 1) && (2 }}", ""},
{"run ${{ 1) && (2 }} now", ""},
{"${{ 1", "${{ 1"}, {"${{ 1", "${{ 1"},
} }
for _, table := range tables { for _, table := range tables {
t.Run("interpolate", func(t *testing.T) { t.Run("interpolate", func(t *testing.T) {
assertObject := assert.New(t) out, err := ee.Interpolate(context.Background(), table.in)
out := ee.Interpolate(context.Background(), table.in) require.NoError(t, err, table.in)
assertObject.Equal(table.out, out, table.in) assert.Equal(t, table.out, out, table.in)
}) })
} }
for _, in := range []string{"${{ 1) && (2 }}", "run ${{ 1) && (2 }} now"} {
_, err := ee.Interpolate(context.Background(), in)
assert.Error(t, err, in)
}
} }
func TestGetEvaluatorInputsBoolean(t *testing.T) { func TestGetEvaluatorInputsBoolean(t *testing.T) {
@@ -352,7 +354,7 @@ on:
} }
ghc := &model.GithubContext{EventName: eventName, Event: table.event} ghc := &model.GithubContext{EventName: eventName, Event: table.event}
inputs := getEvaluatorInputs(context.Background(), rc, nil, ghc) inputs := getEvaluatorInputs(rc, nil, ghc)
assert.Equal(t, table.flag, inputs["flag"]) assert.Equal(t, table.flag, inputs["flag"])
assert.Equal(t, "gitea", inputs["name"]) assert.Equal(t, "gitea", inputs["name"])
}) })
@@ -372,7 +374,8 @@ jobs:
runner := &runnerImpl{config: &Config{Secrets: map[string]string{"A": "s3cr3t-a", "B": "s3cr3t-b"}}} runner := &runnerImpl{config: &Config{Secrets: map[string]string{"A": "s3cr3t-a", "B": "s3cr3t-b"}}}
containerName := func(jobID string) string { containerName := func(jobID string) string {
rc := runner.newRunContext(t.Context(), &model.Run{JobID: jobID, Workflow: workflow}, nil) rc, err := runner.newRunContext(t.Context(), &model.Run{JobID: jobID, Workflow: workflow}, nil)
require.NoError(t, err)
assert.NotContains(t, rc.Name, "s3cr3t") assert.NotContains(t, rc.Name, "s3cr3t")
return rc.jobContainerName() return rc.jobContainerName()
} }
+71 -30
View File
@@ -18,6 +18,7 @@ import (
"slices" "slices"
"strconv" "strconv"
"strings" "strings"
"sync"
"time" "time"
"unicode" "unicode"
@@ -154,11 +155,9 @@ func newJobExecutor(info jobInfo, sf stepFactory, rc *RunContext) common.Executo
if rc.Run == nil { if rc.Run == nil {
return nil return nil
} }
rc.ExprEval = rc.NewExpressionEvaluator(ctx) if err := evaluateJobEnvAndDefaults(ctx, rc); err != nil {
// evaluate environment variables since they can contain reportStepError(ctx, rc, err)
// GitHub's special environment variables. return err
for k, v := range rc.GetEnv() {
rc.Env[k] = rc.ExprEval.Interpolate(ctx, v)
} }
return nil return nil
}) })
@@ -236,6 +235,15 @@ func newJobExecutor(info jobInfo, sf stepFactory, rc *RunContext) common.Executo
// Ahead of the teardown below, while the job environment is still up. // Ahead of the teardown below, while the job environment is still up.
postExecutor = postExecutor.Finally(rc.runJobCompletedHook) postExecutor = postExecutor.Finally(rc.runJobCompletedHook)
postExecutor = postExecutor.Finally(func(ctx context.Context) error {
// swallowed: a bad output fails this job, it must not abandon the rest of the plan
if err := info.interpolateOutputs()(ctx); err != nil {
reportStepError(ctx, rc, err)
} else if err := setJobOutputs(ctx, rc); err != nil {
reportStepError(ctx, rc, err)
}
return nil
})
postExecutor = postExecutor.Finally(func(ctx context.Context) error { postExecutor = postExecutor.Finally(func(ctx context.Context) error {
jobError := common.JobError(ctx) jobError := common.JobError(ctx)
@@ -251,7 +259,6 @@ func newJobExecutor(info jobInfo, sf stepFactory, rc *RunContext) common.Executo
logger.Errorf("##[error]%s", EscapeCommandData("Error while stop job container: "+err.Error())) logger.Errorf("##[error]%s", EscapeCommandData("Error while stop job container: "+err.Error()))
} }
setJobResult(ctx, info, rc, jobError == nil) setJobResult(ctx, info, rc, jobError == nil)
setJobOutputs(ctx, rc)
return err return err
}) })
@@ -267,7 +274,6 @@ func newJobExecutor(info jobInfo, sf stepFactory, rc *RunContext) common.Executo
defer cancel() defer cancel()
return postExecutor(postCtx) return postExecutor(postCtx)
}). }).
Finally(info.interpolateOutputs()).
Finally(info.closeContainer())) Finally(info.closeContainer()))
} }
@@ -365,7 +371,7 @@ func setJobResult(ctx context.Context, info jobInfo, rc *RunContext, success boo
// concurrent succeeding one. // concurrent succeeding one.
job := rc.Run.Job() job := rc.Run.Job()
var continueOnError bool var continueOnError bool
if !success { if !success && !rc.jobCancelled {
// Use a fresh context so an expired job timeout cannot block expression evaluation. // Use a fresh context so an expired job timeout cannot block expression evaluation.
evalCtx := common.WithLogger(context.Background(), common.Logger(ctx)) evalCtx := common.WithLogger(context.Background(), common.Logger(ctx))
continueOnError = evaluateJobContinueOnError(evalCtx, rc, job) continueOnError = evaluateJobContinueOnError(evalCtx, rc, job)
@@ -378,7 +384,11 @@ func setJobResult(ctx context.Context, info jobInfo, rc *RunContext, success boo
if len(info.matrix()) > 0 && job.Result != "" { if len(info.matrix()) > 0 && job.Result != "" {
result = job.Result result = job.Result
} }
if !success { // cancelled is sticky, so a sibling combination finishing last cannot mask it
switch {
case rc.jobCancelled:
result = "cancelled"
case !success && result != "cancelled":
result = "failure" result = "failure"
job.SetContinueOnError(continueOnError) job.SetContinueOnError(continueOnError)
} }
@@ -392,38 +402,69 @@ func setJobResult(ctx context.Context, info jobInfo, rc *RunContext, success boo
return return
} }
jobResultMessage := "succeeded" jobResultMessage := "failed"
if jobResult != "success" { switch jobResult {
jobResultMessage = "failed" case "success":
jobResultMessage = "succeeded"
case "cancelled":
jobResultMessage = "cancelled"
} }
logger.WithField("jobResult", jobResult).Infof("Job %s", jobResultMessage) logger.WithField("jobResult", jobResult).Infof("Job %s", jobResultMessage)
} }
func setJobOutputs(ctx context.Context, rc *RunContext) { // evaluateJobEnvAndDefaults resolves the job's env and defaults.run once, as GitHub does at job setup.
if rc.caller != nil { func evaluateJobEnvAndDefaults(ctx context.Context, rc *RunContext) error {
// map outputs for reusable workflows rc.ExprEval = rc.NewExpressionEvaluator(ctx)
callerOutputs := make(map[string]string) var err error
for k, v := range rc.GetEnv() {
ee := rc.NewExpressionEvaluator(ctx) if rc.Env[k], err = rc.ExprEval.Interpolate(ctx, v); err != nil {
return fmt.Errorf("unable to interpolate env %s: %w", k, err)
for k, v := range rc.Run.Workflow.WorkflowCallConfig().Outputs {
callerOutputs[k] = ee.Interpolate(ctx, ee.Interpolate(ctx, v.Value))
} }
// Matrix combinations of a reusable-workflow caller share the caller's *model.Job;
// serialize the write so parallel combos don't race on its Outputs field.
callerJob := rc.caller.runContext.Run.Job()
defer lockJob(callerJob)()
callerJob.Outputs = callerOutputs
} }
defaults := rc.Run.Job().Defaults.Run
if rc.jobRunDefaults.Shell, err = rc.ExprEval.Interpolate(ctx, defaults.Shell); err != nil {
return fmt.Errorf("unable to interpolate defaults.run.shell: %w", err)
}
if rc.jobRunDefaults.WorkingDirectory, err = rc.ExprEval.Interpolate(ctx, defaults.WorkingDirectory); err != nil {
return fmt.Errorf("unable to interpolate defaults.run.working-directory: %w", err)
}
return nil
}
func setJobOutputs(ctx context.Context, rc *RunContext) error {
if rc.caller == nil {
return nil
}
outputs := rc.Run.Workflow.WorkflowCallConfig().Outputs
callerOutputs := make(map[string]string, len(outputs))
ee := sync.OnceValue(func() *expressionEvaluator { return rc.NewExpressionEvaluator(ctx) })
for k, v := range outputs {
value := v.Value
for range 2 { // two passes, the value resolves through a job output
var err error
if value, err = ee().Interpolate(ctx, value); err != nil {
return fmt.Errorf("unable to interpolate workflow output %s: %w", k, err)
}
}
callerOutputs[k] = value
}
// Matrix combinations of a reusable-workflow caller share the caller's *model.Job;
// serialize the write so parallel combos don't race on its Outputs field.
callerJob := rc.caller.runContext.Run.Job()
defer lockJob(callerJob)()
callerJob.Outputs = callerOutputs
return nil
} }
// applyJobTimeout applies the job-level timeout-minutes to ctx, mirroring the // applyJobTimeout applies the job-level timeout-minutes to ctx, mirroring the
// step-level evaluateStepTimeout in step.go. // step-level evaluateStepTimeout in step.go.
func applyJobTimeout(ctx context.Context, rc *RunContext, job *model.Job) (context.Context, context.CancelFunc) { func applyJobTimeout(ctx context.Context, rc *RunContext, job *model.Job) (context.Context, context.CancelFunc) {
timeout := rc.ExprEval.Interpolate(ctx, job.TimeoutMinutes) timeout, err := rc.ExprEval.Interpolate(ctx, job.TimeoutMinutes)
if timeout != "" { if err != nil {
common.Logger(ctx).Errorf("An error occurred when attempting to determine the job timeout: %s", err)
} else if timeout != "" {
if timeoutMinutes, err := strconv.ParseInt(timeout, 10, 64); err == nil { if timeoutMinutes, err := strconv.ParseInt(timeout, 10, 64); err == nil {
return context.WithTimeout(ctx, time.Duration(timeoutMinutes)*time.Minute) return context.WithTimeout(ctx, time.Duration(timeoutMinutes)*time.Minute)
} }
@@ -622,7 +663,7 @@ func archiveEntryMatchesPath(entryName, requestedPath string) bool {
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.InterpolateName(ctx, stepModel.String()), stage.String())
logWriter := rc.commandLogWriter(ctx) logWriter := rc.commandLogWriter(ctx)
+24 -6
View File
@@ -299,6 +299,7 @@ func TestNewJobExecutor(t *testing.T) {
executedSteps []string executedSteps []string
result string result string
hasError bool hasError bool
output string
}{ }{
{ {
name: "zeroSteps", name: "zeroSteps",
@@ -319,8 +320,8 @@ func TestNewJobExecutor(t *testing.T) {
executedSteps: []string{ executedSteps: []string{
"startContainer", "startContainer",
"step1", "step1",
"stopContainer",
"interpolateOutputs", "interpolateOutputs",
"stopContainer",
"closeContainer", "closeContainer",
}, },
result: "success", result: "success",
@@ -336,8 +337,8 @@ func TestNewJobExecutor(t *testing.T) {
executedSteps: []string{ executedSteps: []string{
"startContainer", "startContainer",
"step1", "step1",
"stopContainer",
"interpolateOutputs", "interpolateOutputs",
"stopContainer",
"closeContainer", "closeContainer",
}, },
result: "failure", result: "failure",
@@ -354,8 +355,8 @@ func TestNewJobExecutor(t *testing.T) {
"startContainer", "startContainer",
"pre1", "pre1",
"step1", "step1",
"stopContainer",
"interpolateOutputs", "interpolateOutputs",
"stopContainer",
"closeContainer", "closeContainer",
}, },
result: "success", result: "success",
@@ -372,8 +373,8 @@ func TestNewJobExecutor(t *testing.T) {
"startContainer", "startContainer",
"step1", "step1",
"post1", "post1",
"stopContainer",
"interpolateOutputs", "interpolateOutputs",
"stopContainer",
"closeContainer", "closeContainer",
}, },
result: "success", result: "success",
@@ -391,8 +392,8 @@ func TestNewJobExecutor(t *testing.T) {
"pre1", "pre1",
"step1", "step1",
"post1", "post1",
"stopContainer",
"interpolateOutputs", "interpolateOutputs",
"stopContainer",
"closeContainer", "closeContainer",
}, },
result: "success", result: "success",
@@ -418,13 +419,22 @@ func TestNewJobExecutor(t *testing.T) {
"step3", "step3",
"post3", "post3",
"post2", "post2",
"stopContainer",
"interpolateOutputs", "interpolateOutputs",
"stopContainer",
"closeContainer", "closeContainer",
}, },
result: "success", result: "success",
hasError: false, hasError: false,
}, },
{
name: "jobOutputExpressionFailure",
steps: []*model.Step{{ID: "1"}},
preSteps: []bool{false},
postSteps: []bool{false},
executedSteps: []string{"startContainer", "step1", "interpolateOutputs", "stopContainer", "closeContainer"},
result: "failure",
output: "${{ 'test' != test }}",
},
} }
contains := func(needle string, haystack []string) bool { contains := func(needle string, haystack []string) bool {
@@ -450,6 +460,10 @@ func TestNewJobExecutor(t *testing.T) {
}, },
Config: &Config{}, Config: &Config{},
} }
if tt.output != "" {
rc.Run.Job().Outputs = map[string]string{"bad": tt.output}
rc.outputTemplate = map[string]string{"bad": tt.output}
}
rc.ExprEval = rc.NewExpressionEvaluator(ctx) rc.ExprEval = rc.NewExpressionEvaluator(ctx)
executorOrder := make([]string, 0) executorOrder := make([]string, 0)
@@ -497,6 +511,9 @@ func TestNewJobExecutor(t *testing.T) {
jim.On("interpolateOutputs").Return(func(ctx context.Context) error { jim.On("interpolateOutputs").Return(func(ctx context.Context) error {
executorOrder = append(executorOrder, "interpolateOutputs") executorOrder = append(executorOrder, "interpolateOutputs")
if tt.output != "" {
return rc.interpolateOutputs()(ctx)
}
return nil return nil
}) })
@@ -518,6 +535,7 @@ func TestNewJobExecutor(t *testing.T) {
executor := newJobExecutor(jim, sfm, rc) executor := newJobExecutor(jim, sfm, rc)
err := executor(ctx) err := executor(ctx)
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
assert.Empty(t, rc.Run.Job().Outputs["bad"])
assert.Equal(t, tt.executedSteps, executorOrder) assert.Equal(t, tt.executedSteps, executorOrder)
jim.AssertExpectations(t) jim.AssertExpectations(t)
+6 -4
View File
@@ -51,10 +51,11 @@ func (rc *RunContext) runJobHook(ctx context.Context, hookPath, name string) err
rawLogger.Infof("shell: %s", shell) rawLogger.Infof("shell: %s", shell)
} }
env := maps.Clone(rc.GetEnv()) env := map[string]string{}
if jobContainer := rc.Run.Job().Container(); jobContainer != nil { if jobContainer := rc.Run.Job().Container(); jobContainer != nil {
maps.Copy(env, jobContainer.Env) maps.Copy(env, jobContainer.Env)
} }
maps.Copy(env, rc.GetEnv())
rc.withGithubEnv(ctx, rc.getGithubContext(ctx), env) rc.withGithubEnv(ctx, rc.getGithubContext(ctx), env)
rc.ApplyExtraPath(ctx, &env) rc.ApplyExtraPath(ctx, &env)
@@ -95,10 +96,11 @@ func (rc *RunContext) setupHookFileCommands(ctx context.Context, env map[string]
} }
func (rc *RunContext) processHookFileCommands(ctx context.Context) error { func (rc *RunContext) processHookFileCommands(ctx context.Context) error {
if err := processRunnerEnvFileCommand(ctx, hookEnvFileCommand, rc, rc.setEnv); err != nil { err := processRunnerEnvFileCommand(ctx, hookEnvFileCommand, rc, rc.setEnvFile)
return err if pathErr := rc.UpdateExtraPath(ctx, path.Join(rc.JobContainer.GetActPath(), hookPathFileCommand)); pathErr != nil && err == nil {
err = pathErr
} }
return rc.UpdateExtraPath(ctx, path.Join(rc.JobContainer.GetActPath(), hookPathFileCommand)) return err
} }
// hookCommand mirrors actions/runner, which deliberately does not apply the shell flags it // hookCommand mirrors actions/runner, which deliberately does not apply the shell flags it
+68 -42
View File
@@ -168,49 +168,77 @@ func withStepLogger(ctx context.Context, stepNumber int, stepID, stepName, stage
type entryProcessor func(entry *logrus.Entry) *logrus.Entry type entryProcessor func(entry *logrus.Entry) *logrus.Entry
// secretValueEncoders are the shapes a secret takes on its way into a log: a base64
// payload, a JSON string, or a URL component. An action that serializes a secret leaks
// it in one of these forms, which a mask of the verbatim value alone does not catch, so
// every form is masked as well. This mirrors the value encoders of GitHub's runner.
var secretValueEncoders = []func(string) string{ var secretValueEncoders = []func(string) string{
func(v string) string { return base64.StdEncoding.EncodeToString([]byte(v)) }, func(v string) string { return base64.StdEncoding.EncodeToString([]byte(v)) },
base64ShiftEncoder(1), base64ShiftEncoder(1),
base64ShiftEncoder(2), base64ShiftEncoder(2),
base64InteriorEncoder(0),
base64InteriorEncoder(1),
base64InteriorEncoder(2),
expressionStringEscape,
jsonStringEscape, jsonStringEscape,
jsonStringEscapeNoHTML, jsonStringEscapeNoHTML,
url.QueryEscape, uriDataEscape,
url.QueryEscape, // the form-encoded twin of uriDataEscape, which spells a space "+"
url.PathEscape, url.PathEscape,
xmlDataEscape,
trimDoubleQuotes,
} }
// minShiftedBase64Len is the shortest shifted base64 fragment worth masking. A shorter // base64ShiftEncoder reproduces the 3-byte alignments of `Basic base64("user:token")`, and
// one carries too few bytes of the secret to identify it and would mask unrelated output. // its padded tail only matches a secret that ends the payload.
const minShiftedBase64Len = 8
// base64ShiftEncoder returns the part of a secret's base64 form that survives when the
// secret does not start on a 3-byte boundary of the payload it is embedded in. base64
// encodes three bytes at a time, so `Authorization: Basic base64("user:token")` contains
// the base64 of the token alone only when the prefix length happens to be a multiple of
// three; at the other two alignments the encoding of the whole value differs. Encoding
// the secret behind shift filler bytes reproduces those alignments, which is what the
// Base64StringEscapeShift1/2 encoders of GitHub's runner do.
//
// The leading group (filler mixed with the secret's first bytes) and the trailing group
// (padded here, but continuing into whatever follows the secret) are dropped, leaving the
// group-aligned middle that does appear verbatim in the log.
func base64ShiftEncoder(shift int) func(string) string { func base64ShiftEncoder(shift int) func(string) string {
return func(v string) string {
value := []byte(v)
if len(value) > shift {
value = value[shift:]
}
return base64.StdEncoding.EncodeToString(value)
}
}
const minInteriorBase64Len = 8 // below this a fragment matches unrelated output
// base64InteriorEncoder keeps the aligned middle, so a secret with data after it still matches.
func base64InteriorEncoder(shift int) func(string) string {
return func(v string) string { return func(v string) string {
buf := make([]byte, shift+len(v)) buf := make([]byte, shift+len(v))
copy(buf[shift:], v) copy(buf[shift:], v)
encoded := base64.StdEncoding.EncodeToString(buf) encoded := base64.StdEncoding.EncodeToString(buf)
// Keep only the aligned middle, and only when enough of it is left to be a if len(encoded) < 8+minInteriorBase64Len {
// distinctive pattern rather than a fragment that matches unrelated output.
if len(encoded) < 8+minShiftedBase64Len {
return "" return ""
} }
return encoded[4 : len(encoded)-4] return encoded[4 : len(encoded)-4]
} }
} }
func expressionStringEscape(v string) string {
return strings.ReplaceAll(v, "'", "''")
}
func uriDataEscape(v string) string {
return strings.ReplaceAll(url.QueryEscape(v), "+", "%20")
}
var xmlDataEscaper = strings.NewReplacer(
"&", "&amp;",
"<", "&lt;",
">", "&gt;",
`"`, "&quot;",
"'", "&apos;",
)
func xmlDataEscape(v string) string {
return xmlDataEscaper.Replace(v)
}
func trimDoubleQuotes(v string) string {
if len(v) > 8 && strings.HasPrefix(v, `"`) && strings.HasSuffix(v, `"`) {
return v[1 : len(v)-1]
}
return ""
}
// jsonStringEscape returns v as it appears inside a JSON string, without the quotes, // jsonStringEscape returns v as it appears inside a JSON string, without the quotes,
// which is what `toJSON(secrets)` or any action logging a JSON body produces. Go's encoder // which is what `toJSON(secrets)` or any action logging a JSON body produces. Go's encoder
// escapes <, >, & (as act's own toJSON does); the non-HTML variant below covers the runtimes // escapes <, >, & (as act's own toJSON does); the non-HTML variant below covers the runtimes
@@ -245,36 +273,34 @@ func AppendSecretMaskers(oldnew []string, secrets map[string]string) []string {
return oldnew return oldnew
} }
// AppendSecretMasker registers v and each of its lines, as GitHub does.
func AppendSecretMasker(oldnew []string, v string) []string { func AppendSecretMasker(oldnew []string, v string) []string {
ret := oldnew ret := appendMaskedValue(oldnew, v)
for l := range strings.FieldsFuncSeq(v, func(r rune) bool { return r == '\r' || r == '\n' }) {
for l := range strings.SplitSeq(v, "\n") { ret = appendMaskedValue(ret, strings.TrimSpace(l))
tm := strings.TrimSpace(l)
// formatted JSON secrets could otherwise mask {,[,],} everywhere
if len(tm) > 1 {
ret = append(ret, tm, "***")
// command data reaches the log escaped, so "pass%word" also arrives as "pass%25word"
if strings.ContainsAny(tm, "%\r\n") {
ret = append(ret, EscapeCommandData(tm), "***")
}
}
} }
return ret
}
// The encoded forms are derived from the whole value: a multi-line secret is // appendMaskedValue registers one value and every shape it takes on its way into a log.
// encoded as one string, not line by line. func appendMaskedValue(ret []string, v string) []string {
trimmed := strings.TrimSpace(v) // formatted JSON secrets could otherwise mask {,[,],} everywhere
if len(trimmed) <= 1 { if len(strings.TrimSpace(v)) <= 1 || slices.Contains(ret, v) {
return ret return ret
} }
ret = append(ret, v, "***")
// command data reaches the log escaped, so "pass%word" also arrives as "pass%25word"
if strings.ContainsAny(v, "%\r\n") {
ret = append(ret, EscapeCommandData(v), "***")
}
for _, encode := range secretValueEncoders { for _, encode := range secretValueEncoders {
encoded := encode(trimmed) encoded := encode(v)
// An encoding that leaves the value unchanged is already masked above. // An encoding that leaves the value unchanged is already masked above.
if encoded == trimmed || len(encoded) <= 1 || slices.Contains(ret, encoded) { if encoded == v || len(encoded) <= 1 || slices.Contains(ret, encoded) {
continue continue
} }
ret = append(ret, encoded, "***") ret = append(ret, encoded, "***")
} }
return ret return ret
} }
+33 -29
View File
@@ -64,26 +64,27 @@ func TestValueMasker(t *testing.T) {
// A secret that reaches the log through an encoding — a base64 payload, a JSON body, a // A secret that reaches the log through an encoding — a base64 payload, a JSON body, a
// URL — must be masked as well: masking only the verbatim value leaks it. // URL — must be masked as well: masking only the verbatim value leaks it.
func TestValueMaskerEncodedSecrets(t *testing.T) { func TestValueMaskerEncodedSecrets(t *testing.T) {
secret := `p@ss w"rd/1`
masker := valueMasker(false, AppendSecretMaskers(nil, map[string]string{"TOKEN": secret}))
for _, tc := range []struct { for _, tc := range []struct {
name string name, secret string
line string encoded []string
}{ }{
{"verbatim", "the token is " + secret}, {"common encodings", `p@ss w"rd/1`, []string{
{"base64", "Authorization: Basic " + base64.StdEncoding.EncodeToString([]byte(secret))}, `p@ss w"rd/1`, base64.StdEncoding.EncodeToString([]byte(`p@ss w"rd/1`)),
{"json", `{"token":"` + jsonStringEscape(secret) + `"}`}, jsonStringEscape(`p@ss w"rd/1`), url.PathEscape(`p@ss w"rd/1`),
{"query escaped", "https://example.com/?token=" + url.QueryEscape(secret)}, }},
{"path escaped", "https://example.com/" + url.PathEscape(secret) + "/x"}, {"XML expression and quotes", `"a'b&c<d>"`, []string{
`&quot;a&apos;b&amp;c&lt;d&gt;&quot;`, `"a''b&c<d>"`, `a'b&c<d>`,
}},
{"URI spaces", "a b", []string{"a%20b", "a+b"}},
} { } {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
entry := masker(&logrus.Entry{Context: t.Context(), Message: tc.line}) masker := valueMasker(false, AppendSecretMaskers(nil, map[string]string{"TOKEN": tc.secret}))
entry := masker(&logrus.Entry{Context: t.Context(), Message: strings.Join(tc.encoded, " ")})
assert.Contains(t, entry.Message, "***") assert.Contains(t, entry.Message, "***")
assert.NotContains(t, entry.Message, secret) for _, disallowed := range tc.encoded {
assert.NotContains(t, entry.Message, base64.StdEncoding.EncodeToString([]byte(secret))) assert.NotContains(t, entry.Message, disallowed)
assert.NotContains(t, entry.Message, url.QueryEscape(secret)) }
}) })
} }
} }
@@ -124,15 +125,21 @@ func TestValueMaskerHidesExtraMasks(t *testing.T) {
// ::add-mask:: values go through the same masker, so they get the same treatment. // ::add-mask:: values go through the same masker, so they get the same treatment.
func TestValueMaskerEncodedMasks(t *testing.T) { func TestValueMaskerEncodedMasks(t *testing.T) {
masks := []string{"s3cr3t value"} masks := []string{"s3cr3t value", "first\rsecond", " s3cr3t "}
masker := valueMasker(false, AppendSecretMaskers(nil, nil)) masker := valueMasker(false, AppendSecretMaskers(nil, nil))
entry := masker(&logrus.Entry{ for _, tc := range []struct {
Context: WithMasks(t.Context(), &masks), line, want string
Message: "encoded: " + base64.StdEncoding.EncodeToString([]byte("s3cr3t value")), }{
}) {"encoded: " + base64.StdEncoding.EncodeToString([]byte("s3cr3t value")), "encoded: ***"},
{"first and second", "*** and ***"},
assert.Equal(t, "encoded: ***", entry.Message) {"encoded: " + base64.StdEncoding.EncodeToString([]byte(" s3cr3t ")), "encoded: ***"},
{"encoded: " + base64.StdEncoding.EncodeToString([]byte("s3cr3t")), "encoded: ***"},
{"encoded: " + base64.StdEncoding.EncodeToString([]byte("first")), "encoded: ***"},
} {
entry := masker(&logrus.Entry{Context: WithMasks(t.Context(), &masks), Message: tc.line})
assert.Equal(t, tc.want, entry.Message, tc.line)
}
} }
// A token in a Basic auth header is base64'd together with the user name, so the token's // A token in a Basic auth header is base64'd together with the user name, so the token's
@@ -140,7 +147,7 @@ func TestValueMaskerEncodedMasks(t *testing.T) {
// alignments must be masked as well, or `Authorization: Basic base64("user:token")` leaks // alignments must be masked as well, or `Authorization: Basic base64("user:token")` leaks
// the token to anyone who can decode the log. // the token to anyone who can decode the log.
func TestValueMaskerBase64Alignments(t *testing.T) { func TestValueMaskerBase64Alignments(t *testing.T) {
secret := "s3cr3t-token-value" secret := "s3cr3t"
masker := valueMasker(false, AppendSecretMaskers(nil, map[string]string{"TOKEN": secret})) masker := valueMasker(false, AppendSecretMaskers(nil, map[string]string{"TOKEN": secret}))
// One prefix per alignment: len%3 of 0, 1 and 2. // One prefix per alignment: len%3 of 0, 1 and 2.
@@ -150,8 +157,6 @@ func TestValueMaskerBase64Alignments(t *testing.T) {
entry := masker(&logrus.Entry{Context: t.Context(), Message: "Authorization: Basic " + encoded}) entry := masker(&logrus.Entry{Context: t.Context(), Message: "Authorization: Basic " + encoded})
assert.Contains(t, entry.Message, "***") assert.Contains(t, entry.Message, "***")
// The aligned middle of the secret must be gone, so the payload can no longer be
// decoded back into the token.
assert.NotEqual(t, "Authorization: Basic "+encoded, entry.Message) assert.NotEqual(t, "Authorization: Basic "+encoded, entry.Message)
decodable := strings.TrimPrefix(entry.Message, "Authorization: Basic ") decodable := strings.TrimPrefix(entry.Message, "Authorization: Basic ")
decoded, err := base64.StdEncoding.DecodeString(decodable) decoded, err := base64.StdEncoding.DecodeString(decodable)
@@ -190,15 +195,14 @@ func TestAppendSecretMaskerSkipsUselessEncodings(t *testing.T) {
// JSON, query and path escaping all leave it unchanged. // JSON, query and path escaping all leave it unchanged.
pairs := AppendSecretMasker(nil, "plaintoken") pairs := AppendSecretMasker(nil, "plaintoken")
assert.Equal(t, []string{ assert.Equal(t, []string{
"plaintoken", "***", "plaintoken", "***", "cGxhaW50b2tlbg==", "***", "bGFpbnRva2Vu", "***", "YWludG9rZW4=", "***",
base64.StdEncoding.EncodeToString([]byte("plaintoken")), "***", "aW50b2tl", "***", "YWludG9r", "***", "bGFpbnRv", "***",
// The two shifted alignments, each without its leading and trailing group.
"YWludG9r", "***",
"bGFpbnRv", "***",
}, pairs) }, pairs)
// Too short to mask. // Too short to mask.
assert.Empty(t, AppendSecretMasker(nil, "x")) assert.Empty(t, AppendSecretMasker(nil, "x"))
assert.Empty(t, AppendSecretMasker(nil, " \t"))
assert.NotContains(t, AppendSecretMasker(nil, `"123456"`), "123456")
} }
func TestJobLogFormatterDecodesCommandData(t *testing.T) { func TestJobLogFormatterDecodesCommandData(t *testing.T) {
+1 -1
View File
@@ -244,7 +244,7 @@ func TestPatchActionsAtTheContainerCopy(t *testing.T) {
require.NoError(t, os.WriteFile(script, []byte(gateTSC), 0o600)) require.NoError(t, os.WriteFile(script, []byte(gateTSC), 0o600))
var copied string var copied string
cm.On("CopyDir", mock.Anything, mock.Anything, mock.Anything).Return(func(context.Context) error { cm.On("CopyDir", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(func(context.Context) error {
body, err := os.ReadFile(script) body, err := os.ReadFile(script)
require.NoError(t, err) require.NoError(t, err)
copied = string(body) copied = string(body)
+5 -2
View File
@@ -95,9 +95,12 @@ func newRemoteReusableWorkflowExecutor(rc *RunContext) common.Executor {
// remoteReusableWorkflow.URL = rc.getGithubContext(ctx).ServerURL // remoteReusableWorkflow.URL = rc.getGithubContext(ctx).ServerURL
func cloneRemoteReusableWorkflow(rc *RunContext, cloneURL, ref, targetDirectory, token string) common.Executor { func cloneRemoteReusableWorkflow(rc *RunContext, cloneURL, ref, targetDirectory, token string) common.Executor {
return func(ctx context.Context) error { return func(ctx context.Context) error {
cloneURL = rc.NewExpressionEvaluator(ctx).Interpolate(ctx, cloneURL) interpolatedURL, err := rc.NewExpressionEvaluator(ctx).Interpolate(ctx, cloneURL)
if err != nil {
return fmt.Errorf("unable to interpolate the workflow clone URL: %w", err)
}
return git.NewGitCloneExecutor(git.NewGitCloneExecutorInput{ return git.NewGitCloneExecutor(git.NewGitCloneExecutorInput{
URL: cloneURL, URL: interpolatedURL,
Ref: ref, Ref: ref,
Dir: targetDirectory, Dir: targetDirectory,
Token: token, Token: token,
+183 -66
View File
@@ -37,6 +37,8 @@ import (
"github.com/moby/moby/api/types/mount" "github.com/moby/moby/api/types/mount"
"github.com/opencontainers/selinux/go-selinux" "github.com/opencontainers/selinux/go-selinux"
"golang.org/x/sync/errgroup" "golang.org/x/sync/errgroup"
"golang.org/x/text/encoding/unicode"
"golang.org/x/text/transform"
) )
// RunContext contains info about current job // RunContext contains info about current job
@@ -65,7 +67,11 @@ type RunContext struct {
actionInputs map[string]any // inputs of the composite action this runs, nil for a job actionInputs map[string]any // inputs of the composite action this runs, nil for a job
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)
workflowCallInputs map[string]any // the caller's with:, resolved once by resolveWorkflowCall
workflowCallSecrets map[string]string // the caller's secrets:, resolved once by resolveWorkflowCall
jobRunDefaults model.RunDefaults // defaults.run, resolved once at job setup as GitHub does
platformImage string // container.image or the runs-on pick, resolved once by isEnabled
// summaryFileInitialized tracks which per-step summary files (workflow/step-summary-N.md) // 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 // 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 // via JobContainer.Copy at the start of every phase, which truncates them — fine for
@@ -88,7 +94,9 @@ type RunContext struct {
jobFailed bool jobFailed bool
// empty for a host-mode job, which starts no container // empty for a host-mode job, which starts no container
jobContainerID string jobContainerID string
hasBash *bool // memoized implicit-shell probe, only set on the top-level RunContext
jobNetworkName string jobNetworkName string
dockerProxy *container.DockerProxy
// stepEnv is a copy of the running step's environment, so that workflow commands parsed out // stepEnv is a copy of the running step's environment, so that workflow commands parsed out
// of the container's output can be judged against it. Written by runStepExecutor and read on // of the container's output can be judged against it. Written by runStepExecutor and read on
// the log-writer goroutine, hence unsecureCommandMu, which also guards unsecureCommandErr. // the log-writer goroutine, hence unsecureCommandMu, which also guards unsecureCommandErr.
@@ -243,11 +251,41 @@ func (rc *RunContext) validVolumes() []string {
if rc.Config.SharedToolCache { if rc.Config.SharedToolCache {
volumes = append(volumes, sharedToolCacheVolume) volumes = append(volumes, sharedToolCacheVolume)
} }
if rc.Config.BindWorkdir {
volumes = append(volumes, rc.Config.Workdir)
}
if rc.dockerProxy != nil {
volumes = append(volumes, rc.dockerProxy.Socket)
}
// TODO: add a new configuration to control whether the docker daemon can be mounted // TODO: add a new configuration to control whether the docker daemon can be mounted
return append(volumes, name, name+"-env", return append(volumes, name, name+"-env",
getDockerDaemonSocketMountPath(rc.containerDaemonSocket())) getDockerDaemonSocketMountPath(rc.containerDaemonSocket()))
} }
func (rc *RunContext) jobDockerSocket() string {
if rc.dockerProxy != nil {
return rc.dockerProxy.Socket
}
return getDockerDaemonSocketMountPath(rc.containerDaemonSocket())
}
func (rc *RunContext) startDockerProxy(ctx context.Context) {
daemonSocket := rc.containerDaemonSocket()
if daemonSocket == "-" || strings.HasPrefix(strings.ToLower(daemonSocket), "npipe://") {
return
}
dir := container.DockerProxyDir(ctx)
if dir == "" {
return
}
proxy, err := container.StartDockerProxy(getDockerDaemonSocketMountPath(daemonSocket), dir, rc.jobContainerName())
if err != nil {
common.Logger(ctx).Warnf("docker proxy not started, the job gets the daemon socket directly: %v", err)
return
}
rc.dockerProxy = proxy
}
// toolCache returns the tool cache path the job sees, relocatable through RUNNER_TOOL_CACHE. // toolCache returns the tool cache path the job sees, relocatable through RUNNER_TOOL_CACHE.
func (rc *RunContext) toolCache(fallback string) string { func (rc *RunContext) toolCache(fallback string) string {
if path := rc.GetEnv()["RUNNER_TOOL_CACHE"]; path != "" { if path := rc.GetEnv()["RUNNER_TOOL_CACHE"]; path != "" {
@@ -296,7 +334,7 @@ func splitVolumes(specs []string) ([]string, map[string]string, map[string]bool)
} }
// Returns the binds and mounts for the container, resolving paths as appopriate // Returns the binds and mounts for the container, resolving paths as appopriate
func (rc *RunContext) GetBindsAndMounts() ([]string, map[string]string) { func (rc *RunContext) GetBindsAndMounts() ([]string, map[string]string, error) {
name := rc.jobContainerName() name := rc.jobContainerName()
ext := container.LinuxContainerEnvironmentExtensions{} ext := container.LinuxContainerEnvironmentExtensions{}
@@ -305,7 +343,10 @@ func (rc *RunContext) GetBindsAndMounts() ([]string, map[string]string) {
if container := job.Container(); container != nil { if container := job.Container(); container != nil {
for _, v := range container.Volumes { for _, v := range container.Volumes {
if rc.ExprEval != nil { if rc.ExprEval != nil {
v = rc.ExprEval.Interpolate(context.Background(), v) var err error
if v, err = rc.ExprEval.Interpolate(context.Background(), v); err != nil {
return nil, nil, fmt.Errorf("unable to interpolate container.volumes: %w", err)
}
} }
volumes = append(volumes, v) volumes = append(volumes, v)
} }
@@ -314,8 +355,8 @@ func (rc *RunContext) GetBindsAndMounts() ([]string, map[string]string) {
// the runner's own mounts below yield to the targets the job claims // the runner's own mounts below yield to the targets the job claims
binds, mounts, claimed := splitVolumes(volumes) binds, mounts, claimed := splitVolumes(volumes)
if daemonSocket := rc.containerDaemonSocket(); daemonSocket != "-" && !claimed["/var/run/docker.sock"] { if rc.containerDaemonSocket() != "-" && !claimed["/var/run/docker.sock"] {
binds = append(binds, getDockerDaemonSocketMountPath(daemonSocket)+":/var/run/docker.sock") binds = append(binds, rc.jobDockerSocket()+":/var/run/docker.sock")
} }
if rc.Config.SharedToolCache { if rc.Config.SharedToolCache {
if toolCache := rc.toolCache(container.DefaultToolCache); !claimed[toolCache] { if toolCache := rc.toolCache(container.DefaultToolCache); !claimed[toolCache] {
@@ -339,7 +380,7 @@ func (rc *RunContext) GetBindsAndMounts() ([]string, map[string]string) {
} }
} }
return binds, mounts return binds, mounts, nil
} }
func (rc *RunContext) startHostEnvironment() common.Executor { func (rc *RunContext) startHostEnvironment() common.Executor {
@@ -430,7 +471,7 @@ var newContainer = container.NewContainer
func (rc *RunContext) startJobContainer() common.Executor { func (rc *RunContext) startJobContainer() common.Executor {
return func(ctx context.Context) error { return func(ctx context.Context) error {
logger := common.Logger(ctx) logger := common.Logger(ctx)
image := rc.platformImage(ctx) image := rc.platformImage
logWriter := rc.commandLogWriter(ctx) logWriter := rc.commandLogWriter(ctx)
username, password, err := rc.handleCredentials(ctx) username, password, err := rc.handleCredentials(ctx)
@@ -442,6 +483,7 @@ func (rc *RunContext) startJobContainer() common.Executor {
// For gitea, to support --volumes-from <container_name_or_id> in options. // For gitea, to support --volumes-from <container_name_or_id> in options.
// We need to set the container name to the environment variable. // We need to set the container name to the environment variable.
rc.Env["JOB_CONTAINER_NAME"] = name rc.Env["JOB_CONTAINER_NAME"] = name
rc.startDockerProxy(ctx)
envList := make([]string, 0) envList := make([]string, 0)
@@ -449,7 +491,10 @@ func (rc *RunContext) startJobContainer() common.Executor {
envList = append(envList, fmt.Sprintf("%s=%s", "LANG", "C.UTF-8")) // Use same locale as GitHub Actions envList = append(envList, fmt.Sprintf("%s=%s", "LANG", "C.UTF-8")) // Use same locale as GitHub Actions
ext := container.LinuxContainerEnvironmentExtensions{} ext := container.LinuxContainerEnvironmentExtensions{}
binds, mounts := rc.GetBindsAndMounts() binds, mounts, err := rc.GetBindsAndMounts()
if err != nil {
return err
}
// specify the network to which the container will connect when `docker create` stage. (like execute command line: docker create --network <networkName> <image>) // specify the network to which the container will connect when `docker create` stage. (like execute command line: docker create --network <networkName> <image>)
// if using service containers, will create a new network for the containers. // if using service containers, will create a new network for the containers.
@@ -460,7 +505,10 @@ func (rc *RunContext) startJobContainer() common.Executor {
for serviceID, spec := range rc.Run.Job().Services { for serviceID, spec := range rc.Run.Job().Services {
// GitHub compatibility: skip services whose image evaluates to an // GitHub compatibility: skip services whose image evaluates to an
// empty string, enabling conditional services via expressions // empty string, enabling conditional services via expressions
serviceImage := rc.ExprEval.Interpolate(ctx, spec.Image) serviceImage, err := rc.ExprEval.Interpolate(ctx, spec.Image)
if err != nil {
return fmt.Errorf("unable to interpolate service %s image: %w", serviceID, err)
}
if serviceImage == "" { if serviceImage == "" {
logger.Infof("The service '%s' will not be started because the container definition has an empty image.", serviceID) logger.Infof("The service '%s' will not be started because the container definition has an empty image.", serviceID)
continue continue
@@ -470,16 +518,20 @@ func (rc *RunContext) startJobContainer() common.Executor {
// a service reaches the internet the way the job does; its own env still wins // a service reaches the internet the way the job does; its own env still wins
maps0.Copy(interpolatedEnvs, rc.Config.ProxyEnv) maps0.Copy(interpolatedEnvs, rc.Config.ProxyEnv)
for k, v := range spec.Env { for k, v := range spec.Env {
interpolatedEnvs[k] = rc.ExprEval.Interpolate(ctx, v) if interpolatedEnvs[k], err = rc.ExprEval.Interpolate(ctx, v); err != nil {
return fmt.Errorf("unable to interpolate service %s env %s: %w", serviceID, k, err)
}
} }
envs := make([]string, 0, len(interpolatedEnvs)) envs := make([]string, 0, len(interpolatedEnvs))
for k, v := range interpolatedEnvs { for k, v := range interpolatedEnvs {
envs = append(envs, fmt.Sprintf("%s=%s", k, v)) envs = append(envs, fmt.Sprintf("%s=%s", k, v))
} }
// interpolate cmd // interpolate cmd
interpolatedCmd := make([]string, 0, len(spec.Cmd)) interpolatedCmd := make([]string, len(spec.Cmd))
for _, v := range spec.Cmd { for i, v := range spec.Cmd {
interpolatedCmd = append(interpolatedCmd, rc.ExprEval.Interpolate(ctx, v)) if interpolatedCmd[i], err = rc.ExprEval.Interpolate(ctx, v); err != nil {
return fmt.Errorf("unable to interpolate service %s command: %w", serviceID, err)
}
} }
// keep these local: reusing username/password would overwrite the // keep these local: reusing username/password would overwrite the
// credentials the job container is pulled with further down // credentials the job container is pulled with further down
@@ -488,25 +540,32 @@ func (rc *RunContext) startJobContainer() common.Executor {
return fmt.Errorf("failed to handle service %s credentials: %w", serviceID, err) return fmt.Errorf("failed to handle service %s credentials: %w", serviceID, err)
} }
interpolatedVolumes := make([]string, 0, len(spec.Volumes)) interpolatedVolumes := make([]string, len(spec.Volumes))
for _, volume := range spec.Volumes { for i, volume := range spec.Volumes {
interpolatedVolumes = append(interpolatedVolumes, rc.ExprEval.Interpolate(ctx, volume)) if interpolatedVolumes[i], err = rc.ExprEval.Interpolate(ctx, volume); err != nil {
return fmt.Errorf("unable to interpolate service %s volumes: %w", serviceID, err)
}
} }
serviceBinds, serviceMounts, _ := splitVolumes(interpolatedVolumes) serviceBinds, serviceMounts, _ := splitVolumes(interpolatedVolumes)
interpolatedPorts := make([]string, 0, len(spec.Ports)) interpolatedPorts := make([]string, len(spec.Ports))
for _, port := range spec.Ports { for i, port := range spec.Ports {
interpolatedPorts = append(interpolatedPorts, rc.ExprEval.Interpolate(ctx, port)) if interpolatedPorts[i], err = rc.ExprEval.Interpolate(ctx, port); err != nil {
return fmt.Errorf("unable to interpolate service %s ports: %w", serviceID, err)
}
} }
exposedPorts, portBindings, err := nat.ParsePortSpecs(interpolatedPorts) exposedPorts, portBindings, err := nat.ParsePortSpecs(interpolatedPorts)
if err != nil { if err != nil {
return fmt.Errorf("failed to parse service %s ports: %w", serviceID, err) return fmt.Errorf("failed to parse service %s ports: %w", serviceID, err)
} }
serviceOptions, err := rc.ExprEval.Interpolate(ctx, spec.Options)
if err != nil {
return fmt.Errorf("unable to interpolate service %s options: %w", serviceID, err)
}
serviceContainerName := createContainerName(rc.jobContainerName(), serviceID) serviceContainerName := createContainerName(rc.jobContainerName(), serviceID)
c := newContainer(&container.NewContainerInput{ c := newContainer(&container.NewContainerInput{
Name: serviceContainerName, Name: serviceContainerName,
WorkingDir: ext.ToContainerPath(rc.Config.Workdir),
Image: serviceImage, Image: serviceImage,
Username: serviceUsername, Username: serviceUsername,
Password: servicePassword, Password: servicePassword,
@@ -520,7 +579,7 @@ func (rc *RunContext) startJobContainer() common.Executor {
UsernsMode: rc.Config.UsernsMode, UsernsMode: rc.Config.UsernsMode,
Platform: rc.Config.ContainerArchitecture, Platform: rc.Config.ContainerArchitecture,
AutoRemove: false, // so a dead service's log survives, cleanupJobResources removes it AutoRemove: false, // so a dead service's log survives, cleanupJobResources removes it
WorkflowOptions: rc.ExprEval.Interpolate(ctx, spec.Options), WorkflowOptions: serviceOptions,
NetworkMode: networkName, NetworkMode: networkName,
NetworkAliases: []string{serviceID}, NetworkAliases: []string{serviceID},
ExposedPorts: exposedPorts, ExposedPorts: exposedPorts,
@@ -536,6 +595,10 @@ func (rc *RunContext) startJobContainer() common.Executor {
// For Gitea, `jobContainerNetwork` should be the same as `networkName` // For Gitea, `jobContainerNetwork` should be the same as `networkName`
jobContainerNetwork := networkName jobContainerNetwork := networkName
workflowOptions, err := rc.workflowOptions(ctx)
if err != nil {
return err
}
rc.JobContainer = newContainer(&container.NewContainerInput{ rc.JobContainer = newContainer(&container.NewContainerInput{
Cmd: nil, Cmd: nil,
Entrypoint: []string{"/bin/sleep", fmt.Sprint(rc.Config.ContainerMaxLifetime.Round(time.Second).Seconds())}, Entrypoint: []string{"/bin/sleep", fmt.Sprint(rc.Config.ContainerMaxLifetime.Round(time.Second).Seconds())},
@@ -555,7 +618,7 @@ func (rc *RunContext) startJobContainer() common.Executor {
UsernsMode: rc.Config.UsernsMode, UsernsMode: rc.Config.UsernsMode,
Platform: rc.Config.ContainerArchitecture, Platform: rc.Config.ContainerArchitecture,
RunnerOptions: rc.Config.ContainerOptions, RunnerOptions: rc.Config.ContainerOptions,
WorkflowOptions: rc.workflowOptions(ctx), WorkflowOptions: workflowOptions,
AutoRemove: true, AutoRemove: true,
ValidVolumes: rc.validVolumes(), ValidVolumes: rc.validVolumes(),
AllocatePTY: rc.Config.AllocatePTY, AllocatePTY: rc.Config.AllocatePTY,
@@ -617,6 +680,12 @@ func (rc *RunContext) cleanupJobResources(networkName string, createAndDeleteNet
logger.Errorf("Error while cleaning services: %v", err) logger.Errorf("Error while cleaning services: %v", err)
} }
} }
if rc.dockerProxy != nil {
if err := rc.dockerProxy.Close(ctx); err != nil {
logger.Errorf("Error while removing what the job created: %v", err)
}
rc.dockerProxy = nil
}
if removeJobContainer { if removeJobContainer {
// after the containers using them, services can hold these via `--volumes-from` // after the containers using them, services can hold these via `--volumes-from`
name := rc.jobContainerName() name := rc.jobContainerName()
@@ -679,13 +748,18 @@ func (rc *RunContext) UpdateExtraPath(ctx context.Context, githubEnvPath string)
if err != nil && err != io.EOF { if err != nil && err != io.EOF {
return err return err
} }
s := bufio.NewScanner(reader) decoded := transform.NewReader(reader, unicode.BOMOverride(unicode.UTF8.NewDecoder()))
s := bufio.NewScanner(decoded)
s.Buffer(make([]byte, 0, 64*1024), 16*1024*1024)
for s.Scan() { for s.Scan() {
line := s.Text() line := s.Text()
if len(line) > 0 { if len(line) > 0 {
rc.addPath(ctx, line) rc.addPath(ctx, line)
} }
} }
if err := s.Err(); err != nil {
return fmt.Errorf("reading path file: %w", err)
}
return nil return nil
} }
@@ -875,6 +949,9 @@ func (rc *RunContext) captureJobContainerInfo() common.Executor {
return nil return nil
} }
rc.jobContainerID = info.ID rc.jobContainerID = info.ID
if source := info.Mounts[rc.githubWorkspace()]; source != "" {
rc.Env["GITEA_DOCKER_WORKSPACE"] = source
}
return nil return nil
} }
} }
@@ -917,18 +994,31 @@ func (rc *RunContext) interpolateOutputs() common.Executor {
// pristine snapshot (outputTemplate) and write under the lock, so each combo overwrites // pristine snapshot (outputTemplate) and write under the lock, so each combo overwrites
// with its own resolved values (last wins, as on GitHub) instead of the first combo's // with its own resolved values (last wins, as on GitHub) instead of the first combo's
// resolved values freezing the shared template against later combos. // resolved values freezing the shared template against later combos.
defer lockJob(job)() // Resolved up front so one failure publishes none of them, as GitHub does.
outputs := make(map[string]string, len(rc.outputTemplate))
var err error
for k, v := range rc.outputTemplate { for k, v := range rc.outputTemplate {
job.Outputs[k] = ee.Interpolate(ctx, v) if outputs[k], err = ee.Interpolate(ctx, v); err != nil {
err = fmt.Errorf("failed to evaluate job output %q: %w", k, err)
break
}
} }
return nil defer lockJob(job)()
for k := range rc.outputTemplate {
if err != nil {
job.Outputs[k] = ""
continue
}
job.Outputs[k] = outputs[k]
}
return err
} }
} }
func (rc *RunContext) startContainer() common.Executor { func (rc *RunContext) startContainer() common.Executor {
return func(ctx context.Context) error { return func(ctx context.Context) error {
var err error var err error
if rc.IsHostEnv(ctx) { if rc.IsHostEnv() {
err = rc.startHostEnvironment()(ctx) err = rc.startHostEnvironment()(ctx)
} else { } else {
err = rc.startJobContainer()(ctx) err = rc.startJobContainer()(ctx)
@@ -958,10 +1048,8 @@ func (rc *RunContext) cleanupFailedStart(ctx context.Context) {
} }
} }
func (rc *RunContext) IsHostEnv(ctx context.Context) bool { func (rc *RunContext) IsHostEnv() bool {
platform := rc.runsOnImage(ctx) return strings.EqualFold(rc.platformImage, "-self-hosted")
image := rc.containerImage(ctx)
return image == "" && strings.EqualFold(platform, "-self-hosted")
} }
func (rc *RunContext) stopContainer() common.Executor { func (rc *RunContext) stopContainer() common.Executor {
@@ -1046,32 +1134,35 @@ func (rc *RunContext) Executor() (common.Executor, error) {
}, nil }, nil
} }
func (rc *RunContext) containerImage(ctx context.Context) string { func (rc *RunContext) containerImage(ctx context.Context) (string, error) {
job := rc.Run.Job() c := rc.Run.Job().Container()
if c == nil {
c := job.Container() return "", nil
if c != nil {
return rc.ExprEval.Interpolate(ctx, c.Image)
} }
image, err := rc.ExprEval.Interpolate(ctx, c.Image)
return "" if err != nil {
return "", fmt.Errorf("unable to interpolate container.image: %w", err)
}
return image, nil
} }
func (rc *RunContext) runsOnImage(ctx context.Context) string { func (rc *RunContext) runsOnImage(ctx context.Context) (string, error) {
if rc.Run.Job().RunsOn() == nil { if rc.Run.Job().RunsOn() == nil {
common.Logger(ctx).Errorf("'runs-on' key not defined in %s", rc.String()) common.Logger(ctx).Errorf("'runs-on' key not defined in %s", rc.String())
} }
job := rc.Run.Job() runsOn := rc.Run.Job().RunsOn()
runsOn := job.RunsOn()
for i, v := range runsOn { for i, v := range runsOn {
runsOn[i] = rc.ExprEval.Interpolate(ctx, v) var err error
if runsOn[i], err = rc.ExprEval.Interpolate(ctx, v); err != nil {
return "", fmt.Errorf("unable to interpolate runs-on: %w", err)
}
} }
if rc.Config.PlatformPicker != nil { if rc.Config.PlatformPicker != nil {
return rc.Config.PlatformPicker(runsOn) return rc.Config.PlatformPicker(runsOn), nil
} }
return "" return "", nil
} }
func (rc *RunContext) runsOnPlatformNames(ctx context.Context) []string { func (rc *RunContext) runsOnPlatformNames(ctx context.Context) []string {
@@ -1092,21 +1183,26 @@ func (rc *RunContext) runsOnPlatformNames(ctx context.Context) []string {
return model.RunsOnFromNode(rawRunsOn) return model.RunsOnFromNode(rawRunsOn)
} }
func (rc *RunContext) platformImage(ctx context.Context) string { // resolvePlatformImage evaluates the job's image once, so every consumer sees the image the job started with.
if containerImage := rc.containerImage(ctx); containerImage != "" { func (rc *RunContext) resolvePlatformImage(ctx context.Context) error {
return containerImage image, err := rc.containerImage(ctx)
if err == nil && image == "" {
image, err = rc.runsOnImage(ctx)
} }
rc.platformImage = image
return rc.runsOnImage(ctx) return err
} }
func (rc *RunContext) workflowOptions(ctx context.Context) string { func (rc *RunContext) workflowOptions(ctx context.Context) (string, error) {
c := rc.Run.Job().Container() c := rc.Run.Job().Container()
if c == nil { if c == nil {
return "" return "", nil
} }
options, err := rc.ExprEval.Interpolate(ctx, c.Options)
return rc.ExprEval.Interpolate(ctx, c.Options) if err != nil {
return "", fmt.Errorf("unable to interpolate container.options: %w", err)
}
return options, nil
} }
func (rc *RunContext) isEnabled(ctx context.Context) (bool, error) { func (rc *RunContext) isEnabled(ctx context.Context) (bool, error) {
@@ -1136,8 +1232,10 @@ func (rc *RunContext) isEnabled(ctx context.Context) (bool, error) {
return true, nil return true, nil
} }
img := rc.platformImage(ctx) if err := rc.resolvePlatformImage(ctx); err != nil {
if img == "" { return false, err
}
if rc.platformImage == "" {
for _, platformName := range rc.runsOnPlatformNames(ctx) { for _, platformName := range rc.runsOnPlatformNames(ctx) {
l.Infof("Skipping unsupported platform -- Try running with `-P %+v=...`", platformName) l.Infof("Skipping unsupported platform -- Try running with `-P %+v=...`", platformName)
} }
@@ -1245,12 +1343,23 @@ func (rc *RunContext) getRunnerContext(ctx context.Context) map[string]any {
} }
runnerContext["name"] = rc.Config.RunnerName runnerContext["name"] = rc.Config.RunnerName
runnerContext["environment"] = "self-hosted" runnerContext["environment"] = "self-hosted"
runnerContext["workspace"] = parentDir(rc.githubWorkspace())
if rc.Config.RunnerDebug() { if rc.Config.RunnerDebug() {
runnerContext["debug"] = "1" runnerContext["debug"] = "1"
} }
return runnerContext return runnerContext
} }
func (rc *RunContext) githubWorkspace() string {
if rc.JobContainer != nil {
return rc.JobContainer.ToContainerPath(rc.Config.Workdir)
}
if workspace := rc.Config.Env["GITHUB_WORKSPACE"]; workspace != "" {
return workspace
}
return rc.Config.Workdir
}
func (rc *RunContext) getGithubContext(ctx context.Context) *model.GithubContext { func (rc *RunContext) getGithubContext(ctx context.Context) *model.GithubContext {
logger := common.Logger(ctx) logger := common.Logger(ctx)
ghc := &model.GithubContext{ ghc := &model.GithubContext{
@@ -1277,11 +1386,10 @@ func (rc *RunContext) getGithubContext(ctx context.Context) *model.GithubContext
RefType: rc.Config.Env["GITHUB_REF_TYPE"], RefType: rc.Config.Env["GITHUB_REF_TYPE"],
BaseRef: rc.Config.Env["GITHUB_BASE_REF"], BaseRef: rc.Config.Env["GITHUB_BASE_REF"],
HeadRef: rc.Config.Env["GITHUB_HEAD_REF"], HeadRef: rc.Config.Env["GITHUB_HEAD_REF"],
Workspace: rc.Config.Env["GITHUB_WORKSPACE"], Workspace: rc.githubWorkspace(),
} }
if rc.JobContainer != nil { if rc.JobContainer != nil {
ghc.EventPath = rc.JobContainer.GetActPath() + "/workflow/event.json" ghc.EventPath = rc.JobContainer.GetActPath() + "/workflow/event.json"
ghc.Workspace = rc.JobContainer.ToContainerPath(rc.Config.Workdir)
} }
if ghc.RunID == "" { if ghc.RunID == "" {
@@ -1514,7 +1622,7 @@ func (rc *RunContext) imageOS(ctx context.Context) string {
// log that runs-on is missing. // log that runs-on is missing.
return "" return ""
} }
if imageOS := imageOSFromImage(rc.platformImage(ctx)); imageOS != "" { if imageOS := imageOSFromImage(rc.platformImage); imageOS != "" {
return imageOS return imageOS
} }
@@ -1576,14 +1684,23 @@ func (rc *RunContext) interpolateCredentials(ctx context.Context, credentials ma
} }
ee := rc.NewExpressionEvaluator(ctx) ee := rc.NewExpressionEvaluator(ctx)
username := ee.Interpolate(ctx, credentials["username"]) interpolate := func(key string) (string, error) {
if username == "" { value, err := ee.Interpolate(ctx, credentials[key])
return "", "", errors.New("failed to interpolate " + prefix + "credentials.username") if err != nil {
return "", fmt.Errorf("failed to interpolate %scredentials.%s: %w", prefix, key, err)
}
if value == "" {
return "", fmt.Errorf("failed to interpolate %scredentials.%s", prefix, key)
}
return value, nil
} }
password := ee.Interpolate(ctx, credentials["password"]) username, err := interpolate("username")
if password == "" { if err != nil {
return "", "", errors.New("failed to interpolate " + prefix + "credentials.password") return "", "", err
}
password, err := interpolate("password")
if err != nil {
return "", "", err
} }
return username, password, nil return username, password, nil
} }
+90 -8
View File
@@ -9,6 +9,7 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"io"
"os" "os"
"runtime" "runtime"
"strings" "strings"
@@ -266,6 +267,7 @@ func startJobContainerInputs(t *testing.T, workflowYAML string, cfg *Config) []*
}, },
} }
rc.ExprEval = rc.NewExpressionEvaluator(t.Context()) rc.ExprEval = rc.NewExpressionEvaluator(t.Context())
require.NoError(t, rc.resolvePlatformImage(t.Context()))
// the inputs are built before the missing daemon fails the first call // the inputs are built before the missing daemon fails the first call
t.Setenv("DOCKER_HOST", "unix:///nonexistent.sock") t.Setenv("DOCKER_HOST", "unix:///nonexistent.sock")
@@ -326,6 +328,7 @@ jobs:
require.Equal(t, []string{"data"}, redis.ValidVolumes) require.Equal(t, []string{"data"}, redis.ValidVolumes)
require.Equal(t, map[string]string{"data": "/data"}, redis.Mounts) require.Equal(t, map[string]string{"data": "/data"}, redis.Mounts)
require.Empty(t, redis.Binds) // the docker socket is the job container's alone require.Empty(t, redis.Binds) // the docker socket is the job container's alone
require.Empty(t, redis.WorkingDir)
} }
// Only the workflow's options may be stripped later, so the two sources have to reach the // Only the workflow's options may be stripped later, so the two sources have to reach the
@@ -449,7 +452,8 @@ func TestRunContext_GetBindsAndMounts(t *testing.T) {
config := testcase.rc.Config config := testcase.rc.Config
config.Workdir = testcase.name config.Workdir = testcase.name
config.BindWorkdir = bindWorkDir config.BindWorkdir = bindWorkDir
gotbind, gotmount := rctemplate.GetBindsAndMounts() gotbind, gotmount, err := rctemplate.GetBindsAndMounts()
require.NoError(t, err)
// Name binds/mounts are either/or // Name binds/mounts are either/or
if config.BindWorkdir { if config.BindWorkdir {
@@ -508,7 +512,8 @@ func TestRunContext_GetBindsAndMounts(t *testing.T) {
rc.Run.Workflow.Jobs = map[string]*model.Job{"job1": job} rc.Run.Workflow.Jobs = map[string]*model.Job{"job1": job}
rc.ExprEval = rc.NewExpressionEvaluator(context.Background()) rc.ExprEval = rc.NewExpressionEvaluator(context.Background())
gotbind, gotmount := rc.GetBindsAndMounts() gotbind, gotmount, err := rc.GetBindsAndMounts()
require.NoError(t, err)
assert.Contains(t, gotbind, "/host/mame/roms:/root/.mame/roms:ro") assert.Contains(t, gotbind, "/host/mame/roms:/root/.mame/roms:ro")
assert.NotContains(t, gotbind, "${{ secrets.MAME }}") assert.NotContains(t, gotbind, "${{ secrets.MAME }}")
assert.NotContains(t, gotmount, "${{ secrets.MAME }}") assert.NotContains(t, gotmount, "${{ secrets.MAME }}")
@@ -537,7 +542,8 @@ func TestRunContext_GetBindsAndMounts(t *testing.T) {
rc.Run.JobID = "job1" rc.Run.JobID = "job1"
rc.Run.Workflow.Jobs = map[string]*model.Job{"job1": job} rc.Run.Workflow.Jobs = map[string]*model.Job{"job1": job}
gotbind, gotmount := rc.GetBindsAndMounts() gotbind, gotmount, err := rc.GetBindsAndMounts()
require.NoError(t, err)
if len(testcase.wantbind) > 0 { if len(testcase.wantbind) > 0 {
assert.Contains(t, gotbind, testcase.wantbind) assert.Contains(t, gotbind, testcase.wantbind)
@@ -572,11 +578,13 @@ func TestRunContext_GetBindsAndMounts(t *testing.T) {
Config: &Config{}, Config: &Config{},
} }
_, gotmount := rc.GetBindsAndMounts() _, gotmount, err := rc.GetBindsAndMounts()
require.NoError(t, err)
assert.NotContains(t, gotmount, sharedToolCacheVolume) assert.NotContains(t, gotmount, sharedToolCacheVolume)
rc.Config.SharedToolCache = true rc.Config.SharedToolCache = true
_, gotmount = rc.GetBindsAndMounts() _, gotmount, err = rc.GetBindsAndMounts()
require.NoError(t, err)
assert.Equal(t, container.DefaultToolCache, gotmount[sharedToolCacheVolume]) assert.Equal(t, container.DefaultToolCache, gotmount[sharedToolCacheVolume])
}) })
} }
@@ -602,6 +610,11 @@ func TestRunContextValidVolumes(t *testing.T) {
// a job may mount it only while the runner does // a job may mount it only while the runner does
rc.Config.SharedToolCache = false rc.Config.SharedToolCache = false
assert.NotContains(t, rc.validVolumes(), sharedToolCacheVolume) assert.NotContains(t, rc.validVolumes(), sharedToolCacheVolume)
rc.Config.Workdir = "/workspace/1/owner/repo"
assert.NotContains(t, rc.validVolumes(), rc.Config.Workdir)
rc.Config.BindWorkdir = true
assert.Contains(t, rc.validVolumes(), rc.Config.Workdir)
} }
func TestCleanupJobResourcesCleansServicesWithoutJobContainer(t *testing.T) { func TestCleanupJobResourcesCleansServicesWithoutJobContainer(t *testing.T) {
@@ -653,8 +666,10 @@ func TestInterpolateOutputsIsPerMatrixCombo(t *testing.T) {
r := &runnerImpl{config: &Config{}} r := &runnerImpl{config: &Config{}}
ctx := context.Background() ctx := context.Background()
rcA := r.newRunContext(ctx, run, map[string]any{"v": "a"}) rcA, err := r.newRunContext(ctx, run, map[string]any{"v": "a"})
rcB := r.newRunContext(ctx, run, map[string]any{"v": "b"}) require.NoError(t, err)
rcB, err := r.newRunContext(ctx, run, map[string]any{"v": "b"})
require.NoError(t, err)
require.NoError(t, rcA.interpolateOutputs()(ctx)) require.NoError(t, rcA.interpolateOutputs()(ctx))
require.NoError(t, rcB.interpolateOutputs()(ctx)) require.NoError(t, rcB.interpolateOutputs()(ctx))
@@ -1279,6 +1294,29 @@ func TestGetJobContextReportsContainers(t *testing.T) {
}, jobContext.Services) }, jobContext.Services)
} }
func TestCaptureJobContainerInfoExportsDockerWorkspace(t *testing.T) {
job := &containerMock{}
job.On("Inspect", mock.Anything).Return(&container.Info{
ID: "job-container-id",
Mounts: map[string]string{"/workspace/owner/repo": "/var/lib/docker/volumes/job/_data"},
}, nil)
rc := &RunContext{
Config: &Config{Workdir: "/workspace/owner/repo/"},
Env: map[string]string{},
JobContainer: job,
}
require.NoError(t, rc.captureJobContainerInfo()(context.Background()))
assert.Equal(t, "job-container-id", rc.jobContainerID)
assert.Equal(t, "/var/lib/docker/volumes/job/_data", rc.Env["GITEA_DOCKER_WORKSPACE"])
rc.Config.Workdir = "/elsewhere"
rc.Env = map[string]string{}
require.NoError(t, rc.captureJobContainerInfo()(context.Background()))
assert.NotContains(t, rc.Env, "GITEA_DOCKER_WORKSPACE")
}
// A job that never started a container reports an empty context, not a placeholder. // A job that never started a container reports an empty context, not a placeholder.
func TestGetJobContextWithoutContainer(t *testing.T) { func TestGetJobContextWithoutContainer(t *testing.T) {
jobContext := (&RunContext{}).getJobContext() jobContext := (&RunContext{}).getJobContext()
@@ -1327,12 +1365,14 @@ func TestRunContextImageOS(t *testing.T) {
t.Run("prefers the release in the resolved image tag", func(t *testing.T) { t.Run("prefers the release in the resolved image tag", func(t *testing.T) {
rc := createRunsOnRunContext(t, "ubuntu-latest") rc := createRunsOnRunContext(t, "ubuntu-latest")
rc.Config.PlatformPicker = func([]string) string { return "docker.gitea.com/runner-images:ubuntu-24.04" } rc.Config.PlatformPicker = func([]string) string { return "docker.gitea.com/runner-images:ubuntu-24.04" }
require.NoError(t, rc.resolvePlatformImage(ctx))
assert.Equal(t, "ubuntu24", rc.imageOS(ctx)) assert.Equal(t, "ubuntu24", rc.imageOS(ctx))
}) })
t.Run("falls back to the runs-on label", func(t *testing.T) { t.Run("falls back to the runs-on label", func(t *testing.T) {
rc := createRunsOnRunContext(t, "ubuntu-22.04") rc := createRunsOnRunContext(t, "ubuntu-22.04")
rc.Config.PlatformPicker = func([]string) string { return "some-image" } rc.Config.PlatformPicker = func([]string) string { return "some-image" }
require.NoError(t, rc.resolvePlatformImage(ctx))
assert.Equal(t, "ubuntu22", rc.imageOS(ctx)) assert.Equal(t, "ubuntu22", rc.imageOS(ctx))
}) })
@@ -1352,10 +1392,15 @@ func TestRunContextGetRunnerContext(t *testing.T) {
t.Run("adds the runner values the container cannot know", func(t *testing.T) { t.Run("adds the runner values the container cannot know", func(t *testing.T) {
rc := createRunsOnRunContext(t, "ubuntu-latest") rc := createRunsOnRunContext(t, "ubuntu-latest")
rc.Config.RunnerName = "runner-1" rc.Config.RunnerName = "runner-1"
rc.Config.Workdir = "/workspace/owner/repo"
runnerContext := rc.getRunnerContext(ctx) runnerContext := rc.getRunnerContext(ctx)
assert.Equal(t, "runner-1", runnerContext["name"]) assert.Equal(t, "runner-1", runnerContext["name"])
assert.Equal(t, "self-hosted", runnerContext["environment"]) assert.Equal(t, "self-hosted", runnerContext["environment"])
assert.Equal(t, "/workspace/owner", runnerContext["workspace"])
assert.Equal(t, "/workspace/owner/repo", rc.getGithubContext(ctx).Workspace)
rc.Config.Env = map[string]string{"GITHUB_WORKSPACE": "/configured/work"}
assert.Equal(t, "/configured/work", rc.getGithubContext(ctx).Workspace)
assert.NotContains(t, runnerContext, "debug") assert.NotContains(t, runnerContext, "debug")
}) })
@@ -1368,15 +1413,52 @@ func TestRunContextGetRunnerContext(t *testing.T) {
t.Run("keeps the execution environment values", func(t *testing.T) { t.Run("keeps the execution environment values", func(t *testing.T) {
rc := createRunsOnRunContext(t, "ubuntu-latest") rc := createRunsOnRunContext(t, "ubuntu-latest")
rc.JobContainer = &container.HostEnvironment{TmpDir: "/tmp/act", ToolCache: "/tmp/tool_cache"} rc.Config.Workdir = "/host/work/owner/repo"
rc.JobContainer = &container.HostEnvironment{Workdir: rc.Config.Workdir, Path: "/container/work/owner/repo", TmpDir: "/tmp/act", ToolCache: "/tmp/tool_cache"}
runnerContext := rc.getRunnerContext(ctx) runnerContext := rc.getRunnerContext(ctx)
assert.Equal(t, "/tmp/act", runnerContext["temp"]) assert.Equal(t, "/tmp/act", runnerContext["temp"])
assert.Equal(t, "/tmp/tool_cache", runnerContext["tool_cache"]) assert.Equal(t, "/tmp/tool_cache", runnerContext["tool_cache"])
assert.Equal(t, "/container/work/owner", runnerContext["workspace"])
assert.Equal(t, "/container/work/owner/repo", rc.getGithubContext(ctx).Workspace)
assert.NotEmpty(t, runnerContext["os"]) assert.NotEmpty(t, runnerContext["os"])
}) })
} }
func TestRunContextUpdateExtraPath(t *testing.T) {
longPath := strings.Repeat("x", 64*1024+1)
for _, testcase := range []struct {
name string
content []byte
want string
wantErr bool
}{
{name: "UTF-8 BOM", content: []byte("\xef\xbb\xbf/utf8/tools\n"), want: "/utf8/tools"},
{name: "UTF-16 BOM", content: []byte{0xff, 0xfe, 'C', 0, ':', 0, '\\', 0, 't', 0, 'o', 0, 'o', 0, 'l', 0, 's', 0, '\n', 0}, want: `C:\tools`},
{name: "over 64 KiB", content: []byte(longPath + "\n"), want: longPath},
{name: "over 16 MiB", content: []byte(strings.Repeat("x", 16*1024*1024+1)), wantErr: true},
} {
t.Run(testcase.name, func(t *testing.T) {
logger := log.New()
logger.SetOutput(io.Discard)
ctx := common.WithLogger(t.Context(), logger)
jobContainer := &containerMock{}
jobContainer.On("GetContainerArchive", mock.Anything, "/github/path").
Return(io.NopCloser(bytes.NewReader(tarArchive(t, tarEntry{name: "path", body: string(testcase.content)}))), nil).Once()
defer jobContainer.AssertExpectations(t)
rc := &RunContext{JobContainer: jobContainer}
err := rc.UpdateExtraPath(ctx, "/github/path")
if testcase.wantErr {
require.ErrorContains(t, err, "reading path file")
return
}
require.NoError(t, err)
assert.Equal(t, []string{testcase.want}, rc.ExtraPath)
})
}
}
func TestParentDir(t *testing.T) { func TestParentDir(t *testing.T) {
assert.Empty(t, parentDir("")) assert.Empty(t, parentDir(""))
assert.Empty(t, parentDir("repo")) assert.Empty(t, parentDir("repo"))
+14 -5
View File
@@ -198,7 +198,10 @@ func (runner *runnerImpl) NewPlanExecutor(plan *model.Plan) common.Executor {
log.Debugf("Job.Strategy.MaxParallelString: %v", job.Strategy.MaxParallelString) log.Debugf("Job.Strategy.MaxParallelString: %v", job.Strategy.MaxParallelString)
log.Debugf("Job.Strategy.RawMatrix: %v", job.Strategy.RawMatrix) log.Debugf("Job.Strategy.RawMatrix: %v", job.Strategy.RawMatrix)
strategyRc := runner.newRunContext(ctx, run, nil) strategyRc, err := runner.newRunContext(ctx, run, nil)
if err != nil {
return err
}
// Resolve template expressions in the matrix node before Matrix() is called. // Resolve template expressions in the matrix node before Matrix() is called.
// On failure the literal string is kept and normalizeMatrixValue wraps it as a fallback. // On failure the literal string is kept and normalizeMatrixValue wraps it as a fallback.
if err := strategyRc.NewExpressionEvaluator(ctx).EvaluateYamlNode(ctx, &job.Strategy.RawMatrix); err != nil { if err := strategyRc.NewExpressionEvaluator(ctx).EvaluateYamlNode(ctx, &job.Strategy.RawMatrix); err != nil {
@@ -230,7 +233,10 @@ func (runner *runnerImpl) NewPlanExecutor(plan *model.Plan) common.Executor {
log.Infof("Running job with maxParallel=%d for %d matrix combinations", maxParallel, len(matrixes)) log.Infof("Running job with maxParallel=%d for %d matrix combinations", maxParallel, len(matrixes))
for i, matrix := range matrixes { for i, matrix := range matrixes {
rc := runner.newRunContext(ctx, run, matrix) rc, err := runner.newRunContext(ctx, run, matrix)
if err != nil {
return err
}
rc.JobName = rc.Name rc.JobName = rc.Name
if len(matrixes) > 1 { if len(matrixes) > 1 {
rc.Name = fmt.Sprintf("%s-%d", rc.Name, i+1) rc.Name = fmt.Sprintf("%s-%d", rc.Name, i+1)
@@ -315,7 +321,7 @@ func handleFailure(plan *model.Plan) common.Executor {
} }
} }
func (runner *runnerImpl) newRunContext(ctx context.Context, run *model.Run, matrix map[string]any) *RunContext { func (runner *runnerImpl) newRunContext(ctx context.Context, run *model.Run, matrix map[string]any) (*RunContext, error) {
rc := &RunContext{ rc := &RunContext{
Config: runner.config, Config: runner.config,
Run: run, Run: run,
@@ -324,15 +330,18 @@ func (runner *runnerImpl) newRunContext(ctx context.Context, run *model.Run, mat
Matrix: matrix, Matrix: matrix,
caller: runner.caller, caller: runner.caller,
} }
if err := rc.resolveWorkflowCall(ctx); err != nil {
return nil, err
}
rc.ExprEval = rc.NewExpressionEvaluator(ctx) rc.ExprEval = rc.NewExpressionEvaluator(ctx)
rc.Name = rc.maskSecrets(rc.ExprEval.Interpolate(ctx, run.String())) rc.Name = rc.maskSecrets(rc.ExprEval.InterpolateName(ctx, run.String()))
// Snapshot the job's pristine output expressions now, before any matrix combo runs and // Snapshot the job's pristine output expressions now, before any matrix combo runs and
// rewrites the shared Job.Outputs (see interpolateOutputs). // rewrites the shared Job.Outputs (see interpolateOutputs).
if job := run.Job(); job != nil { if job := run.Job(); job != nil {
rc.outputTemplate = maps.Clone(job.Outputs) rc.outputTemplate = maps.Clone(job.Outputs)
} }
return rc return rc, nil
} }
// For Gitea // For Gitea
+45 -42
View File
@@ -11,6 +11,7 @@ import (
"path" "path"
"strconv" "strconv"
"strings" "strings"
"sync"
"time" "time"
"gitea.com/gitea/runner/act/common" "gitea.com/gitea/runner/act/common"
@@ -58,13 +59,10 @@ func (s stepStage) String() string {
func processRunnerEnvFileCommand(ctx context.Context, fileName string, rc *RunContext, setter func(context.Context, map[string]string, string)) error { func processRunnerEnvFileCommand(ctx context.Context, fileName string, rc *RunContext, setter func(context.Context, map[string]string, string)) error {
env := map[string]string{} env := map[string]string{}
err := rc.JobContainer.UpdateFromEnv(path.Join(rc.JobContainer.GetActPath(), fileName), &env)(ctx) err := rc.JobContainer.UpdateFromEnv(path.Join(rc.JobContainer.GetActPath(), fileName), &env)(ctx)
if err != nil {
return err
}
for k, v := range env { for k, v := range env {
setter(ctx, map[string]string{"name": k}, v) setter(ctx, map[string]string{"name": k}, v)
} }
return nil return err
} }
func runStepExecutor(step step, stage stepStage, executor common.Executor) common.Executor { func runStepExecutor(step step, stage stepStage, executor common.Executor) common.Executor {
@@ -85,23 +83,26 @@ func runStepExecutor(step step, stage stepStage, executor common.Executor) commo
rc.StepResults[rc.CurrentStep] = stepResult rc.StepResults[rc.CurrentStep] = stepResult
} }
setupEnv(ctx, step) err := setupEnv(ctx, step)
var runStep bool
runStep, err := isStepEnabled(ctx, ifExpression, step, stage) if err == nil {
runStep, err = isStepEnabled(ctx, ifExpression, step, stage)
}
if err != nil { if err != nil {
stepResult.Conclusion = model.StepStatusFailure stepResult.Conclusion = model.StepStatusFailure
stepResult.Outcome = model.StepStatusFailure stepResult.Outcome = model.StepStatusFailure
logger.WithField("stepResult", stepResult.Conclusion).Infof("Failure - %s %s", stage, stepModel)
return err return err
} }
if !runStep { if !runStep {
stepResult.Conclusion = model.StepStatusSkipped stepResult.Conclusion = model.StepStatusSkipped
stepResult.Outcome = model.StepStatusSkipped stepResult.Outcome = model.StepStatusSkipped
logger.WithField("stepResult", stepResult.Outcome).Debugf("Skipping step '%s' due to '%s'", stepModel, ifExpression) logger.WithField("stepResult", stepResult.Conclusion).Debugf("Skipping step '%s' due to '%s'", stepModel, ifExpression)
return nil return nil
} }
stepString := rc.ExprEval.Interpolate(ctx, stepModel.String()) stepString := rc.ExprEval.InterpolateName(ctx, stepModel.String())
if strings.Contains(stepString, "::add-mask::") { if strings.Contains(stepString, "::add-mask::") {
stepString = "add-mask command" stepString = "add-mask command"
} }
@@ -157,11 +158,11 @@ func runStepExecutor(step step, stage stepStage, executor common.Executor) commo
if topRC.summaryFileInitialized == nil { if topRC.summaryFileInitialized == nil {
topRC.summaryFileInitialized = map[int]bool{} topRC.summaryFileInitialized = map[int]bool{}
} }
_ = rc.JobContainer.Copy(actPath, files...)(ctx)
if !topRC.summaryFileInitialized[stepSummaryIndex] { if !topRC.summaryFileInitialized[stepSummaryIndex] {
files = append(files, &container.FileEntry{Name: summaryFileCommand, Mode: 0o666}) _ = rc.JobContainer.Copy(actPath, &container.FileEntry{Name: summaryFileCommand, Mode: 0o666})(ctx)
topRC.summaryFileInitialized[stepSummaryIndex] = true topRC.summaryFileInitialized[stepSummaryIndex] = true
} }
_ = rc.JobContainer.Copy(actPath, files...)(ctx)
// The command handler needs the step's env to judge ACTIONS_ALLOW_UNSECURE_COMMANDS. // The command handler needs the step's env to judge ACTIONS_ALLOW_UNSECURE_COMMANDS.
// Cloned: the step executor keeps writing to its own env map after this point, on a // Cloned: the step executor keeps writing to its own env map after this point, on a
@@ -179,15 +180,29 @@ func runStepExecutor(step step, stage stepStage, executor common.Executor) commo
if err == nil { if err == nil {
err = insecureErr err = insecureErr
} }
if fileErr := processRunnerEnvFileCommand(ctx, envFileCommand, rc, rc.setEnvFile); fileErr != nil && err == nil {
err = fileErr
}
if fileErr := processRunnerEnvFileCommand(ctx, stateFileCommand, rc, rc.saveState); fileErr != nil && err == nil {
err = fileErr
}
if fileErr := processRunnerEnvFileCommand(ctx, outputFileCommand, rc, rc.setOutput); fileErr != nil && err == nil {
err = fileErr
}
if fileErr := rc.UpdateExtraPath(ctx, path.Join(actPath, pathFileCommand)); fileErr != nil && err == nil {
err = fileErr
}
_ = rc.JobContainer.Copy(actPath, files...)(ctx)
if err == nil { if err == nil {
logger.WithField("stepResult", stepResult.Outcome).Infof("Success - %s %s", stage, stepString) logger.WithField("stepResult", stepResult.Conclusion).Infof("Success - %s %s", stage, stepString)
} else { } else {
stepResult.Outcome = model.StepStatusFailure stepResult.Outcome = model.StepStatusFailure
continueOnError, parseErr := isContinueOnError(ctx, stepModel.RawContinueOnError, step, stage) continueOnError, parseErr := isContinueOnError(ctx, stepModel.RawContinueOnError, step, stage)
if parseErr != nil { if parseErr != nil {
stepResult.Conclusion = model.StepStatusFailure stepResult.Conclusion = model.StepStatusFailure
logger.WithField("stepResult", stepResult.Conclusion).Infof("Failure - %s %s", stage, stepString)
return parseErr return parseErr
} }
@@ -202,64 +217,51 @@ func runStepExecutor(step step, stage stepStage, executor common.Executor) commo
// Infof: Errorf entries are promoted to the user log by the reporter, // Infof: Errorf entries are promoted to the user log by the reporter,
// which would duplicate the ##[error] annotation emitted elsewhere. // which would duplicate the ##[error] annotation emitted elsewhere.
logger.WithField("stepResult", stepResult.Outcome).Infof("Failure - %s %s", stage, stepString) logger.WithField("stepResult", stepResult.Conclusion).Infof("Failure - %s %s", stage, stepString)
}
// Process Runner File Commands
orgerr := err
err = processRunnerEnvFileCommand(ctx, envFileCommand, rc, rc.setEnv)
if err != nil {
return err
}
err = processRunnerEnvFileCommand(ctx, stateFileCommand, rc, rc.saveState)
if err != nil {
return err
}
err = processRunnerEnvFileCommand(ctx, outputFileCommand, rc, rc.setOutput)
if err != nil {
return err
}
err = rc.UpdateExtraPath(ctx, path.Join(actPath, pathFileCommand))
if err != nil {
return err
}
if orgerr != nil {
return orgerr
} }
return err return err
} }
} }
func evaluateStepTimeout(ctx context.Context, exprEval *expressionEvaluator, stepModel *model.Step) (context.Context, context.CancelFunc) { func evaluateStepTimeout(ctx context.Context, exprEval *expressionEvaluator, stepModel *model.Step) (context.Context, context.CancelFunc) {
timeout := exprEval.Interpolate(ctx, stepModel.TimeoutMinutes) timeout, err := exprEval.Interpolate(ctx, stepModel.TimeoutMinutes)
if timeout != "" { if err != nil {
if timeOutMinutes, err := strconv.ParseInt(timeout, 10, 64); err == nil { common.Logger(ctx).Errorf("An error occurred when attempting to determine the step timeout: %s", err)
} else if timeout != "" {
if timeOutMinutes, err := strconv.ParseInt(timeout, 10, 64); err == nil && timeOutMinutes > 0 {
return context.WithTimeout(ctx, time.Duration(timeOutMinutes)*time.Minute) return context.WithTimeout(ctx, time.Duration(timeOutMinutes)*time.Minute)
} }
} }
return ctx, func() {} return ctx, func() {}
} }
func setupEnv(ctx context.Context, step step) { func setupEnv(ctx context.Context, step step) error {
rc := step.getRunContext() rc := step.getRunContext()
mergeEnv(ctx, step) mergeEnv(ctx, step)
// merge step env last, since it should not be overwritten // merge step env last, since it should not be overwritten
mergeIntoMap(step, step.getEnv(), step.getStepModel().GetEnv()) mergeIntoMap(step, step.getEnv(), step.getStepModel().GetEnv())
var err error
exprEval := rc.NewExpressionEvaluator(ctx) exprEval := rc.NewExpressionEvaluator(ctx)
for k, v := range *step.getEnv() { for k, v := range *step.getEnv() {
if !strings.HasPrefix(k, "INPUT_") { if !strings.HasPrefix(k, "INPUT_") {
(*step.getEnv())[k] = exprEval.Interpolate(ctx, v) if (*step.getEnv())[k], err = exprEval.Interpolate(ctx, v); err != nil {
return fmt.Errorf("unable to interpolate env %s: %w", k, err)
}
} }
} }
// after we have an evaluated step context, update the expressions evaluator with a new env context // after we have an evaluated step context, update the expressions evaluator with a new env context
// you can use step level env in the with property of a uses construct // you can use step level env in the with property of a uses construct
exprEval = rc.NewExpressionEvaluatorWithEnv(ctx, *step.getEnv()) inputEval := sync.OnceValue(func() *expressionEvaluator { return rc.NewExpressionEvaluatorWithEnv(ctx, *step.getEnv()) })
for k, v := range *step.getEnv() { for k, v := range *step.getEnv() {
if strings.HasPrefix(k, "INPUT_") { if strings.HasPrefix(k, "INPUT_") {
(*step.getEnv())[k] = exprEval.Interpolate(ctx, v) if (*step.getEnv())[k], err = inputEval().Interpolate(ctx, v); err != nil {
return fmt.Errorf("unable to interpolate env %s: %w", k, err)
}
} }
} }
return nil
} }
func mergeEnv(ctx context.Context, step step) { func mergeEnv(ctx context.Context, step step) {
@@ -269,7 +271,8 @@ func mergeEnv(ctx context.Context, step step) {
c := job.Container() c := job.Container()
if c != nil { if c != nil {
mergeIntoMap(step, env, rc.GetEnv(), c.Env) // container env is the image's baseline, which job env and $GITHUB_ENV override
mergeIntoMap(step, env, c.Env, rc.GetEnv())
} else { } else {
mergeIntoMap(step, env, rc.GetEnv()) mergeIntoMap(step, env, rc.GetEnv())
} }
+8 -4
View File
@@ -122,15 +122,19 @@ func (sal *stepActionLocal) getActionModel() *model.Action {
return sal.action return sal.action
} }
func (sal *stepActionLocal) getCompositeRunContext(ctx context.Context) *RunContext { func (sal *stepActionLocal) getCompositeRunContext(ctx context.Context) (*RunContext, error) {
if sal.compositeRunContext == nil { if sal.compositeRunContext == nil {
actionDir := filepath.Join(sal.RunContext.Config.Workdir, sal.Step.Uses) actionDir := filepath.Join(sal.RunContext.Config.Workdir, sal.Step.Uses)
_, containerActionDir := getContainerActionPaths(sal.getStepModel(), actionDir, sal.RunContext) _, containerActionDir := getContainerActionPaths(sal.getStepModel(), actionDir, sal.RunContext)
sal.compositeRunContext = newCompositeRunContext(ctx, sal.RunContext, sal, containerActionDir) compositeRunContext, err := newCompositeRunContext(ctx, sal.RunContext, sal, containerActionDir)
sal.compositeSteps = sal.compositeRunContext.compositeExecutor(sal.action) if err != nil {
return nil, err
}
sal.compositeRunContext = compositeRunContext
sal.compositeSteps = compositeRunContext.compositeExecutor(sal.action)
} }
return sal.compositeRunContext return sal.compositeRunContext, nil
} }
func (sal *stepActionLocal) getCompositeSteps() *compositeSteps { func (sal *stepActionLocal) getCompositeSteps() *compositeSteps {
+2 -1
View File
@@ -254,7 +254,8 @@ func TestStepActionLocalPost(t *testing.T) {
if tt.mocks.exec { if tt.mocks.exec {
suffixMatcher := func(suffix string) any { suffixMatcher := func(suffix string) any {
return mock.MatchedBy(func(array []string) bool { return mock.MatchedBy(func(array []string) bool {
return strings.HasSuffix(array[1], suffix) return len(array) == 3 && array[0] == "node" && array[1] == "--preserve-symlinks-main" &&
strings.HasSuffix(array[2], suffix)
}) })
} }
cm.On("Exec", suffixMatcher("runner/local/action/post.js"), sal.env, "", "").Return(func(ctx context.Context) error { return tt.err }) cm.On("Exec", suffixMatcher("runner/local/action/post.js"), sal.env, "", "").Return(func(ctx context.Context) error { return tt.err })
+24 -10
View File
@@ -51,7 +51,11 @@ func (sar *stepActionRemote) prepareActionExecutor() common.Executor {
// Since actions can specify the download source via a url prefix. // Since actions can specify the download source via a url prefix.
// The prefix may contain some sensitive information that needs to be stored in secrets, // The prefix may contain some sensitive information that needs to be stored in secrets,
// so we need to interpolate the expression value for uses first. // so we need to interpolate the expression value for uses first.
sar.Step.Uses = sar.RunContext.NewExpressionEvaluator(ctx).Interpolate(ctx, sar.Step.Uses) uses, err := sar.RunContext.NewExpressionEvaluator(ctx).Interpolate(ctx, sar.Step.Uses)
if err != nil {
return fmt.Errorf("unable to interpolate uses: %w", err)
}
sar.Step.Uses = uses
github := sar.getGithubContext(ctx) // read before remoteAction is set, so `$/` resolves against the enclosing action github := sar.getGithubContext(ctx) // read before remoteAction is set, so `$/` resolves against the enclosing action
if strings.HasPrefix(sar.Step.Uses, selfRepoPrefix) { if strings.HasPrefix(sar.Step.Uses, selfRepoPrefix) {
@@ -160,9 +164,12 @@ func (sar *stepActionRemote) main() common.Executor {
common.Logger(ctx).Debugf("Skipping local actions/checkout because you bound your workspace") common.Logger(ctx).Debugf("Skipping local actions/checkout because you bound your workspace")
return nil return nil
} }
eval := sar.RunContext.NewExpressionEvaluator(ctx) checkoutPath, err := sar.RunContext.NewExpressionEvaluator(ctx).Interpolate(ctx, sar.Step.With["path"])
copyToPath := path.Join(sar.RunContext.JobContainer.ToContainerPath(sar.RunContext.Config.Workdir), eval.Interpolate(ctx, sar.Step.With["path"])) if err != nil {
return sar.RunContext.JobContainer.CopyDir(copyToPath, sar.RunContext.Config.Workdir+string(filepath.Separator)+".", sar.RunContext.Config.UseGitIgnore)(ctx) return fmt.Errorf("unable to interpolate with.path: %w", err)
}
copyToPath := path.Join(sar.RunContext.JobContainer.ToContainerPath(sar.RunContext.Config.Workdir), checkoutPath)
return sar.RunContext.JobContainer.CopyDir(copyToPath, sar.RunContext.Config.Workdir+string(filepath.Separator)+".", sar.RunContext.Config.UseGitIgnore, false)(ctx)
} }
return sar.runAction(sar, sar.actionDir(), sar.remoteAction)(ctx) return sar.runAction(sar, sar.actionDir(), sar.remoteAction)(ctx)
@@ -229,14 +236,18 @@ func (sar *stepActionRemote) getActionModel() *model.Action {
return sar.action return sar.action
} }
func (sar *stepActionRemote) getCompositeRunContext(ctx context.Context) *RunContext { func (sar *stepActionRemote) getCompositeRunContext(ctx context.Context) (*RunContext, error) {
if sar.compositeRunContext == nil { if sar.compositeRunContext == nil {
actionDir := sar.actionDir() actionDir := sar.actionDir()
actionLocation := path.Join(actionDir, sar.remoteAction.Path) actionLocation := path.Join(actionDir, sar.remoteAction.Path)
_, containerActionDir := getContainerActionPaths(sar.getStepModel(), actionLocation, sar.RunContext) _, containerActionDir := getContainerActionPaths(sar.getStepModel(), actionLocation, sar.RunContext)
sar.compositeRunContext = newCompositeRunContext(ctx, sar.RunContext, sar, containerActionDir) compositeRunContext, err := newCompositeRunContext(ctx, sar.RunContext, sar, containerActionDir)
sar.compositeSteps = sar.compositeRunContext.compositeExecutor(sar.action) if err != nil {
return nil, err
}
sar.compositeRunContext = compositeRunContext
sar.compositeSteps = compositeRunContext.compositeExecutor(sar.action)
} else { } else {
// Re-evaluate environment here. For remote actions the environment // Re-evaluate environment here. For remote actions the environment
// need to be re-created for every stage (pre, main, post) as there // need to be re-created for every stage (pre, main, post) as there
@@ -244,11 +255,14 @@ func (sar *stepActionRemote) getCompositeRunContext(ctx context.Context) *RunCon
// stages are executed. (e.g. the output of another action is the // stages are executed. (e.g. the output of another action is the
// input for this action during the main stage, but the env // input for this action during the main stage, but the env
// was already created during the pre stage) // was already created during the pre stage)
env := evaluateCompositeInputAndEnv(ctx, sar.RunContext, sar) env, err := evaluateCompositeInputAndEnv(ctx, sar.RunContext, sar)
sar.compositeRunContext.setActionEnv(env) if err != nil {
return nil, err
}
sar.compositeRunContext.setCompositeActionEnv(env)
sar.compositeRunContext.ExtraPath = sar.RunContext.ExtraPath sar.compositeRunContext.ExtraPath = sar.RunContext.ExtraPath
} }
return sar.compositeRunContext return sar.compositeRunContext, nil
} }
func (sar *stepActionRemote) getCompositeSteps() *compositeSteps { func (sar *stepActionRemote) getCompositeSteps() *compositeSteps {
+20 -2
View File
@@ -242,6 +242,24 @@ func TestStepActionRemote(t *testing.T) {
cm.AssertExpectations(t) cm.AssertExpectations(t)
}) })
} }
t.Run("refreshes composite inputs without leaking environment", func(t *testing.T) {
step := &stepActionRemote{
Step: &model.Step{Uses: "org/composite@v1", With: map[string]string{"SHARED": "first"}},
RunContext: &RunContext{Config: &Config{ActionCacheDir: t.TempDir()}, Run: &model.Run{JobID: "job", Workflow: &model.Workflow{Jobs: map[string]*model.Job{"job": {}}}}, JobContainer: &jobContainerMock{}},
action: &model.Action{Inputs: map[string]model.Input{"shared": {}}},
env: map[string]string{},
remoteAction: newRemoteAction("org/composite@v1"),
}
for _, value := range []string{"first", "second"} {
step.env["INPUT_SHARED"] = value
composite, err := step.getCompositeRunContext(t.Context())
require.NoError(t, err)
assert.Equal(t, map[string]any{"shared": value}, composite.actionInputs)
assert.NotContains(t, composite.Env, "INPUT_SHARED")
}
})
} }
func TestStepActionRemotePrepare(t *testing.T) { func TestStepActionRemotePrepare(t *testing.T) {
@@ -453,10 +471,10 @@ func TestStepActionRemotePost(t *testing.T) {
if tt.mocks.exec { if tt.mocks.exec {
// Use mock.MatchedBy to match the exec command with hash-based path // Use mock.MatchedBy to match the exec command with hash-based path
execMatcher := mock.MatchedBy(func(args []string) bool { execMatcher := mock.MatchedBy(func(args []string) bool {
if len(args) != 2 { if len(args) != 3 {
return false return false
} }
return args[0] == "node" && strings.Contains(args[1], "post.js") return args[0] == "node" && args[1] == "--preserve-symlinks-main" && strings.Contains(args[2], "post.js")
}) })
cm.On("Exec", execMatcher, sar.env, "", "").Return(func(ctx context.Context) error { return tt.err }) cm.On("Exec", execMatcher, sar.env, "", "").Return(func(ctx context.Context) error { return tt.err })
+15 -3
View File
@@ -6,6 +6,7 @@ package runner
import ( import (
"context" "context"
"fmt"
"strings" "strings"
"gitea.com/gitea/runner/act/common" "gitea.com/gitea/runner/act/common"
@@ -58,17 +59,28 @@ func (sd *stepDocker) runUsesContainer() common.Executor {
return func(ctx context.Context) error { return func(ctx context.Context) error {
image := strings.TrimPrefix(step.Uses, "docker://") image := strings.TrimPrefix(step.Uses, "docker://")
eval := rc.NewExpressionEvaluator(ctx) eval := rc.NewExpressionEvaluator(ctx)
cmd, err := shellquote.Split(eval.Interpolate(ctx, step.With["args"])) args, err := eval.Interpolate(ctx, step.With["args"])
if err != nil {
return fmt.Errorf("unable to interpolate with.args: %w", err)
}
cmd, err := shellquote.Split(args)
if err != nil { if err != nil {
return err return err
} }
var entrypoint []string var entrypoint []string
if entry := eval.Interpolate(ctx, step.With["entrypoint"]); entry != "" { entry, err := eval.Interpolate(ctx, step.With["entrypoint"])
if err != nil {
return fmt.Errorf("unable to interpolate with.entrypoint: %w", err)
}
if entry != "" {
entrypoint = []string{entry} entrypoint = []string{entry}
} }
stepContainer := newStepContainer(ctx, sd, image, cmd, entrypoint, "") stepContainer, err := newStepContainer(ctx, sd, image, cmd, entrypoint, "")
if err != nil {
return err
}
return common.NewPipelineExecutor( return common.NewPipelineExecutor(
stepContainer.Pull(rc.Config.ForcePull), stepContainer.Pull(rc.Config.ForcePull),
+5 -4
View File
@@ -16,6 +16,7 @@ import (
"gitea.dev/actionslib/pkg/model" "gitea.dev/actionslib/pkg/model"
"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 TestStepDockerMain(t *testing.T) { func TestStepDockerMain(t *testing.T) {
@@ -144,7 +145,7 @@ func TestStepDockerNewStepContainerAllocatePTY(t *testing.T) {
} }
sd.RunContext.ExprEval = sd.RunContext.NewExpressionEvaluator(ctx) sd.RunContext.ExprEval = sd.RunContext.NewExpressionEvaluator(ctx)
_ = newStepContainer(ctx, sd, "node:14", []string{"echo", "hi"}, nil, "") _, _ = newStepContainer(ctx, sd, "node:14", []string{"echo", "hi"}, nil, "")
assert.Equal(t, tc.allocPTY, captured.AllocatePTY) assert.Equal(t, tc.allocPTY, captured.AllocatePTY)
}) })
} }
@@ -210,10 +211,10 @@ func TestStepDockerNewStepContainerNetworkMode(t *testing.T) {
} }
sd.RunContext.ExprEval = sd.RunContext.NewExpressionEvaluator(ctx) sd.RunContext.ExprEval = sd.RunContext.NewExpressionEvaluator(ctx)
assert.Equal(t, tc.expectDefault, sd.RunContext.IsHostEnv(ctx), require.NoError(t, sd.RunContext.resolvePlatformImage(ctx))
"IsHostEnv mismatch for platform %q", tc.platform) assert.Equal(t, tc.expectDefault, sd.RunContext.IsHostEnv(), "IsHostEnv mismatch for platform %q", tc.platform)
_ = newStepContainer(ctx, sd, "alpine:3.20", []string{"echo", "hello"}, nil, "") _, _ = newStepContainer(ctx, sd, "alpine:3.20", []string{"echo", "hello"}, nil, "")
if tc.expectDefault { if tc.expectDefault {
assert.Equal(t, "default", captured.NetworkMode, assert.Equal(t, "default", captured.NetworkMode,
+71 -33
View File
@@ -7,6 +7,7 @@ package runner
import ( import (
"context" "context"
"fmt" "fmt"
"io"
"maps" "maps"
"runtime" "runtime"
"slices" "slices"
@@ -22,6 +23,8 @@ import (
yaml "go.yaml.in/yaml/v4" yaml "go.yaml.in/yaml/v4"
) )
var builtinShells = []string{"bash", "sh", "pwsh", "powershell", "cmd", "python"}
type stepRun struct { type stepRun struct {
Step *model.Step Step *model.Step
RunContext *RunContext RunContext *RunContext
@@ -251,15 +254,35 @@ func getScriptName(rc *RunContext, step *model.Step) string {
// OCI runtime exec failed: exec failed: container_linux.go:380: starting container process caused: exec: "${{": executable file not found in $PATH: unknown // OCI runtime exec failed: exec failed: container_linux.go:380: starting container process caused: exec: "${{": executable file not found in $PATH: unknown
func (sr *stepRun) setupShellCommand(ctx context.Context) (name, script string, err error) { func (sr *stepRun) setupShellCommand(ctx context.Context) (name, script string, err error) {
logger := common.Logger(ctx) logger := common.Logger(ctx)
sr.setupShell(ctx) eval := sr.RunContext.NewStepExpressionEvaluator(ctx, sr)
sr.setupWorkingDirectory(ctx) implicitShell, err := sr.setupShell(ctx, eval)
if err != nil {
return "", "", err
}
if err := sr.setupWorkingDirectory(ctx, eval); err != nil {
return "", "", err
}
step := sr.Step step := sr.Step
script = sr.RunContext.NewStepExpressionEvaluator(ctx, sr).Interpolate(ctx, step.Run) script, err = eval.Interpolate(ctx, step.Run)
if err != nil {
return "", "", fmt.Errorf("unable to interpolate the run script: %w", err)
}
sr.interpolatedScript = script sr.interpolatedScript = script
// GitHub matches the built-in names case-insensitively, so `shell: PWSH` is valid
if slices.Contains(builtinShells, strings.ToLower(step.Shell)) {
step.Shell = strings.ToLower(step.Shell)
}
scCmd := step.ShellCommand() scCmd := step.ShellCommand()
if implicitShell && (step.Shell == "bash" || step.Shell == "sh") {
scCmd = step.Shell + " -e {0}"
}
if !strings.Contains(scCmd, "{0}") {
return "", "", fmt.Errorf("invalid shell option %q: format must contain {0}", step.Shell)
}
sr.shellCommand = scCmd sr.shellCommand = scCmd
name = getScriptName(sr.RunContext, step) name = getScriptName(sr.RunContext, step)
@@ -268,7 +291,8 @@ func (sr *stepRun) setupShellCommand(ctx context.Context) (name, script string,
// Reference: https://github.com/actions/runner/blob/8109c962f09d9acc473d92c595ff43afceddb347/src/Runner.Worker/Handlers/ScriptHandlerHelpers.cs#L19-L27 // Reference: https://github.com/actions/runner/blob/8109c962f09d9acc473d92c595ff43afceddb347/src/Runner.Worker/Handlers/ScriptHandlerHelpers.cs#L19-L27
runPrepend := "" runPrepend := ""
runAppend := "" runAppend := ""
switch step.Shell { shellCommand, _, _ := strings.Cut(step.Shell, " ")
switch shellCommand {
case "bash", "sh": case "bash", "sh":
name += ".sh" name += ".sh"
case "pwsh", "powershell": case "pwsh", "powershell":
@@ -292,64 +316,78 @@ func (sr *stepRun) setupShellCommand(ctx context.Context) (name, script string,
rc := sr.getRunContext() rc := sr.getRunContext()
scriptPath := fmt.Sprintf("%s/%s", rc.JobContainer.GetActPath(), name) scriptPath := fmt.Sprintf("%s/%s", rc.JobContainer.GetActPath(), name)
sr.cmdline = strings.Replace(scCmd, `{0}`, scriptPath, 1) sr.cmdline = strings.ReplaceAll(scCmd, `{0}`, scriptPath)
sr.cmd, err = shellquote.Split(sr.cmdline) sr.cmd, err = shellquote.Split(sr.cmdline)
return name, script, err return name, script, err
} }
func (sr *stepRun) setupShell(ctx context.Context) { func (sr *stepRun) setupShell(ctx context.Context, eval *expressionEvaluator) (bool, error) {
rc := sr.RunContext rc := sr.RunContext
step := sr.Step step := sr.Step
if step.Shell == "" { shell, err := eval.Interpolate(ctx, step.Shell)
step.Shell = rc.Run.Job().Defaults.Run.Shell if err != nil {
return false, fmt.Errorf("unable to interpolate the shell: %w", err)
} }
if shell == "" {
step.Shell = rc.NewExpressionEvaluator(ctx).Interpolate(ctx, step.Shell) shell = rc.jobRunDefaults.Shell
if step.Shell == "" {
step.Shell = rc.Run.Workflow.Defaults.Run.Shell
} }
if shell == "" {
shell = rc.Run.Workflow.Defaults.Run.Shell
}
step.Shell = shell
if step.Shell == "" { implicitShell := step.Shell == ""
if implicitShell {
shellWithFallback := []string{"bash", "sh"}
env := maps.Clone(sr.env)
rc.ApplyExtraPath(ctx, &env)
if _, ok := rc.JobContainer.(*container.HostEnvironment); ok { if _, ok := rc.JobContainer.(*container.HostEnvironment); ok {
shellWithFallback := []string{"bash", "sh"}
// Don't use bash on windows by default, if not using a docker container // Don't use bash on windows by default, if not using a docker container
if runtime.GOOS == "windows" { if runtime.GOOS == "windows" {
shellWithFallback = []string{"pwsh", "powershell"} shellWithFallback = []string{"pwsh", "powershell"}
} }
step.Shell = shellWithFallback[0] step.Shell = shellWithFallback[0]
env := maps.Clone(sr.env)
sr.getRunContext().ApplyExtraPath(ctx, &env)
_, err := lookpath.LookPath2(shellWithFallback[0], env) _, err := lookpath.LookPath2(shellWithFallback[0], env)
if err != nil { if err != nil {
step.Shell = shellWithFallback[1] step.Shell = shellWithFallback[1]
} }
} else if containerImage := rc.containerImage(ctx); containerImage != "" { } else {
// Currently only linux containers are supported, use sh by default like actions/runner step.Shell = shellWithFallback[0]
step.Shell = "sh" if !rc.containerHasBash(ctx, env) {
step.Shell = shellWithFallback[1]
}
} }
} }
return implicitShell, nil
} }
func (sr *stepRun) setupWorkingDirectory(ctx context.Context) { // containerHasBash probes once per job, else every implicit-shell step pays for an exec.
rc := sr.RunContext func (rc *RunContext) containerHasBash(ctx context.Context, env map[string]string) bool {
step := sr.Step top := rc.topLevelRunContext()
var workingdirectory string if top.hasBash == nil {
stdout, stderr := rc.JobContainer.ReplaceLogWriter(io.Discard, io.Discard)
if step.WorkingDirectory == "" { found := rc.JobContainer.Exec([]string{"sh", "-c", "command -v bash >/dev/null 2>&1"}, env, "", "")(ctx) == nil
workingdirectory = rc.Run.Job().Defaults.Run.WorkingDirectory rc.JobContainer.ReplaceLogWriter(stdout, stderr)
} else { top.hasBash = &found
workingdirectory = step.WorkingDirectory
} }
return *top.hasBash
}
// jobs can receive context values, so we interpolate func (sr *stepRun) setupWorkingDirectory(ctx context.Context, eval *expressionEvaluator) error {
workingdirectory = rc.NewExpressionEvaluator(ctx).Interpolate(ctx, workingdirectory) rc := sr.RunContext
workingdirectory, err := eval.Interpolate(ctx, sr.Step.WorkingDirectory)
// but top level keys in workflow file like `defaults` or `env` can't if err != nil {
return fmt.Errorf("unable to interpolate the working directory: %w", err)
}
if workingdirectory == "" {
workingdirectory = rc.jobRunDefaults.WorkingDirectory
}
// top level keys in workflow file like `defaults` or `env` can't hold expressions
if workingdirectory == "" { if workingdirectory == "" {
workingdirectory = rc.Run.Workflow.Defaults.Run.WorkingDirectory workingdirectory = rc.Run.Workflow.Defaults.Run.WorkingDirectory
} }
sr.WorkingDirectory = workingdirectory sr.WorkingDirectory = workingdirectory
return nil
} }
+127 -8
View File
@@ -6,8 +6,12 @@ package runner
import ( import (
"bytes" "bytes"
"cmp"
"context" "context"
"io" "io"
"os"
"path/filepath"
"runtime"
"testing" "testing"
"gitea.com/gitea/runner/act/container" "gitea.com/gitea/runner/act/container"
@@ -15,8 +19,13 @@ import (
"gitea.dev/actionslib/pkg/model" "gitea.dev/actionslib/pkg/model"
"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"
) )
type shellContainerMock struct{ *containerMock }
func (*shellContainerMock) ReplaceLogWriter(_, _ io.Writer) (io.Writer, io.Writer) { return nil, nil }
func TestStepRun(t *testing.T) { func TestStepRun(t *testing.T) {
cm := &containerMock{} cm := &containerMock{}
fileEntry := &container.FileEntry{ fileEntry := &container.FileEntry{
@@ -34,17 +43,12 @@ func TestStepRun(t *testing.T) {
JobID: "1", JobID: "1",
Workflow: &model.Workflow{ Workflow: &model.Workflow{
Jobs: map[string]*model.Job{ Jobs: map[string]*model.Job{
"1": { "1": {},
Defaults: model.Defaults{
Run: model.RunDefaults{
Shell: "bash",
},
},
},
}, },
}, },
}, },
JobContainer: cm, JobContainer: cm,
jobRunDefaults: model.RunDefaults{Shell: "bash"},
}, },
Step: &model.Step{ Step: &model.Step{
ID: "1", ID: "1",
@@ -73,3 +77,118 @@ func TestStepRun(t *testing.T) {
cm.AssertExpectations(t) cm.AssertExpectations(t)
} }
func TestStepRunShellParity(t *testing.T) {
tests := []struct {
name, run, shell, workingDir string
env map[string]string
host bool
probeErr error
wantExt string
wantCmd []string
wantErr string
}{
{
name: "implicit host bash",
host: true,
wantCmd: []string{"bash", "-e", "/var/run/act/workflow/1.sh"},
},
{
name: "implicit container bash",
wantCmd: []string{"bash", "-e", "/var/run/act/workflow/1.sh"},
},
{
name: "implicit container sh fallback",
probeErr: assert.AnError,
wantCmd: []string{"sh", "-e", "/var/run/act/workflow/1.sh"},
},
{
name: "custom pwsh template",
shell: "pwsh -NoProfile -File {0}",
wantExt: ".ps1",
wantCmd: []string{"pwsh", "-NoProfile", "-File", "/var/run/act/workflow/1.ps1"},
},
{
name: "missing placeholder",
shell: "bash -e",
wantErr: `invalid shell option "bash -e": format must contain {0}`,
},
{
name: "all placeholders",
shell: "bash -c '. {0}; . {0}'",
wantCmd: []string{"bash", "-c", ". /var/run/act/workflow/1.sh; . /var/run/act/workflow/1.sh"},
},
{
name: "step env expressions",
shell: "${{ env.SHELL }}",
workingDir: "${{ env.DIR }}",
env: map[string]string{"SHELL": "python {0}", "DIR": "subdir"},
wantExt: ".py",
wantCmd: []string{"python", "/var/run/act/workflow/1.py"},
},
{
name: "run expression without a context",
run: "echo ${{ COMMIT_SHA }}",
wantErr: "unable to interpolate the run script:",
},
{
name: "shell expression without a context",
shell: "${{ SHELL }}",
wantErr: "unable to interpolate the shell:",
},
{
name: "working directory expression without a context",
workingDir: "${{ DIR }}",
wantErr: "unable to interpolate the working directory:",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
cm := &containerMock{}
var jobContainer container.ExecutionsEnvironment = &shellContainerMock{cm}
if test.host {
if runtime.GOOS == "windows" {
t.Skip("Linux host shell selection")
}
test.env = map[string]string{"PATH": t.TempDir()}
require.NoError(t, os.WriteFile(filepath.Join(test.env["PATH"], "bash"), nil, 0o755))
jobContainer = &container.HostEnvironment{ActPath: "/var/run/act"}
} else if test.shell == "" {
cm.On("Exec", []string{"sh", "-c", "command -v bash >/dev/null 2>&1"},
mock.AnythingOfType("map[string]string"), "", "").Return(func(context.Context) error {
return test.probeErr
})
}
sr := &stepRun{
RunContext: &RunContext{
Config: &Config{},
Run: &model.Run{JobID: "1", Workflow: &model.Workflow{Jobs: map[string]*model.Job{"1": {}}}},
JobContainer: jobContainer,
},
Step: &model.Step{ID: "1", Run: cmp.Or(test.run, "echo hi"), Shell: test.shell, WorkingDirectory: test.workingDir},
env: test.env,
}
name, script, err := sr.setupShellCommand(t.Context())
if test.wantErr != "" {
require.ErrorContains(t, err, test.wantErr)
return
}
require.NoError(t, err)
if test.wantExt == "" {
test.wantExt = ".sh"
}
wantScript := "\necho hi\n"
if test.wantExt == ".ps1" {
wantScript = "$ErrorActionPreference = 'stop'\necho hi\nif ((Test-Path -LiteralPath variable:/LASTEXITCODE)) { exit $LASTEXITCODE }"
}
assert.Equal(t, "workflow/1"+test.wantExt, name)
assert.Equal(t, wantScript, script)
assert.Equal(t, test.wantCmd, sr.cmd)
assert.Equal(t, test.env["DIR"], sr.WorkingDirectory)
cm.AssertExpectations(t)
})
}
}
+93 -1
View File
@@ -7,12 +7,15 @@ package runner
import ( import (
"context" "context"
"errors" "errors"
"os"
"testing" "testing"
"gitea.com/gitea/runner/act/common" "gitea.com/gitea/runner/act/common"
"gitea.com/gitea/runner/act/container"
"gitea.dev/actionslib/pkg/model" "gitea.dev/actionslib/pkg/model"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
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" "github.com/stretchr/testify/require"
@@ -157,7 +160,7 @@ func TestSetupEnv(t *testing.T) {
sm.On("getStepModel").Return(step) sm.On("getStepModel").Return(step)
sm.On("getEnv").Return(&env) sm.On("getEnv").Return(&env)
setupEnv(context.Background(), sm) require.NoError(t, setupEnv(context.Background(), sm))
// These are commit or system specific // These are commit or system specific
delete(env, "GITHUB_REF") delete(env, "GITHUB_REF")
@@ -404,3 +407,92 @@ func TestRunStepExecutorDoesNotLeakRefusalToNextStep(t *testing.T) {
errB := runStepExecutor(stepB, stepStageMain, func(context.Context) error { return nil })(ctx) errB := runStepExecutor(stepB, stepStageMain, func(context.Context) error { return nil })(ctx)
require.NoError(t, errB) require.NoError(t, errB)
} }
func TestRunStepExecutorParity(t *testing.T) {
newStep := func(t *testing.T, stepModel *model.Step) *stepRun {
rc := createRunContext(t)
rc.JobContainer = &container.HostEnvironment{ActPath: t.TempDir()}
rc.ExprEval = rc.NewExpressionEvaluator(context.Background())
return &stepRun{RunContext: rc, Step: stepModel, env: map[string]string{}}
}
badExpression := "${{ 'test' != test }}"
for _, test := range []struct {
name, wantError string
step *model.Step
executor common.Executor
}{
{"condition error", "if-expression", &model.Step{ID: "condition", If: yaml.Node{Value: badExpression}}, noopExecutor},
{"continue-on-error expression error", "continue-on-error expression", &model.Step{ID: "continue", RawContinueOnError: badExpression}, common.NewErrorExecutor(assert.AnError)},
} {
t.Run(test.name, func(t *testing.T) {
step := newStep(t, test.step)
logger, hook := logrustest.NewNullLogger()
err := runStepExecutor(step, stepStageMain, test.executor)(common.WithLogger(context.Background(), logger))
require.ErrorContains(t, err, test.wantError)
assert.Equal(t, model.StepStatusFailure, step.RunContext.StepResults[test.step.ID].Conclusion)
assert.Equal(t, model.StepStatusFailure, hook.LastEntry().Data["stepResult"])
})
}
t.Run("file command error honors continue-on-error", func(t *testing.T) {
step := newStep(t, &model.Step{ID: "commands", RawContinueOnError: "true"})
logger, hook := logrustest.NewNullLogger()
err := runStepExecutor(step, stepStageMain, func(context.Context) error {
require.NoError(t, os.WriteFile(step.env["GITHUB_ENV"], []byte("GOOD=1\nmalformed\n"), 0o600))
require.NoError(t, os.WriteFile(step.env["GITHUB_OUTPUT"], []byte("kept=value\n"), 0o600))
require.NoError(t, os.WriteFile(step.env["GITHUB_STATE"], []byte("saved=value\n"), 0o600))
return nil
})(common.WithLogger(context.Background(), logger))
require.NoError(t, err)
result := step.RunContext.StepResults[step.Step.ID]
assert.Equal(t, model.StepStatusFailure, result.Outcome)
assert.Equal(t, model.StepStatusSuccess, result.Conclusion)
assert.Equal(t, model.StepStatusSuccess, hook.LastEntry().Data["stepResult"])
assert.Equal(t, "1", step.RunContext.Env["GOOD"])
assert.Equal(t, "value", result.Outputs["kept"])
assert.Equal(t, "value", step.RunContext.IntraActionState[step.Step.ID]["saved"])
for _, name := range []string{"GITHUB_ENV", "GITHUB_OUTPUT", "GITHUB_STATE", "GITHUB_PATH"} {
contents, readErr := os.ReadFile(step.env[name])
require.NoError(t, readErr)
assert.Empty(t, contents)
}
})
t.Run("composite child files do not leak", func(t *testing.T) {
outer := newStep(t, &model.Step{ID: "outer"})
childRC := &RunContext{
Config: outer.RunContext.Config, Run: outer.RunContext.Run, Env: map[string]string{}, StepResults: map[string]*model.StepResult{},
JobContainer: outer.RunContext.JobContainer, Parent: outer.RunContext,
}
childRC.ExprEval = childRC.NewExpressionEvaluator(context.Background())
child := &stepRun{RunContext: childRC, Step: &model.Step{ID: "child"}, env: map[string]string{}}
err := runStepExecutor(outer, stepStageMain, func(ctx context.Context) error {
require.NoError(t, runStepExecutor(child, stepStageMain, func(context.Context) error {
require.NoError(t, os.WriteFile(child.env["GITHUB_OUTPUT"], []byte("declared=child\nundeclared=leak\n"), 0o600))
require.NoError(t, os.WriteFile(child.env["GITHUB_STATE"], []byte("saved=child\n"), 0o600))
return nil
})(ctx))
outer.RunContext.setOutput(ctx, map[string]string{"name": "declared"}, "outer")
return nil
})(context.Background())
require.NoError(t, err)
assert.Equal(t, map[string]string{"declared": "outer"}, outer.RunContext.StepResults[outer.Step.ID].Outputs)
assert.Equal(t, map[string]string{"declared": "child", "undeclared": "leak"}, childRC.StepResults[child.Step.ID].Outputs)
assert.Empty(t, outer.RunContext.IntraActionState)
assert.Equal(t, "child", childRC.IntraActionState[child.Step.ID]["saved"])
})
t.Run("timeout must be positive", func(t *testing.T) {
exprEval := createRunContext(t).NewExpressionEvaluator(context.Background())
for timeout, wantDeadline := range map[string]bool{"-1": false, "0": false, "1": true} {
ctx, cancel := evaluateStepTimeout(context.Background(), exprEval, &model.Step{TimeoutMinutes: timeout})
_, hasDeadline := ctx.Deadline()
cancel()
assert.Equal(t, wantDeadline, hasDeadline, timeout)
}
})
}
+1 -1
View File
@@ -102,6 +102,6 @@ require (
go.opentelemetry.io/otel/metric v1.45.0 // indirect go.opentelemetry.io/otel/metric v1.45.0 // indirect
go.opentelemetry.io/otel/trace v1.45.0 // indirect go.opentelemetry.io/otel/trace v1.45.0 // indirect
go.yaml.in/yaml/v3 v3.0.5 // indirect go.yaml.in/yaml/v3 v3.0.5 // indirect
golang.org/x/crypto v0.55.0 // indirect golang.org/x/crypto v0.56.0 // indirect
gopkg.in/warnings.v0 v0.1.2 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect
) )
+2 -2
View File
@@ -234,8 +234,8 @@ go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
go.yaml.in/yaml/v4 v4.0.0-rc.3 h1:3h1fjsh1CTAPjW7q/EMe+C8shx5d8ctzZTrLcs/j8Go= go.yaml.in/yaml/v4 v4.0.0-rc.3 h1:3h1fjsh1CTAPjW7q/EMe+C8shx5d8ctzZTrLcs/j8Go=
go.yaml.in/yaml/v4 v4.0.0-rc.3/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0= go.yaml.in/yaml/v4 v4.0.0-rc.3/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0=
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= golang.org/x/crypto v0.56.0 h1:GUh5Ii4J5jtcseSMiRqr1jXCNHoxjeV9Fmekc2oLy6Y=
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= golang.org/x/crypto v0.56.0/go.mod h1:OMW5y6CY9l38uPLmxU6l6pwcXp1obtLo3e6gT7gQR2I=
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM= golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM=
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80= golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
+118 -61
View File
@@ -8,6 +8,7 @@ import (
"context" "context"
"crypto/rand" "crypto/rand"
"encoding/hex" "encoding/hex"
"encoding/json/v2"
"errors" "errors"
"fmt" "fmt"
"maps" "maps"
@@ -45,6 +46,8 @@ type executeArgs struct {
forcePull bool forcePull bool
forceRebuild bool forceRebuild bool
jsonLogger bool jsonLogger bool
inputs []string
inputfile string
envs []string envs []string
envfile string envfile string
secrets []string secrets []string
@@ -90,6 +93,11 @@ func (i *executeArgs) Envfile() string {
return i.resolve(i.envfile) return i.resolve(i.envfile)
} }
// Inputfile returns path to .env-format inputfile
func (i *executeArgs) Inputfile() string {
return i.resolve(i.inputfile)
}
func (i *executeArgs) LoadSecrets() map[string]string { func (i *executeArgs) LoadSecrets() map[string]string {
s := make(map[string]string) s := make(map[string]string)
for _, secretPair := range i.secrets { for _, secretPair := range i.secrets {
@@ -128,19 +136,55 @@ func readEnvs(path string, envs map[string]string) bool {
return false return false
} }
func (i *executeArgs) LoadEnvs() map[string]string { func (i *executeArgs) LoadVars() map[string]string {
envs := make(map[string]string) return parseKVAndFile(i.vars, "")
if i.envs != nil { }
for _, envVar := range i.envs {
e := strings.SplitN(envVar, `=`, 2) func (i *executeArgs) LoadInputs() map[string]string {
if len(e) == 2 { return parseKVAndFile(i.inputs, i.Inputfile())
envs[e[0]] = e[1] }
} else {
envs[e[0]] = "" // eventJSON assembles the payload the run is triggered with, the `--input` values overriding
} // the inputs the `--eventpath` file carries.
func (i *executeArgs) eventJSON() (string, error) {
payload := []byte("{}")
if path := i.resolve(i.eventpath); path != "" {
var err error
if payload, err = os.ReadFile(path); err != nil {
return "", fmt.Errorf("failed to read %s: %w", path, err)
} }
} }
_ = readEnvs(i.Envfile(), envs)
cliInputs := i.LoadInputs()
if len(cliInputs) == 0 {
return string(payload), nil
}
var event map[string]any
if err := json.Unmarshal(payload, &event); err != nil {
return "", fmt.Errorf("failed to parse event payload: %w", err)
}
if event == nil { // a JSON `null` payload unmarshals into a nil map
event = make(map[string]any)
}
inputs, ok := event["inputs"].(map[string]any)
if !ok {
inputs = make(map[string]any)
}
for name, value := range cliInputs {
inputs[name] = value
}
event["inputs"] = inputs
merged, err := json.Marshal(event)
if err != nil {
return "", fmt.Errorf("failed to marshal event payload: %w", err)
}
return string(merged), nil
}
func (i *executeArgs) LoadEnvs() map[string]string {
envs := parseKVAndFile(i.envs, i.Envfile())
envs["ACTIONS_CACHE_URL"] = i.cacheHandler.ExternalURL() + "/" envs["ACTIONS_CACHE_URL"] = i.cacheHandler.ExternalURL() + "/"
// The same server answers the cache service v2 API, so let the actions reach it. // The same server answers the cache service v2 API, so let the actions reach it.
@@ -149,20 +193,19 @@ func (i *executeArgs) LoadEnvs() map[string]string {
return envs return envs
} }
func (i *executeArgs) LoadVars() map[string]string { func parseKVAndFile(rawKVs []string, filePath string) map[string]string {
vars := make(map[string]string) result := make(map[string]string)
if i.vars != nil { _ = readEnvs(filePath, result)
for _, runVar := range i.vars {
e := strings.SplitN(runVar, `=`, 2) for _, raw := range rawKVs {
if len(e) == 2 { parts := strings.SplitN(raw, "=", 2)
vars[e[0]] = e[1] if len(parts) == 2 {
} else { result[parts[0]] = parts[1]
vars[e[0]] = "" } else {
} result[parts[0]] = ""
} }
} }
return result
return vars
} }
// Workdir returns path to workdir // Workdir returns path to workdir
@@ -329,6 +372,52 @@ func runExecList(planner model.WorkflowPlanner, execArgs *executeArgs) error {
return nil return nil
} }
func (i *executeArgs) runnerConfig(eventName string, env, proxyEnv map[string]string, maxLifetime time.Duration, sharedToolCache bool) (*runner.Config, error) {
eventJSON, err := i.eventJSON()
if err != nil {
return nil, err
}
return &runner.Config{
Workdir: i.Workdir(),
BindWorkdir: false,
ForcePull: i.forcePull,
ForceRebuild: i.forceRebuild,
JSONLogger: i.jsonLogger,
Env: env,
ProxyEnv: proxyEnv,
Vars: i.LoadVars(),
Secrets: i.LoadSecrets(),
InsecureSecrets: i.insecureSecrets,
Privileged: i.privileged,
UsernsMode: i.usernsMode,
ContainerArchitecture: i.containerArchitecture,
ContainerDaemonSocket: i.containerDaemonSocket,
UseGitIgnore: i.useGitIgnore,
GitHubInstance: i.githubInstance,
ContainerCapAdd: i.containerCapAdd,
ContainerCapDrop: i.containerCapDrop,
ContainerOptions: i.containerOptions,
ArtifactServerPath: i.artifactServerPath,
ArtifactServerPort: i.artifactServerPort,
ArtifactServerAddr: i.artifactServerAddr,
NoSkipCheckout: i.noSkipCheckout,
EventName: eventName,
EventJSON: eventJSON,
// PresetGitHubContext: preset,
ContainerNamePrefix: "GITEA-ACTIONS-TASK-" + eventName,
ContainerMaxLifetime: maxLifetime,
ContainerNetworkMode: container.NetworkMode(i.network),
DefaultActionInstance: i.defaultActionsURL,
DefaultActionInstanceIsSelfHosted: i.defaultActionsURL != "" && i.defaultActionsURL != "https://github.com",
PlatformPicker: func(_ []string) string {
return i.image
},
ValidVolumes: []string{"**"}, // All volumes are allowed for `exec` command
SharedToolCache: sharedToolCache,
}, nil
}
func runExec(ctx context.Context, execArgs *executeArgs) func(cmd *cobra.Command, args []string) error { func runExec(ctx context.Context, execArgs *executeArgs) func(cmd *cobra.Command, args []string) error {
return func(cmd *cobra.Command, args []string) error { return func(cmd *cobra.Command, args []string) error {
planner, err := model.NewWorkflowPlanner(execArgs.WorkflowsPath(), execArgs.noWorkflowRecurse) planner, err := model.NewWorkflowPlanner(execArgs.WorkflowsPath(), execArgs.noWorkflowRecurse)
@@ -445,43 +534,9 @@ func runExec(ctx context.Context, execArgs *executeArgs) func(cmd *cobra.Command
} }
// run the plan // run the plan
config := &runner.Config{ config, err := execArgs.runnerConfig(eventName, env, proxyEnv, maxLifetime, shared)
Workdir: execArgs.Workdir(), if err != nil {
BindWorkdir: false, return err
ForcePull: execArgs.forcePull,
ForceRebuild: execArgs.forceRebuild,
JSONLogger: execArgs.jsonLogger,
Env: env,
ProxyEnv: proxyEnv,
Vars: execArgs.LoadVars(),
Secrets: execArgs.LoadSecrets(),
InsecureSecrets: execArgs.insecureSecrets,
Privileged: execArgs.privileged,
UsernsMode: execArgs.usernsMode,
ContainerArchitecture: execArgs.containerArchitecture,
ContainerDaemonSocket: execArgs.containerDaemonSocket,
UseGitIgnore: execArgs.useGitIgnore,
GitHubInstance: execArgs.githubInstance,
ContainerCapAdd: execArgs.containerCapAdd,
ContainerCapDrop: execArgs.containerCapDrop,
ContainerOptions: execArgs.containerOptions,
ArtifactServerPath: execArgs.artifactServerPath,
ArtifactServerPort: execArgs.artifactServerPort,
ArtifactServerAddr: execArgs.artifactServerAddr,
NoSkipCheckout: execArgs.noSkipCheckout,
EventPath: execArgs.resolve(execArgs.eventpath),
// PresetGitHubContext: preset,
// EventJSON: string(eventJSON),
ContainerNamePrefix: "GITEA-ACTIONS-TASK-" + eventName,
ContainerMaxLifetime: maxLifetime,
ContainerNetworkMode: container.NetworkMode(execArgs.network),
DefaultActionInstance: execArgs.defaultActionsURL,
DefaultActionInstanceIsSelfHosted: execArgs.defaultActionsURL != "" && execArgs.defaultActionsURL != "https://github.com",
PlatformPicker: func(_ []string) string {
return execArgs.image
},
ValidVolumes: []string{"**"}, // All volumes are allowed for `exec` command
SharedToolCache: shared,
} }
config.Env["ACT_EXEC"] = "true" config.Env["ACT_EXEC"] = "true"
@@ -536,7 +591,9 @@ func loadExecCmd(ctx context.Context) *cobra.Command {
execCmd.Flags().BoolVarP(&execArg.forcePull, "pull", "p", false, "pull docker image(s) even if already present") execCmd.Flags().BoolVarP(&execArg.forcePull, "pull", "p", false, "pull docker image(s) even if already present")
execCmd.Flags().BoolVarP(&execArg.forceRebuild, "rebuild", "", false, "rebuild local action docker image(s) even if already present") execCmd.Flags().BoolVarP(&execArg.forceRebuild, "rebuild", "", false, "rebuild local action docker image(s) even if already present")
execCmd.PersistentFlags().BoolVar(&execArg.jsonLogger, "json", false, "Output logs in json format") execCmd.PersistentFlags().BoolVar(&execArg.jsonLogger, "json", false, "Output logs in json format")
execCmd.Flags().StringArrayVarP(&execArg.envs, "env", "", []string{}, "env to make available to actions with optional value (e.g. --env myenv=foo or --env myenv)") execCmd.Flags().StringArrayVarP(&execArg.inputs, "input", "", []string{}, "set an input the workflow declares under its workflow_dispatch or workflow_call trigger, others stay invisible to the inputs context (e.g. --input name=bar; can be specified multiple times with highest precedence)")
execCmd.Flags().StringVarP(&execArg.inputfile, "input-file", "", "", "path to an .env-format file containing key=value pairs as baseline workflow inputs (override event inputs)")
execCmd.Flags().StringArrayVarP(&execArg.envs, "env", "", []string{}, "env to make available to actions with optional value (e.g. --env myenv=foo or --env myenv; override env-file)")
execCmd.PersistentFlags().StringVarP(&execArg.envfile, "env-file", "", ".env", "environment file to read and use as env in the containers") execCmd.PersistentFlags().StringVarP(&execArg.envfile, "env-file", "", ".env", "environment file to read and use as env in the containers")
execCmd.Flags().StringArrayVarP(&execArg.secrets, "secret", "s", []string{}, "secret to make available to actions with optional value (e.g. -s mysecret=foo or -s mysecret)") execCmd.Flags().StringArrayVarP(&execArg.secrets, "secret", "s", []string{}, "secret to make available to actions with optional value (e.g. -s mysecret=foo or -s mysecret)")
execCmd.Flags().StringArrayVarP(&execArg.vars, "var", "", []string{}, "variable to make available to actions with optional value (e.g. --var myvar=foo or --var myvar)") execCmd.Flags().StringArrayVarP(&execArg.vars, "var", "", []string{}, "variable to make available to actions with optional value (e.g. --var myvar=foo or --var myvar)")
+75
View File
@@ -56,6 +56,81 @@ func TestExecuteArgsPaths(t *testing.T) {
require.Equal(t, workdir, args.Workdir()) require.Equal(t, workdir, args.Workdir())
} }
func TestExecuteArgsLoadInputs(t *testing.T) {
require.Empty(t, (&executeArgs{}).LoadInputs())
dir := t.TempDir()
envFile := filepath.Join(dir, ".env")
require.NoError(t, os.WriteFile(envFile, []byte("BAZ=qux\nOVERRIDE=from_file\n"), 0o600))
args := &executeArgs{inputs: []string{"FOO=bar", "EMPTY", "WITH=eq=sign", "OVERRIDE=from_cli"}, inputfile: envFile}
require.Equal(t, map[string]string{
"FOO": "bar",
"EMPTY": "",
"WITH": "eq=sign",
"BAZ": "qux",
"OVERRIDE": "from_cli",
}, args.LoadInputs())
}
func TestExecuteArgsEventJSON(t *testing.T) {
payload := func(content string) string {
path := filepath.Join(t.TempDir(), "event.json")
require.NoError(t, os.WriteFile(path, []byte(content), 0o600))
return path
}
tests := []struct {
name string
eventpath string
inputs []string
expected string
}{
{
name: "keeps a payload without inputs to merge",
eventpath: payload(`{"inputs": {"kept": "from_event"}}`),
expected: `{"inputs": {"kept": "from_event"}}`,
},
{
name: "overrides the event inputs it names, keeping the rest",
eventpath: payload(`{"inputs": {"kept": "from_event", "named": "from_event"}}`),
inputs: []string{"named=from_cli"},
expected: `{"inputs": {"kept": "from_event", "named": "from_cli"}}`,
},
{
name: "adds inputs to a payload carrying none",
eventpath: payload(`{"repository": {"name": "test-repo"}}`),
inputs: []string{"flag=val"},
expected: `{"repository": {"name": "test-repo"}, "inputs": {"flag": "val"}}`,
},
{
name: "handles a null payload",
eventpath: payload("null"),
inputs: []string{"flag=val"},
expected: `{"inputs": {"flag": "val"}}`,
},
{
name: "builds a payload without an event file",
inputs: []string{"flag=val"},
expected: `{"inputs": {"flag": "val"}}`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
eventJSON, err := (&executeArgs{eventpath: tt.eventpath, inputs: tt.inputs}).eventJSON()
require.NoError(t, err)
require.JSONEq(t, tt.expected, eventJSON)
})
}
args := &executeArgs{inputs: []string{"flag=val"}}
config, err := args.runnerConfig("workflow_dispatch", nil, nil, 0, false)
require.NoError(t, err)
require.JSONEq(t, `{"inputs": {"flag": "val"}}`, config.EventJSON)
require.Equal(t, "workflow_dispatch", config.EventName) // the inputs context stays empty without it
}
func TestExecuteArgsLoadVars(t *testing.T) { func TestExecuteArgsLoadVars(t *testing.T) {
require.Empty(t, (&executeArgs{}).LoadVars()) require.Empty(t, (&executeArgs{}).LoadVars())
+4 -5
View File
@@ -256,11 +256,10 @@ container:
#require_docker: false #require_docker: false
# Timeout to wait for the docker daemon to be reachable, if docker is required by require_docker or runner # Timeout to wait for the docker daemon to be reachable, if docker is required by require_docker or runner
#docker_timeout: 0s #docker_timeout: 0s
# Bind the workspace to the host filesystem instead of using Docker volumes. # Mount the workspace from a host directory instead of a Docker volume, so jobs
# This is required for Docker-in-Docker (DinD) setups when jobs use docker compose # can bind-mount it by its own path into sibling containers (".:/app" in docker
# with bind mounts (e.g., ".:/app"), as volume-based workspaces are not accessible # compose). Not needed when workflows use the GITEA_DOCKER_WORKSPACE variable.
# from the DinD daemon's filesystem. When enabled, ensure the workspace parent # The workspace parent directory must be mounted into the runner container.
# directory is also mounted into the runner container and listed in valid_volumes.
#bind_workdir: false #bind_workdir: false
# How long a job waits for a service container that declares a healthcheck to become # How long a job waits for a service container that declares a healthcheck to become
# healthy. A negative value (e.g. -1s) starts the steps without waiting. # healthy. A negative value (e.g. -1s) starts the steps without waiting.
+1 -1
View File
@@ -157,7 +157,7 @@ type Container struct {
ForceRebuild bool `yaml:"force_rebuild"` // Rebuild docker image(s) even if already present ForceRebuild bool `yaml:"force_rebuild"` // Rebuild docker image(s) even if already present
RequireDocker bool `yaml:"require_docker"` // Always require a reachable docker daemon, even if not required by runner RequireDocker bool `yaml:"require_docker"` // Always require a reachable docker daemon, even if not required by runner
DockerTimeout time.Duration `yaml:"docker_timeout"` // Timeout to wait for the docker daemon to be reachable, if docker is required by require_docker or runner DockerTimeout time.Duration `yaml:"docker_timeout"` // Timeout to wait for the docker daemon to be reachable, if docker is required by require_docker or runner
BindWorkdir bool `yaml:"bind_workdir"` // BindWorkdir binds the workspace to the host filesystem instead of using Docker volumes. Required for DinD when jobs use docker compose with bind mounts. BindWorkdir bool `yaml:"bind_workdir"` // BindWorkdir mounts the workspace from a host directory instead of a Docker volume.
ServiceReadyTimeout time.Duration `yaml:"service_ready_timeout"` // ServiceReadyTimeout bounds how long a job waits for a service container that declares a healthcheck to report healthy. Negative disables waiting. ServiceReadyTimeout time.Duration `yaml:"service_ready_timeout"` // ServiceReadyTimeout bounds how long a job waits for a service container that declares a healthcheck to report healthy. Negative disables waiting.
} }
+11 -3
View File
@@ -783,8 +783,9 @@ var cmdRegex = regexp.MustCompile(`^::([^ :]+)( [^:]*)?::(.*)$`)
// handleCommand takes value still escaped, so that the web UI decodes it exactly once. Only // handleCommand takes value still escaped, so that the web UI decodes it exactly once. Only
// the branches that consume the payload here decode it. // the branches that consume the payload here decode it.
func (r *Reporter) handleCommand(originalContent, command, properties, value string) *string { func (r *Reporter) handleCommand(originalContent, command, properties, value string) *string {
command = strings.ToLower(command) // GitHub matches command names case-insensitively
if r.stopCommandEndToken != "" { if r.stopCommandEndToken != "" {
if command != r.stopCommandEndToken { if !strings.EqualFold(command, r.stopCommandEndToken) {
return &originalContent return &originalContent
} }
// Resumed here rather than from the switch, because the end token is arbitrary and a // Resumed here rather than from the switch, because the end token is arbitrary and a
@@ -871,13 +872,20 @@ func parseCommandProperties(properties string) map[string]string {
return props return props
} }
func cutPrefixFold(s, prefix string) (string, bool) {
if len(s) < len(prefix) || !strings.EqualFold(s[:len(prefix)], prefix) {
return s, false
}
return s[len(prefix):], true
}
func (r *Reporter) parseLogRow(entry *log.Entry) *runnerv1.LogRow { func (r *Reporter) parseLogRow(entry *log.Entry) *runnerv1.LogRow {
content := strings.TrimRight(entry.Message, "\r\n") content := strings.TrimRight(entry.Message, "\r\n")
// cmdRegex only covers the ::cmd:: form, so the ##[add-mask] one would otherwise reach // cmdRegex only covers the ::cmd:: form, so the ##[add-mask] one would otherwise reach
// the log carrying its own secret. Registered and dropped like its ::add-mask:: twin. // the log carrying its own secret. Registered and dropped like its ::add-mask:: twin.
if arg, ok := strings.CutPrefix(content, "##[add-mask]"); ok { if arg, ok := cutPrefixFold(content, "##[add-mask]"); ok {
r.addMask(runner.UnescapeCommandData(arg)) r.addMask(runner.UnescapeLegacyCommand(arg))
return nil return nil
} }
+3 -2
View File
@@ -23,6 +23,7 @@ import (
"gitea.com/gitea/runner/internal/pkg/metrics" "gitea.com/gitea/runner/internal/pkg/metrics"
connect_go "connectrpc.com/connect" connect_go "connectrpc.com/connect"
"gitea.dev/actionslib/pkg/model"
runnerv1 "gitea.dev/actionslib/runner/v1" runnerv1 "gitea.dev/actionslib/runner/v1"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
logrustest "github.com/sirupsen/logrus/hooks/test" logrustest "github.com/sirupsen/logrus/hooks/test"
@@ -273,14 +274,14 @@ func TestReporter_Fire(t *testing.T) {
"stepID": []string{"0", "0"}, "stepID": []string{"0", "0"},
"stepNumber": 0, "stepNumber": 0,
"raw_output": true, "raw_output": true,
"stepResult": "failure", "stepResult": model.StepStatusFailure,
}})) }}))
assert.Equal(t, runnerv1.Result_RESULT_UNSPECIFIED, reporter.state.Steps[0].Result) assert.Equal(t, runnerv1.Result_RESULT_UNSPECIFIED, reporter.state.Steps[0].Result)
require.NoError(t, reporter.Fire(&log.Entry{Message: "step result", Data: map[string]any{ require.NoError(t, reporter.Fire(&log.Entry{Message: "step result", Data: map[string]any{
"stage": "Main", "stage": "Main",
"stepNumber": 0, "stepNumber": 0,
"raw_output": true, "raw_output": true,
"stepResult": "success", "stepResult": model.StepStatusSuccess,
}})) }}))
assert.Equal(t, runnerv1.Result_RESULT_SUCCESS, reporter.state.Steps[0].Result) assert.Equal(t, runnerv1.Result_RESULT_SUCCESS, reporter.state.Steps[0].Result)