53 Commits

Author SHA1 Message Date
Renovate Bot
a17a10101f chore(deps): update docker docker tag to v29.5.3 2026-06-10 00:14:16 +00:00
Nicolas
355289bc54 docs(docker-images): Update docs (#1020)
make docs better

https://gitea.com/gitea/runner/issues/997

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

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

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

## Changes

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

## ⚠️ BREAKING ⚠️

This is a breaking change.

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

Migration options:

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

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

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

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

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

---

Related:

- Fixes #386

---------

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

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

---

### Release Notes

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

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

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

#### What's Changed

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

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

</details>

---

### Configuration

📅 **Schedule**: (UTC)

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

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

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

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

---

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

---

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

---------

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

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

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

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

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

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

## Changes

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

## Related

- Fixes #1010

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

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

---

### Configuration

📅 **Schedule**: (UTC)

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

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

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

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

---

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

---

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

---------

Co-authored-by: Nicolas <bircni@icloud.com>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1009
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: Renovate Bot <renovate-bot@gitea.com>
Co-committed-by: Renovate Bot <renovate-bot@gitea.com>
2026-06-02 17:32:36 +00:00
Nicolas
c749e52bb7 fix(cleanup): kill Windows step process tree on cancel to avoid hang (#1011)
## Problem

Cancelling a job on a Windows host runner can leave the spawned process
tree running and hang the runner. When a step launches a shell that
starts a child which in turn spawns further GUI/background processes,
cancelling the job kills only the direct child (the default
`exec.CommandContext` behaviour). The surviving descendants inherited
the step's stdout/stderr pipe, so the read end never hit EOF and
`cmd.Wait()` blocked forever.

Because the step executor never returned:
- the orphaned processes kept running (the cancelled work was not
  actually stopped), and
- end-of-job cleanup (`Remove` → `terminateRunningProcesses`) was never
  reached, so the runner appeared to go offline / stop picking up jobs.

`CREATE_NEW_PROCESS_GROUP` does not help here — it affects Ctrl-C signal
delivery, not handle inheritance or tree termination.

## Fix

- Assign each Windows step process to a **Job Object** immediately after
  `cmd.Start()`. Descendants created afterwards are automatically part
  of the job.
- Override `cmd.Cancel` to `TerminateJobObject`, so cancellation kills
  the **entire descendant tree** atomically. This also closes the
  inherited pipe handles, so `cmd.Wait()` can return.
- Set `cmd.WaitDelay` (10s) as a safety net: once the process has
  exited, Wait force-closes the pipes and returns rather than blocking
  forever — covering the case where the job-object setup fails (e.g.
  nested-job restrictions), in which we fall back to the previous
  single-process kill.
- The Job Object is created **without** `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE`,
  so closing the handle on normal completion does not kill legitimate
  background processes; the tree is only torn down on explicit cancel.

Implemented behind `runtime.GOOS == "windows"` with a Windows-only
`processKiller` (Job Object) and no-op stubs elsewhere, so non-Windows
behaviour (default cancellation + `Setpgid`) is unchanged.

## Changes

- `act/container/process_windows.go` — Job Object `processKiller`
  (create / assign / terminate).
- `act/container/process_other.go` — no-op stubs (`//go:build !windows`).
- `act/container/host_environment.go` — wire `cmd.Cancel` (tree kill)
  and `cmd.WaitDelay` into `exec()`.
- `go.mod` / `go.sum` — promote `golang.org/x/sys` to a direct
  dependency.

## Testing

I fully tested it already

## Notes

Follow-up to the Windows leftover-process reaping in #996: that sweep
now actually runs on cancellation because the step no longer hangs
before reaching it.

Reviewed-on: https://gitea.com/gitea/runner/pulls/1011
Reviewed-by: techknowlogick <9+techknowlogick@noreply.gitea.com>
2026-06-02 16:53:27 +00:00
silverwind
f17b6b9fc3 fix(container): re-validate cached container id before reuse (#1003)
`containerReference.id` was cached from `Create()` and never re-validated, so a container torn down out-of-band (AutoRemove on an unexpected exit, daemon-side cleanup, sibling-job race in a parallel matrix) left a stale id behind. The next `Copy`/`Exec` then hit the daemon with that dead id and failed the otherwise-successful job with `Could not find the file /var/run/act/ in container <id>`.

`find()` now `ContainerInspect`s the cached id and clears it only on a definitive `NotFound`; transient errors trust the cache so cleanup pipelines don't abort on a daemon blip. Operations that need a live container (`copyContent`/`copyDir`/`CopyTarStream`/`exec`/`GetContainerArchive`) fail fast with a clear `container "<name>" does not exist` instead of the daemon's generic empty-id error.

---
This PR was written with the help of Claude Opus 4.7

---------

Co-authored-by: Nicolas <bircni@icloud.com>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1003
Reviewed-by: Nicolas <bircni@icloud.com>
Co-authored-by: silverwind <2021+silverwind@noreply.gitea.com>
Co-committed-by: silverwind <2021+silverwind@noreply.gitea.com>
2026-05-29 22:33:44 +00:00
Christopher Homberger
c7c4bd600a fix: support multiline secret masking (#1001)
* command logging exposes multiline secrets more often than before
* duplicated add-mask command in reporter now handles this as well

Closes #998
Co-authored-by: silverwind <2021+silverwind@noreply.gitea.com>
Co-authored-by: silverwind <me@silverwind.io>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1001
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
Reviewed-by: silverwind <2021+silverwind@noreply.gitea.com>
Co-authored-by: Christopher Homberger <christopher.homberger@web.de>
Co-committed-by: Christopher Homberger <christopher.homberger@web.de>
2026-05-29 19:58:15 +00:00
silverwind
abec931d98 fix: restore global docker config dir and socket env in tests (#1004)
`TestGetImagePullOptions` left docker/cli's process-global config dir pointed at `testdata/docker-pull-options` (which ships dummy `username:password` creds) via `config.SetDir`, without restoring it. Because that override is process-global, every later docker-gated test in the package then pulled with those creds — `TestDockerCopyToSymlinkPath`'s `alpine:latest` pull failed with `incorrect username or password` and broke CI. The workflow's `DOCKER_CONFIG` override can't mask this, since `SetDir` wins in-process.

Restore `config.Dir()` with `t.Cleanup`, and isolate the socket tests' leaks of the exported `CommonSocketLocations` and `DOCKER_HOST` behind an `isolateSocketEnv` helper.

Refs https://gitea.com/gitea/gitea.com/issues/83

---
This PR was written with the help of Claude Opus 4.8

Reviewed-on: https://gitea.com/gitea/runner/pulls/1004
Reviewed-by: Zettat123 <39446+zettat123@noreply.gitea.com>
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: silverwind <me@silverwind.io>
Co-committed-by: silverwind <me@silverwind.io>
2026-05-29 16:47:10 +00:00
silverwind
270ea41232 fix: matrix-job data races + outputs, leaner offline test suite (#994)
Running the full suite under `-race` (dropping `-short`) exposed pre-existing data races in parallel matrix-job execution, fixed by not sharing mutable state across combinations:

- `containerDaemonSocket()`/`validVolumes()` derive per-job values instead of mutating shared `Config`
- `getWorkflowSecrets` builds a fresh map, `rc.steps()` clones each step, and go-git workdir access is serialized
- every write to a shared `Job`'s result/outputs runs under a per-`Job` lock, each combo interpolating outputs from a pristine snapshot (last wins, as on GitHub)

### Test suite

- capability gates (docker / network / host-tools / Linux) replace the `-short` skips, and the suite runs offline via local fixtures (the artifact flow uses an in-process loopback server, only the docker-action force-pull needs the network)
- drops redundant tests, adds a regression test for https://gitea.com/gitea/runner/issues/981 and a docker-in-docker harness (`make test-dind`)

---
This PR was written with the help of Claude Opus 4.7

Reviewed-on: https://gitea.com/gitea/runner/pulls/994
Reviewed-by: Nicolas <bircni@icloud.com>
Co-authored-by: silverwind <me@silverwind.io>
Co-committed-by: silverwind <me@silverwind.io>
2026-05-29 05:23:10 +00:00
Nicolas
0b9f251b6a fix: deliver cancel ack and reap leftover Windows job processes (#996)
## Summary

- When Gitea cancels a job, the reporter cancels its own task context; the final Close() flush then aborted on that same cancelled context and Gitea never received the runner's acknowledgement (missing tail logs and final state).
- On Windows the cancelled context also neutralised terminateRunningProcesses, leaving step grandchildren alive in the workspace, holding file handles, so the runner could no longer clean up and pick up new work.
- Reporter.Close() now flushes on a detached, bounded context via a new rpcCtx() helper and configurable Runner.ReportCloseTimeout (default 10s).
- terminateRunningProcesses now PowerShell-enumerates Win32_Process and taskkill /T /F's every process whose ExecutablePath or CommandLine references the job's workspace directories, on a detached context.
- The daemon heartbeat loop still exits on <-r.ctx.Done(): the runner is intentionally seen as offline by Gitea during cleanup so it isn't handed a new task overlapping the in-progress teardown.

## Test plan

- [x] go test ./internal/pkg/report/... ./act/container/ -run 'TestReporter_ServerCancelStillFlushesFinal|TestBuildWindowsWorkspaceKillScript'
- [x] make fmt && make lint-go - 0 issues
- [x] GOOS=windows go build ./... - clean
- [x] Manual on a Windows runner: trigger a long-running workflow, cancel from Gitea UI; verify (a) the job ends with tail logs + cancelled state in Gitea, (b) workspace cleans up, (c) the runner picks up a new job without restart.

Authored-by: bircni
🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: silverwind <me@silverwind.io>
Reviewed-on: https://gitea.com/gitea/runner/pulls/996
Reviewed-by: silverwind <2021+silverwind@noreply.gitea.com>
2026-05-24 10:01:01 +00:00
Nicolas
273f6b4247 fix(reporter): respect configured log level for job log forwarding (#989)
## Summary

- Non-raw_output log entries above the globally configured `log.level` are no longer forwarded to the Gitea job log output
- Step output (`raw_output=true`) is always forwarded regardless of level — it is actual job stdout/stderr, not runner internals
- State-machine fields (`stepResult`, `jobResult`) are always processed regardless of level, preserving correct tracking for skipped steps (whose `stepResult` is emitted at `DebugLevel` in `step.go`)
- Extracts a `shouldAppendLogRow` helper to avoid repeating the combined `!duringSteps() && entry.Level <= log.GetLevel()` guard in three places

## Why not the approach in #677

PR #677 adds `if entry.Level != log.GetLevel() { return nil }` at the top of `Fire()`. That has two bugs:
1. Uses `!=` instead of `>`, so `Error`/`Fatal` entries are dropped when the configured level is `Warn`
2. Returns early before processing `stepResult`/`jobResult` state fields — skipped steps (whose `stepResult` is logged at `DebugLevel`) would never be marked complete

This fix instead applies the level guard only at the `r.logRows` append sites, leaving state tracking unconditional.

Relates to #409.

Reviewed-on: https://gitea.com/gitea/runner/pulls/989
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
2026-05-23 17:28:44 +00:00
Renovate Bot
47ee45412a fix(deps): update module github.com/opencontainers/selinux to v1.15.0 (#990)
This PR contains the following updates:

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

---

### Release Notes

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

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

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

This release adds a new function, SetProcessKind, which is to be used instead of KVMProcessLabel\[s] and InitProcessLabel\[s] in case the user only wants to change the type of the existing label, not generate a new one. It also fixes an CI issue and optimizes label.InitLabels for a few common cases.

#### What's Changed

- ci: set timeout for vm jobs by [@&#8203;kolyshkin](https://github.com/kolyshkin) in [#&#8203;270](https://github.com/opencontainers/selinux/pull/270)
- label.InitLabels: optimize by [@&#8203;kolyshkin](https://github.com/kolyshkin) in [#&#8203;269](https://github.com/opencontainers/selinux/pull/269)
- Add SetProcessKind by [@&#8203;kolyshkin](https://github.com/kolyshkin) in [#&#8203;271](https://github.com/opencontainers/selinux/pull/271)

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

</details>

---

### Configuration

📅 **Schedule**: (UTC)

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

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

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

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

---

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

---

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

Reviewed-on: https://gitea.com/gitea/runner/pulls/990
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: Renovate Bot <renovate-bot@gitea.com>
Co-committed-by: Renovate Bot <renovate-bot@gitea.com>
2026-05-22 07:10:19 +00:00
silverwind
38b69bb214 chore: pin Docker base images to explicit versions (#992)
Pin floating image tags:

- `golang` → `1.26-alpine3.23`
- `docker` dind variants → `29.5.2`
- `alpine` (basic stage + test fixture) → `3.23`

`ubuntu:24.04` and `scratch` left unchanged (no more-specific tag).

---
This PR was written with the help of Claude Opus 4.7

Reviewed-on: https://gitea.com/gitea/runner/pulls/992
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
2026-05-22 07:09:56 +00:00
Renovate Bot
1c62c0635f chore(deps): update actions/setup-node action to v6 (#991)
This PR contains the following updates:

| Package | Type | Update | Change |
|---|---|---|---|
| [actions/setup-node](https://github.com/actions/setup-node) | action | major | `v4` → `v6` |

---

### Release Notes

<details>
<summary>actions/setup-node (actions/setup-node)</summary>

### [`v6.4.0`](https://github.com/actions/setup-node/releases/tag/v6.4.0)

[Compare Source](https://github.com/actions/setup-node/compare/v6.3.0...v6.4.0)

#### What's Changed

##### Dependency updates:

- Upgrade [@&#8203;actions](https://github.com/actions) dependencies by [@&#8203;Copilot](https://github.com/Copilot) in [#&#8203;1525](https://github.com/actions/setup-node/pull/1525)
- Update Node.js versions in versions.yml and bump package to v6.4.0  by [@&#8203;priya-kinthali](https://github.com/priya-kinthali) in [#&#8203;1533](https://github.com/actions/setup-node/pull/1533)

#### New Contributors

- [@&#8203;Copilot](https://github.com/Copilot) made their first contribution in [#&#8203;1525](https://github.com/actions/setup-node/pull/1525)

**Full Changelog**: <https://github.com/actions/setup-node/compare/v6...v6.4.0>

### [`v6.3.0`](https://github.com/actions/setup-node/releases/tag/v6.3.0)

[Compare Source](https://github.com/actions/setup-node/compare/v6.2.0...v6.3.0)

#### What's Changed

##### Enhancements:

- Support parsing `devEngines` field by [@&#8203;susnux](https://github.com/susnux) in [#&#8203;1283](https://github.com/actions/setup-node/pull/1283)

> When using node-version-file: package.json, setup-node now prefers devEngines.runtime over engines.node.

##### Dependency updates:

- Fix npm audit issues by [@&#8203;gowridurgad](https://github.com/gowridurgad) in [#&#8203;1491](https://github.com/actions/setup-node/pull/1491)
- Replace uuid with crypto.randomUUID() by [@&#8203;trivikr](https://github.com/trivikr) in [#&#8203;1378](https://github.com/actions/setup-node/pull/1378)
- Upgrade minimatch from 3.1.2 to 3.1.5 by [@&#8203;dependabot](https://github.com/dependabot) in [#&#8203;1498](https://github.com/actions/setup-node/pull/1498)

##### Bug fixes:

- Remove hardcoded bearer for mirror-url [@&#8203;marco-ippolito](https://github.com/marco-ippolito) in [#&#8203;1467](https://github.com/actions/setup-node/pull/1467)
- Scope test lockfiles by package manager and update cache tests by [@&#8203;gowridurgad](https://github.com/gowridurgad) in [#&#8203;1495](https://github.com/actions/setup-node/pull/1495)

#### New Contributors

- [@&#8203;susnux](https://github.com/susnux) made their first contribution in [#&#8203;1283](https://github.com/actions/setup-node/pull/1283)

**Full Changelog**: <https://github.com/actions/setup-node/compare/v6...v6.3.0>

### [`v6.2.0`](https://github.com/actions/setup-node/releases/tag/v6.2.0)

[Compare Source](https://github.com/actions/setup-node/compare/v6.1.0...v6.2.0)

#### What's Changed

##### Documentation

- Documentation update related to absence of Lockfile by [@&#8203;mahabaleshwars](https://github.com/mahabaleshwars) in [#&#8203;1454](https://github.com/actions/setup-node/pull/1454)
- Correct mirror option typos by [@&#8203;MikeMcC399](https://github.com/MikeMcC399) in [#&#8203;1442](https://github.com/actions/setup-node/pull/1442)
- Readme update on checkout version v6 by [@&#8203;deining](https://github.com/deining) in [#&#8203;1446](https://github.com/actions/setup-node/pull/1446)
- Readme typo fixes [@&#8203;munyari](https://github.com/munyari) in [#&#8203;1226](https://github.com/actions/setup-node/pull/1226)
- Advanced document update on checkout version v6 by [@&#8203;aparnajyothi-y](https://github.com/aparnajyothi-y)  in [#&#8203;1468](https://github.com/actions/setup-node/pull/1468)

##### Dependency updates:

- Upgrade [@&#8203;actions/cache](https://github.com/actions/cache) to v5.0.1 by [@&#8203;salmanmkc](https://github.com/salmanmkc) in [#&#8203;1449](https://github.com/actions/setup-node/pull/1449)

#### New Contributors

- [@&#8203;mahabaleshwars](https://github.com/mahabaleshwars) made their first contribution in [#&#8203;1454](https://github.com/actions/setup-node/pull/1454)
- [@&#8203;MikeMcC399](https://github.com/MikeMcC399) made their first contribution in [#&#8203;1442](https://github.com/actions/setup-node/pull/1442)
- [@&#8203;deining](https://github.com/deining) made their first contribution in [#&#8203;1446](https://github.com/actions/setup-node/pull/1446)
- [@&#8203;munyari](https://github.com/munyari) made their first contribution in [#&#8203;1226](https://github.com/actions/setup-node/pull/1226)

**Full Changelog**: <https://github.com/actions/setup-node/compare/v6...v6.2.0>

### [`v6.1.0`](https://github.com/actions/setup-node/releases/tag/v6.1.0)

[Compare Source](https://github.com/actions/setup-node/compare/v6...v6.1.0)

#### What's Changed

##### Enhancement:

- Remove always-auth configuration handling by [@&#8203;priyagupta108](https://github.com/priyagupta108) in [#&#8203;1436](https://github.com/actions/setup-node/pull/1436)

##### Dependency updates:

- Upgrade [@&#8203;actions/cache](https://github.com/actions/cache) from 4.0.3 to 4.1.0 by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;1384](https://github.com/actions/setup-node/pull/1384)
- Upgrade actions/checkout from 5 to 6 by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;1439](https://github.com/actions/setup-node/pull/1439)
- Upgrade js-yaml from 3.14.1 to 3.14.2 by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;1435](https://github.com/actions/setup-node/pull/1435)

##### Documentation update:

- Add example for restore-only cache in documentation by [@&#8203;aparnajyothi-y](https://github.com/aparnajyothi-y) in [#&#8203;1419](https://github.com/actions/setup-node/pull/1419)

**Full Changelog**: <https://github.com/actions/setup-node/compare/v6...v6.1.0>

### [`v6.0.0`](https://github.com/actions/setup-node/releases/tag/v6.0.0)

[Compare Source](https://github.com/actions/setup-node/compare/v6...v6)

#### What's Changed

**Breaking Changes**

- Limit automatic caching to npm, update workflows and documentation by [@&#8203;priyagupta108](https://github.com/priyagupta108) in [#&#8203;1374](https://github.com/actions/setup-node/pull/1374)

**Dependency Upgrades**

- Upgrade ts-jest from 29.1.2 to 29.4.1 and document breaking changes in v5 by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;1336](https://github.com/actions/setup-node/pull/1336)
- Upgrade prettier from 2.8.8 to 3.6.2 by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;1334](https://github.com/actions/setup-node/pull/1334)
- Upgrade actions/publish-action from 0.3.0 to 0.4.0 by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;1362](https://github.com/actions/setup-node/pull/1362)

**Full Changelog**: <https://github.com/actions/setup-node/compare/v5...v6.0.0>

### [`v6`](https://github.com/actions/setup-node/compare/v5.0.0...v6)

[Compare Source](https://github.com/actions/setup-node/compare/v5.0.0...v6)

### [`v5.0.0`](https://github.com/actions/setup-node/releases/tag/v5.0.0)

[Compare Source](https://github.com/actions/setup-node/compare/v5.0.0...v5.0.0)

#### What's Changed

##### Breaking Changes

- Enhance caching in setup-node with automatic package manager detection by [@&#8203;priya-kinthali](https://github.com/priya-kinthali) in [#&#8203;1348](https://github.com/actions/setup-node/pull/1348)

This update, introduces automatic caching when a valid `packageManager` field is present in your `package.json`. This aims to improve workflow performance and make dependency management more seamless.
To disable this automatic caching, set `package-manager-cache: false`

```yaml
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v5
  with:
    package-manager-cache: false
```

- Upgrade action to use node24 by [@&#8203;salmanmkc](https://github.com/salmanmkc) in [#&#8203;1325](https://github.com/actions/setup-node/pull/1325)

Make sure your runner is on version v2.327.1 or later to ensure compatibility with this release. [See Release Notes](https://github.com/actions/runner/releases/tag/v2.327.1)

##### Dependency Upgrades

- Upgrade [@&#8203;octokit/request-error](https://github.com/octokit/request-error) and [@&#8203;actions/github](https://github.com/actions/github) by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;1227](https://github.com/actions/setup-node/pull/1227)
- Upgrade uuid from 9.0.1 to 11.1.0 by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;1273](https://github.com/actions/setup-node/pull/1273)
- Upgrade undici from 5.28.5 to 5.29.0 by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;1295](https://github.com/actions/setup-node/pull/1295)
- Upgrade form-data to bring in fix for critical vulnerability by [@&#8203;gowridurgad](https://github.com/gowridurgad) in [#&#8203;1332](https://github.com/actions/setup-node/pull/1332)
- Upgrade actions/checkout from 4 to 5 by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;1345](https://github.com/actions/setup-node/pull/1345)

#### New Contributors

- [@&#8203;priya-kinthali](https://github.com/priya-kinthali) made their first contribution in [#&#8203;1348](https://github.com/actions/setup-node/pull/1348)
- [@&#8203;salmanmkc](https://github.com/salmanmkc) made their first contribution in [#&#8203;1325](https://github.com/actions/setup-node/pull/1325)

**Full Changelog**: <https://github.com/actions/setup-node/compare/v4...v5.0.0>

### [`v5`](https://github.com/actions/setup-node/compare/v4.4.0...v5.0.0)

[Compare Source](https://github.com/actions/setup-node/compare/v4.4.0...v5.0.0)

### [`v4.4.0`](https://github.com/actions/setup-node/releases/tag/v4.4.0)

[Compare Source](https://github.com/actions/setup-node/compare/v4.3.0...v4.4.0)

#### What's Changed

##### Bug fixes:

- Make eslint-compact matcher compatible with Stylelint by [@&#8203;FloEdelmann](https://github.com/FloEdelmann) in [#&#8203;98](https://github.com/actions/setup-node/pull/98)
- Add support for indented eslint output by [@&#8203;fregante](https://github.com/fregante) in [#&#8203;1245](https://github.com/actions/setup-node/pull/1245)

##### Enhancement:

- Support private mirrors by [@&#8203;marco-ippolito](https://github.com/marco-ippolito) in [#&#8203;1240](https://github.com/actions/setup-node/pull/1240)

##### Dependency update:

- Upgrade [@&#8203;action/cache](https://github.com/action/cache) from 4.0.2 to 4.0.3 by [@&#8203;aparnajyothi-y](https://github.com/aparnajyothi-y) in [#&#8203;1262](https://github.com/actions/setup-node/pull/1262)

#### New Contributors

- [@&#8203;FloEdelmann](https://github.com/FloEdelmann) made their first contribution in [#&#8203;98](https://github.com/actions/setup-node/pull/98)
- [@&#8203;fregante](https://github.com/fregante) made their first contribution in [#&#8203;1245](https://github.com/actions/setup-node/pull/1245)
- [@&#8203;marco-ippolito](https://github.com/marco-ippolito) made their first contribution in [#&#8203;1240](https://github.com/actions/setup-node/pull/1240)

**Full Changelog**: <https://github.com/actions/setup-node/compare/v4...v4.4.0>

### [`v4.3.0`](https://github.com/actions/setup-node/releases/tag/v4.3.0)

[Compare Source](https://github.com/actions/setup-node/compare/v4.2.0...v4.3.0)

#### What's Changed

##### Dependency updates

- Upgrade [@&#8203;actions/glob](https://github.com/actions/glob) from 0.4.0 to 0.5.0 by [@&#8203;dependabot](https://github.com/dependabot) in [#&#8203;1200](https://github.com/actions/setup-node/pull/1200)
- Upgrade [@&#8203;action/cache](https://github.com/action/cache) from 4.0.0 to 4.0.2 by [@&#8203;gowridurgad](https://github.com/gowridurgad) in [#&#8203;1251](https://github.com/actions/setup-node/pull/1251)
- Upgrade [@&#8203;vercel/ncc](https://github.com/vercel/ncc) from 0.38.1 to 0.38.3 by [@&#8203;dependabot](https://github.com/dependabot) in [#&#8203;1203](https://github.com/actions/setup-node/pull/1203)
- Upgrade [@&#8203;actions/tool-cache](https://github.com/actions/tool-cache) from 2.0.1 to 2.0.2 by [@&#8203;dependabot](https://github.com/dependabot) in [#&#8203;1220](https://github.com/actions/setup-node/pull/1220)

#### New Contributors

- [@&#8203;gowridurgad](https://github.com/gowridurgad) made their first contribution in [#&#8203;1251](https://github.com/actions/setup-node/pull/1251)

**Full Changelog**: <https://github.com/actions/setup-node/compare/v4...v4.3.0>

### [`v4.2.0`](https://github.com/actions/setup-node/releases/tag/v4.2.0)

[Compare Source](https://github.com/actions/setup-node/compare/v4.1.0...v4.2.0)

#### What's Changed

- Enhance workflows and upgrade publish-actions from 0.2.2 to 0.3.0 by [@&#8203;aparnajyothi-y](https://github.com/aparnajyothi-y) in [#&#8203;1174](https://github.com/actions/setup-node/pull/1174)
- Add recommended permissions section to readme by [@&#8203;benwells](https://github.com/benwells) in [#&#8203;1193](https://github.com/actions/setup-node/pull/1193)
- Configure Dependabot settings by [@&#8203;HarithaVattikuti](https://github.com/HarithaVattikuti) in [#&#8203;1192](https://github.com/actions/setup-node/pull/1192)
- Upgrade `@actions/cache` to `^4.0.0` by [@&#8203;priyagupta108](https://github.com/priyagupta108) in [#&#8203;1191](https://github.com/actions/setup-node/pull/1191)
- Upgrade pnpm/action-setup from 2 to 4 by [@&#8203;dependabot](https://github.com/dependabot) in [#&#8203;1194](https://github.com/actions/setup-node/pull/1194)
- Upgrade actions/publish-immutable-action from 0.0.3 to 0.0.4 by [@&#8203;dependabot](https://github.com/dependabot) in [#&#8203;1195](https://github.com/actions/setup-node/pull/1195)
- Upgrade semver from 7.6.0 to 7.6.3 by [@&#8203;dependabot](https://github.com/dependabot) in [#&#8203;1196](https://github.com/actions/setup-node/pull/1196)
- Upgrade [@&#8203;types/jest](https://github.com/types/jest) from 29.5.12 to 29.5.14 by [@&#8203;dependabot](https://github.com/dependabot) in [#&#8203;1201](https://github.com/actions/setup-node/pull/1201)
- Upgrade undici from 5.28.4 to 5.28.5 by [@&#8203;dependabot](https://github.com/dependabot) in [#&#8203;1205](https://github.com/actions/setup-node/pull/1205)

#### New Contributors

- [@&#8203;benwells](https://github.com/benwells) made their first contribution in [#&#8203;1193](https://github.com/actions/setup-node/pull/1193)

**Full Changelog**: <https://github.com/actions/setup-node/compare/v4...v4.2.0>

### [`v4.1.0`](https://github.com/actions/setup-node/releases/tag/v4.1.0)

[Compare Source](https://github.com/actions/setup-node/compare/v4.0.4...v4.1.0)

#### What's Changed

- Resolve High Security Alerts by upgrading Dependencies by [@&#8203;aparnajyothi-y](https://github.com/aparnajyothi-y) in [#&#8203;1132](https://github.com/actions/setup-node/pull/1132)
- Upgrade IA Publish by [@&#8203;Jcambass](https://github.com/Jcambass) in [#&#8203;1134](https://github.com/actions/setup-node/pull/1134)
- Revise `isGhes` logic by [@&#8203;jww3](https://github.com/jww3) in [#&#8203;1148](https://github.com/actions/setup-node/pull/1148)
- Add architecture to cache key by [@&#8203;pengx17](https://github.com/pengx17) in [#&#8203;843](https://github.com/actions/setup-node/pull/843)
  This addresses issues with caching by adding the architecture (arch) to the cache key, ensuring that cache keys are accurate to prevent conflicts.
  Note: This change may break previous cache keys as they will no longer be compatible with the new format.

#### New Contributors

- [@&#8203;jww3](https://github.com/jww3) made their first contribution in [#&#8203;1148](https://github.com/actions/setup-node/pull/1148)
- [@&#8203;pengx17](https://github.com/pengx17) made their first contribution in [#&#8203;843](https://github.com/actions/setup-node/pull/843)

**Full Changelog**: <https://github.com/actions/setup-node/compare/v4...v4.1.0>

### [`v4.0.4`](https://github.com/actions/setup-node/releases/tag/v4.0.4)

[Compare Source](https://github.com/actions/setup-node/compare/v4.0.3...v4.0.4)

#### What's Changed

- Add workflow file for publishing releases to immutable action package by [@&#8203;Jcambass](https://github.com/Jcambass) in [#&#8203;1125](https://github.com/actions/setup-node/pull/1125)
- Enhance Windows ARM64 Setup and Update micromatch Dependency by [@&#8203;priyagupta108](https://github.com/priyagupta108) in [#&#8203;1126](https://github.com/actions/setup-node/pull/1126)

##### Documentation changes:

- Documentation update in the README file by [@&#8203;suyashgaonkar](https://github.com/suyashgaonkar) in [#&#8203;1106](https://github.com/actions/setup-node/pull/1106)
- Correct invalid 'lts' version string reference by [@&#8203;fulldecent](https://github.com/fulldecent) in [#&#8203;1124](https://github.com/actions/setup-node/pull/1124)

#### New Contributors

- [@&#8203;suyashgaonkar](https://github.com/suyashgaonkar) made their first contribution in [#&#8203;1106](https://github.com/actions/setup-node/pull/1106)
- [@&#8203;priyagupta108](https://github.com/priyagupta108) made their first contribution in [#&#8203;1126](https://github.com/actions/setup-node/pull/1126)
- [@&#8203;Jcambass](https://github.com/Jcambass) made their first contribution in [#&#8203;1125](https://github.com/actions/setup-node/pull/1125)
- [@&#8203;fulldecent](https://github.com/fulldecent) made their first contribution in [#&#8203;1124](https://github.com/actions/setup-node/pull/1124)

**Full Changelog**: <https://github.com/actions/setup-node/compare/v4...v4.0.4>

### [`v4.0.3`](https://github.com/actions/setup-node/releases/tag/v4.0.3)

[Compare Source](https://github.com/actions/setup-node/compare/v4.0.2...v4.0.3)

#### What's Changed

##### Bug fixes:

- Fix macos latest check failures by [@&#8203;HarithaVattikuti](https://github.com/HarithaVattikuti) in [#&#8203;1041](https://github.com/actions/setup-node/pull/1041)

##### Documentation changes:

- Documentation update to update default Node version to 20 by [@&#8203;bengreeley](https://github.com/bengreeley) in [#&#8203;949](https://github.com/actions/setup-node/pull/949)

##### Dependency  updates:

- Bump undici from 5.26.5 to 5.28.3 by [@&#8203;dependabot](https://github.com/dependabot) in [#&#8203;965](https://github.com/actions/setup-node/pull/965)
- Bump braces from 3.0.2 to 3.0.3 and other dependency updates by [@&#8203;dependabot](https://github.com/dependabot) in [#&#8203;1087](https://github.com/actions/setup-node/pull/1087)

#### New Contributors

- [@&#8203;bengreeley](https://github.com/bengreeley) made their first contribution in [#&#8203;949](https://github.com/actions/setup-node/pull/949)
- [@&#8203;HarithaVattikuti](https://github.com/HarithaVattikuti) made their first contribution in [#&#8203;1041](https://github.com/actions/setup-node/pull/1041)

**Full Changelog**: <https://github.com/actions/setup-node/compare/v4...v4.0.3>

### [`v4.0.2`](https://github.com/actions/setup-node/releases/tag/v4.0.2)

[Compare Source](https://github.com/actions/setup-node/compare/v4.0.1...v4.0.2)

#### What's Changed

- Add support for `volta.extends` by [@&#8203;ThisIsManta](https://github.com/ThisIsManta) in [#&#8203;921](https://github.com/actions/setup-node/pull/921)
- Add support for arm64 Windows by [@&#8203;dmitry-shibanov](https://github.com/dmitry-shibanov) in [#&#8203;927](https://github.com/actions/setup-node/pull/927)

#### New Contributors

- [@&#8203;ThisIsManta](https://github.com/ThisIsManta) made their first contribution in [#&#8203;921](https://github.com/actions/setup-node/pull/921)

**Full Changelog**: <https://github.com/actions/setup-node/compare/v4.0.1...v4.0.2>

### [`v4.0.1`](https://github.com/actions/setup-node/releases/tag/v4.0.1)

[Compare Source](https://github.com/actions/setup-node/compare/v4...v4.0.1)

#### What's Changed

- Ignore engines in Yarn 1 e2e-cache tests by [@&#8203;trivikr](https://github.com/trivikr) in [#&#8203;882](https://github.com/actions/setup-node/pull/882)
- Update setup-node references in the README.md file to setup-node\@&#8203;v4 by [@&#8203;jwetzell](https://github.com/jwetzell) in [#&#8203;884](https://github.com/actions/setup-node/pull/884)
- Update reusable workflows to use Node.js v20 by [@&#8203;MaksimZhukov](https://github.com/MaksimZhukov) in [#&#8203;889](https://github.com/actions/setup-node/pull/889)
- Add fix for cache to resolve slow post action step by [@&#8203;aparnajyothi-y](https://github.com/aparnajyothi-y) in [#&#8203;917](https://github.com/actions/setup-node/pull/917)
- Fix README.md by [@&#8203;takayamaki](https://github.com/takayamaki) in [#&#8203;898](https://github.com/actions/setup-node/pull/898)
- Add `package.json` to `node-version-file` list of examples. by [@&#8203;TWiStErRob](https://github.com/TWiStErRob) in [#&#8203;879](https://github.com/actions/setup-node/pull/879)
- Fix node-version-file interprets entire package.json as a version by [@&#8203;NullVoxPopuli](https://github.com/NullVoxPopuli) in [#&#8203;865](https://github.com/actions/setup-node/pull/865)

#### New Contributors

- [@&#8203;trivikr](https://github.com/trivikr) made their first contribution in [#&#8203;882](https://github.com/actions/setup-node/pull/882)
- [@&#8203;jwetzell](https://github.com/jwetzell) made their first contribution in [#&#8203;884](https://github.com/actions/setup-node/pull/884)
- [@&#8203;aparnajyothi-y](https://github.com/aparnajyothi-y) made their first contribution in [#&#8203;917](https://github.com/actions/setup-node/pull/917)
- [@&#8203;takayamaki](https://github.com/takayamaki) made their first contribution in [#&#8203;898](https://github.com/actions/setup-node/pull/898)
- [@&#8203;TWiStErRob](https://github.com/TWiStErRob) made their first contribution in [#&#8203;879](https://github.com/actions/setup-node/pull/879)
- [@&#8203;NullVoxPopuli](https://github.com/NullVoxPopuli) made their first contribution in [#&#8203;865](https://github.com/actions/setup-node/pull/865)

**Full Changelog**: <https://github.com/actions/setup-node/compare/v4...v4.0.1>

</details>

---

### Configuration

📅 **Schedule**: (UTC)

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

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

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

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

---

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

---

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

Reviewed-on: https://gitea.com/gitea/runner/pulls/991
Reviewed-by: silverwind <2021+silverwind@noreply.gitea.com>
Co-authored-by: Renovate Bot <renovate-bot@gitea.com>
Co-committed-by: Renovate Bot <renovate-bot@gitea.com>
2026-05-22 06:28:26 +00:00
silverwind
0e0c54b272 test: make TestRunEvent integration suite runnable locally (#987)
The `TestRunEvent*` integration tests are skipped in CI (`make test` runs `-short`), which hid several breakages that make them fail when run locally:

- `runTest` built the runner `Config` without `ContainerMaxLifetime`, so the job container ran `/bin/sleep 0` and exited immediately — every step failed with "container is not running". Set it to 1h.
- The root `.gitignore`'s unscoped `.env` and `dist` rules shadowed fixtures under `testdata/`. Anchored `dist` → `/dist` (the goreleaser output) and un-ignored `testdata/secrets/.env`.
- Added the missing `testdata/secrets/.env` fixture for `TestRunEventSecrets`.
- The `node24` local action referenced a `dist/index.js` bundle that was never committed (and was gitignored). Made the fixture self-contained (dependency-free ESM, `main: index.js`) so it runs without an `ncc` build. If you'd rather keep the `@actions/core`-based action and commit the built bundle instead, happy to switch.

Network-dependent subtests (remote `uses:`/composite actions) are out of scope.

---
This PR was written with the help of Claude Opus 4.7

---------

Co-authored-by: Nicolas <bircni@icloud.com>
Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com>
Reviewed-on: https://gitea.com/gitea/runner/pulls/987
Reviewed-by: Nicolas <bircni@icloud.com>
Co-authored-by: silverwind <me@silverwind.io>
Co-committed-by: silverwind <me@silverwind.io>
2026-05-21 19:16:23 +00:00
Nicolas
d6fbe75721 ci: add PR title linting against Conventional Commits (#988)
Lint PR titles

Reviewed-on: https://gitea.com/gitea/runner/pulls/988
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
2026-05-21 19:13:43 +00:00
silverwind
b30204aa94 fix: clean up job network and container when container start fails (#986)
The teardown that removes a job's per-job network and container runs as a `Finally` on the step pipeline in `newJobExecutor`, which only executes after a successful start. When the start itself fails (e.g. a `docker cp` error from a buggy daemon), that `Finally` is skipped, so the network and container leak until Docker's address pool is exhausted and later jobs can no longer create networks.

This tears them down in `startContainer` when the start returns an error, reusing the existing `cleanUpJobContainer` teardown.

Exposed by the daemon regression in https://gitea.com/gitea/runner/issues/981, where every failed `docker cp` leaked a per-job network.

---
This PR was written with the help of Claude Opus 4.7

Reviewed-on: https://gitea.com/gitea/runner/pulls/986
Reviewed-by: Nicolas <bircni@icloud.com>
Co-authored-by: silverwind <me@silverwind.io>
Co-committed-by: silverwind <me@silverwind.io>
2026-05-21 15:19:01 +00:00
Renovate Bot
7b5ebe9618 fix(deps): update module connectrpc.com/connect to v1.20.0 (#985)
This PR contains the following updates:

| Package | Change | [Age](https://docs.renovatebot.com/merge-confidence/) | [Confidence](https://docs.renovatebot.com/merge-confidence/) |
|---|---|---|---|
| [connectrpc.com/connect](https://github.com/connectrpc/connect-go) | `v1.19.2` → `v1.20.0` | ![age](https://developer.mend.io/api/mc/badges/age/go/connectrpc.com%2fconnect/v1.20.0?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/go/connectrpc.com%2fconnect/v1.19.2/v1.20.0?slim=true) |

---

### Release Notes

<details>
<summary>connectrpc/connect-go (connectrpc.com/connect)</summary>

### [`v1.20.0`](https://github.com/connectrpc/connect-go/releases/tag/v1.20.0)

[Compare Source](https://github.com/connectrpc/connect-go/compare/v1.19.2...v1.20.0)

#### What's Changed

##### Other changes

- Bump minimum supported Go version to 1.25 by [@&#8203;jonbodner-buf](https://github.com/jonbodner-buf) in [#&#8203;922](https://github.com/connectrpc/connect-go/issues/922)
- Update Unary-Get query parameter order to match spec recommendation by [@&#8203;oliversun9](https://github.com/oliversun9) in [#&#8203;926](https://github.com/connectrpc/connect-go/issues/926)

#### New Contributors

- [@&#8203;jonbodner-buf](https://github.com/jonbodner-buf) made their first contribution in [#&#8203;922](https://github.com/connectrpc/connect-go/issues/922)

**Full Changelog**: <https://github.com/connectrpc/connect-go/compare/v1.19.2...v1.20.0>

</details>

---

### Configuration

📅 **Schedule**: (UTC)

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

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

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

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

---

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

---

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

Reviewed-on: https://gitea.com/gitea/runner/pulls/985
Reviewed-by: silverwind <2021+silverwind@noreply.gitea.com>
Co-authored-by: Renovate Bot <renovate-bot@gitea.com>
Co-committed-by: Renovate Bot <renovate-bot@gitea.com>
2026-05-21 00:32:31 +00:00
Nicolas
4317662a38 update docker cli to v29.5.2 (#984)
Fixes #981

Reviewed-on: https://gitea.com/gitea/runner/pulls/984
Reviewed-by: Zettat123 <39446+zettat123@noreply.gitea.com>
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: Nicolas <bircni@icloud.com>
Co-committed-by: Nicolas <bircni@icloud.com>
2026-05-20 20:25:44 +00:00
Vi
2208e7ec63 feat: add cache.offline_mode to reuse cached actions (#966)
Co-authored-by: silverwind <me@silverwind.io>
Co-authored-by: silverwind <2021+silverwind@noreply.gitea.com>
Co-authored-by: TKaxv_7S <56359+tkaxv_7s@noreply.gitea.com>
Co-authored-by: techknowlogick <techknowlogick@noreply.gitea.com>
Co-authored-by: TKaxv_7S <954067342@qq.com>
Co-authored-by: TKaxv_7S <tkaxv_7s@noreply.gitea.com>
Reviewed-on: https://gitea.com/gitea/runner/pulls/966
Reviewed-by: Nicolas <bircni@icloud.com>
Reviewed-by: silverwind <2021+silverwind@noreply.gitea.com>
Co-authored-by: Vi <w11b@ya.ru>
Co-committed-by: Vi <w11b@ya.ru>
2026-05-20 14:09:39 +00:00
silverwind
fab9714f9a Remove stale Gitea 1.20 compatibility shims (#978)
The runner already enforces Gitea v1.21+ (see the `connect.CodeUnimplemented` check in `daemon.go`), so several shims kept for v1.20 compatibility have been dead since 2023:

- `compatibleWithOldEnvs` — the `GITEA_DEBUG`, `GITEA_TRACE`, `GITEA_RUNNER_CAPACITY`, `GITEA_RUNNER_FILE`, `GITEA_RUNNER_ENVIRON`, `GITEA_RUNNER_ENV_FILE` env vars (superseded by the config file)
- `VersionHeader` (`x-runner-version`) and the `version` param of `client.New`
- `AgentLabels` field in `RegisterRequest` (replaced by `Labels`)

Also replaces a verbose `strings.TrimRightFunc` closure with `strings.TrimRight(s, "\r\n")` in the log row parser.

---
This PR was written with the help of Claude Opus 4.7

---------

Co-authored-by: Nicolas <bircni@icloud.com>
Reviewed-on: https://gitea.com/gitea/runner/pulls/978
Reviewed-by: Nicolas <bircni@icloud.com>
Co-authored-by: silverwind <me@silverwind.io>
Co-committed-by: silverwind <me@silverwind.io>
2026-05-19 16:49:15 +00:00
Renovate Bot
10475db58a fix(deps): update module github.com/docker/cli to v29.5.1+incompatible (#979)
This PR contains the following updates:

| Package | Change | [Age](https://docs.renovatebot.com/merge-confidence/) | [Confidence](https://docs.renovatebot.com/merge-confidence/) |
|---|---|---|---|
| [github.com/docker/cli](https://github.com/docker/cli) | `v29.5.0+incompatible` → `v29.5.1+incompatible` | ![age](https://developer.mend.io/api/mc/badges/age/go/github.com%2fdocker%2fcli/v29.5.1+incompatible?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/go/github.com%2fdocker%2fcli/v29.5.0+incompatible/v29.5.1+incompatible?slim=true) |

---

### Release Notes

<details>
<summary>docker/cli (github.com/docker/cli)</summary>

### [`v29.5.1+incompatible`](https://github.com/docker/cli/compare/v29.5.0...v29.5.1)

[Compare Source](https://github.com/docker/cli/compare/v29.5.0...v29.5.1)

</details>

---

### Configuration

📅 **Schedule**: (UTC)

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

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

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

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

---

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

---

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

Reviewed-on: https://gitea.com/gitea/runner/pulls/979
Reviewed-by: silverwind <2021+silverwind@noreply.gitea.com>
Co-authored-by: Renovate Bot <renovate-bot@gitea.com>
Co-committed-by: Renovate Bot <renovate-bot@gitea.com>
2026-05-19 16:27:04 +00:00
Renovate Bot
9e738c203c fix(deps): update module github.com/go-git/go-git/v5 to v5.19.1 (#980)
This PR contains the following updates:

| Package | Change | [Age](https://docs.renovatebot.com/merge-confidence/) | [Confidence](https://docs.renovatebot.com/merge-confidence/) |
|---|---|---|---|
| [github.com/go-git/go-git/v5](https://github.com/go-git/go-git) | `v5.19.0` → `v5.19.1` | ![age](https://developer.mend.io/api/mc/badges/age/go/github.com%2fgo-git%2fgo-git%2fv5/v5.19.1?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/go/github.com%2fgo-git%2fgo-git%2fv5/v5.19.0/v5.19.1?slim=true) |

---

### Release Notes

<details>
<summary>go-git/go-git (github.com/go-git/go-git/v5)</summary>

### [`v5.19.1`](https://github.com/go-git/go-git/releases/tag/v5.19.1)

[Compare Source](https://github.com/go-git/go-git/compare/v5.19.0...v5.19.1)

#### What's Changed

- v5: plumbing: transport/ssh, Shell-quote path by [@&#8203;hiddeco](https://github.com/hiddeco) in [#&#8203;2068](https://github.com/go-git/go-git/pull/2068)
- v5: git: submodule, Fix relative URL resolution by [@&#8203;hiddeco](https://github.com/hiddeco) in [#&#8203;2070](https://github.com/go-git/go-git/pull/2070)
- v5: git: submodule, canonical remote for relative URLs by [@&#8203;hiddeco](https://github.com/hiddeco) in [#&#8203;2074](https://github.com/go-git/go-git/pull/2074)
- v5: git: submodule, error on remote without URLs by [@&#8203;hiddeco](https://github.com/hiddeco) in [#&#8203;2078](https://github.com/go-git/go-git/pull/2078)
- v5: plumbing: format/idxfile, Validate offset64 indices by [@&#8203;hiddeco](https://github.com/hiddeco) in [#&#8203;2084](https://github.com/go-git/go-git/pull/2084)
- v5: \*: Reject malformed variable-length integers by [@&#8203;hiddeco](https://github.com/hiddeco) in [#&#8203;2092](https://github.com/go-git/go-git/pull/2092)
- v5: plumbing: format/packfile, Tighten delta validation by [@&#8203;hiddeco](https://github.com/hiddeco) in [#&#8203;2091](https://github.com/go-git/go-git/pull/2091)
- v5: Add `worktreeFilesystem` wrapper for worktree and hardening by [@&#8203;hiddeco](https://github.com/hiddeco) in [#&#8203;2100](https://github.com/go-git/go-git/pull/2100)
- v5: config: validate submodule names by [@&#8203;hiddeco](https://github.com/hiddeco) in [#&#8203;2082](https://github.com/go-git/go-git/pull/2082)
- build: Update module github.com/go-git/go-git/v5 to v5.19.0 \[SECURITY] (releases/v5.x) by [@&#8203;go-git-renovate](https://github.com/go-git-renovate)\[bot] in [#&#8203;2111](https://github.com/go-git/go-git/pull/2111)
- v5: git: Allow MkdirAll on worktree-root paths by [@&#8203;hiddeco](https://github.com/hiddeco) in [#&#8203;2117](https://github.com/go-git/go-git/pull/2117)
- v5: git: Stop validating symlink target paths by [@&#8203;pjbgf](https://github.com/pjbgf) in [#&#8203;2116](https://github.com/go-git/go-git/pull/2116)
- v5: plumbing: format decoder input bounds and contracts by [@&#8203;hiddeco](https://github.com/hiddeco) in [#&#8203;2125](https://github.com/go-git/go-git/pull/2125)
- plumbing: format/packfile, cap delta chain depth in parser by [@&#8203;pjbgf](https://github.com/pjbgf) in [#&#8203;2137](https://github.com/go-git/go-git/pull/2137)

**Full Changelog**: <https://github.com/go-git/go-git/compare/v5.19.0...v5.19.1>

</details>

---

### Configuration

📅 **Schedule**: (UTC)

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

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

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

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

---

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

---

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

Reviewed-on: https://gitea.com/gitea/runner/pulls/980
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: Renovate Bot <renovate-bot@gitea.com>
Co-committed-by: Renovate Bot <renovate-bot@gitea.com>
2026-05-19 16:18:34 +00:00
Nicolas
6023928876 Fix token use with schemaless Gitea instance (#977)
Fixes #973

## Summary
- Normalize schemaless `--gitea-instance` values before comparing clone URL hosts
- Add regression tests for `GITEA_TOKEN` use with private action/reusable workflow clones on the same instance

---------

Co-authored-by: silverwind <2021+silverwind@noreply.gitea.com>
Co-authored-by: silverwind <me@silverwind.io>
Reviewed-on: https://gitea.com/gitea/runner/pulls/977
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
Reviewed-by: silverwind <2021+silverwind@noreply.gitea.com>
Co-authored-by: Nicolas <bircni@icloud.com>
Co-committed-by: Nicolas <bircni@icloud.com>
2026-05-18 02:22:04 +00:00
silverwind
014ce438c1 Add OCI source and version labels to images (#975)
Adds `org.opencontainers.image.source` and `org.opencontainers.image.version` labels to all three image variants (`basic`, `dind`, `dind-rootless`).

- `source` lets tools like renovate retrieve release notes from the source repo.
- `version` exposes the build version on the image itself.

Both `release-tag` and `release-nightly` workflows pass `VERSION` as a build arg so the label reflects the actual git tag (or `git describe` output for nightly).

---
This PR was written with the help of Claude Opus 4.7

---------

Reviewed-on: https://gitea.com/gitea/runner/pulls/975
Reviewed-by: Nicolas <bircni@icloud.com>
Co-authored-by: silverwind <me@silverwind.io>
Co-committed-by: silverwind <me@silverwind.io>
2026-05-17 18:15:19 +00:00
Jacob Alberty
cf7e29c10d fix(parse_env_file): support env-file lines larger than 64 KiB (#974)
My builds kept flaking out with errors like `invalid format delimiter 'ghadelimiter_...' not found before end of file` or just strange failures in the complete job. After some digging I found an issue in `parseEnvFile` and have tested this fix against the test case presented.

  - `parseEnvFile` reads `$GITHUB_ENV` / `$GITHUB_OUTPUT` with a `bufio.Scanner` using the default 64 KiB token size, and never checks `s.Err()`.
  - Any action that writes a multi-line value with a single line >64 KiB silently aborts the scan with `bufio.ErrTooLong`, which surfaces as the misleading `"invalid format delimiter
  'ghadelimiter_…' not found before end of file"`.
  - Real-world trigger: `docker/build-push-action`'s `metadata` output embeds the full `GITHUB_EVENT_PATH` payload via buildx provenance; a long PR description (e.g. a Renovate dependency
  table) puts the body field on one JSON-escaped line well past 64 KiB.
  - Raise the scanner buffer to 1 MiB so realistic outputs parse.

### Reproduction
Test this in an action. This removes the `docker/build-push-action` aspect and reproduces it directly.
```yaml
  jobs:
    repro:
      runs-on: ubuntu-latest
      steps:
        - id: big
          run: |
            {
              echo 'value<<EOF'
              head -c 70000 /dev/urandom | base64 -w0
              echo
              echo 'EOF'
            } >> "$GITHUB_OUTPUT"
```

---------

Co-authored-by: Nicolas <bircni@icloud.com>
Co-authored-by: silverwind <me@silverwind.io>
Reviewed-on: https://gitea.com/gitea/runner/pulls/974
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
Reviewed-by: silverwind <2021+silverwind@noreply.gitea.com>
Co-authored-by: Jacob Alberty <jacob.alberty@gmail.com>
Co-committed-by: Jacob Alberty <jacob.alberty@gmail.com>
2026-05-17 13:00:17 +00:00
Nicolas
8a99506fed Fix host cleanup, volume allowlist, cache upload, and action host edge cases (#970)
## Summary
- prevent host-mode execution from deleting caller-owned workdirs
- harden `valid_volumes` checks against `..` and symlink escapes
- return immediately after artifact cache upload write failures
- default implicit remote action clone hosts to `GitHubInstance`/`github.com`

Authored with assistance from OpenAI Codex GPT-5.

---------

Co-authored-by: silverwind <me@silverwind.io>
Reviewed-on: https://gitea.com/gitea/runner/pulls/970
Reviewed-by: silverwind <2021+silverwind@noreply.gitea.com>
2026-05-17 12:53:04 +00:00
silverwind
5873b8b054 Remove dead code from act/ (#971)
Removes code that whole-program reachability analysis (`deadcode` from `golang.org/x/tools`) confirmed unreachable, plus the `act/workflowpattern` package which no file outside its own directory imports.

- `act/common/draw.go` — CLI box-drawing helpers left over from nektos/act's dropped CLI
- `act/common/file.go` — `CopyFile`/`CopyDir` package-level helpers (container types have their own `CopyDir` methods, kept)
- `act/common/executor.go` — `Warning` type and `Warningf`. The `case Warning:` arm in `(Executor).Then`'s type switch was dead too (no code ever constructed a `Warning`); the switch is replaced with `if err != nil { return err }`
- `act/lookpath/env.go` — `LookPath` no-arg wrapper and `defaultEnv` struct. Only `LookPath2(file, env)` was used externally; the `Env` interface is kept
- `act/runner/action_cache_offline_mode.go` — `GoGitActionCacheOfflineMode` wrapper, never instantiated
- `act/workflowpattern/` — entire package, never imported

Net `-943` lines.

---
This PR was written with the help of Claude Opus 4.7

---------

Co-authored-by: Nicolas <bircni@icloud.com>
Reviewed-on: https://gitea.com/gitea/runner/pulls/971
Reviewed-by: Nicolas <bircni@icloud.com>
Co-authored-by: silverwind <me@silverwind.io>
Co-committed-by: silverwind <me@silverwind.io>
2026-05-17 03:53:28 +00:00
Lunny Xiao
5464d33eef fix: Return if executors length is zero in ParallelExecutor (#960)
It displayed an unused log and start an unused go routine. We should check the executors number before continue.

```
INFO[2026-05-12T21:01:04-07:00] Running job with maxParallel=1 for 1 matrix combinations
INFO[2026-05-12T21:01:04-07:00] NewParallelExecutor: Creating 1 workers for 1 executors
INFO[2026-05-12T21:01:04-07:00] NewParallelExecutor: Creating 1 workers for 0 executors
INFO[2026-05-12T21:01:04-07:00] NewParallelExecutor: Creating 1 workers for 0 executors
```

Reviewed-on: https://gitea.com/gitea/runner/pulls/960
Reviewed-by: silverwind <2021+silverwind@noreply.gitea.com>
Reviewed-by: Nicolas <bircni@icloud.com>
Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-committed-by: Lunny Xiao <xiaolunwen@gmail.com>
2026-05-15 22:14:13 +00:00
silverwind
3c5f03ff8f feat: make pseudo-TTY allocation opt-in (#961)
Fixes #956.

Pseudo-TTY allocation is now an explicit, runner-wide opt-in via `runner.allocate_pty`, applied to both host and docker backends. Default is off, matching GitHub `actions/runner`.

```yaml
runner:
  allocate_pty: false  # default
```

**Before:** the host backend hardcoded `if true /* allocate Terminal */` and the docker backend used `term.IsTerminal(os.Stdout.Fd())`. As a result, `docker build` (and other TTY-aware tools) saw a TTY and emitted cursor-control redraw frames that flooded captured logs with thousands of duplicate-looking progress lines — only on host-mode runners in production, and on docker-mode runners when the daemon happened to be launched from a shell rather than a service.

**After:** both backends consult `Config.AllocatePTY`. The `term.IsTerminal` heuristic is gone, so behavior no longer depends on whether the daemon has a controlling terminal.

**Reproduction:** running `docker build` through `HostEnvironment.Exec` with output captured to a buffer:

| | Before (`if true`) | After (`AllocatePTY=false`) |
|---|---:|---:|
| bytes captured | 18,167 | 1,048 |
| ANSI CSI sequences | 556 | 0 |
| cursor-up `\e[1A` | 181 | 0 |

**Side fix:** `ptyWriter.AutoStop` is now `atomic.Bool`. The field is written from the exec goroutine after `cmd.Wait()` and read from the `copyPtyOutput` goroutine via `ptyWriter.Write`; existing tests never tripped the race detector because their commands produced no output before exit. The new host-mode test does.

---
This PR was written with the help of Claude Opus 4.7

---------

Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: Nicolas <bircni@icloud.com>
Reviewed-on: https://gitea.com/gitea/runner/pulls/961
Reviewed-by: Nicolas <bircni@icloud.com>
Co-authored-by: silverwind <2021+silverwind@noreply.gitea.com>
Co-committed-by: silverwind <2021+silverwind@noreply.gitea.com>
2026-05-15 18:11:39 +00:00
Renovate Bot
880e9755d9 chore(deps): update workflow dependencies (major) (#968)
Reviewed-on: https://gitea.com/gitea/runner/pulls/968
Reviewed-by: silverwind <2021+silverwind@noreply.gitea.com>
Co-authored-by: Renovate Bot <renovate-bot@gitea.com>
Co-committed-by: Renovate Bot <renovate-bot@gitea.com>
2026-05-15 14:08:07 +00:00
Renovate Bot
8d7cf48a6f fix(deps): update module github.com/docker/cli to v29.5.0+incompatible (#969)
This PR contains the following updates:

| Package | Change | [Age](https://docs.renovatebot.com/merge-confidence/) | [Confidence](https://docs.renovatebot.com/merge-confidence/) |
|---|---|---|---|
| [github.com/docker/cli](https://github.com/docker/cli) | `v29.4.3+incompatible` → `v29.5.0+incompatible` | ![age](https://developer.mend.io/api/mc/badges/age/go/github.com%2fdocker%2fcli/v29.5.0+incompatible?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/go/github.com%2fdocker%2fcli/v29.4.3+incompatible/v29.5.0+incompatible?slim=true) |

---

### Release Notes

<details>
<summary>docker/cli (github.com/docker/cli)</summary>

### [`v29.5.0+incompatible`](https://github.com/docker/cli/compare/v29.4.3...v29.5.0)

[Compare Source](https://github.com/docker/cli/compare/v29.4.3...v29.5.0)

</details>

---

### Configuration

📅 **Schedule**: (UTC)

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

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

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

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

---

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

---

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

Reviewed-on: https://gitea.com/gitea/runner/pulls/969
Reviewed-by: silverwind <2021+silverwind@noreply.gitea.com>
Co-authored-by: Renovate Bot <renovate-bot@gitea.com>
Co-committed-by: Renovate Bot <renovate-bot@gitea.com>
2026-05-15 14:05:52 +00:00
Renovate Bot
f23605c614 chore(deps): update workflow dependencies (major) (#967)
Reviewed-on: https://gitea.com/gitea/runner/pulls/967
Reviewed-by: techknowlogick <9+techknowlogick@noreply.gitea.com>
Co-authored-by: Renovate Bot <renovate-bot@gitea.com>
Co-committed-by: Renovate Bot <renovate-bot@gitea.com>
2026-05-14 16:54:48 +00:00
thisisqasim
00b7fec80f Simplify kubernetes dind example allowing for default docker config in workflows (#709)
With this docker clients in workflows can connect on the default socket without needing to change DOCKER_HOST. Startup probe also removes the need for custom shell command.

Co-authored-by: silverwind <me@silverwind.io>
Reviewed-on: https://gitea.com/gitea/runner/pulls/709
Co-authored-by: thisisqasim <40013+thisisqasim@noreply.gitea.com>
Co-committed-by: thisisqasim <40013+thisisqasim@noreply.gitea.com>
2026-05-14 05:52:41 +00:00
silverwind
dda5841af8 chore(deps): bump retry-go, golangci-lint, govulncheck (#965)
Bumps `github.com/avast/retry-go` v4.7.0 -> v5.0.0, `golangci-lint` v2.11.4 -> v2.12.2 (aligns with gitea/gitea), and pins `govulncheck` to v1.3.0.

- `retry-go` v5 replaces the package-level `retry.Do(fn, opts...)` with a builder API `retry.New(opts...).Do(fn)`. The single call site in `internal/pkg/report/reporter.go` was migrated.
- `golangci-lint` v2.12.2 surfaces three new findings in `act/` (modernize/slicesbackward, govet/inline): one backward loop now uses `slices.Backward`, and the deprecated `reflect.Ptr` alias is replaced with `reflect.Pointer`.
- `go.mod`: the two direct-`require` blocks are merged into one, and a stray `gopkg.in/yaml.v3 // indirect` is moved into the indirect block. Purely cosmetic; `go.sum` is unchanged.

---
This PR was written with the help of Claude Opus 4.7

Reviewed-on: https://gitea.com/gitea/runner/pulls/965
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: silverwind <me@silverwind.io>
Co-committed-by: silverwind <me@silverwind.io>
2026-05-14 05:39:28 +00:00
silverwind
32bed52686 fix(deps): bump docker deps, switch to moby/moby (#943)
Fixes: https://gitea.com/gitea/runner/issues/859

Migration approach mirrors [actions-oss/act-cli#154](https://github.com/actions-oss/act-cli/pull/154).

### Dependency changes

- `github.com/docker/docker` v25.0.15 → **removed** (v29 doesn't exist as docker/docker; the project moved to moby/moby)
- `github.com/docker/cli` v25.0.7 → v29.4.3
- `github.com/docker/go-connections` v0.6.0 → v0.7.0
- `github.com/docker/docker-credential-helpers` v0.9.5 → v0.9.6
- `github.com/moby/go-archive` added at v0.2.0
- `github.com/moby/moby/api` added at v1.54.2
- `github.com/moby/moby/client` added at v0.4.1
- `github.com/moby/buildkit` removed (only used `dockerignore.ReadAll`, swapped for `moby/patternmatcher/ignorefile.ReadAll` directly)
- `github.com/containerd/errdefs` v0.3.0 → v1.0.0

### Migration

- v28: type aliases moved to their subpackages (`types.{Container,Image,Network,Exec}*` → `container/image/network/...`); deprecated APIs replaced (`ImageInspectWithRaw`, `client.IsErrNotFound`, `archive.CanonicalTarNameForPath`, `opts.ValidateMACAddress`, `ListOpts.GetAll`)
- v29: structural client redesign — every `cli.X(ctx, ...)` call switched to options-everywhere/Result-typed signatures, `ContainerExec*` → `Exec*`, `ContainerWait` returns a struct with `Result`/`Error` channels, `Tty`→`TTY`, `Copy*Container` takes options struct, `client.NewClientWithOpts` → `client.New`. `pkg/stdcopy` moved to `moby/moby/api/pkg/stdcopy`. The vendored copy of `cli/command/container/opts.go` was refreshed from cli v29 (now uses `netip.Addr` for IPs, port-set conversion helpers). A small local `parsePlatform` helper centralises the `os/arch[/variant]` parsing previously inlined into multiple call sites.

### Behaviour preservation

The migration introduced several behavioural shifts vs the v25 client; all were caught in review and reverted/fixed in follow-up commits:

- `GetDockerClient`: cli v29's `Ping(NegotiateAPIVersion: true)` returns errors that the old `NegotiateAPIVersion` silently swallowed. Restored best-effort behaviour (warn-log + continue) so daemons with blocked `_ping` or API < 1.40 keep working. The SSH-helper `client.New` call no longer inherits `client.FromEnv`, matching the old `NewClientWithOpts(WithHost, WithDialContext)` so `DOCKER_API_VERSION`/`DOCKER_TLS_VERIFY` don't leak into the SSH-tunneled client
- `parsePlatform`: malformed input now returns an explicit error instead of silently dropping to "no platform constraint" and pulling the host-default architecture. Single-segment (`"linux"`), 4+-segment (`"linux/arm/v7/extra"`), and trailing-slash (`"linux/arm/"`) inputs are all rejected
- `LoadDockerAuthConfig`/`LoadDockerAuthConfigs`: `config.LoadDefaultConfigFile(nil)` panics on a malformed config file (it does `fmt.Fprintln` on the nil `io.Writer`). Switched to `config.Load(config.Dir())` so load errors reach the logger and the panic path is gone. Restored the old behaviour of returning `config.Load` and `GetAuthConfig` errors to the caller (the v29 refactor had silently downgraded them to warn-only). A `reference.ParseNormalizedNamed` failure on the image string falls through to the `docker.io` default rather than aborting, since the old string-based hostname extraction was infallible

Test assertions also updated for two upstream error-message string shifts (`go-connections` port-range parser; `cli/opts` envfile BOM check). Added unit-test coverage for the new `parsePlatform` helper, locking in the intentional limits (single-segment, 4+-segment, and trailing-slash platforms rejected).

---
This PR was written with the help of Claude Opus 4.7

Reviewed-on: https://gitea.com/gitea/runner/pulls/943
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: silverwind <me@silverwind.io>
Co-committed-by: silverwind <me@silverwind.io>
2026-05-14 02:29:05 +00:00
Lunny Xiao
a7e972d8de fix: respect proxy env vars in runner client (#962)
Fixes #957.

## Why
The runner builds a custom `http.Transport` for its RPC client. From first principles, once we stop using the default transport we also stop inheriting its default proxy resolution behavior, so `HTTP_PROXY`/`HTTPS_PROXY` are ignored unless we wire that behavior back explicitly.

## What
- set `Proxy: http.ProxyFromEnvironment` on the custom transport
- add a regression test that verifies `getHTTPClient` honors proxy environment variables

Reviewed-on: https://gitea.com/gitea/runner/pulls/962
Reviewed-by: ChristopherHX <38043+christopherhx@noreply.gitea.com>
2026-05-13 19:10:52 +00:00
Lunny Xiao
763b38ece3 fix: isolate per-task runner envs (#959)
## Summary
- clone the runner environment map for each task before injecting runtime and OIDC tokens
- keep the shared base environment immutable so concurrent jobs cannot hit `concurrent map writes`
- add a unit test covering task-local env cloning

Fixes #958

---------

Co-authored-by: Nicolas <bircni@icloud.com>
Reviewed-on: https://gitea.com/gitea/runner/pulls/959
Reviewed-by: Nicolas <bircni@icloud.com>
2026-05-12 18:50:25 +00:00
Renovate Bot
a1f13cb970 fix(deps): update module github.com/opencontainers/selinux to v1.14.1 (#955)
This PR contains the following updates:

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

---

> ⚠️ **Warning**
>
> Some dependencies could not be looked up. Check the [Dependency Dashboard](issues/856) for more information.

---

### Configuration

📅 **Schedule**: (UTC)

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

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

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

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

---

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

---

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

Reviewed-on: https://gitea.com/gitea/runner/pulls/955
Reviewed-by: silverwind <2021+silverwind@noreply.gitea.com>
Co-authored-by: Renovate Bot <renovate-bot@gitea.com>
Co-committed-by: Renovate Bot <renovate-bot@gitea.com>
2026-05-12 13:34:52 +00:00
silverwind
1e3ab0c40a fix(deps): update mergo to v1.0.2 (now dario.cat/mergo) (#954)
At v1.0.0 the `github.com/imdario/mergo` module was relocated to `dario.cat/mergo`, so a plain version bump (as in https://gitea.com/gitea/runner/pulls/951) leaves the import path pointing at the old, unmaintained location. This PR updates the import in `act/container/docker_run.go` and adjusts `go.mod` accordingly. The public API (`mergo.Merge`, `mergo.WithOverride`, `mergo.WithAppendSlice`) is unchanged.

Supersedes https://gitea.com/gitea/runner/pulls/951.

---
This PR was written with the help of Claude Opus 4.7

Reviewed-on: https://gitea.com/gitea/runner/pulls/954
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: silverwind <me@silverwind.io>
Co-committed-by: silverwind <me@silverwind.io>
2026-05-10 22:05:05 +00:00
silverwind
295eecb9af fix: ensure dbfs_data is cleaned up after task completion (#952)
Fixes #950.

After #819, the daemon flushes logs eagerly on the job-result entry (via the `stateNotify` path), so `Close()` typically runs `ReportLog(true)` with an empty buffer. Gitea's `UpdateLog` handler short-circuits on `len(Rows)==0` before honoring `NoMore`, so the final request never runs `TransferLogs` and `dbfs_data` rows leak. The server-side short-circuit is latent since the original Actions implementation in 2023; #819 made it deterministically reachable.

Workaround: inject a sentinel row in `Close()` after the daemon has exited so the final `UpdateLog` always carries at least one row. Done after the daemon waits so the sentinel can't be flushed before `ReportLog(true)` reads it.

https://github.com/go-gitea/gitea/pull/37631 drops the empty-rows short-circuit when `NoMore=true`; that would work with or without this PR.

Reviewed-on: https://gitea.com/gitea/runner/pulls/952
Reviewed-by: Nicolas <bircni@icloud.com>
Reviewed-by: Zettat123 <39446+zettat123@noreply.gitea.com>
Co-authored-by: silverwind <me@silverwind.io>
Co-committed-by: silverwind <me@silverwind.io>
2026-05-10 20:55:57 +00:00
Zettat123
ef6ca957b5 fix(artifactcache): preserve cache key case to stop redundant uploads (#947)
## Summary

`artifactcache.Handler` was lowercasing cache keys before storing and returning them. This caused actions like `actions/setup-go` to treat every restore as a partial hit and re-upload the cache on every job run.

Similar issue: [act#2497](https://github.com/nektos/act/issues/2497)

## Root Cause

These actions build cache keys that include `RUNNER_OS` (e.g. `setup-go-Linux-x64-...` See [setup-go/cache-restore.ts](78961f6f84/src/cache-restore.ts (L11-L51)) ). In `gitea/runner`,  `RUNNER_OS` is always `Linux` by default (See https://gitea.com/gitea/runner/search?q=RUNNER_OS).

These actions decide whether to save the cache data in their post step using **strict** `===` comparison between the primary key and the key returned from the runner. See [setup-go cache-save.ts](78961f6f84/src/cache-save.ts (L44-L86)) .

|State | Value|
|--- | ---|
|CachePrimaryKey | `setup-go-Linux-x64-ubuntu-22.04-go-1.24.9-abc123` |
|CacheMatchedKey | `setup-go-linux-x64-ubuntu-22.04-go-1.24.9-abc123` |

Because the runner's cache server lowercased the stored key, the response carried `setup-go-linux-...` while the action's primary key was `setup-go-Linux-...`. Strict equality failed, then the actions updated same data again. This repeated on every run, wasting disk and bandwidth. The duplicate blobs accumulate until GC .

https://gitea.com/gitea/runner/actions/runs/462560/jobs/737401#jobstep-2-15
![image.png](/attachments/d3487457-1d09-44b5-9937-a0b8cab1bcc5)

https://gitea.com/gitea/runner/actions/runs/462560/jobs/737401#jobstep-6-22
![image.png](/attachments/9217dc71-cb0c-456b-a516-0017458123c7)

## Fix
Drop the `strings.ToLower` calls in `find` and `reserve` so the original key case is preserved end-to-end. This fix will invalidate existing "case insensitive" keys.

## Notes

The [original act review](https://github.com/nektos/act/pull/1770/changes/BASE..d44b8d15649d9d09d1d891130b8f3962097a81f3#r1177624608) suggested making cache keys case-insensitive because `isExactKeyMatch` compares cache key ignoring case. However, the actions (`setup-go` / `setup-node` / `setup-ruby`) compare with strict `===` rather than `isExactKeyMatch`.

---------

Co-authored-by: Nicolas <bircni@icloud.com>
Reviewed-on: https://gitea.com/gitea/runner/pulls/947
Reviewed-by: silverwind <2021+silverwind@noreply.gitea.com>
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
Reviewed-by: Nicolas <bircni@icloud.com>
Co-authored-by: Zettat123 <zettat123@gmail.com>
Co-committed-by: Zettat123 <zettat123@gmail.com>
2026-05-09 12:27:52 +00:00
Renovate Bot
8088df52b9 fix(deps): update module golang.org/x/term to v0.43.0 (#948)
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/term](https://pkg.go.dev/golang.org/x/term) | [`v0.42.0` → `v0.43.0`](https://cs.opensource.google/go/x/term/+/refs/tags/v0.42.0...refs/tags/v0.43.0) | ![age](https://developer.mend.io/api/mc/badges/age/go/golang.org%2fx%2fterm/v0.43.0?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/go/golang.org%2fx%2fterm/v0.42.0/v0.43.0?slim=true) |

---

> ⚠️ **Warning**
>
> Some dependencies could not be looked up. Check the [Dependency Dashboard](issues/856) for more information.

---

### Configuration

📅 **Schedule**: (UTC)

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

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

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

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

---

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

---

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

Reviewed-on: https://gitea.com/gitea/runner/pulls/948
Reviewed-by: silverwind <2021+silverwind@noreply.gitea.com>
Co-authored-by: Renovate Bot <renovate-bot@gitea.com>
Co-committed-by: Renovate Bot <renovate-bot@gitea.com>
2026-05-09 05:28:44 +00:00
Nicolas
3ea7d39690 fix: overwrite read-only files when copying action directories (#942)
## Summary

  - `CopyCollector.WriteFile` now removes any existing destination file
    before writing, handling read-only modes (e.g. git pack files at
    `0444`) that cause `EACCES`/`ERROR_ACCESS_DENIED` on macOS and Windows.
  - Added `O_TRUNC` to the `OpenFile` flags as a safety net.

  ## Root cause

  When a composite action with a post step runs on a host runner,
  `runPostStep` calls `maybeCopyToActionDir`, which re-copies the action
  into `miscpath/act/actions/<name>/`. The first copy (main step) writes
  `.git/objects/pack/*.idx` at the destination with mode `0444` (as set
  by go-git). The second copy (post step) calls
  `os.OpenFile(dest, O_CREATE|O_WRONLY, …)` on that existing `0444` file,
  which fails immediately:

  - macOS: `open <path>: permission denied`
  - Windows: `open <path>: Access is denied`

Fixes: https://gitea.com/gitea/runner/issues/941
Fixes: https://gitea.com/gitea/runner/issues/876

---------

Co-authored-by: silverwind <me@silverwind.io>
Co-authored-by: silverwind <2021+silverwind@noreply.gitea.com>
Reviewed-on: https://gitea.com/gitea/runner/pulls/942
Reviewed-by: silverwind <2021+silverwind@noreply.gitea.com>
Co-authored-by: Nicolas <bircni@icloud.com>
Co-committed-by: Nicolas <bircni@icloud.com>
2026-05-08 04:11:42 +00:00
Schallbert
861d351845 add apparmor=rootlesskit in security_opt (#937)
paste depends_on chain from socket-runner-setup  to runner-dind-setup
add apparmor=rootlesscit in security_opt
add explanations for elevated privileges

---------

Co-authored-by: silverwind <me@silverwind.io>
Co-authored-by: silverwind <2021+silverwind@noreply.gitea.com>
Reviewed-on: https://gitea.com/gitea/runner/pulls/937
Reviewed-by: silverwind <2021+silverwind@noreply.gitea.com>
Co-authored-by: Schallbert <schallbert@mailbox.org>
Co-committed-by: Schallbert <schallbert@mailbox.org>
2026-05-07 21:20:33 +00:00
silverwind
cce8543d06 fix: serialize action-cache reads to prevent worktree race (#938)
`NewGitCloneExecutor` holds a per-directory mutex while it `git checkout --force`s a remote action into the shared `<ActionCacheDir>/<UsesHash>`, but four read sites ran unlocked:

- `maybeCopyToActionDir`'s tar walk via `JobContainer.CopyDir`
- `prepareActionExecutor`'s `readAction` parse of `action.yml`
- `newReusableWorkflowExecutor`'s `model.NewWorkflowPlanner` after `cloneRemoteReusableWorkflow` released its lock
- `execAsDocker` when `ActionCache == nil`: `docker build` walks `contextDir` for the daemon-side build context

When two matrix jobs share a `uses:`, a read interleaved with a peer's checkout produces partial state — observed as `Cannot find module .../dist/index.js` and `setup-uv` failing on a half-written `action.yml`.

Exports `acquireCloneLock` as `AcquireCloneLock` and takes it at all four sites. `container.ImageExistsLocally` / `NewDockerBuildExecutor` and `model.NewWorkflowPlanner` are indirected through package-level vars so the docker-action build path and the reusable-workflow read site are testable without a real daemon, mirroring `ContainerNewContainer`. Three regression tests cover the higher-risk sites (`maybeCopyToActionDir`, `execAsDocker`, `newReusableWorkflowExecutor`); each fails if its `AcquireCloneLock` is removed.

Subsumed by https://gitea.com/gitea/runner/pulls/814 once that lands. Related: https://gitea.com/gitea/runner/pulls/930

---
This PR was written with the help of Claude Opus 4.7

---------

Co-authored-by: Nicolas <bircni@icloud.com>
Reviewed-on: https://gitea.com/gitea/runner/pulls/938
Reviewed-by: Nicolas <bircni@icloud.com>
Reviewed-by: Zettat123 <39446+zettat123@noreply.gitea.com>
Co-authored-by: silverwind <me@silverwind.io>
Co-committed-by: silverwind <me@silverwind.io>
2026-05-07 19:57:04 +00:00
silverwind
75643645f0 feat: remove emojis from runner logging, add Starting job container group (#940)
Aligns runner log output more closely with `actions/runner`:

- Strip the whale, rocket, cloud, construction, chequered-flag, and exclamation-mark glyphs from log lines and drop the now-unused `logPrefix` constant.
- Reword `no outputs used step '%s'` → `No outputs registered for step '%s'` (the original was ungrammatical and inaccurate — it fires when `set-output` references an unknown step ID).
- Wrap the docker pull/network/create/start phase of job container startup in a `::group::Starting job container` / `::endgroup::` collapsible section, mirroring `actions/runner`. Since act drives Docker through the SDK rather than the CLI, we can't echo `##[command]/usr/bin/docker create ...` lines verbatim — instead the helper emits a summary inside the group:

  ```
  ::group::Starting job container
  image: <image>
  name: <container-name>
  network: <network-name>
  ::endgroup::
  ```

- Extracted the emit into a `printStartJobContainerGroup` helper (parallel to `printRunActionHeader` in `step_run.go`) and added a golden-style test `TestPrintStartJobContainerGroupGolden`.
- Drive-by: replace two remaining literal `"raw_output"` strings in `run_context.go` with the existing `rawOutputField` constant.

Closes #935

---
This PR was written with the help of Claude Opus 4.7

Reviewed-on: https://gitea.com/gitea/runner/pulls/940
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: silverwind <me@silverwind.io>
Co-committed-by: silverwind <me@silverwind.io>
2026-05-07 19:32:53 +00:00
153 changed files with 5067 additions and 3284 deletions

View File

@@ -0,0 +1,27 @@
name: pr-title
on:
pull_request:
types:
- opened
- edited
- reopened
- synchronize
- ready_for_review
permissions:
contents: read
jobs:
lint-pr-title:
if: github.event.pull_request.draft == false
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: 24
- run: make lint-pr-title
env:
PR_TITLE: ${{ github.event.pull_request.title }}

View File

@@ -24,7 +24,7 @@ jobs:
with: with:
go-version-file: "go.mod" go-version-file: "go.mod"
- name: goreleaser - name: goreleaser
uses: goreleaser/goreleaser-action@v6 uses: goreleaser/goreleaser-action@v7
with: with:
distribution: goreleaser-pro distribution: goreleaser-pro
args: release --nightly args: release --nightly
@@ -57,13 +57,13 @@ jobs:
fetch-depth: 0 # all history for all branches and tags fetch-depth: 0 # all history for all branches and tags
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@v3 uses: docker/setup-qemu-action@v4
- name: Set up Docker BuildX - name: Set up Docker BuildX
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v4
- name: Login to DockerHub - name: Login to DockerHub
uses: docker/login-action@v3 uses: docker/login-action@v4
with: with:
username: ${{ secrets.DOCKER_USERNAME }} username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }} password: ${{ secrets.DOCKER_PASSWORD }}
@@ -71,8 +71,13 @@ jobs:
- name: Echo the tag - name: Echo the tag
run: echo "${{ env.DOCKER_ORG }}/runner:nightly${{ matrix.variant.tag_suffix }}" run: echo "${{ env.DOCKER_ORG }}/runner:nightly${{ matrix.variant.tag_suffix }}"
- name: Get Meta
id: meta
run: |
echo REPO_VERSION=$(git describe --tags --always | sed 's/-/+/' | sed 's/^v//') >> $GITHUB_OUTPUT
- name: Build and push - name: Build and push
uses: docker/build-push-action@v6 uses: docker/build-push-action@v7
with: with:
context: . context: .
file: ./Dockerfile file: ./Dockerfile
@@ -83,3 +88,5 @@ jobs:
push: true push: true
tags: | tags: |
${{ env.DOCKER_ORG }}/runner:nightly${{ matrix.variant.tag_suffix }} ${{ env.DOCKER_ORG }}/runner:nightly${{ matrix.variant.tag_suffix }}
build-args: |
VERSION=${{ steps.meta.outputs.REPO_VERSION }}

View File

@@ -23,7 +23,7 @@ jobs:
passphrase: ${{ secrets.PASSPHRASE }} passphrase: ${{ secrets.PASSPHRASE }}
fingerprint: CC64B1DB67ABBEECAB24B6455FC346329753F4B0 fingerprint: CC64B1DB67ABBEECAB24B6455FC346329753F4B0
- name: goreleaser - name: goreleaser
uses: goreleaser/goreleaser-action@v6 uses: goreleaser/goreleaser-action@v7
with: with:
distribution: goreleaser-pro distribution: goreleaser-pro
args: release args: release
@@ -60,20 +60,20 @@ jobs:
fetch-depth: 0 # all history for all branches and tags fetch-depth: 0 # all history for all branches and tags
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@v3 uses: docker/setup-qemu-action@v4
- name: Set up Docker BuildX - name: Set up Docker BuildX
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v4
- name: Login to DockerHub - name: Login to DockerHub
uses: docker/login-action@v3 uses: docker/login-action@v4
with: with:
username: ${{ secrets.DOCKER_USERNAME }} username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }} password: ${{ secrets.DOCKER_PASSWORD }}
- name: "Docker meta" - name: "Docker meta"
id: docker_meta id: docker_meta
uses: https://github.com/docker/metadata-action@v5 uses: docker/metadata-action@v6
with: with:
images: | images: |
${{ env.DOCKER_ORG }}/runner ${{ env.DOCKER_ORG }}/runner
@@ -86,7 +86,7 @@ jobs:
suffix=${{ matrix.variant.tag_suffix }},onlatest=true suffix=${{ matrix.variant.tag_suffix }},onlatest=true
- name: Build and push - name: Build and push
uses: docker/build-push-action@v6 uses: docker/build-push-action@v7
with: with:
context: . context: .
file: ./Dockerfile file: ./Dockerfile
@@ -96,3 +96,5 @@ jobs:
linux/arm64 linux/arm64
push: true push: true
tags: ${{ steps.docker_meta.outputs.tags }} tags: ${{ steps.docker_meta.outputs.tags }}
build-args: |
VERSION=${{ steps.docker_meta.outputs.version }}

View File

@@ -9,14 +9,36 @@ jobs:
lint: lint:
name: check and test name: check and test
runs-on: ubuntu-latest runs-on: ubuntu-latest
env:
# The runner image ships a stale docker.io login; point docker at an empty config so
# image pulls go straight to anonymous instead of attempting (and failing) that auth
# first. The path must be a literal: the `runner` context is unavailable in job-level
# env, so `${{ runner.temp }}` would resolve to empty and config.Dir() would fall back
# to ~/.docker with the stale credentials.
DOCKER_CONFIG: /tmp/docker-noauth
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v6
- uses: actions/setup-go@v6 - uses: actions/setup-go@v6
with: with:
go-version-file: 'go.mod' go-version-file: 'go.mod'
- name: prepare anonymous docker config
run: mkdir -p "$DOCKER_CONFIG" && echo '{}' > "$DOCKER_CONFIG/config.json"
# Pre-pull act/runner's two largest base images so a slow pull can't dominate `make test`;
# the rest (alpine/ubuntu) pull on demand, absorbed by the make-test -timeout. The host
# daemon retains them between runs, so this is usually a fast manifest re-check.
- name: pre-pull test images
run: |
for img in node:24-bookworm-slim nginx:alpine; do
for try in 1 2 3; do docker pull "$img" && break || sleep 5; done
done
- name: lint - name: lint
run: make lint run: make lint
- name: build - name: build
run: make build run: make build
- name: test - name: test
run: make test run: make test
# Build the dind image and run the daemon-facing tests against the docker version it
# ships, catching daemon-level regressions (e.g. gitea/runner#981) before release. Runs
# after `make test` so the images it needs are already present on the host daemon.
- name: test against dind image
run: make test-dind

3
.gitignore vendored
View File

@@ -1,5 +1,6 @@
/gitea-runner /gitea-runner
.env .env
!/act/runner/testdata/secrets/.env
.runner .runner
coverage.txt coverage.txt
/config.yaml /config.yaml
@@ -10,4 +11,4 @@ coverage.txt
.vscode .vscode
__debug_bin __debug_bin
# gorelease binary folder # gorelease binary folder
dist /dist

View File

@@ -1,7 +1,7 @@
### BUILDER STAGE ### BUILDER STAGE
# #
# #
FROM golang:1.26-alpine AS builder FROM golang:1.26-alpine3.23 AS builder
# Do not remove `git` here, it is required for getting runner version when executing `make build` # Do not remove `git` here, it is required for getting runner version when executing `make build`
RUN apk add --no-cache make git RUN apk add --no-cache make git
@@ -17,7 +17,12 @@ RUN make clean && make build
### DIND VARIANT ### DIND VARIANT
# #
# #
FROM docker:29-dind AS dind FROM docker:29.5.3-dind AS dind
ARG VERSION=dev
LABEL org.opencontainers.image.source="https://gitea.com/gitea/runner"
LABEL org.opencontainers.image.version="${VERSION}"
RUN apk add --no-cache s6 bash git tzdata RUN apk add --no-cache s6 bash git tzdata
@@ -32,7 +37,12 @@ ENTRYPOINT ["s6-svscan","/etc/s6"]
### DIND-ROOTLESS VARIANT ### DIND-ROOTLESS VARIANT
# #
# #
FROM docker:29-dind-rootless AS dind-rootless FROM docker:29.5.3-dind-rootless AS dind-rootless
ARG VERSION=dev
LABEL org.opencontainers.image.source="https://gitea.com/gitea/runner"
LABEL org.opencontainers.image.version="${VERSION}"
USER root USER root
RUN apk add --no-cache s6 bash git tzdata RUN apk add --no-cache s6 bash git tzdata
@@ -53,7 +63,13 @@ ENTRYPOINT ["s6-svscan","/etc/s6"]
### BASIC VARIANT ### BASIC VARIANT
# #
# #
FROM alpine AS basic FROM alpine:3.23 AS basic
ARG VERSION=dev
LABEL org.opencontainers.image.source="https://gitea.com/gitea/runner"
LABEL org.opencontainers.image.version="${VERSION}"
RUN apk add --no-cache tini bash git tzdata RUN apk add --no-cache tini bash git tzdata
COPY --from=builder /opt/src/runner/gitea-runner /usr/local/bin/gitea-runner COPY --from=builder /opt/src/runner/gitea-runner /usr/local/bin/gitea-runner

View File

@@ -18,8 +18,8 @@ DOCKER_TAG ?= nightly
DOCKER_REF := $(DOCKER_IMAGE):$(DOCKER_TAG) DOCKER_REF := $(DOCKER_IMAGE):$(DOCKER_TAG)
DOCKER_ROOTLESS_REF := $(DOCKER_IMAGE):$(DOCKER_TAG)-dind-rootless DOCKER_ROOTLESS_REF := $(DOCKER_IMAGE):$(DOCKER_TAG)-dind-rootless
GOLANGCI_LINT_PACKAGE ?= github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.11.4 GOLANGCI_LINT_PACKAGE ?= github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.12.2
GOVULNCHECK_PACKAGE ?= golang.org/x/vuln/cmd/govulncheck@v1 GOVULNCHECK_PACKAGE ?= golang.org/x/vuln/cmd/govulncheck@v1.3.0
STATIC ?= STATIC ?=
EXTLDFLAGS ?= EXTLDFLAGS ?=
@@ -118,6 +118,10 @@ lint-go: ## lint go files
lint-go-fix: ## lint go files and fix issues lint-go-fix: ## lint go files and fix issues
$(GO) run $(GOLANGCI_LINT_PACKAGE) run --fix $(GO) run $(GOLANGCI_LINT_PACKAGE) run --fix
.PHONY: lint-pr-title
lint-pr-title: ## lint PR title against Conventional Commits (set PR_TITLE=...)
@node ./tools/lint-pr-title.ts
.PHONY: security-check .PHONY: security-check
security-check: deps-tools security-check: deps-tools
GOEXPERIMENT= $(GO) run $(GOVULNCHECK_PACKAGE) -show color ./... || true GOEXPERIMENT= $(GO) run $(GOVULNCHECK_PACKAGE) -show color ./... || true
@@ -136,8 +140,12 @@ tidy-check: tidy
fi fi
.PHONY: test .PHONY: test
test: fmt-check security-check ## test everything test: fmt-check security-check ## test everything (integration tests self-skip without docker/network)
@$(GO) test -race -short -v -cover -coverprofile coverage.txt ./... && echo "\n==>\033[32m Ok\033[m\n" || exit 1 @$(GO) test -race -timeout 20m -v -cover -coverprofile coverage.txt ./... && echo "\n==>\033[32m Ok\033[m\n" || exit 1
.PHONY: test-dind
test-dind: ## run the daemon-facing tests against the built dind image (TARGET=dind|dind-rootless)
@./scripts/test-dind.sh $(TARGET)
.PHONY: install .PHONY: install
install: $(GOFILES) ## install the runner binary via `go install` install: $(GOFILES) ## install the runner binary via `go install`

View File

@@ -85,6 +85,44 @@ docker run -e GITEA_INSTANCE_URL=https://your_gitea.com -e GITEA_RUNNER_REGISTRA
Mount a volume on `/data` if you want the registration file and optional config to survive container recreation (see [scripts/run.sh](scripts/run.sh)). Mount a volume on `/data` if you want the registration file and optional config to survive container recreation (see [scripts/run.sh](scripts/run.sh)).
### Image flavours
The image is published in three flavours, all built from the single multi-stage [Dockerfile](Dockerfile) in this repository. They differ only in how a Docker daemon is made available to the jobs the runner executes; the `gitea-runner` binary inside them is identical.
| Tag | Build target | Base image | Docker daemon | Process supervisor | Runs as |
| --- | --- | --- | --- | --- | --- |
| `latest` (and `<version>`) | `basic` | `alpine` | none — uses an external daemon you provide | [`tini`](https://github.com/krallin/tini) | `root` |
| `latest-dind` | `dind` | `docker:dind` | bundled, started inside the container | [`s6`](https://skarnet.org/software/s6/) | `root` (privileged) |
| `latest-dind-rootless` | `dind-rootless` | `docker:dind-rootless` | bundled, started rootless inside the container | [`s6`](https://skarnet.org/software/s6/) | `rootless` (UID 1000) |
#### `latest` — basic
The default flavour ships only the runner on a minimal Alpine base. It contains **no Docker daemon of its own**: jobs that use `docker://` images need a daemon supplied from outside the container, typically by bind-mounting the host's socket:
```bash
docker run -e GITEA_INSTANCE_URL=https://your_gitea.com -e GITEA_RUNNER_REGISTRATION_TOKEN=<your_token> \
-v /var/run/docker.sock:/var/run/docker.sock --name my_runner gitea/runner:latest
```
`tini` is the entrypoint (it reaps zombie processes), and it just runs [`scripts/run.sh`](scripts/run.sh), which registers the runner on first start and then execs `gitea-runner daemon`. This flavour does not need `--privileged`. The trade-off is that jobs share the host's daemon, so they can see other containers and images on that daemon.
#### `latest-dind` — Docker-in-Docker
This flavour is based on the official `docker:dind` image and bundles its own Docker daemon, so it needs no external socket — only the `--privileged` flag that Docker-in-Docker requires:
```bash
docker run --privileged -e GITEA_INSTANCE_URL=https://your_gitea.com -e GITEA_RUNNER_REGISTRATION_TOKEN=<your_token> \
--name my_runner gitea/runner:latest-dind
```
Two processes have to run side by side here (the Docker daemon and the runner), so the entrypoint is the [`s6`](https://skarnet.org/software/s6/) supervision tree under [`scripts/s6`](scripts/s6) instead of `tini`. `s6` starts `dockerd`, and the runner service waits for the daemon to come up (`s6-svwait`) before launching [`run.sh`](scripts/run.sh). Each container has a private daemon isolated from the host's, at the cost of running privileged.
#### `latest-dind-rootless` — rootless Docker-in-Docker
Same idea as `dind`, but built on `docker:dind-rootless` so the bundled daemon and the runner run as an unprivileged user (`rootless`, UID 1000) rather than `root`. `DOCKER_HOST` is preset to `unix:///run/user/1000/docker.sock` so the runner talks to the rootless daemon. This reduces the blast radius compared to the privileged `dind` flavour, but rootless Docker carries the usual rootless limitations (networking, cgroups, storage drivers, and some operations that need additional host configuration such as `/etc/subuid` / `/etc/subgid` mappings and unprivileged user-namespace support).
> **Note on Podman:** these images target the Docker daemon. The bundled `dind`/`dind-rootless` daemons are `dockerd`, not Podman, and the `basic` flavour expects a Docker-compatible socket. Running them under rootless Podman is not a supported configuration, though pointing the `basic` flavour at a Podman socket that emulates the Docker API may work for some workloads.
### Configuration ### Configuration
The runner is configured with a YAML file. Generate a starting point (this matches what ships in the tree): The runner is configured with a YAML file. Generate a starting point (this matches what ships in the tree):

View File

@@ -325,10 +325,6 @@ func (h *Handler) openDB() (*bolthold.Store, error) {
func (h *Handler) find(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { func (h *Handler) find(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
cred := credFromContext(r.Context()) cred := credFromContext(r.Context())
keys := strings.Split(r.URL.Query().Get("keys"), ",") keys := strings.Split(r.URL.Query().Get("keys"), ",")
// cache keys are case insensitive
for i, key := range keys {
keys[i] = strings.ToLower(key)
}
version := r.URL.Query().Get("version") version := r.URL.Query().Get("version")
db, err := h.openDB() db, err := h.openDB()
@@ -371,8 +367,6 @@ func (h *Handler) reserve(w http.ResponseWriter, r *http.Request, _ httprouter.P
h.responseJSON(w, r, 400, err) h.responseJSON(w, r, 400, err)
return return
} }
// cache keys are case insensitive
api.Key = strings.ToLower(api.Key)
cache := api.ToCache() cache := api.ToCache()
cache.Repo = cred.Repo cache.Repo = cred.Repo
@@ -437,6 +431,7 @@ func (h *Handler) upload(w http.ResponseWriter, r *http.Request, params httprout
} }
if err := h.storage.Write(cache.ID, start, r.Body); err != nil { if err := h.storage.Write(cache.ID, start, r.Body); err != nil {
h.responseJSON(w, r, 500, err) h.responseJSON(w, r, 500, err)
return
} }
h.useCache(id) h.useCache(id)
h.responseJSON(w, r, 200) h.responseJSON(w, r, 200)

View File

@@ -11,6 +11,7 @@ import (
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"os"
"path/filepath" "path/filepath"
"strings" "strings"
"testing" "testing"
@@ -338,6 +339,54 @@ func TestHandler(t *testing.T) {
} }
}) })
t.Run("upload write failure returns only error", func(t *testing.T) {
key := strings.ToLower(t.Name())
version := "c19da02a2bd7e77277f1ac29ab45c09b7d46a4ee758284e26bb3045ad11d9d20"
var id uint64
{
body, err := json.Marshal(&Request{
Key: key,
Version: version,
Size: 100,
})
require.NoError(t, err)
resp, err := testClient.Post(base+"/caches", "application/json", bytes.NewReader(body))
require.NoError(t, err)
defer resp.Body.Close()
require.Equal(t, 200, resp.StatusCode)
got := struct {
CacheID uint64 `json:"cacheId"`
}{}
require.NoError(t, json.NewDecoder(resp.Body).Decode(&got))
id = got.CacheID
}
storageFile := filepath.Join(dir, "not-a-directory")
require.NoError(t, os.WriteFile(storageFile, []byte("blocked"), 0o600))
originalStorage := handler.storage
handler.storage = &Storage{rootDir: storageFile}
defer func() {
handler.storage = originalStorage
}()
req, err := http.NewRequest(http.MethodPatch,
fmt.Sprintf("%s/caches/%d", base, id), bytes.NewReader(make([]byte, 100)))
require.NoError(t, err)
req.Header.Set("Content-Type", "application/octet-stream")
req.Header.Set("Content-Range", "bytes 0-99/*")
resp, err := testClient.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
require.Equal(t, 500, resp.StatusCode)
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
var got map[string]string
require.NoError(t, json.Unmarshal(body, &got))
assert.NotEmpty(t, got["error"])
})
t.Run("commit early", func(t *testing.T) { t.Run("commit early", func(t *testing.T) {
key := strings.ToLower(t.Name()) key := strings.ToLower(t.Name())
version := "c19da02a2bd7e77277f1ac29ab45c09b7d46a4ee758284e26bb3045ad11d9d20" version := "c19da02a2bd7e77277f1ac29ab45c09b7d46a4ee758284e26bb3045ad11d9d20"
@@ -459,17 +508,20 @@ func TestHandler(t *testing.T) {
assert.Equal(t, contents[except], content) assert.Equal(t, contents[except], content)
}) })
t.Run("case insensitive", func(t *testing.T) { t.Run("case preserved", func(t *testing.T) {
// Some actions (e.g. actions/setup-go, actions/setup-node) build cache keys that contain mixed-case fragments such as RUNNER_OS=Linux,
// then compare the cacheKey returned by the cache server to their original key with case-sensitive equality to decide whether the
// cache was a complete hit. The server must therefore preserve the original key case.
version := "c19da02a2bd7e77277f1ac29ab45c09b7d46a4ee758284e26bb3045ad11d9d20" version := "c19da02a2bd7e77277f1ac29ab45c09b7d46a4ee758284e26bb3045ad11d9d20"
key := strings.ToLower(t.Name()) key := strings.ToLower(t.Name()) + "_ABC"
content := make([]byte, 100) content := make([]byte, 100)
_, err := rand.Read(content) _, err := rand.Read(content)
require.NoError(t, err) require.NoError(t, err)
uploadCacheNormally(t, base, key+"_ABC", version, content) uploadCacheNormally(t, base, key, version, content)
{ {
reqKey := key + "_aBc" resp, err := testClient.Get(fmt.Sprintf("%s/cache?keys=%s&version=%s", base, key, version))
resp, err := testClient.Get(fmt.Sprintf("%s/cache?keys=%s&version=%s", base, reqKey, version))
require.NoError(t, err) require.NoError(t, err)
defer resp.Body.Close() defer resp.Body.Close()
require.Equal(t, 200, resp.StatusCode) require.Equal(t, 200, resp.StatusCode)
@@ -480,7 +532,8 @@ func TestHandler(t *testing.T) {
}{} }{}
require.NoError(t, json.NewDecoder(resp.Body).Decode(&got)) require.NoError(t, json.NewDecoder(resp.Body).Decode(&got))
assert.Equal(t, "hit", got.Result) assert.Equal(t, "hit", got.Result)
assert.Equal(t, key+"_abc", got.CacheKey) assert.Equal(t, key, got.CacheKey)
assert.NotEqual(t, strings.ToLower(key), got.CacheKey)
} }
}) })
@@ -643,7 +696,7 @@ func uploadCacheNormally(t *testing.T, base, key, version string, content []byte
}{} }{}
require.NoError(t, json.NewDecoder(resp.Body).Decode(&got)) require.NoError(t, json.NewDecoder(resp.Body).Decode(&got))
assert.Equal(t, "hit", got.Result) assert.Equal(t, "hit", got.Result)
assert.Equal(t, strings.ToLower(key), got.CacheKey) assert.Equal(t, key, got.CacheKey)
archiveLocation = got.ArchiveLocation archiveLocation = got.ArchiveLocation
} }
{ {

View File

@@ -5,24 +5,25 @@
package artifacts package artifacts
import ( import (
"context" "bytes"
"compress/gzip"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io"
"maps"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"net/url"
"os" "os"
"path"
"path/filepath" "path/filepath"
"strings" "strings"
"testing" "testing"
"testing/fstest" "testing/fstest"
"time"
"gitea.com/gitea/runner/act/model"
"gitea.com/gitea/runner/act/runner"
"github.com/julienschmidt/httprouter" "github.com/julienschmidt/httprouter"
log "github.com/sirupsen/logrus"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
) )
type writableMapFile struct { type writableMapFile struct {
@@ -234,89 +235,133 @@ func TestDownloadArtifactFile(t *testing.T) {
assert.Equal("content", string(data)) assert.Equal("content", string(data))
} }
type TestJobFileInfo struct { // TestArtifactFlow drives the real Serve() artifact server over a loopback socket, exercising
workdir string // the same upload -> finalize -> list -> download protocol the upload-artifact/download-artifact
workflowPath string // actions speak. Running it in-process (rather than from a job container) keeps it network-free
eventName string // and reachable everywhere, including when the CI job is itself a container.
errorMessage string
platforms map[string]string
containerArchitecture string
}
var (
artifactsPath = path.Join(os.TempDir(), "test-artifacts")
artifactsAddr = "127.0.0.1"
artifactsPort = "12345"
)
func TestArtifactFlow(t *testing.T) { func TestArtifactFlow(t *testing.T) {
if testing.Short() { artifactPath := t.TempDir()
t.Skip("skipping integration test")
// Serve the exact routes Serve() wires up, on a real loopback socket via httptest. httptest
// picks a free port and Close() tears the server down synchronously — avoiding both the
// port-rebind race and Serve()'s detached ListenAndServe goroutine, which logger.Fatal()s
// (process exit) on a bind error and can outlive the test's temp-dir cleanup.
router := httprouter.New()
fsys := readWriteFSImpl{}
uploads(router, artifactPath, fsys)
downloads(router, artifactPath, fsys)
server := httptest.NewServer(router)
defer server.Close()
baseURL := server.URL
client := server.Client()
client.Timeout = 5 * time.Second
// request performs one HTTP call and returns the status and body. The default transport adds
// Accept-Encoding: gzip and transparently decompresses, so gzipped downloads come back plain.
request := func(t *testing.T, method, rawURL string, body io.Reader, header http.Header) (int, []byte) {
t.Helper()
req, err := http.NewRequest(method, rawURL, body)
require.NoError(t, err)
maps.Copy(req.Header, header)
resp, err := client.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
data, err := io.ReadAll(resp.Body)
require.NoError(t, err)
return resp.StatusCode, data
} }
ctx := context.Background() t.Run("upload-and-download", func(t *testing.T) {
const runID, item, content = "1", "my-artifact/data.txt", "hello artifact\n"
cancel := Serve(ctx, artifactsPath, artifactsAddr, artifactsPort) status, data := request(t, http.MethodPost, baseURL+"/_apis/pipelines/workflows/"+runID+"/artifacts", nil, nil)
defer cancel() require.Equal(t, http.StatusOK, status, string(data))
var prep FileContainerResourceURL
require.NoError(t, json.Unmarshal(data, &prep))
require.Equal(t, baseURL+"/upload/"+runID, prep.FileContainerResourceURL)
platforms := map[string]string{ status, data = request(t, http.MethodPut, prep.FileContainerResourceURL+"?itemPath="+url.QueryEscape(item), strings.NewReader(content), nil)
"ubuntu-latest": "node:24-bookworm", // Don't use node:24-bookworm-slim because it doesn't have curl command, which is used in the tests require.Equal(t, http.StatusOK, status, string(data))
} var msg ResponseMessage
require.NoError(t, json.Unmarshal(data, &msg))
require.Equal(t, "success", msg.Message)
tables := []TestJobFileInfo{ status, data = request(t, http.MethodPatch, baseURL+"/_apis/pipelines/workflows/"+runID+"/artifacts", nil, nil)
{"testdata", "upload-and-download", "push", "", platforms, ""}, require.Equal(t, http.StatusOK, status, string(data))
{"testdata", "GHSL-2023-004", "push", "", platforms, ""},
}
log.SetLevel(log.DebugLevel)
for _, table := range tables { status, data = request(t, http.MethodGet, baseURL+"/_apis/pipelines/workflows/"+runID+"/artifacts", nil, nil)
runTestJobFile(ctx, t, table) require.Equal(t, http.StatusOK, status, string(data))
} var list NamedFileContainerResourceURLResponse
} require.NoError(t, json.Unmarshal(data, &list))
require.Equal(t, 1, list.Count)
require.Equal(t, "my-artifact", list.Value[0].Name)
func runTestJobFile(ctx context.Context, t *testing.T, tjfi TestJobFileInfo) { status, data = request(t, http.MethodGet, list.Value[0].FileContainerResourceURL+"?itemPath=my-artifact", nil, nil)
t.Run(tjfi.workflowPath, func(t *testing.T) { require.Equal(t, http.StatusOK, status, string(data))
fmt.Printf("::group::%s\n", tjfi.workflowPath) //nolint:forbidigo // pre-existing issue from nektos/act var items ContainerItemResponse
require.NoError(t, json.Unmarshal(data, &items))
require.Len(t, items.Value, 1)
require.Equal(t, "file", items.Value[0].ItemType)
require.Equal(t, "my-artifact/data.txt", items.Value[0].Path)
if err := os.RemoveAll(artifactsPath); err != nil { status, data = request(t, http.MethodGet, items.Value[0].ContentLocation, nil, nil)
panic(err) require.Equal(t, http.StatusOK, status)
} require.Equal(t, content, string(data))
workdir, err := filepath.Abs(tjfi.workdir) stored, err := os.ReadFile(filepath.Join(artifactPath, runID, "my-artifact", "data.txt"))
assert.NoError(t, err, workdir) //nolint:testifylint // pre-existing issue from nektos/act require.NoError(t, err)
fullWorkflowPath := filepath.Join(workdir, tjfi.workflowPath) require.Equal(t, content, string(stored))
runnerConfig := &runner.Config{ })
Workdir: workdir,
BindWorkdir: false,
EventName: tjfi.eventName,
Platforms: tjfi.platforms,
ReuseContainers: false,
ContainerArchitecture: tjfi.containerArchitecture,
GitHubInstance: "github.com",
ArtifactServerPath: artifactsPath,
ArtifactServerAddr: artifactsAddr,
ArtifactServerPort: artifactsPort,
}
runner, err := runner.New(runnerConfig) t.Run("gzip-roundtrip", func(t *testing.T) {
assert.NoError(t, err, tjfi.workflowPath) //nolint:testifylint // pre-existing issue from nektos/act const runID, item, content = "2", "logs/app.log", "compressed payload\n"
planner, err := model.NewWorkflowPlanner(fullWorkflowPath, true) var buf bytes.Buffer
assert.NoError(t, err, fullWorkflowPath) //nolint:testifylint // pre-existing issue from nektos/act gz := gzip.NewWriter(&buf)
_, err := gz.Write([]byte(content))
require.NoError(t, err)
require.NoError(t, gz.Close())
plan, err := planner.PlanEvent(tjfi.eventName) status, data := request(t, http.MethodPut, baseURL+"/upload/"+runID+"?itemPath="+url.QueryEscape(item),
if err == nil { &buf, http.Header{"Content-Encoding": []string{"gzip"}})
err = runner.NewPlanExecutor(plan)(ctx) require.Equal(t, http.StatusOK, status, string(data))
if tjfi.errorMessage == "" {
assert.NoError(t, err, fullWorkflowPath) //nolint:testifylint // pre-existing issue from nektos/act
} else {
assert.Error(t, err, tjfi.errorMessage) //nolint:testifylint // pre-existing issue from nektos/act
}
} else {
assert.Nil(t, plan)
}
fmt.Println("::endgroup::") //nolint:forbidigo // pre-existing issue from nektos/act // stored compressed, with the server's gzip marker suffix
_, err = os.Stat(filepath.Join(artifactPath, runID, "logs", "app.log.gz__"))
require.NoError(t, err)
status, data = request(t, http.MethodGet, baseURL+"/download/"+runID+"?itemPath=logs", nil, nil)
require.Equal(t, http.StatusOK, status, string(data))
var items ContainerItemResponse
require.NoError(t, json.Unmarshal(data, &items))
require.Len(t, items.Value, 1)
require.Equal(t, "logs/app.log", items.Value[0].Path)
status, data = request(t, http.MethodGet, items.Value[0].ContentLocation, nil, nil)
require.Equal(t, http.StatusOK, status)
require.Equal(t, content, string(data))
})
// GHSL-2023-004: an itemPath that climbs out of the run directory must be neutralised so the
// blob cannot be written outside the artifact root.
t.Run("GHSL-2023-004", func(t *testing.T) {
const runID, content = "3", "contained\n"
status, data := request(t, http.MethodPut, baseURL+"/upload/"+runID+"?itemPath="+url.QueryEscape("../../escape.txt"),
strings.NewReader(content), nil)
require.Equal(t, http.StatusOK, status, string(data))
stored, err := os.ReadFile(filepath.Join(artifactPath, runID, "escape.txt"))
require.NoError(t, err)
require.Equal(t, content, string(stored))
_, err = os.Stat(filepath.Join(filepath.Dir(artifactPath), "escape.txt"))
require.True(t, os.IsNotExist(err), "upload escaped the artifact root")
status, data = request(t, http.MethodGet, baseURL+"/artifact/"+runID+"/escape.txt", nil, nil)
require.Equal(t, http.StatusOK, status)
require.Equal(t, content, string(data))
}) })
} }

View File

@@ -1,39 +0,0 @@
name: "GHSL-2023-0004"
on: push
jobs:
test-artifacts:
runs-on: ubuntu-latest
steps:
- run: echo "hello world" > test.txt
- name: curl upload
run: curl --silent --show-error --fail ${ACTIONS_RUNTIME_URL}upload/1?itemPath=../../my-artifact/secret.txt --upload-file test.txt
- uses: actions/download-artifact@v2
with:
name: my-artifact
path: test-artifacts
- name: 'Verify Artifact #1'
run: |
file="test-artifacts/secret.txt"
if [ ! -f $file ] ; then
echo "Expected file does not exist"
exit 1
fi
if [ "$(cat $file)" != "hello world" ] ; then
echo "File contents of downloaded artifact are incorrect"
exit 1
fi
- name: Verify download should work by clean extra dots
run: curl --silent --show-error --fail --path-as-is -o out.txt ${ACTIONS_RUNTIME_URL}artifact/1/../../../1/my-artifact/secret.txt
- name: 'Verify download content'
run: |
file="out.txt"
if [ ! -f $file ] ; then
echo "Expected file does not exist"
exit 1
fi
if [ "$(cat $file)" != "hello world" ] ; then
echo "File contents of downloaded artifact are incorrect"
exit 1
fi

View File

@@ -1,230 +0,0 @@
name: "Test that artifact uploads and downloads succeed"
on: push
jobs:
test-artifacts:
runs-on: ubuntu-latest
steps:
- run: mkdir -p path/to/artifact
- run: echo hello > path/to/artifact/world.txt
- uses: actions/upload-artifact@v2
with:
name: my-artifact
path: path/to/artifact/world.txt
- run: rm -rf path
- uses: actions/download-artifact@v2
with:
name: my-artifact
- name: Display structure of downloaded files
run: ls -la
# Test end-to-end by uploading two artifacts and then downloading them
- name: Create artifact files
run: |
mkdir -p path/to/dir-1
mkdir -p path/to/dir-2
mkdir -p path/to/dir-3
mkdir -p path/to/dir-5
mkdir -p path/to/dir-6
mkdir -p path/to/dir-7
echo "Lorem ipsum dolor sit amet" > path/to/dir-1/file1.txt
echo "Hello world from file #2" > path/to/dir-2/file2.txt
echo "This is a going to be a test for a large enough file that should get compressed with GZip. The @actions/artifact package uses GZip to upload files. This text should have a compression ratio greater than 100% so it should get uploaded using GZip" > path/to/dir-3/gzip.txt
dd if=/dev/random of=path/to/dir-5/file5.rnd bs=1024 count=1024
dd if=/dev/random of=path/to/dir-6/file6.rnd bs=1024 count=$((10*1024))
dd if=/dev/random of=path/to/dir-7/file7.rnd bs=1024 count=$((10*1024))
# Upload a single file artifact
- name: 'Upload artifact #1'
uses: actions/upload-artifact@v2
with:
name: 'Artifact-A'
path: path/to/dir-1/file1.txt
# Upload using a wildcard pattern, name should default to 'artifact' if not provided
- name: 'Upload artifact #2'
uses: actions/upload-artifact@v2
with:
path: path/**/dir*/
# Upload a directory that contains a file that will be uploaded with GZip
- name: 'Upload artifact #3'
uses: actions/upload-artifact@v2
with:
name: 'GZip-Artifact'
path: path/to/dir-3/
# Upload a directory that contains a file that will be uploaded with GZip
- name: 'Upload artifact #4'
uses: actions/upload-artifact@v2
with:
name: 'Multi-Path-Artifact'
path: |
path/to/dir-1/*
path/to/dir-[23]/*
!path/to/dir-3/*.txt
# Upload a mid-size file artifact
- name: 'Upload artifact #5'
uses: actions/upload-artifact@v2
with:
name: 'Mid-Size-Artifact'
path: path/to/dir-5/file5.rnd
# Upload a big file artifact
- name: 'Upload artifact #6'
uses: actions/upload-artifact@v2
with:
name: 'Big-Artifact'
path: path/to/dir-6/file6.rnd
# Upload a big file artifact twice
- name: 'Upload artifact #7 (First)'
uses: actions/upload-artifact@v2
with:
name: 'Big-Uploaded-Twice'
path: path/to/dir-7/file7.rnd
# Upload a big file artifact twice
- name: 'Upload artifact #7 (Second)'
uses: actions/upload-artifact@v2
with:
name: 'Big-Uploaded-Twice'
path: path/to/dir-7/file7.rnd
# Verify artifacts. Switch to download-artifact@v2 once it's out of preview
# Download Artifact #1 and verify the correctness of the content
- name: 'Download artifact #1'
uses: actions/download-artifact@v2
with:
name: 'Artifact-A'
path: some/new/path
- name: 'Verify Artifact #1'
run: |
file="some/new/path/file1.txt"
if [ ! -f $file ] ; then
echo "Expected file does not exist"
exit 1
fi
if [ "$(cat $file)" != "Lorem ipsum dolor sit amet" ] ; then
echo "File contents of downloaded artifact are incorrect"
exit 1
fi
# Download Artifact #2 and verify the correctness of the content
- name: 'Download artifact #2'
uses: actions/download-artifact@v2
with:
name: 'artifact'
path: some/other/path
- name: 'Verify Artifact #2'
run: |
file1="some/other/path/to/dir-1/file1.txt"
file2="some/other/path/to/dir-2/file2.txt"
if [ ! -f $file1 -o ! -f $file2 ] ; then
echo "Expected files do not exist"
exit 1
fi
if [ "$(cat $file1)" != "Lorem ipsum dolor sit amet" -o "$(cat $file2)" != "Hello world from file #2" ] ; then
echo "File contents of downloaded artifacts are incorrect"
exit 1
fi
# Download Artifact #3 and verify the correctness of the content
- name: 'Download artifact #3'
uses: actions/download-artifact@v2
with:
name: 'GZip-Artifact'
path: gzip/artifact/path
# Because a directory was used as input during the upload the parent directories, path/to/dir-3/, should not be included in the uploaded artifact
- name: 'Verify Artifact #3'
run: |
gzipFile="gzip/artifact/path/gzip.txt"
if [ ! -f $gzipFile ] ; then
echo "Expected file do not exist"
exit 1
fi
if [ "$(cat $gzipFile)" != "This is a going to be a test for a large enough file that should get compressed with GZip. The @actions/artifact package uses GZip to upload files. This text should have a compression ratio greater than 100% so it should get uploaded using GZip" ] ; then
echo "File contents of downloaded artifact is incorrect"
exit 1
fi
- name: 'Download artifact #4'
uses: actions/download-artifact@v2
with:
name: 'Multi-Path-Artifact'
path: multi/artifact
- name: 'Verify Artifact #4'
run: |
file1="multi/artifact/dir-1/file1.txt"
file2="multi/artifact/dir-2/file2.txt"
if [ ! -f $file1 -o ! -f $file2 ] ; then
echo "Expected files do not exist"
exit 1
fi
if [ "$(cat $file1)" != "Lorem ipsum dolor sit amet" -o "$(cat $file2)" != "Hello world from file #2" ] ; then
echo "File contents of downloaded artifacts are incorrect"
exit 1
fi
- name: 'Download artifact #5'
uses: actions/download-artifact@v2
with:
name: 'Mid-Size-Artifact'
path: mid-size/artifact/path
- name: 'Verify Artifact #5'
run: |
file="mid-size/artifact/path/file5.rnd"
if [ ! -f $file ] ; then
echo "Expected file does not exist"
exit 1
fi
if ! diff $file path/to/dir-5/file5.rnd ; then
echo "File contents of downloaded artifact are incorrect"
exit 1
fi
- name: 'Download artifact #6'
uses: actions/download-artifact@v2
with:
name: 'Big-Artifact'
path: big/artifact/path
- name: 'Verify Artifact #6'
run: |
file="big/artifact/path/file6.rnd"
if [ ! -f $file ] ; then
echo "Expected file does not exist"
exit 1
fi
if ! diff $file path/to/dir-6/file6.rnd ; then
echo "File contents of downloaded artifact are incorrect"
exit 1
fi
- name: 'Download artifact #7'
uses: actions/download-artifact@v2
with:
name: 'Big-Uploaded-Twice'
path: big-uploaded-twice/artifact/path
- name: 'Verify Artifact #7'
run: |
file="big-uploaded-twice/artifact/path/file7.rnd"
if [ ! -f $file ] ; then
echo "Expected file does not exist"
exit 1
fi
if ! diff $file path/to/dir-7/file7.rnd ; then
echo "File contents of downloaded artifact are incorrect"
exit 1
fi

View File

@@ -4,6 +4,8 @@
package common package common
import "slices"
// CartesianProduct takes map of lists and returns list of unique tuples // CartesianProduct takes map of lists and returns list of unique tuples
func CartesianProduct(mapOfLists map[string][]any) []map[string]any { func CartesianProduct(mapOfLists map[string][]any) []map[string]any {
listNames := make([]string, 0) listNames := make([]string, 0)
@@ -46,7 +48,7 @@ func cartN(a ...[]any) [][]any {
for j, n := range n { for j, n := range n {
pi[j] = a[j][n] pi[j] = a[j][n]
} }
for j := len(n) - 1; j >= 0; j-- { for j := range slices.Backward(n) {
n[j]++ n[j]++
if n[j] < len(a[j]) { if n[j] < len(a[j]) {
break break

View File

@@ -1,146 +0,0 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// Copyright 2020 The nektos/act Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package common
import (
"fmt"
"io"
"os"
"strings"
)
// Style is a specific style
type Style int
// Styles
const (
StyleDoubleLine = iota
StyleSingleLine
StyleDashedLine
StyleNoLine
)
// NewPen creates a new pen
func NewPen(style Style, color int) *Pen {
bgcolor := 49
if os.Getenv("CLICOLOR") == "0" {
color = 0
bgcolor = 0
}
return &Pen{
style: style,
color: color,
bgcolor: bgcolor,
}
}
type styleDef struct {
cornerTL string
cornerTR string
cornerBL string
cornerBR string
lineH string
lineV string
}
var styleDefs = []styleDef{
{"\u2554", "\u2557", "\u255a", "\u255d", "\u2550", "\u2551"},
{"\u256d", "\u256e", "\u2570", "\u256f", "\u2500", "\u2502"},
{"\u250c", "\u2510", "\u2514", "\u2518", "\u254c", "\u254e"},
{" ", " ", " ", " ", " ", " "},
}
// Pen struct
type Pen struct {
style Style
color int
bgcolor int
}
// Drawing struct
type Drawing struct {
buf *strings.Builder
width int
}
func (p *Pen) drawTopBars(buf io.Writer, labels ...string) {
style := styleDefs[p.style]
for _, label := range labels {
bar := strings.Repeat(style.lineH, len(label)+2)
fmt.Fprintf(buf, " ")
fmt.Fprintf(buf, "\x1b[%d;%dm", p.color, p.bgcolor)
fmt.Fprintf(buf, "%s%s%s", style.cornerTL, bar, style.cornerTR)
fmt.Fprintf(buf, "\x1b[%dm", 0)
}
fmt.Fprintf(buf, "\n")
}
func (p *Pen) drawBottomBars(buf io.Writer, labels ...string) {
style := styleDefs[p.style]
for _, label := range labels {
bar := strings.Repeat(style.lineH, len(label)+2)
fmt.Fprintf(buf, " ")
fmt.Fprintf(buf, "\x1b[%d;%dm", p.color, p.bgcolor)
fmt.Fprintf(buf, "%s%s%s", style.cornerBL, bar, style.cornerBR)
fmt.Fprintf(buf, "\x1b[%dm", 0)
}
fmt.Fprintf(buf, "\n")
}
func (p *Pen) drawLabels(buf io.Writer, labels ...string) {
style := styleDefs[p.style]
for _, label := range labels {
fmt.Fprintf(buf, " ")
fmt.Fprintf(buf, "\x1b[%d;%dm", p.color, p.bgcolor)
fmt.Fprintf(buf, "%s %s %s", style.lineV, label, style.lineV)
fmt.Fprintf(buf, "\x1b[%dm", 0)
}
fmt.Fprintf(buf, "\n")
}
// DrawArrow between boxes
func (p *Pen) DrawArrow() *Drawing {
drawing := &Drawing{
buf: new(strings.Builder),
width: 1,
}
fmt.Fprintf(drawing.buf, "\x1b[%dm", p.color)
fmt.Fprintf(drawing.buf, "\u2b07")
fmt.Fprintf(drawing.buf, "\x1b[%dm", 0)
return drawing
}
// DrawBoxes to draw boxes
func (p *Pen) DrawBoxes(labels ...string) *Drawing {
width := 0
for _, l := range labels {
width += len(l) + 2 + 2 + 1
}
drawing := &Drawing{
buf: new(strings.Builder),
width: width,
}
p.drawTopBars(drawing.buf, labels...)
p.drawLabels(drawing.buf, labels...)
p.drawBottomBars(drawing.buf, labels...)
return drawing
}
// Draw to writer
func (d *Drawing) Draw(writer io.Writer, centerOnWidth int) {
padSize := max((centerOnWidth-d.GetWidth())/2, 0)
for l := range strings.SplitSeq(d.buf.String(), "\n") {
if len(l) > 0 {
padding := strings.Repeat(" ", padSize)
fmt.Fprintf(writer, "%s%s\n", padding, l)
}
}
}
// GetWidth of drawing
func (d *Drawing) GetWidth() int {
return d.width
}

View File

@@ -12,24 +12,6 @@ import (
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
) )
// Warning that implements `error` but safe to ignore
type Warning struct {
Message string
}
// Error the contract for error
func (w Warning) Error() string {
return w.Message
}
// Warningf create a warning
func Warningf(format string, args ...any) Warning {
w := Warning{
Message: fmt.Sprintf(format, args...),
}
return w
}
// Executor define contract for the steps of a workflow // Executor define contract for the steps of a workflow
type Executor func(ctx context.Context) error type Executor func(ctx context.Context) error
@@ -97,6 +79,12 @@ func NewErrorExecutor(err error) Executor {
// NewParallelExecutor creates a new executor from a parallel of other executors // NewParallelExecutor creates a new executor from a parallel of other executors
func NewParallelExecutor(parallel int, executors ...Executor) Executor { func NewParallelExecutor(parallel int, executors ...Executor) Executor {
if len(executors) == 0 {
return func(ctx context.Context) error {
return ctx.Err()
}
}
return func(ctx context.Context) error { return func(ctx context.Context) error {
work := make(chan Executor, len(executors)) work := make(chan Executor, len(executors))
errs := make(chan error, len(executors)) errs := make(chan error, len(executors))
@@ -156,14 +144,8 @@ func NewParallelExecutor(parallel int, executors ...Executor) Executor {
// Then runs another executor if this executor succeeds // Then runs another executor if this executor succeeds
func (e Executor) Then(then Executor) Executor { func (e Executor) Then(then Executor) Executor {
return func(ctx context.Context) error { return func(ctx context.Context) error {
err := e(ctx) if err := e(ctx); err != nil {
if err != nil { return err
switch err.(type) {
case Warning:
Logger(ctx).Warning(err.Error())
default:
return err
}
} }
if ctx.Err() != nil { if ctx.Err() != nil {
return ctx.Err() return ctx.Err()

View File

@@ -170,68 +170,6 @@ func TestMaxParallelWithErrors(t *testing.T) {
}) })
} }
// TestMaxParallelPerformance tests performance characteristics
func TestMaxParallelPerformance(t *testing.T) {
if testing.Short() {
t.Skip("Skipping performance test in short mode")
}
t.Run("ParallelFasterThanSequential", func(t *testing.T) {
executors := make([]Executor, 10)
for i := range 10 {
executors[i] = func(ctx context.Context) error {
time.Sleep(50 * time.Millisecond)
return nil
}
}
ctx := context.Background()
// Sequential (max-parallel=1)
start := time.Now()
err := NewParallelExecutor(1, executors...)(ctx)
sequentialDuration := time.Since(start)
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
// Parallel (max-parallel=5)
start = time.Now()
err = NewParallelExecutor(5, executors...)(ctx)
parallelDuration := time.Since(start)
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
// Parallel should be significantly faster
assert.Less(t, parallelDuration, sequentialDuration/2,
"Parallel execution should be at least 2x faster")
})
t.Run("OptimalWorkerCount", func(t *testing.T) {
executors := make([]Executor, 20)
for i := range 20 {
executors[i] = func(ctx context.Context) error {
time.Sleep(10 * time.Millisecond)
return nil
}
}
ctx := context.Background()
// Test with different worker counts
workerCounts := []int{1, 2, 5, 10, 20}
durations := make(map[int]time.Duration)
for _, count := range workerCounts {
start := time.Now()
err := NewParallelExecutor(count, executors...)(ctx)
durations[count] = time.Since(start)
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
}
// More workers should generally be faster (up to a point)
assert.Less(t, durations[5], durations[1], "5 workers should be faster than 1")
assert.Less(t, durations[10], durations[2], "10 workers should be faster than 2")
})
}
// TestMaxParallelResourceSharing tests resource sharing scenarios // TestMaxParallelResourceSharing tests resource sharing scenarios
func TestMaxParallelResourceSharing(t *testing.T) { func TestMaxParallelResourceSharing(t *testing.T) {
t.Run("SharedResourceWithMutex", func(t *testing.T) { t.Run("SharedResourceWithMutex", func(t *testing.T) {

View File

@@ -12,6 +12,7 @@ import (
"time" "time"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
) )
func TestNewWorkflow(t *testing.T) { func TestNewWorkflow(t *testing.T) {
@@ -119,6 +120,19 @@ func TestNewParallelExecutor(t *testing.T) {
assert.NoError(errSingle) assert.NoError(errSingle)
} }
func TestNewParallelExecutorEmpty(t *testing.T) {
assert := assert.New(t)
ctx := context.Background()
require.NoError(t, NewParallelExecutor(2)(ctx))
canceledCtx, cancel := context.WithCancel(context.Background())
cancel()
err := NewParallelExecutor(2)(canceledCtx)
assert.ErrorIs(err, context.Canceled)
}
func TestNewParallelExecutorFailed(t *testing.T) { func TestNewParallelExecutorFailed(t *testing.T) {
assert := assert.New(t) assert := assert.New(t)

View File

@@ -1,77 +0,0 @@
// Copyright 2022 The Gitea Authors. All rights reserved.
// Copyright 2020 The nektos/act Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package common
import (
"fmt"
"io"
"os"
)
// CopyFile copy file
func CopyFile(source, dest string) (err error) {
sourcefile, err := os.Open(source)
if err != nil {
return err
}
defer sourcefile.Close()
destfile, err := os.Create(dest)
if err != nil {
return err
}
defer destfile.Close()
_, err = io.Copy(destfile, sourcefile)
if err == nil {
sourceinfo, err := os.Stat(source)
if err != nil {
_ = os.Chmod(dest, sourceinfo.Mode())
}
}
return err
}
// CopyDir recursive copy of directory
func CopyDir(source, dest string) (err error) {
// get properties of source dir
sourceinfo, err := os.Stat(source)
if err != nil {
return err
}
// create dest dir
err = os.MkdirAll(dest, sourceinfo.Mode())
if err != nil {
return err
}
objects, err := os.ReadDir(source)
for _, obj := range objects {
sourcefilepointer := source + "/" + obj.Name()
destinationfilepointer := dest + "/" + obj.Name()
if obj.IsDir() {
// create sub-directories - recursively
err = CopyDir(sourcefilepointer, destinationfilepointer)
if err != nil {
fmt.Println(err) //nolint:forbidigo // pre-existing issue from nektos/act
}
} else {
// perform copy
err = CopyFile(sourcefilepointer, destinationfilepointer)
if err != nil {
fmt.Println(err) //nolint:forbidigo // pre-existing issue from nektos/act
}
}
}
return err
}

View File

@@ -38,9 +38,11 @@ var (
ErrNoRepo = errors.New("unable to find git repo") ErrNoRepo = errors.New("unable to find git repo")
) )
// acquireCloneLock returns an unlock function after locking the per-directory mutex for dir. // AcquireCloneLock returns an unlock function after locking the per-directory mutex for dir.
// Only concurrent operations targeting the same directory are erialized; clones into different directories run in parallel. // Only concurrent operations targeting the same directory are serialized; clones into different directories run in parallel.
func acquireCloneLock(dir string) func() { // Callers reading files inside dir (e.g. tarring a checked-out action into a job container) must hold this lock too,
// otherwise a concurrent NewGitCloneExecutor on the same dir can mutate the worktree mid-read.
func AcquireCloneLock(dir string) func() {
v, _ := cloneLocks.LoadOrStore(dir, &sync.Mutex{}) v, _ := cloneLocks.LoadOrStore(dir, &sync.Mutex{})
mu := v.(*sync.Mutex) mu := v.(*sync.Mutex)
mu.Lock() mu.Lock()
@@ -64,8 +66,21 @@ func (e *Error) Commit() string {
return e.commit return e.commit
} }
// goGitMu serializes go-git repository access across the process. go-git is not safe for
// concurrent use of the same repository (even read access decodes packfiles into shared
// state), so parallel jobs inspecting the shared workdir repo race without this. The guarded
// operations are fast local reads; gitea runs one job per process, so the lock is effectively
// uncontended in production.
var goGitMu sync.Mutex
// FindGitRevision get the current git revision // FindGitRevision get the current git revision
func FindGitRevision(ctx context.Context, file string) (shortSha, sha string, err error) { func FindGitRevision(ctx context.Context, file string) (shortSha, sha string, err error) {
goGitMu.Lock()
defer goGitMu.Unlock()
return findGitRevision(ctx, file)
}
func findGitRevision(ctx context.Context, file string) (shortSha, sha string, err error) {
logger := common.Logger(ctx) logger := common.Logger(ctx)
gitDir, err := git.PlainOpenWithOptions( gitDir, err := git.PlainOpenWithOptions(
@@ -97,10 +112,13 @@ func FindGitRevision(ctx context.Context, file string) (shortSha, sha string, er
// FindGitRef get the current git ref // FindGitRef get the current git ref
func FindGitRef(ctx context.Context, file string) (string, error) { func FindGitRef(ctx context.Context, file string) (string, error) {
goGitMu.Lock()
defer goGitMu.Unlock()
logger := common.Logger(ctx) logger := common.Logger(ctx)
logger.Debugf("Loading revision from git directory") logger.Debugf("Loading revision from git directory")
_, ref, err := FindGitRevision(ctx, file) _, ref, err := findGitRevision(ctx, file)
if err != nil { if err != nil {
return "", err return "", err
} }
@@ -172,6 +190,8 @@ func FindGitRef(ctx context.Context, file string) (string, error) {
// FindGithubRepo get the repo // FindGithubRepo get the repo
func FindGithubRepo(ctx context.Context, file, githubInstance, remoteName string) (string, error) { func FindGithubRepo(ctx context.Context, file, githubInstance, remoteName string) (string, error) {
goGitMu.Lock()
defer goGitMu.Unlock()
if remoteName == "" { if remoteName == "" {
remoteName = "origin" remoteName = "origin"
} }
@@ -241,47 +261,65 @@ type NewGitCloneExecutorInput struct {
InsecureSkipTLS bool InsecureSkipTLS bool
} }
// CloneIfRequired ... // CloneIfRequired returns the repository and a boolean indicating whether an existing local clone was reused.
func CloneIfRequired(ctx context.Context, refName plumbing.ReferenceName, input NewGitCloneExecutorInput, logger log.FieldLogger) (*git.Repository, error) { func CloneIfRequired(ctx context.Context, refName plumbing.ReferenceName, input NewGitCloneExecutorInput, logger log.FieldLogger) (*git.Repository, bool, error) {
r, err := git.PlainOpen(input.Dir) r, err := git.PlainOpen(input.Dir)
if err != nil { if err == nil {
var progressWriter io.Writer // Verify the cached clone still points to the resolved URL before reusing it.
if isatty.IsTerminal(os.Stdout.Fd()) || isatty.IsCygwinTerminal(os.Stdout.Fd()) { remote, err := r.Remote("origin")
if entry, ok := logger.(*log.Entry); ok { if err == nil && len(remote.Config().URLs) > 0 && remote.Config().URLs[0] == input.URL {
progressWriter = entry.WriterLevel(log.DebugLevel) // Reuse existing clone
} else if lgr, ok := logger.(*log.Logger); ok { return r, true, nil
progressWriter = lgr.WriterLevel(log.DebugLevel)
} else {
log.Errorf("Unable to get writer from logger (type=%T)", logger)
progressWriter = os.Stdout
}
} }
cloneOptions := git.CloneOptions{
URL: input.URL,
Progress: progressWriter,
InsecureSkipTLS: input.InsecureSkipTLS, // For Gitea
}
if input.Token != "" {
cloneOptions.Auth = &http.BasicAuth{
Username: "token",
Password: input.Token,
}
}
r, err = git.PlainCloneContext(ctx, input.Dir, false, &cloneOptions)
if err != nil { if err != nil {
logger.Errorf("Unable to clone %v %s: %v", input.URL, refName, err) logger.Debugf("Removing cached clone at %s because origin cannot be read: %v", input.Dir, err)
return nil, err } else if len(remote.Config().URLs) == 0 {
logger.Debugf("Removing cached clone at %s because origin has no URL", input.Dir)
} else {
logger.Debugf("Removing cached clone at %s because origin URL changed from %s to %s", input.Dir, remote.Config().URLs[0], input.URL)
} }
if err := os.RemoveAll(input.Dir); err != nil {
if err = os.Chmod(input.Dir, 0o755); err != nil { return nil, false, fmt.Errorf("remove cached clone %s: %w", input.Dir, err)
return nil, err
} }
} }
return r, nil var progressWriter io.Writer
if isatty.IsTerminal(os.Stdout.Fd()) || isatty.IsCygwinTerminal(os.Stdout.Fd()) {
if entry, ok := logger.(*log.Entry); ok {
progressWriter = entry.WriterLevel(log.DebugLevel)
} else if lgr, ok := logger.(*log.Logger); ok {
progressWriter = lgr.WriterLevel(log.DebugLevel)
} else {
log.Errorf("Unable to get writer from logger (type=%T)", logger)
progressWriter = os.Stdout
}
}
cloneOptions := git.CloneOptions{
URL: input.URL,
Progress: progressWriter,
InsecureSkipTLS: input.InsecureSkipTLS, // For Gitea
}
if input.Token != "" {
cloneOptions.Auth = &http.BasicAuth{
Username: "token",
Password: input.Token,
}
}
r, err = git.PlainCloneContext(ctx, input.Dir, false, &cloneOptions)
if err != nil {
logger.Errorf("Unable to clone %v %s: %v", input.URL, refName, err)
return nil, false, err
}
if err = os.Chmod(input.Dir, 0o755); err != nil {
return nil, false, err
}
return r, false, nil
} }
func gitOptions(token string) (fetchOptions git.FetchOptions, pullOptions git.PullOptions) { func gitOptions(token string) (fetchOptions git.FetchOptions, pullOptions git.PullOptions) {
@@ -305,13 +343,13 @@ func gitOptions(token string) (fetchOptions git.FetchOptions, pullOptions git.Pu
func NewGitCloneExecutor(input NewGitCloneExecutorInput) common.Executor { func NewGitCloneExecutor(input NewGitCloneExecutorInput) common.Executor {
return func(ctx context.Context) error { return func(ctx context.Context) error {
logger := common.Logger(ctx) logger := common.Logger(ctx)
logger.Infof(" \u2601 git clone '%s' # ref=%s", input.URL, input.Ref) logger.Infof("git clone '%s' # ref=%s", input.URL, input.Ref)
logger.Debugf(" cloning %s to %s", input.URL, input.Dir) logger.Debugf(" cloning %s to %s", input.URL, input.Dir)
defer acquireCloneLock(input.Dir)() defer AcquireCloneLock(input.Dir)()
refName := plumbing.ReferenceName("refs/heads/" + input.Ref) refName := plumbing.ReferenceName("refs/heads/" + input.Ref)
r, err := CloneIfRequired(ctx, refName, input, logger) r, reused, err := CloneIfRequired(ctx, refName, input, logger)
if err != nil { if err != nil {
return err return err
} }
@@ -336,10 +374,10 @@ func NewGitCloneExecutor(input NewGitCloneExecutorInput) common.Executor {
var hash *plumbing.Hash var hash *plumbing.Hash
rev := plumbing.Revision(input.Ref) rev := plumbing.Revision(input.Ref)
if hash, err = r.ResolveRevision(rev); err != nil { if hash, err = r.ResolveRevision(rev); err != nil {
// ResolveRevision returns a nil hash on error, and a branch ref legitimately fails
// here (no local refs/heads/<ref>); the duck-typing below resolves it.
logger.Errorf("Unable to resolve %s: %v", input.Ref, err) logger.Errorf("Unable to resolve %s: %v", input.Ref, err)
} } else if hash.String() != input.Ref && strings.HasPrefix(hash.String(), input.Ref) {
if hash.String() != input.Ref && strings.HasPrefix(hash.String(), input.Ref) {
return &Error{ return &Error{
err: ErrShortRef, err: ErrShortRef,
commit: hash.String(), commit: hash.String(),
@@ -390,12 +428,18 @@ func NewGitCloneExecutor(input NewGitCloneExecutorInput) common.Executor {
return err return err
} }
} }
reusedMsg := ""
if !isOfflineMode { if !isOfflineMode {
if err = w.Pull(&pullOptions); err != nil && err != git.NoErrAlreadyUpToDate { if err = w.Pull(&pullOptions); err != nil && err != git.NoErrAlreadyUpToDate {
logger.Debugf("Unable to pull %s: %v", refName, err) logger.Debugf("Unable to pull %s: %v", refName, err)
} }
} else if reused {
reusedMsg = " (reused in offline mode)"
} }
logger.Debugf("Cloned %s to %s", input.URL, input.Dir)
logger.Debugf("Cloned %s to %s%s", input.URL, input.Dir, reusedMsg)
if hash.String() != input.Ref && refType == "branch" { if hash.String() != input.Ref && refType == "branch" {
logger.Debugf("Provided ref is not a sha. Updating branch ref after pull") logger.Debugf("Provided ref is not a sha. Updating branch ref after pull")

View File

@@ -16,7 +16,6 @@ import (
"testing" "testing"
"time" "time"
log "github.com/sirupsen/logrus"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
@@ -50,10 +49,6 @@ func TestFindGitSlug(t *testing.T) {
} }
} }
func testDir(t *testing.T) string {
return t.TempDir()
}
func cleanGitHooks(dir string) error { func cleanGitHooks(dir string) error {
hooksDir := filepath.Join(dir, ".git", "hooks") hooksDir := filepath.Join(dir, ".git", "hooks")
files, err := os.ReadDir(hooksDir) files, err := os.ReadDir(hooksDir)
@@ -78,8 +73,7 @@ func cleanGitHooks(dir string) error {
func TestFindGitRemoteURL(t *testing.T) { func TestFindGitRemoteURL(t *testing.T) {
assert := assert.New(t) assert := assert.New(t)
basedir := testDir(t) basedir := t.TempDir()
gitConfig()
err := gitCmd("init", basedir) err := gitCmd("init", basedir)
assert.NoError(err) //nolint:testifylint // pre-existing issue from nektos/act assert.NoError(err) //nolint:testifylint // pre-existing issue from nektos/act
err = cleanGitHooks(basedir) err = cleanGitHooks(basedir)
@@ -102,8 +96,7 @@ func TestFindGitRemoteURL(t *testing.T) {
} }
func TestGitFindRef(t *testing.T) { func TestGitFindRef(t *testing.T) {
basedir := testDir(t) basedir := t.TempDir()
gitConfig()
for name, tt := range map[string]struct { for name, tt := range map[string]struct {
Prepare func(t *testing.T, dir string) Prepare func(t *testing.T, dir string)
@@ -180,36 +173,55 @@ func TestGitFindRef(t *testing.T) {
} }
func TestGitCloneExecutor(t *testing.T) { func TestGitCloneExecutor(t *testing.T) {
// Build a local bare "remote" so this runs offline and fast. The cases below mirror
// the tag/branch/sha/short-sha ref paths the executor handles, formerly exercised by
// cloning actions/checkout and anchore/scan-action over the network.
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", "initial"))
require.NoError(t, gitCmd("-C", workDir, "tag", "v2"))
require.NoError(t, gitCmd("-C", workDir, "push", "-u", "origin", "main"))
require.NoError(t, gitCmd("-C", workDir, "push", "origin", "v2"))
// A branch with a dash in the name (mirrors the historical scan-action@act-fails case).
require.NoError(t, gitCmd("-C", workDir, "checkout", "-b", "act-fails"))
require.NoError(t, gitCmd("-C", workDir, "commit", "--allow-empty", "-m", "branch-commit"))
require.NoError(t, gitCmd("-C", workDir, "push", "origin", "act-fails"))
out, err := exec.Command("git", "-C", workDir, "rev-parse", "main").Output()
require.NoError(t, err)
fullSha := strings.TrimSpace(string(out))
for name, tt := range map[string]struct { for name, tt := range map[string]struct {
Err error Err error
URL, Ref string Ref string
}{ }{
"tag": { "tag": {
Err: nil, Err: nil,
URL: "https://github.com/actions/checkout",
Ref: "v2", Ref: "v2",
}, },
"branch": { "branch": {
Err: nil, Err: nil,
URL: "https://github.com/anchore/scan-action",
Ref: "act-fails", Ref: "act-fails",
}, },
"sha": { "sha": {
Err: nil, Err: nil,
URL: "https://github.com/actions/checkout", Ref: fullSha,
Ref: "5a4ac9002d0be2fb38bd78e4b4dbde5606d7042f", // v2
}, },
"short-sha": { "short-sha": {
Err: &Error{ErrShortRef, "5a4ac9002d0be2fb38bd78e4b4dbde5606d7042f"}, Err: &Error{ErrShortRef, fullSha},
URL: "https://github.com/actions/checkout", Ref: fullSha[:7],
Ref: "5a4ac90", // v2
}, },
} { } {
t.Run(name, func(t *testing.T) { t.Run(name, func(t *testing.T) {
clone := NewGitCloneExecutor(NewGitCloneExecutorInput{ clone := NewGitCloneExecutor(NewGitCloneExecutorInput{
URL: tt.URL, URL: remoteDir,
Ref: tt.Ref, Ref: tt.Ref,
Dir: testDir(t), Dir: t.TempDir(),
}) })
err := clone(context.Background()) err := clone(context.Background())
@@ -223,13 +235,56 @@ func TestGitCloneExecutor(t *testing.T) {
} }
} }
func TestGitCloneExecutorReclonesWhenOriginURLChanges(t *testing.T) {
createRemote := func(message string) string {
remoteDir := t.TempDir()
require.NoError(t, gitCmd("init", "--bare", "--initial-branch=main", remoteDir))
workDir := t.TempDir()
require.NoError(t, gitCmd("clone", remoteDir, workDir))
require.NoError(t, gitCmd("-C", workDir, "checkout", "-b", "main"))
require.NoError(t, gitCmd("-C", workDir, "commit", "--allow-empty", "-m", message))
require.NoError(t, gitCmd("-C", workDir, "push", "-u", "origin", "main"))
return remoteDir
}
oldRemoteDir := createRemote("old-action")
newRemoteDir := createRemote("new-action")
cacheDir := t.TempDir()
require.NoError(t, NewGitCloneExecutor(NewGitCloneExecutorInput{
URL: oldRemoteDir,
Ref: "main",
Dir: cacheDir,
})(t.Context()))
markerPath := filepath.Join(cacheDir, "stale-marker")
require.NoError(t, os.WriteFile(markerPath, []byte("stale"), 0o644))
require.NoError(t, NewGitCloneExecutor(NewGitCloneExecutorInput{
URL: newRemoteDir,
Ref: "main",
Dir: cacheDir,
})(t.Context()))
originURL, err := findGitRemoteURL(t.Context(), cacheDir, "origin")
require.NoError(t, err)
assert.Equal(t, newRemoteDir, originURL)
out, err := exec.Command("git", "-C", cacheDir, "log", "--oneline", "-1", "--format=%s").Output()
require.NoError(t, err)
assert.Equal(t, "new-action", strings.TrimSpace(string(out)))
_, err = os.Stat(markerPath)
require.True(t, os.IsNotExist(err), "stale cached directory should be removed before recloning")
}
func TestGitCloneExecutorNonFastForwardRef(t *testing.T) { func TestGitCloneExecutorNonFastForwardRef(t *testing.T) {
// Simulate the scenario where a remote ref (e.g. a GitHub PR head ref) changes // Simulate the scenario where a remote ref (e.g. a GitHub PR head ref) changes
// non-fast-forward between two fetches. Before the fix, the fetch used Force=false, // non-fast-forward between two fetches. Before the fix, the fetch used Force=false,
// causing go-git to return ErrForceNeeded and short-circuit the checkout. // causing go-git to return ErrForceNeeded and short-circuit the checkout.
gitConfig()
// Create a bare "remote" repo with an initial commit on main and a feature branch. // Create a bare "remote" repo with an initial commit on main and a feature branch.
remoteDir := t.TempDir() remoteDir := t.TempDir()
require.NoError(t, gitCmd("init", "--bare", "--initial-branch=main", remoteDir)) require.NoError(t, gitCmd("init", "--bare", "--initial-branch=main", remoteDir))
@@ -279,22 +334,67 @@ func TestGitCloneExecutorNonFastForwardRef(t *testing.T) {
assert.Equal(t, "second", strings.TrimSpace(string(out)), "working tree should be at the latest commit") assert.Equal(t, "second", strings.TrimSpace(string(out)), "working tree should be at the latest commit")
} }
func gitConfig() { func TestGitCloneExecutorOfflineMode(t *testing.T) {
if os.Getenv("GITHUB_ACTIONS") == "true" { // Build a local "remote" with a single commit on main.
var err error remoteDir := t.TempDir()
if err = gitCmd("config", "--global", "user.email", "test@test.com"); err != nil { require.NoError(t, gitCmd("init", "--bare", "--initial-branch=main", remoteDir))
log.Error(err) workDir := t.TempDir()
} require.NoError(t, gitCmd("clone", remoteDir, workDir))
if err = gitCmd("config", "--global", "user.name", "Unit Test"); err != nil { require.NoError(t, gitCmd("-C", workDir, "checkout", "-b", "main"))
log.Error(err) require.NoError(t, gitCmd("-C", workDir, "commit", "--allow-empty", "-m", "initial"))
} require.NoError(t, gitCmd("-C", workDir, "push", "-u", "origin", "main"))
}
// Prime the cache with an online clone of main.
cacheDir := t.TempDir()
require.NoError(t, NewGitCloneExecutor(NewGitCloneExecutorInput{
URL: remoteDir,
Ref: "main",
Dir: cacheDir,
})(context.Background()))
t.Run("cached branch resolves without fetching", func(t *testing.T) {
// Offline reuse of a cached branch must succeed even though ResolveRevision(input.Ref)
// finds no local refs/heads/<ref>.
err := NewGitCloneExecutor(NewGitCloneExecutorInput{
URL: remoteDir,
Ref: "main",
Dir: cacheDir,
OfflineMode: true,
})(context.Background())
require.NoError(t, err)
out, err := exec.Command("git", "-C", cacheDir, "log", "--oneline", "-1", "--format=%s").Output()
require.NoError(t, err)
assert.Equal(t, "initial", strings.TrimSpace(string(out)))
})
t.Run("unresolvable cached ref returns error", func(t *testing.T) {
// The ref was never cached; offline mode cannot resolve it and must return an error.
err := NewGitCloneExecutor(NewGitCloneExecutorInput{
URL: remoteDir,
Ref: "never-fetched",
Dir: cacheDir,
OfflineMode: true,
})(context.Background())
require.Error(t, err)
})
} }
func gitCmd(args ...string) error { func gitCmd(args ...string) error {
cmd := exec.Command("git", args...) cmd := exec.Command("git", args...)
cmd.Stdout = os.Stdout cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr cmd.Stderr = os.Stderr
// Inject a deterministic identity and ignore the host's global/system config so commits
// succeed regardless of the host having no user.name/user.email (e.g. CI, GITHUB_ACTIONS
// unset) or a global commit.gpgsign, and without mutating the developer's ~/.gitconfig.
cmd.Env = append(os.Environ(),
"GIT_AUTHOR_NAME=Unit Test",
"GIT_AUTHOR_EMAIL=test@test.com",
"GIT_COMMITTER_NAME=Unit Test",
"GIT_COMMITTER_EMAIL=test@test.com",
"GIT_CONFIG_GLOBAL=/dev/null",
"GIT_CONFIG_SYSTEM=/dev/null",
)
err := cmd.Run() err := cmd.Run()
if exitError, ok := err.(*exec.ExitError); ok { if exitError, ok := err.(*exec.ExitError); ok {
@@ -310,11 +410,11 @@ func TestAcquireCloneLock(t *testing.T) {
t.Run("same directory serializes", func(t *testing.T) { t.Run("same directory serializes", func(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
unlock1 := acquireCloneLock(dir) unlock1 := AcquireCloneLock(dir)
secondAcquired := make(chan struct{}) secondAcquired := make(chan struct{})
go func() { go func() {
unlock := acquireCloneLock(dir) unlock := AcquireCloneLock(dir)
close(secondAcquired) close(secondAcquired)
unlock() unlock()
}() }()
@@ -338,12 +438,12 @@ func TestAcquireCloneLock(t *testing.T) {
dirA := t.TempDir() dirA := t.TempDir()
dirB := t.TempDir() dirB := t.TempDir()
unlockA := acquireCloneLock(dirA) unlockA := AcquireCloneLock(dirA)
defer unlockA() defer unlockA()
done := make(chan struct{}) done := make(chan struct{})
go func() { go func() {
unlock := acquireCloneLock(dirB) unlock := AcquireCloneLock(dirB)
unlock() unlock()
close(done) close(done)
}() }()

View File

@@ -47,6 +47,7 @@ type NewContainerInput struct {
// Gitea specific // Gitea specific
AutoRemove bool AutoRemove bool
ValidVolumes []string ValidVolumes []string
AllocatePTY bool // allocate a pseudo-TTY for the container's exec processes
} }
// FileEntry is a file to copy to a container // FileEntry is a file to copy to a container

View File

@@ -8,34 +8,33 @@ package container
import ( import (
"context" "context"
"strings"
"gitea.com/gitea/runner/act/common" "gitea.com/gitea/runner/act/common"
"github.com/distribution/reference"
"github.com/docker/cli/cli/config" "github.com/docker/cli/cli/config"
"github.com/docker/cli/cli/config/credentials" "github.com/moby/moby/api/types/registry"
"github.com/docker/docker/api/types/registry"
) )
func LoadDockerAuthConfig(ctx context.Context, image string) (registry.AuthConfig, error) { func LoadDockerAuthConfig(ctx context.Context, image string) (registry.AuthConfig, error) {
logger := common.Logger(ctx) logger := common.Logger(ctx)
config, err := config.Load(config.Dir()) // config.LoadDefaultConfigFile panics on nil io.Writer when the config
// file is malformed; use config.Load to route errors through the logger.
cfg, err := config.Load(config.Dir())
if err != nil { if err != nil {
logger.Warnf("Could not load docker config: %v", err) logger.Warnf("Could not load docker config: %v", err)
return registry.AuthConfig{}, err return registry.AuthConfig{}, err
} }
registryKey := registryAuthConfigKey("docker.io")
if !config.ContainsAuth() { if image != "" {
config.CredentialsStore = credentials.DetectDefaultStore(config.CredentialsStore) if registryRef, refErr := reference.ParseNormalizedNamed(image); refErr != nil {
logger.Warnf("Could not normalize image reference: %v", refErr)
} else {
registryKey = registryAuthConfigKey(reference.Domain(registryRef))
}
} }
hostName := "index.docker.io" authConfig, err := cfg.GetAuthConfig(registryKey)
index := strings.IndexRune(image, '/')
if index > -1 && (strings.ContainsAny(image[:index], ".:") || image[:index] == "localhost") {
hostName = image[:index]
}
authConfig, err := config.GetAuthConfig(hostName)
if err != nil { if err != nil {
logger.Warnf("Could not get auth config from docker config: %v", err) logger.Warnf("Could not get auth config from docker config: %v", err)
return registry.AuthConfig{}, err return registry.AuthConfig{}, err
@@ -46,17 +45,16 @@ func LoadDockerAuthConfig(ctx context.Context, image string) (registry.AuthConfi
func LoadDockerAuthConfigs(ctx context.Context) map[string]registry.AuthConfig { func LoadDockerAuthConfigs(ctx context.Context) map[string]registry.AuthConfig {
logger := common.Logger(ctx) logger := common.Logger(ctx)
config, err := config.Load(config.Dir()) cfg, err := config.Load(config.Dir())
if err != nil { if err != nil {
logger.Warnf("Could not load docker config: %v", err) logger.Warnf("Could not load docker config: %v", err)
return nil return nil
} }
creds, err := cfg.GetAllCredentials()
if !config.ContainsAuth() { if err != nil {
config.CredentialsStore = credentials.DetectDefaultStore(config.CredentialsStore) logger.Warnf("Could not get docker auth configs: %v", err)
return nil
} }
creds, _ := config.GetAllCredentials()
authConfigs := make(map[string]registry.AuthConfig, len(creds)) authConfigs := make(map[string]registry.AuthConfig, len(creds))
for k, v := range creds { for k, v := range creds {
authConfigs[k] = registry.AuthConfig(v) authConfigs[k] = registry.AuthConfig(v)
@@ -64,3 +62,10 @@ func LoadDockerAuthConfigs(ctx context.Context) map[string]registry.AuthConfig {
return authConfigs return authConfigs
} }
func registryAuthConfigKey(domainName string) string {
if domainName == "docker.io" || domainName == "index.docker.io" {
return "https://index.docker.io/v1/"
}
return domainName
}

View File

@@ -14,10 +14,12 @@ import (
"gitea.com/gitea/runner/act/common" "gitea.com/gitea/runner/act/common"
"github.com/docker/docker/api/types" "github.com/moby/go-archive"
"github.com/docker/docker/pkg/archive" "github.com/moby/go-archive/compression"
"github.com/moby/buildkit/frontend/dockerfile/dockerignore" "github.com/moby/moby/client"
"github.com/moby/patternmatcher" "github.com/moby/patternmatcher"
"github.com/moby/patternmatcher/ignorefile"
specs "github.com/opencontainers/image-spec/specs-go/v1"
) )
// NewDockerBuildExecutor function to create a run executor for the container // NewDockerBuildExecutor function to create a run executor for the container
@@ -25,9 +27,9 @@ func NewDockerBuildExecutor(input NewDockerBuildExecutorInput) common.Executor {
return func(ctx context.Context) error { return func(ctx context.Context) error {
logger := common.Logger(ctx) logger := common.Logger(ctx)
if input.Platform != "" { if input.Platform != "" {
logger.Infof("%sdocker build -t %s --platform %s %s", logPrefix, input.ImageTag, input.Platform, input.ContextDir) logger.Infof("docker build -t %s --platform %s %s", input.ImageTag, input.Platform, input.ContextDir)
} else { } else {
logger.Infof("%sdocker build -t %s %s", logPrefix, input.ImageTag, input.ContextDir) logger.Infof("docker build -t %s %s", input.ImageTag, input.ContextDir)
} }
if common.Dryrun(ctx) { if common.Dryrun(ctx) {
return nil return nil
@@ -42,13 +44,19 @@ func NewDockerBuildExecutor(input NewDockerBuildExecutorInput) common.Executor {
logger.Debugf("Building image from '%v'", input.ContextDir) logger.Debugf("Building image from '%v'", input.ContextDir)
tags := []string{input.ImageTag} tags := []string{input.ImageTag}
options := types.ImageBuildOptions{ options := client.ImageBuildOptions{
Tags: tags, Tags: tags,
Remove: true, Remove: true,
Platform: input.Platform,
AuthConfigs: LoadDockerAuthConfigs(ctx), AuthConfigs: LoadDockerAuthConfigs(ctx),
Dockerfile: input.Dockerfile, Dockerfile: input.Dockerfile,
} }
platform, err := parsePlatform(input.Platform)
if err != nil {
return err
}
if platform != nil {
options.Platforms = []specs.Platform{*platform}
}
var buildContext io.ReadCloser var buildContext io.ReadCloser
if input.BuildContext != nil { if input.BuildContext != nil {
buildContext = io.NopCloser(input.BuildContext) buildContext = io.NopCloser(input.BuildContext)
@@ -76,7 +84,7 @@ func createBuildContext(ctx context.Context, contextDir, relDockerfile string) (
common.Logger(ctx).Debugf("Creating archive for build context dir '%s' with relative dockerfile '%s'", contextDir, relDockerfile) common.Logger(ctx).Debugf("Creating archive for build context dir '%s' with relative dockerfile '%s'", contextDir, relDockerfile)
// And canonicalize dockerfile name to a platform-independent one // And canonicalize dockerfile name to a platform-independent one
relDockerfile = archive.CanonicalTarNameForPath(relDockerfile) relDockerfile = filepath.ToSlash(relDockerfile)
f, err := os.Open(filepath.Join(contextDir, ".dockerignore")) f, err := os.Open(filepath.Join(contextDir, ".dockerignore"))
if err != nil && !os.IsNotExist(err) { if err != nil && !os.IsNotExist(err) {
@@ -86,7 +94,7 @@ func createBuildContext(ctx context.Context, contextDir, relDockerfile string) (
var excludes []string var excludes []string
if err == nil { if err == nil {
excludes, err = dockerignore.ReadAll(f) //nolint:staticcheck // pre-existing issue from nektos/act excludes, err = ignorefile.ReadAll(f)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -106,9 +114,8 @@ func createBuildContext(ctx context.Context, contextDir, relDockerfile string) (
includes = append(includes, ".dockerignore", relDockerfile) includes = append(includes, ".dockerignore", relDockerfile)
} }
compression := archive.Uncompressed
buildCtx, err := archive.TarWithOptions(contextDir, &archive.TarOptions{ buildCtx, err := archive.TarWithOptions(contextDir, &archive.TarOptions{
Compression: compression, Compression: compression.None,
ExcludePatterns: excludes, ExcludePatterns: excludes,
IncludeFiles: includes, IncludeFiles: includes,
}) })

File diff suppressed because it is too large Load Diff

View File

@@ -16,15 +16,18 @@ package container
import ( import (
"fmt" "fmt"
"io" "io"
"net/netip"
"os" "os"
"runtime" "runtime"
"strings" "strings"
"testing" "testing"
"time" "time"
"github.com/docker/docker/api/types/container"
networktypes "github.com/docker/docker/api/types/network"
"github.com/docker/go-connections/nat" "github.com/docker/go-connections/nat"
"github.com/google/go-cmp/cmp"
"github.com/google/go-cmp/cmp/cmpopts"
"github.com/moby/moby/api/types/container"
networktypes "github.com/moby/moby/api/types/network"
"github.com/pkg/errors" "github.com/pkg/errors"
"github.com/spf13/pflag" "github.com/spf13/pflag"
"gotest.tools/v3/assert" "gotest.tools/v3/assert"
@@ -77,21 +80,21 @@ func setupRunFlags() (*pflag.FlagSet, *containerOptions) {
return flags, copts return flags, copts
} }
func mustParse(t *testing.T, args string) (*container.Config, *container.HostConfig) { func mustParse(t *testing.T, args string) (*container.Config, *container.HostConfig, *networktypes.NetworkingConfig) {
t.Helper() t.Helper()
config, hostConfig, _, err := parseRun(append(strings.Split(args, " "), "ubuntu", "bash")) config, hostConfig, networkingConfig, err := parseRun(append(strings.Split(args, " "), "ubuntu", "bash"))
assert.NilError(t, err) assert.NilError(t, err)
return config, hostConfig return config, hostConfig, networkingConfig
} }
func TestParseRunLinks(t *testing.T) { func TestParseRunLinks(t *testing.T) {
if _, hostConfig := mustParse(t, "--link a:b"); len(hostConfig.Links) == 0 || hostConfig.Links[0] != "a:b" { if _, hostConfig, _ := mustParse(t, "--link a:b"); len(hostConfig.Links) == 0 || hostConfig.Links[0] != "a:b" {
t.Fatalf("Error parsing links. Expected []string{\"a:b\"}, received: %v", hostConfig.Links) t.Fatalf("Error parsing links. Expected []string{\"a:b\"}, received: %v", hostConfig.Links)
} }
if _, hostConfig := mustParse(t, "--link a:b --link c:d"); len(hostConfig.Links) < 2 || hostConfig.Links[0] != "a:b" || hostConfig.Links[1] != "c:d" { if _, hostConfig, _ := mustParse(t, "--link a:b --link c:d"); len(hostConfig.Links) < 2 || hostConfig.Links[0] != "a:b" || hostConfig.Links[1] != "c:d" {
t.Fatalf("Error parsing links. Expected []string{\"a:b\", \"c:d\"}, received: %v", hostConfig.Links) t.Fatalf("Error parsing links. Expected []string{\"a:b\", \"c:d\"}, received: %v", hostConfig.Links)
} }
if _, hostConfig := mustParse(t, ""); len(hostConfig.Links) != 0 { if _, hostConfig, _ := mustParse(t, ""); len(hostConfig.Links) != 0 {
t.Fatalf("Error parsing links. No link expected, received: %v", hostConfig.Links) t.Fatalf("Error parsing links. No link expected, received: %v", hostConfig.Links)
} }
} }
@@ -140,7 +143,7 @@ func TestParseRunAttach(t *testing.T) {
} }
for _, tc := range tests { for _, tc := range tests {
t.Run(tc.input, func(t *testing.T) { t.Run(tc.input, func(t *testing.T) {
config, _ := mustParse(t, tc.input) config, _, _ := mustParse(t, tc.input)
assert.Equal(t, config.AttachStdin, tc.expected.AttachStdin) assert.Equal(t, config.AttachStdin, tc.expected.AttachStdin)
assert.Equal(t, config.AttachStdout, tc.expected.AttachStdout) assert.Equal(t, config.AttachStdout, tc.expected.AttachStdout)
assert.Equal(t, config.AttachStderr, tc.expected.AttachStderr) assert.Equal(t, config.AttachStderr, tc.expected.AttachStderr)
@@ -194,10 +197,10 @@ func TestParseRunWithInvalidArgs(t *testing.T) {
} }
} }
func TestParseWithVolumes(t *testing.T) { func TestParseWithVolumes(t *testing.T) { //nolint:gocyclo // verbatim copy from docker/cli tests
// A single volume // A single volume
arr, tryit := setupPlatformVolume([]string{`/tmp`}, []string{`c:\tmp`}) arr, tryit := setupPlatformVolume([]string{`/tmp`}, []string{`c:\tmp`})
if config, hostConfig := mustParse(t, tryit); hostConfig.Binds != nil { if config, hostConfig, _ := mustParse(t, tryit); hostConfig.Binds != nil {
t.Fatalf("Error parsing volume flags, %q should not mount-bind anything. Received %v", tryit, hostConfig.Binds) t.Fatalf("Error parsing volume flags, %q should not mount-bind anything. Received %v", tryit, hostConfig.Binds)
} else if _, exists := config.Volumes[arr[0]]; !exists { } else if _, exists := config.Volumes[arr[0]]; !exists {
t.Fatalf("Error parsing volume flags, %q is missing from volumes. Received %v", tryit, config.Volumes) t.Fatalf("Error parsing volume flags, %q is missing from volumes. Received %v", tryit, config.Volumes)
@@ -205,7 +208,7 @@ func TestParseWithVolumes(t *testing.T) {
// Two volumes // Two volumes
arr, tryit = setupPlatformVolume([]string{`/tmp`, `/var`}, []string{`c:\tmp`, `c:\var`}) arr, tryit = setupPlatformVolume([]string{`/tmp`, `/var`}, []string{`c:\tmp`, `c:\var`})
if config, hostConfig := mustParse(t, tryit); hostConfig.Binds != nil { if config, hostConfig, _ := mustParse(t, tryit); hostConfig.Binds != nil {
t.Fatalf("Error parsing volume flags, %q should not mount-bind anything. Received %v", tryit, hostConfig.Binds) t.Fatalf("Error parsing volume flags, %q should not mount-bind anything. Received %v", tryit, hostConfig.Binds)
} else if _, exists := config.Volumes[arr[0]]; !exists { } else if _, exists := config.Volumes[arr[0]]; !exists {
t.Fatalf("Error parsing volume flags, %s is missing from volumes. Received %v", arr[0], config.Volumes) t.Fatalf("Error parsing volume flags, %s is missing from volumes. Received %v", arr[0], config.Volumes)
@@ -215,13 +218,13 @@ func TestParseWithVolumes(t *testing.T) {
// A single bind mount // A single bind mount
arr, tryit = setupPlatformVolume([]string{`/hostTmp:/containerTmp`}, []string{os.Getenv("TEMP") + `:c:\containerTmp`}) arr, tryit = setupPlatformVolume([]string{`/hostTmp:/containerTmp`}, []string{os.Getenv("TEMP") + `:c:\containerTmp`})
if config, hostConfig := mustParse(t, tryit); hostConfig.Binds == nil || hostConfig.Binds[0] != arr[0] { if config, hostConfig, _ := mustParse(t, tryit); hostConfig.Binds == nil || hostConfig.Binds[0] != arr[0] {
t.Fatalf("Error parsing volume flags, %q should mount-bind the path before the colon into the path after the colon. Received %v %v", arr[0], hostConfig.Binds, config.Volumes) t.Fatalf("Error parsing volume flags, %q should mount-bind the path before the colon into the path after the colon. Received %v %v", arr[0], hostConfig.Binds, config.Volumes)
} }
// Two bind mounts. // Two bind mounts.
arr, tryit = setupPlatformVolume([]string{`/hostTmp:/containerTmp`, `/hostVar:/containerVar`}, []string{os.Getenv("ProgramData") + `:c:\ContainerPD`, os.Getenv("TEMP") + `:c:\containerTmp`}) arr, tryit = setupPlatformVolume([]string{`/hostTmp:/containerTmp`, `/hostVar:/containerVar`}, []string{os.Getenv("ProgramData") + `:c:\ContainerPD`, os.Getenv("TEMP") + `:c:\containerTmp`})
if _, hostConfig := mustParse(t, tryit); hostConfig.Binds == nil || compareRandomizedStrings(hostConfig.Binds[0], hostConfig.Binds[1], arr[0], arr[1]) != nil { if _, hostConfig, _ := mustParse(t, tryit); hostConfig.Binds == nil || compareRandomizedStrings(hostConfig.Binds[0], hostConfig.Binds[1], arr[0], arr[1]) != nil {
t.Fatalf("Error parsing volume flags, `%s and %s` did not mount-bind correctly. Received %v", arr[0], arr[1], hostConfig.Binds) t.Fatalf("Error parsing volume flags, `%s and %s` did not mount-bind correctly. Received %v", arr[0], arr[1], hostConfig.Binds)
} }
@@ -230,26 +233,26 @@ func TestParseWithVolumes(t *testing.T) {
arr, tryit = setupPlatformVolume( arr, tryit = setupPlatformVolume(
[]string{`/hostTmp:/containerTmp:ro`, `/hostVar:/containerVar:rw`}, []string{`/hostTmp:/containerTmp:ro`, `/hostVar:/containerVar:rw`},
[]string{os.Getenv("TEMP") + `:c:\containerTmp:rw`, os.Getenv("ProgramData") + `:c:\ContainerPD:rw`}) []string{os.Getenv("TEMP") + `:c:\containerTmp:rw`, os.Getenv("ProgramData") + `:c:\ContainerPD:rw`})
if _, hostConfig := mustParse(t, tryit); hostConfig.Binds == nil || compareRandomizedStrings(hostConfig.Binds[0], hostConfig.Binds[1], arr[0], arr[1]) != nil { if _, hostConfig, _ := mustParse(t, tryit); hostConfig.Binds == nil || compareRandomizedStrings(hostConfig.Binds[0], hostConfig.Binds[1], arr[0], arr[1]) != nil {
t.Fatalf("Error parsing volume flags, `%s and %s` did not mount-bind correctly. Received %v", arr[0], arr[1], hostConfig.Binds) t.Fatalf("Error parsing volume flags, `%s and %s` did not mount-bind correctly. Received %v", arr[0], arr[1], hostConfig.Binds)
} }
// Similar to previous test but with alternate modes which are only supported by Linux // Similar to previous test but with alternate modes which are only supported by Linux
if runtime.GOOS != "windows" { if runtime.GOOS != "windows" {
arr, tryit = setupPlatformVolume([]string{`/hostTmp:/containerTmp:ro,Z`, `/hostVar:/containerVar:rw,Z`}, []string{}) arr, tryit = setupPlatformVolume([]string{`/hostTmp:/containerTmp:ro,Z`, `/hostVar:/containerVar:rw,Z`}, []string{})
if _, hostConfig := mustParse(t, tryit); hostConfig.Binds == nil || compareRandomizedStrings(hostConfig.Binds[0], hostConfig.Binds[1], arr[0], arr[1]) != nil { if _, hostConfig, _ := mustParse(t, tryit); hostConfig.Binds == nil || compareRandomizedStrings(hostConfig.Binds[0], hostConfig.Binds[1], arr[0], arr[1]) != nil {
t.Fatalf("Error parsing volume flags, `%s and %s` did not mount-bind correctly. Received %v", arr[0], arr[1], hostConfig.Binds) t.Fatalf("Error parsing volume flags, `%s and %s` did not mount-bind correctly. Received %v", arr[0], arr[1], hostConfig.Binds)
} }
arr, tryit = setupPlatformVolume([]string{`/hostTmp:/containerTmp:Z`, `/hostVar:/containerVar:z`}, []string{}) arr, tryit = setupPlatformVolume([]string{`/hostTmp:/containerTmp:Z`, `/hostVar:/containerVar:z`}, []string{})
if _, hostConfig := mustParse(t, tryit); hostConfig.Binds == nil || compareRandomizedStrings(hostConfig.Binds[0], hostConfig.Binds[1], arr[0], arr[1]) != nil { if _, hostConfig, _ := mustParse(t, tryit); hostConfig.Binds == nil || compareRandomizedStrings(hostConfig.Binds[0], hostConfig.Binds[1], arr[0], arr[1]) != nil {
t.Fatalf("Error parsing volume flags, `%s and %s` did not mount-bind correctly. Received %v", arr[0], arr[1], hostConfig.Binds) t.Fatalf("Error parsing volume flags, `%s and %s` did not mount-bind correctly. Received %v", arr[0], arr[1], hostConfig.Binds)
} }
} }
// One bind mount and one volume // One bind mount and one volume
arr, tryit = setupPlatformVolume([]string{`/hostTmp:/containerTmp`, `/containerVar`}, []string{os.Getenv("TEMP") + `:c:\containerTmp`, `c:\containerTmp`}) arr, tryit = setupPlatformVolume([]string{`/hostTmp:/containerTmp`, `/containerVar`}, []string{os.Getenv("TEMP") + `:c:\containerTmp`, `c:\containerTmp`})
if config, hostConfig := mustParse(t, tryit); hostConfig.Binds == nil || len(hostConfig.Binds) > 1 || hostConfig.Binds[0] != arr[0] { if config, hostConfig, _ := mustParse(t, tryit); hostConfig.Binds == nil || len(hostConfig.Binds) > 1 || hostConfig.Binds[0] != arr[0] {
t.Fatalf("Error parsing volume flags, %s and %s should only one and only one bind mount %s. Received %s", arr[0], arr[1], arr[0], hostConfig.Binds) t.Fatalf("Error parsing volume flags, %s and %s should only one and only one bind mount %s. Received %s", arr[0], arr[1], arr[0], hostConfig.Binds)
} else if _, exists := config.Volumes[arr[1]]; !exists { } else if _, exists := config.Volumes[arr[1]]; !exists {
t.Fatalf("Error parsing volume flags %s and %s. %s is missing from volumes. Received %v", arr[0], arr[1], arr[1], config.Volumes) t.Fatalf("Error parsing volume flags %s and %s. %s is missing from volumes. Received %v", arr[0], arr[1], arr[1], config.Volumes)
@@ -258,7 +261,7 @@ func TestParseWithVolumes(t *testing.T) {
// Root to non-c: drive letter (Windows specific) // Root to non-c: drive letter (Windows specific)
if runtime.GOOS == "windows" { if runtime.GOOS == "windows" {
arr, tryit = setupPlatformVolume([]string{}, []string{os.Getenv("SystemDrive") + `\:d:`}) arr, tryit = setupPlatformVolume([]string{}, []string{os.Getenv("SystemDrive") + `\:d:`})
if config, hostConfig := mustParse(t, tryit); hostConfig.Binds == nil || len(hostConfig.Binds) > 1 || hostConfig.Binds[0] != arr[0] || len(config.Volumes) != 0 { if config, hostConfig, _ := mustParse(t, tryit); hostConfig.Binds == nil || len(hostConfig.Binds) > 1 || hostConfig.Binds[0] != arr[0] || len(config.Volumes) != 0 {
t.Fatalf("Error parsing %s. Should have a single bind mount and no volumes", arr[0]) t.Fatalf("Error parsing %s. Should have a single bind mount and no volumes", arr[0])
} }
} }
@@ -294,6 +297,36 @@ func compareRandomizedStrings(a, b, c, d string) error {
return errors.Errorf("strings don't match") return errors.Errorf("strings don't match")
} }
func mustNetworkPort(t *testing.T, value string) networktypes.Port {
t.Helper()
port, err := networktypes.ParsePort(value)
if err != nil {
t.Fatalf("failed to parse network port %q: %v", value, err)
}
return port
}
func mustAddr(t *testing.T, value string) netip.Addr {
t.Helper()
addr, err := netip.ParseAddr(value)
if err != nil {
t.Fatalf("failed to parse address %q: %v", value, err)
}
return addr
}
func mustAddrs(t *testing.T, values ...string) []netip.Addr {
t.Helper()
addrs := make([]netip.Addr, 0, len(values))
for _, value := range values {
addrs = append(addrs, mustAddr(t, value))
}
return addrs
}
// Simple parse with MacAddress validation // Simple parse with MacAddress validation
func TestParseWithMacAddress(t *testing.T) { func TestParseWithMacAddress(t *testing.T) {
invalidMacAddress := "--mac-address=invalidMacAddress" invalidMacAddress := "--mac-address=invalidMacAddress"
@@ -301,9 +334,10 @@ func TestParseWithMacAddress(t *testing.T) {
if _, _, _, err := parseRun([]string{invalidMacAddress, "img", "cmd"}); err != nil && err.Error() != "invalidMacAddress is not a valid mac address" { if _, _, _, err := parseRun([]string{invalidMacAddress, "img", "cmd"}); err != nil && err.Error() != "invalidMacAddress is not a valid mac address" {
t.Fatalf("Expected an error with %v mac-address, got %v", invalidMacAddress, err) t.Fatalf("Expected an error with %v mac-address, got %v", invalidMacAddress, err)
} }
if config, _ := mustParse(t, validMacAddress); config.MacAddress != "92:d0:c6:0a:29:33" { //nolint:staticcheck // pre-existing issue from nektos/act _, hostConfig, networkingConfig := mustParse(t, validMacAddress)
t.Fatalf("Expected the config to have '92:d0:c6:0a:29:33' as MacAddress, got '%v'", config.MacAddress) //nolint:staticcheck // pre-existing issue from nektos/act endpoint := networkingConfig.EndpointsConfig[string(hostConfig.NetworkMode)]
} assert.Check(t, endpoint != nil)
assert.Equal(t, "92:d0:c6:0a:29:33", endpoint.MacAddress.String())
} }
func TestRunFlagsParseWithMemory(t *testing.T) { func TestRunFlagsParseWithMemory(t *testing.T) {
@@ -312,7 +346,7 @@ func TestRunFlagsParseWithMemory(t *testing.T) {
err := flags.Parse(args) err := flags.Parse(args)
assert.ErrorContains(t, err, `invalid argument "invalid" for "-m, --memory" flag`) assert.ErrorContains(t, err, `invalid argument "invalid" for "-m, --memory" flag`)
_, hostconfig := mustParse(t, "--memory=1G") _, hostconfig, _ := mustParse(t, "--memory=1G")
assert.Check(t, is.Equal(int64(1073741824), hostconfig.Memory)) assert.Check(t, is.Equal(int64(1073741824), hostconfig.Memory))
} }
@@ -322,10 +356,10 @@ func TestParseWithMemorySwap(t *testing.T) {
err := flags.Parse(args) err := flags.Parse(args)
assert.ErrorContains(t, err, `invalid argument "invalid" for "--memory-swap" flag`) assert.ErrorContains(t, err, `invalid argument "invalid" for "--memory-swap" flag`)
_, hostconfig := mustParse(t, "--memory-swap=1G") _, hostconfig, _ := mustParse(t, "--memory-swap=1G")
assert.Check(t, is.Equal(int64(1073741824), hostconfig.MemorySwap)) assert.Check(t, is.Equal(int64(1073741824), hostconfig.MemorySwap))
_, hostconfig = mustParse(t, "--memory-swap=-1") _, hostconfig, _ = mustParse(t, "--memory-swap=-1")
assert.Check(t, is.Equal(int64(-1), hostconfig.MemorySwap)) assert.Check(t, is.Equal(int64(-1), hostconfig.MemorySwap))
} }
@@ -340,14 +374,14 @@ func TestParseHostname(t *testing.T) {
hostnameWithDomain := "--hostname=hostname.domainname" hostnameWithDomain := "--hostname=hostname.domainname"
hostnameWithDomainTld := "--hostname=hostname.domainname.tld" hostnameWithDomainTld := "--hostname=hostname.domainname.tld"
for hostname, expectedHostname := range validHostnames { for hostname, expectedHostname := range validHostnames {
if config, _ := mustParse(t, "--hostname="+hostname); config.Hostname != expectedHostname { if config, _, _ := mustParse(t, "--hostname="+hostname); config.Hostname != expectedHostname {
t.Fatalf("Expected the config to have 'hostname' as %q, got %q", expectedHostname, config.Hostname) t.Fatalf("Expected the config to have 'hostname' as %q, got %q", expectedHostname, config.Hostname)
} }
} }
if config, _ := mustParse(t, hostnameWithDomain); config.Hostname != "hostname.domainname" || config.Domainname != "" { if config, _, _ := mustParse(t, hostnameWithDomain); config.Hostname != "hostname.domainname" || config.Domainname != "" {
t.Fatalf("Expected the config to have 'hostname' as hostname.domainname, got %q", config.Hostname) t.Fatalf("Expected the config to have 'hostname' as hostname.domainname, got %q", config.Hostname)
} }
if config, _ := mustParse(t, hostnameWithDomainTld); config.Hostname != "hostname.domainname.tld" || config.Domainname != "" { if config, _, _ := mustParse(t, hostnameWithDomainTld); config.Hostname != "hostname.domainname.tld" || config.Domainname != "" {
t.Fatalf("Expected the config to have 'hostname' as hostname.domainname.tld, got %q", config.Hostname) t.Fatalf("Expected the config to have 'hostname' as hostname.domainname.tld, got %q", config.Hostname)
} }
} }
@@ -361,26 +395,28 @@ func TestParseHostnameDomainname(t *testing.T) {
"domainname-63-bytes-long-should-be-valid-and-without-any-errors": "domainname-63-bytes-long-should-be-valid-and-without-any-errors", "domainname-63-bytes-long-should-be-valid-and-without-any-errors": "domainname-63-bytes-long-should-be-valid-and-without-any-errors",
} }
for domainname, expectedDomainname := range validDomainnames { for domainname, expectedDomainname := range validDomainnames {
if config, _ := mustParse(t, "--domainname="+domainname); config.Domainname != expectedDomainname { if config, _, _ := mustParse(t, "--domainname="+domainname); config.Domainname != expectedDomainname {
t.Fatalf("Expected the config to have 'domainname' as %q, got %q", expectedDomainname, config.Domainname) t.Fatalf("Expected the config to have 'domainname' as %q, got %q", expectedDomainname, config.Domainname)
} }
} }
if config, _ := mustParse(t, "--hostname=some.prefix --domainname=domainname"); config.Hostname != "some.prefix" || config.Domainname != "domainname" { if config, _, _ := mustParse(t, "--hostname=some.prefix --domainname=domainname"); config.Hostname != "some.prefix" || config.Domainname != "domainname" {
t.Fatalf("Expected the config to have 'hostname' as 'some.prefix' and 'domainname' as 'domainname', got %q and %q", config.Hostname, config.Domainname) t.Fatalf("Expected the config to have 'hostname' as 'some.prefix' and 'domainname' as 'domainname', got %q and %q", config.Hostname, config.Domainname)
} }
if config, _ := mustParse(t, "--hostname=another-prefix --domainname=domainname.tld"); config.Hostname != "another-prefix" || config.Domainname != "domainname.tld" { if config, _, _ := mustParse(t, "--hostname=another-prefix --domainname=domainname.tld"); config.Hostname != "another-prefix" || config.Domainname != "domainname.tld" {
t.Fatalf("Expected the config to have 'hostname' as 'another-prefix' and 'domainname' as 'domainname.tld', got %q and %q", config.Hostname, config.Domainname) t.Fatalf("Expected the config to have 'hostname' as 'another-prefix' and 'domainname' as 'domainname.tld', got %q and %q", config.Hostname, config.Domainname)
} }
} }
func TestParseWithExpose(t *testing.T) { func TestParseWithExpose(t *testing.T) {
invalids := map[string]string{ invalids := []string{
":": "invalid port format for --expose: :", ":",
"8080:9090": "invalid port format for --expose: 8080:9090", "8080:9090",
"NaN/tcp": `invalid range format for --expose: NaN/tcp, error: strconv.ParseUint: parsing "NaN": invalid syntax`, "/tcp",
"NaN-NaN/tcp": `invalid range format for --expose: NaN-NaN/tcp, error: strconv.ParseUint: parsing "NaN": invalid syntax`, "/udp",
"8080-NaN/tcp": `invalid range format for --expose: 8080-NaN/tcp, error: strconv.ParseUint: parsing "NaN": invalid syntax`, "NaN/tcp",
"1234567890-8080/tcp": `invalid range format for --expose: 1234567890-8080/tcp, error: strconv.ParseUint: parsing "1234567890": value out of range`, "NaN-NaN/tcp",
"8080-NaN/tcp",
"1234567890-8080/tcp",
} }
valids := map[string][]nat.Port{ valids := map[string][]nat.Port{
"8080/tcp": {"8080/tcp"}, "8080/tcp": {"8080/tcp"},
@@ -389,9 +425,9 @@ func TestParseWithExpose(t *testing.T) {
"8080-8080/udp": {"8080/udp"}, "8080-8080/udp": {"8080/udp"},
"8080-8082/tcp": {"8080/tcp", "8081/tcp", "8082/tcp"}, "8080-8082/tcp": {"8080/tcp", "8081/tcp", "8082/tcp"},
} }
for expose, expectedError := range invalids { for _, expose := range invalids {
if _, _, _, err := parseRun([]string{fmt.Sprintf("--expose=%v", expose), "img", "cmd"}); err == nil || err.Error() != expectedError { if _, _, _, err := parseRun([]string{fmt.Sprintf("--expose=%v", expose), "img", "cmd"}); err == nil {
t.Fatalf("Expected error '%v' with '--expose=%v', got '%v'", expectedError, expose, err) t.Fatalf("Expected error with '--expose=%v', got none", expose)
} }
} }
for expose, exposedPorts := range valids { for expose, exposedPorts := range valids {
@@ -403,7 +439,7 @@ func TestParseWithExpose(t *testing.T) {
t.Fatalf("Expected %v exposed port, got %v", len(exposedPorts), len(config.ExposedPorts)) t.Fatalf("Expected %v exposed port, got %v", len(exposedPorts), len(config.ExposedPorts))
} }
for _, port := range exposedPorts { for _, port := range exposedPorts {
if _, ok := config.ExposedPorts[port]; !ok { if _, ok := config.ExposedPorts[mustNetworkPort(t, string(port))]; !ok {
t.Fatalf("Expected %v, got %v", exposedPorts, config.ExposedPorts) t.Fatalf("Expected %v, got %v", exposedPorts, config.ExposedPorts)
} }
} }
@@ -418,7 +454,7 @@ func TestParseWithExpose(t *testing.T) {
} }
ports := []nat.Port{"80/tcp", "81/tcp"} ports := []nat.Port{"80/tcp", "81/tcp"}
for _, port := range ports { for _, port := range ports {
if _, ok := config.ExposedPorts[port]; !ok { if _, ok := config.ExposedPorts[mustNetworkPort(t, string(port))]; !ok {
t.Fatalf("Expected %v, got %v", ports, config.ExposedPorts) t.Fatalf("Expected %v, got %v", ports, config.ExposedPorts)
} }
} }
@@ -498,9 +534,9 @@ func TestParseNetworkConfig(t *testing.T) {
expected: map[string]*networktypes.EndpointSettings{ expected: map[string]*networktypes.EndpointSettings{
"net1": { "net1": {
IPAMConfig: &networktypes.EndpointIPAMConfig{ IPAMConfig: &networktypes.EndpointIPAMConfig{
IPv4Address: "172.20.88.22", IPv4Address: mustAddr(t, "172.20.88.22"),
IPv6Address: "2001:db8::8822", IPv6Address: mustAddr(t, "2001:db8::8822"),
LinkLocalIPs: []string{"169.254.2.2", "fe80::169:254:2:2"}, LinkLocalIPs: mustAddrs(t, "169.254.2.2", "fe80::169:254:2:2"),
}, },
Links: []string{"foo:bar", "bar:baz"}, Links: []string{"foo:bar", "bar:baz"},
Aliases: []string{"web1", "web2"}, Aliases: []string{"web1", "web2"},
@@ -527,9 +563,9 @@ func TestParseNetworkConfig(t *testing.T) {
"net1": { "net1": {
DriverOpts: map[string]string{"field1": "value1"}, DriverOpts: map[string]string{"field1": "value1"},
IPAMConfig: &networktypes.EndpointIPAMConfig{ IPAMConfig: &networktypes.EndpointIPAMConfig{
IPv4Address: "172.20.88.22", IPv4Address: mustAddr(t, "172.20.88.22"),
IPv6Address: "2001:db8::8822", IPv6Address: mustAddr(t, "2001:db8::8822"),
LinkLocalIPs: []string{"169.254.2.2", "fe80::169:254:2:2"}, LinkLocalIPs: mustAddrs(t, "169.254.2.2", "fe80::169:254:2:2"),
}, },
Links: []string{"foo:bar", "bar:baz"}, Links: []string{"foo:bar", "bar:baz"},
Aliases: []string{"web1", "web2"}, Aliases: []string{"web1", "web2"},
@@ -538,8 +574,8 @@ func TestParseNetworkConfig(t *testing.T) {
"net3": { "net3": {
DriverOpts: map[string]string{"field3": "value3"}, DriverOpts: map[string]string{"field3": "value3"},
IPAMConfig: &networktypes.EndpointIPAMConfig{ IPAMConfig: &networktypes.EndpointIPAMConfig{
IPv4Address: "172.20.88.22", IPv4Address: mustAddr(t, "172.20.88.22"),
IPv6Address: "2001:db8::8822", IPv6Address: mustAddr(t, "2001:db8::8822"),
}, },
Aliases: []string{"web3"}, Aliases: []string{"web3"},
}, },
@@ -556,8 +592,8 @@ func TestParseNetworkConfig(t *testing.T) {
"field2": "value2", "field2": "value2",
}, },
IPAMConfig: &networktypes.EndpointIPAMConfig{ IPAMConfig: &networktypes.EndpointIPAMConfig{
IPv4Address: "172.20.88.22", IPv4Address: mustAddr(t, "172.20.88.22"),
IPv6Address: "2001:db8::8822", IPv6Address: mustAddr(t, "2001:db8::8822"),
}, },
Aliases: []string{"web1", "web2"}, Aliases: []string{"web1", "web2"},
}, },
@@ -610,7 +646,9 @@ func TestParseNetworkConfig(t *testing.T) {
assert.NilError(t, err) assert.NilError(t, err)
assert.DeepEqual(t, hConfig.NetworkMode, tc.expectedCfg.NetworkMode) assert.DeepEqual(t, hConfig.NetworkMode, tc.expectedCfg.NetworkMode)
assert.DeepEqual(t, nwConfig.EndpointsConfig, tc.expected) if diff := cmp.Diff(tc.expected, nwConfig.EndpointsConfig, cmpopts.EquateComparable(netip.Addr{})); diff != "" {
t.Fatalf("unexpected endpoints (-want +got):\n%s", diff)
}
}) })
} }
} }
@@ -631,7 +669,7 @@ func TestParseModes(t *testing.T) {
} }
// uts ko // uts ko
_, _, _, err = parseRun([]string{"--uts=container:", "img", "cmd"}) _, _, _, err = parseRun([]string{"--uts=container:", "img", "cmd"}) //nolint:dogsled // verbatim copy from docker/cli tests
assert.ErrorContains(t, err, "--uts: invalid UTS mode") assert.ErrorContains(t, err, "--uts: invalid UTS mode")
// uts ok // uts ok
@@ -691,10 +729,9 @@ func TestParseRestartPolicy(t *testing.T) {
} }
func TestParseRestartPolicyAutoRemove(t *testing.T) { func TestParseRestartPolicyAutoRemove(t *testing.T) {
expected := "Conflicting options: --restart and --rm" _, _, _, err := parseRun([]string{"--rm", "--restart=always", "img", "cmd"}) //nolint:dogsled // verbatim copy from docker/cli tests
_, _, _, err := parseRun([]string{"--rm", "--restart=always", "img", "cmd"}) if err == nil {
if err == nil || err.Error() != expected { t.Fatal("Expected error for conflicting --restart and --rm, but got none")
t.Fatalf("Expected error %v, but got none", expected)
} }
} }
@@ -752,7 +789,7 @@ func TestParseLoggingOpts(t *testing.T) {
} }
} }
func TestParseEnvfileVariables(t *testing.T) { //nolint:dupl // pre-existing issue from nektos/act func TestParseEnvfileVariables(t *testing.T) { //nolint:dupl // verbatim copy from docker/cli tests
e := "open nonexistent: no such file or directory" e := "open nonexistent: no such file or directory"
if runtime.GOOS == "windows" { if runtime.GOOS == "windows" {
e = "open nonexistent: The system cannot find the file specified." e = "open nonexistent: The system cannot find the file specified."
@@ -795,7 +832,7 @@ func TestParseEnvfileVariablesWithBOMUnicode(t *testing.T) {
} }
// UTF16 with BOM // UTF16 with BOM
e := "contains invalid utf8 bytes at line" e := "invalid env file"
if _, _, _, err := parseRun([]string{"--env-file=testdata/utf16.env", "img", "cmd"}); err == nil || !strings.Contains(err.Error(), e) { if _, _, _, err := parseRun([]string{"--env-file=testdata/utf16.env", "img", "cmd"}); err == nil || !strings.Contains(err.Error(), e) {
t.Fatalf("Expected an error with message '%s', got %v", e, err) t.Fatalf("Expected an error with message '%s', got %v", e, err)
} }
@@ -805,7 +842,7 @@ func TestParseEnvfileVariablesWithBOMUnicode(t *testing.T) {
} }
} }
func TestParseLabelfileVariables(t *testing.T) { //nolint:dupl // pre-existing issue from nektos/act func TestParseLabelfileVariables(t *testing.T) { //nolint:dupl // verbatim copy from docker/cli tests
e := "open nonexistent: no such file or directory" e := "open nonexistent: no such file or directory"
if runtime.GOOS == "windows" { if runtime.GOOS == "windows" {
e = "open nonexistent: The system cannot find the file specified." e = "open nonexistent: The system cannot find the file specified."

View File

@@ -10,8 +10,8 @@ import (
"context" "context"
"fmt" "fmt"
"github.com/docker/docker/api/types" cerrdefs "github.com/containerd/errdefs"
"github.com/docker/docker/client" "github.com/moby/moby/client"
) )
// ImageExistsLocally returns a boolean indicating if an image with the // ImageExistsLocally returns a boolean indicating if an image with the
@@ -23,8 +23,8 @@ func ImageExistsLocally(ctx context.Context, imageName, platform string) (bool,
} }
defer cli.Close() defer cli.Close()
inspectImage, _, err := cli.ImageInspectWithRaw(ctx, imageName) inspectImage, err := cli.ImageInspect(ctx, imageName)
if client.IsErrNotFound(err) { if cerrdefs.IsNotFound(err) {
return false, nil return false, nil
} else if err != nil { } else if err != nil {
return false, err return false, err
@@ -46,14 +46,14 @@ func RemoveImage(ctx context.Context, imageName string, force, pruneChildren boo
} }
defer cli.Close() defer cli.Close()
inspectImage, _, err := cli.ImageInspectWithRaw(ctx, imageName) inspectImage, err := cli.ImageInspect(ctx, imageName)
if client.IsErrNotFound(err) { if cerrdefs.IsNotFound(err) {
return false, nil return false, nil
} else if err != nil { } else if err != nil {
return false, err return false, err
} }
if _, err = cli.ImageRemove(ctx, inspectImage.ID, types.ImageRemoveOptions{ if _, err = cli.ImageRemove(ctx, inspectImage.ID, client.ImageRemoveOptions{
Force: force, Force: force,
PruneChildren: pruneChildren, PruneChildren: pruneChildren,
}); err != nil { }); err != nil {

View File

@@ -6,66 +6,64 @@ package container
import ( import (
"context" "context"
"io" "fmt"
"os"
"os/exec"
"strings"
"testing" "testing"
"github.com/docker/docker/api/types"
"github.com/docker/docker/client"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
) )
func init() { func init() {
log.SetLevel(log.DebugLevel) log.SetLevel(log.DebugLevel)
} }
func TestImageExistsLocally(t *testing.T) { // buildScratchImage builds a tiny empty image for the given platform locally (FROM scratch, no
if testing.Short() { // network or emulation since there is nothing to run) and returns its tag, removing it after
t.Skip("skipping integration test") // the test.
} func buildScratchImage(t *testing.T, platform string) string {
ctx := context.Background() t.Helper()
// to help make this test reliable and not flaky, we need to have tag := fmt.Sprintf("act-test-exists-%s:latest", strings.TrimPrefix(platform, "linux/"))
// an image that will exist, and onew that won't exist cmd := exec.Command("docker", "build", "--platform", platform, "-t", tag, "-")
cmd.Stdin = strings.NewReader("FROM scratch\nLABEL act-test=1\n")
// Test if image exists with specific tag // Force BuildKit: it records the requested architecture in the image config for a
invalidImageTag, err := ImageExistsLocally(ctx, "library/alpine:this-random-tag-will-never-exist", "linux/amd64") // FROM-scratch build, whereas the classic builder ignores --platform and tags it with the
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act // host arch, which would break the per-platform existence assertions below.
assert.False(t, invalidImageTag) cmd.Env = append(os.Environ(), "DOCKER_BUILDKIT=1")
out, err := cmd.CombinedOutput()
// Test if image exists with specific architecture (image platform) require.NoError(t, err, string(out))
invalidImagePlatform, err := ImageExistsLocally(ctx, "alpine:latest", "windows/amd64") t.Cleanup(func() { _ = exec.Command("docker", "rmi", "-f", tag).Run() })
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act return tag
assert.False(t, invalidImagePlatform) }
// pull an image func TestImageExistsLocally(t *testing.T) {
cli, err := client.NewClientWithOpts(client.FromEnv) requireDocker(t)
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act ctx := context.Background()
cli.NegotiateAPIVersion(context.Background())
// a non-existent image is reported absent
// Chose alpine latest because it's so small missing, err := ImageExistsLocally(ctx, "library/alpine:this-random-tag-will-never-exist", "linux/amd64")
// maybe we should build an image instead so that tests aren't reliable on dockerhub assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
readerDefault, err := cli.ImagePull(ctx, "node:24-bookworm-slim", types.ImagePullOptions{ assert.False(t, missing)
Platform: "linux/amd64",
}) // Build tiny images for two architectures locally so per-platform existence can be checked
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act // offline (formerly pulled node:24-bookworm-slim for amd64 and arm64 over the network).
defer readerDefault.Close() amd64Ref := buildScratchImage(t, "linux/amd64")
_, err = io.ReadAll(readerDefault) arm64Ref := buildScratchImage(t, "linux/arm64")
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
amd64Exists, err := ImageExistsLocally(ctx, amd64Ref, "linux/amd64")
imageDefaultArchExists, err := ImageExistsLocally(ctx, "node:24-bookworm-slim", "linux/amd64") 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.True(t, amd64Exists)
assert.True(t, imageDefaultArchExists)
// a non-host architecture image is detected for its own architecture
// Validate if another architecture platform can be pulled arm64Exists, err := ImageExistsLocally(ctx, arm64Ref, "linux/arm64")
readerArm64, err := cli.ImagePull(ctx, "node:24-bookworm-slim", types.ImagePullOptions{ assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
Platform: "linux/arm64", assert.True(t, arm64Exists)
})
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act // a present image is reported absent for a different platform
defer readerArm64.Close() wrongPlatform, err := ImageExistsLocally(ctx, amd64Ref, "linux/arm64")
_, err = io.ReadAll(readerArm64) 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.False(t, wrongPlatform)
imageArm64Exists, err := ImageExistsLocally(ctx, "node:24-bookworm-slim", "linux/arm64")
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
assert.True(t, imageArm64Exists)
} }

View File

@@ -26,8 +26,6 @@ type dockerMessage struct {
Progress string `json:"progress"` Progress string `json:"progress"`
} }
const logPrefix = " \U0001F433 "
func logDockerResponse(logger logrus.FieldLogger, dockerResponse io.ReadCloser, isError bool) error { func logDockerResponse(logger logrus.FieldLogger, dockerResponse io.ReadCloser, isError bool) error {
if dockerResponse == nil { if dockerResponse == nil {
return nil return nil

View File

@@ -11,7 +11,7 @@ import (
"gitea.com/gitea/runner/act/common" "gitea.com/gitea/runner/act/common"
"github.com/docker/docker/api/types" "github.com/moby/moby/client"
) )
func NewDockerNetworkCreateExecutor(name string) common.Executor { func NewDockerNetworkCreateExecutor(name string) common.Executor {
@@ -23,20 +23,20 @@ func NewDockerNetworkCreateExecutor(name string) common.Executor {
defer cli.Close() defer cli.Close()
// Only create the network if it doesn't exist // Only create the network if it doesn't exist
networks, err := cli.NetworkList(ctx, types.NetworkListOptions{}) networks, err := cli.NetworkList(ctx, client.NetworkListOptions{})
if err != nil { if err != nil {
return err return err
} }
// For Gitea, reduce log noise // For Gitea, reduce log noise
// common.Logger(ctx).Debugf("%v", networks) // common.Logger(ctx).Debugf("%v", networks)
for _, network := range networks { for _, n := range networks.Items {
if network.Name == name { if n.Name == name {
common.Logger(ctx).Debugf("Network %v exists", name) common.Logger(ctx).Debugf("Network %v exists", name)
return nil return nil
} }
} }
_, err = cli.NetworkCreate(ctx, name, types.NetworkCreate{ _, err = cli.NetworkCreate(ctx, name, client.NetworkCreateOptions{
Driver: "bridge", Driver: "bridge",
Scope: "local", Scope: "local",
}) })
@@ -56,23 +56,23 @@ func NewDockerNetworkRemoveExecutor(name string) common.Executor {
} }
defer cli.Close() defer cli.Close()
// Make shure that all network of the specified name are removed // Make sure that all network of the specified name are removed
// cli.NetworkRemove refuses to remove a network if there are duplicates // cli.NetworkRemove refuses to remove a network if there are duplicates
networks, err := cli.NetworkList(ctx, types.NetworkListOptions{}) networks, err := cli.NetworkList(ctx, client.NetworkListOptions{})
if err != nil { if err != nil {
return err return err
} }
// For Gitea, reduce log noise // For Gitea, reduce log noise
// common.Logger(ctx).Debugf("%v", networks) // common.Logger(ctx).Debugf("%v", networks)
for _, network := range networks { for _, n := range networks.Items {
if network.Name == name { if n.Name == name {
result, err := cli.NetworkInspect(ctx, network.ID, types.NetworkInspectOptions{}) result, err := cli.NetworkInspect(ctx, n.ID, client.NetworkInspectOptions{})
if err != nil { if err != nil {
return err return err
} }
if len(result.Containers) == 0 { if len(result.Network.Containers) == 0 {
if err = cli.NetworkRemove(ctx, network.ID); err != nil { if _, err = cli.NetworkRemove(ctx, n.ID, client.NetworkRemoveOptions{}); err != nil {
common.Logger(ctx).Debugf("%v", err) common.Logger(ctx).Debugf("%v", err)
} }
} else { } else {

View File

@@ -0,0 +1,39 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// Copyright 2025 The nektos/act Authors. All rights reserved.
// SPDX-License-Identifier: MIT
//go:build !(WITHOUT_DOCKER || !(linux || darwin || windows || netbsd))
package container
import (
"fmt"
"strings"
specs "github.com/opencontainers/image-spec/specs-go/v1"
)
// parsePlatform parses an "os/arch[/variant]" string into a Platform. An empty input
// returns (nil, nil), meaning "no platform constraint". A non-empty but malformed
// string is rejected explicitly so it cannot silently fall through to the daemon's
// default architecture.
func parsePlatform(platform string) (*specs.Platform, error) {
if platform == "" {
return nil, nil //nolint:nilnil // no platform constraint requested
}
parts := strings.Split(platform, "/")
if len(parts) < 2 || len(parts) > 3 || parts[0] == "" || parts[1] == "" || (len(parts) == 3 && parts[2] == "") {
return nil, fmt.Errorf("invalid platform %q: expected os/arch[/variant]", platform)
}
spec := &specs.Platform{
OS: strings.ToLower(parts[0]),
Architecture: strings.ToLower(parts[1]),
}
if len(parts) == 3 {
spec.Variant = strings.ToLower(parts[2])
}
return spec, nil
}

View File

@@ -0,0 +1,63 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package container
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestParsePlatform(t *testing.T) {
t.Run("empty input returns nil platform without error", func(t *testing.T) {
got, err := parsePlatform("")
require.NoError(t, err)
assert.Nil(t, got)
})
t.Run("os/arch", func(t *testing.T) {
got, err := parsePlatform("linux/amd64")
require.NoError(t, err)
require.NotNil(t, got)
assert.Equal(t, "linux", got.OS)
assert.Equal(t, "amd64", got.Architecture)
assert.Empty(t, got.Variant)
})
t.Run("os/arch/variant", func(t *testing.T) {
got, err := parsePlatform("linux/arm/v7")
require.NoError(t, err)
require.NotNil(t, got)
assert.Equal(t, "linux", got.OS)
assert.Equal(t, "arm", got.Architecture)
assert.Equal(t, "v7", got.Variant)
})
t.Run("input is lowercased", func(t *testing.T) {
got, err := parsePlatform("Linux/AMD64/V8")
require.NoError(t, err)
require.NotNil(t, got)
assert.Equal(t, "linux", got.OS)
assert.Equal(t, "amd64", got.Architecture)
assert.Equal(t, "v8", got.Variant)
})
for _, bad := range []string{
"amd64",
"linux",
"linux/",
"/amd64",
"/",
"//",
"linux/arm/",
"linux/arm/v7/extra",
} {
t.Run("rejects "+bad, func(t *testing.T) {
got, err := parsePlatform(bad)
require.Error(t, err)
assert.Nil(t, got)
})
}
}

View File

@@ -8,23 +8,23 @@ package container
import ( import (
"context" "context"
"encoding/base64"
"encoding/json"
"fmt" "fmt"
"strings" "strings"
"gitea.com/gitea/runner/act/common" "gitea.com/gitea/runner/act/common"
"github.com/distribution/reference" "github.com/distribution/reference"
"github.com/docker/docker/api/types" "github.com/moby/moby/api/pkg/authconfig"
"github.com/docker/docker/api/types/registry" "github.com/moby/moby/api/types/registry"
"github.com/moby/moby/client"
specs "github.com/opencontainers/image-spec/specs-go/v1"
) )
// NewDockerPullExecutor function to create a run executor for the container // NewDockerPullExecutor function to create a run executor for the container
func NewDockerPullExecutor(input NewDockerPullExecutorInput) common.Executor { func NewDockerPullExecutor(input NewDockerPullExecutorInput) common.Executor {
return func(ctx context.Context) error { return func(ctx context.Context) error {
logger := common.Logger(ctx) logger := common.Logger(ctx)
logger.Debugf("%sdocker pull %v", logPrefix, input.Image) logger.Debugf("docker pull %v", input.Image)
if common.Dryrun(ctx) { if common.Dryrun(ctx) {
return nil return nil
@@ -78,26 +78,29 @@ func NewDockerPullExecutor(input NewDockerPullExecutorInput) common.Executor {
} }
} }
func getImagePullOptions(ctx context.Context, input NewDockerPullExecutorInput) (types.ImagePullOptions, error) { func getImagePullOptions(ctx context.Context, input NewDockerPullExecutorInput) (client.ImagePullOptions, error) {
imagePullOptions := types.ImagePullOptions{ imagePullOptions := client.ImagePullOptions{}
Platform: input.Platform, platform, err := parsePlatform(input.Platform)
if err != nil {
return imagePullOptions, err
}
if platform != nil {
imagePullOptions.Platforms = []specs.Platform{*platform}
} }
logger := common.Logger(ctx) logger := common.Logger(ctx)
if input.Username != "" && input.Password != "" { if input.Username != "" && input.Password != "" {
logger.Debugf("using authentication for docker pull") logger.Debugf("using authentication for docker pull")
authConfig := registry.AuthConfig{ encodedAuth, err := authconfig.Encode(registry.AuthConfig{
Username: input.Username, Username: input.Username,
Password: input.Password, Password: input.Password,
} })
encodedJSON, err := json.Marshal(authConfig)
if err != nil { if err != nil {
return imagePullOptions, err return imagePullOptions, err
} }
imagePullOptions.RegistryAuth = base64.URLEncoding.EncodeToString(encodedJSON) imagePullOptions.RegistryAuth = encodedAuth
} else { } else {
authConfig, err := LoadDockerAuthConfig(ctx, input.Image) authConfig, err := LoadDockerAuthConfig(ctx, input.Image)
if err != nil { if err != nil {
@@ -108,19 +111,17 @@ func getImagePullOptions(ctx context.Context, input NewDockerPullExecutorInput)
} }
logger.Info("using DockerAuthConfig authentication for docker pull") logger.Info("using DockerAuthConfig authentication for docker pull")
encodedJSON, err := json.Marshal(authConfig) imagePullOptions.RegistryAuth, err = authconfig.Encode(authConfig)
if err != nil { if err != nil {
return imagePullOptions, err return imagePullOptions, err
} }
imagePullOptions.RegistryAuth = base64.URLEncoding.EncodeToString(encodedJSON)
} }
return imagePullOptions, nil return imagePullOptions, nil
} }
func cleanImage(ctx context.Context, image string) string { func cleanImage(ctx context.Context, imageName string) string {
ref, err := reference.ParseAnyReference(image) ref, err := reference.ParseAnyReference(imageName)
if err != nil { if err != nil {
common.Logger(ctx).Error(err) common.Logger(ctx).Error(err)
return "" return ""

View File

@@ -40,6 +40,9 @@ func TestCleanImage(t *testing.T) {
func TestGetImagePullOptions(t *testing.T) { func TestGetImagePullOptions(t *testing.T) {
ctx := context.Background() ctx := context.Background()
orig := config.Dir()
t.Cleanup(func() { config.SetDir(orig) })
config.SetDir("/non-existent/docker") config.SetDir("/non-existent/docker")
options, err := getImagePullOptions(ctx, NewDockerPullExecutorInput{}) options, err := getImagePullOptions(ctx, NewDockerPullExecutorInput{})

View File

@@ -17,31 +17,32 @@ import (
"path/filepath" "path/filepath"
"regexp" "regexp"
"runtime" "runtime"
"slices"
"strconv" "strconv"
"strings" "strings"
"gitea.com/gitea/runner/act/common" "gitea.com/gitea/runner/act/common"
"gitea.com/gitea/runner/act/filecollector" "gitea.com/gitea/runner/act/filecollector"
"dario.cat/mergo"
"github.com/Masterminds/semver" "github.com/Masterminds/semver"
cerrdefs "github.com/containerd/errdefs"
"github.com/docker/cli/cli/compose/loader" "github.com/docker/cli/cli/compose/loader"
"github.com/docker/cli/cli/connhelper" "github.com/docker/cli/cli/connhelper"
"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/mount"
"github.com/docker/docker/api/types/network"
"github.com/docker/docker/client"
"github.com/docker/docker/pkg/stdcopy"
"github.com/go-git/go-billy/v5/helper/polyfill" "github.com/go-git/go-billy/v5/helper/polyfill"
"github.com/go-git/go-billy/v5/osfs" "github.com/go-git/go-billy/v5/osfs"
"github.com/go-git/go-git/v5/plumbing/format/gitignore" "github.com/go-git/go-git/v5/plumbing/format/gitignore"
"github.com/gobwas/glob" "github.com/gobwas/glob"
"github.com/imdario/mergo"
"github.com/joho/godotenv" "github.com/joho/godotenv"
"github.com/kballard/go-shellquote" "github.com/kballard/go-shellquote"
"github.com/moby/moby/api/pkg/stdcopy"
"github.com/moby/moby/api/types/container"
"github.com/moby/moby/api/types/mount"
"github.com/moby/moby/api/types/network"
"github.com/moby/moby/api/types/system"
"github.com/moby/moby/client"
specs "github.com/opencontainers/image-spec/specs-go/v1" specs "github.com/opencontainers/image-spec/specs-go/v1"
"github.com/spf13/pflag" "github.com/spf13/pflag"
"golang.org/x/term"
) )
// NewContainer creates a reference to a container // NewContainer creates a reference to a container
@@ -53,7 +54,7 @@ func NewContainer(input *NewContainerInput) ExecutionsEnvironment {
func (cr *containerReference) ConnectToNetwork(name string) common.Executor { func (cr *containerReference) ConnectToNetwork(name string) common.Executor {
return common. return common.
NewDebugExecutor("%sdocker network connect %s %s", logPrefix, name, cr.input.Name). NewDebugExecutor("docker network connect %s %s", name, cr.input.Name).
Then( Then(
common.NewPipelineExecutor( common.NewPipelineExecutor(
cr.connect(), cr.connect(),
@@ -64,9 +65,13 @@ func (cr *containerReference) ConnectToNetwork(name string) common.Executor {
func (cr *containerReference) connectToNetwork(name string, aliases []string) common.Executor { func (cr *containerReference) connectToNetwork(name string, aliases []string) common.Executor {
return func(ctx context.Context) error { return func(ctx context.Context) error {
return cr.cli.NetworkConnect(ctx, name, cr.input.Name, &network.EndpointSettings{ _, err := cr.cli.NetworkConnect(ctx, name, client.NetworkConnectOptions{
Aliases: aliases, Container: cr.input.Name,
EndpointConfig: &network.EndpointSettings{
Aliases: aliases,
},
}) })
return err
} }
} }
@@ -74,7 +79,7 @@ func (cr *containerReference) connectToNetwork(name string, aliases []string) co
// API version is 1.41 and beyond // API version is 1.41 and beyond
func supportsContainerImagePlatform(ctx context.Context, cli client.APIClient) bool { func supportsContainerImagePlatform(ctx context.Context, cli client.APIClient) bool {
logger := common.Logger(ctx) logger := common.Logger(ctx)
ver, err := cli.ServerVersion(ctx) ver, err := cli.ServerVersion(ctx, client.ServerVersionOptions{})
if err != nil { if err != nil {
logger.Panicf("Failed to get Docker API Version: %s", err) logger.Panicf("Failed to get Docker API Version: %s", err)
return false return false
@@ -90,7 +95,7 @@ func supportsContainerImagePlatform(ctx context.Context, cli client.APIClient) b
func (cr *containerReference) Create(capAdd, capDrop []string) common.Executor { func (cr *containerReference) Create(capAdd, capDrop []string) common.Executor {
return common. return common.
NewInfoExecutor("%sdocker create image=%s platform=%s entrypoint=%+q cmd=%+q network=%+q", logPrefix, cr.input.Image, cr.input.Platform, cr.input.Entrypoint, cr.input.Cmd, cr.input.NetworkMode). NewInfoExecutor("docker create image=%s platform=%s entrypoint=%+q cmd=%+q network=%+q", cr.input.Image, cr.input.Platform, cr.input.Entrypoint, cr.input.Cmd, cr.input.NetworkMode).
Then( Then(
common.NewPipelineExecutor( common.NewPipelineExecutor(
cr.connect(), cr.connect(),
@@ -102,7 +107,7 @@ func (cr *containerReference) Create(capAdd, capDrop []string) common.Executor {
func (cr *containerReference) Start(attach bool) common.Executor { func (cr *containerReference) Start(attach bool) common.Executor {
return common. return common.
NewInfoExecutor("%sdocker run image=%s platform=%s entrypoint=%+q cmd=%+q network=%+q", logPrefix, cr.input.Image, cr.input.Platform, cr.input.Entrypoint, cr.input.Cmd, cr.input.NetworkMode). NewInfoExecutor("docker run image=%s platform=%s entrypoint=%+q cmd=%+q network=%+q", cr.input.Image, cr.input.Platform, cr.input.Entrypoint, cr.input.Cmd, cr.input.NetworkMode).
Then( Then(
common.NewPipelineExecutor( common.NewPipelineExecutor(
cr.connect(), cr.connect(),
@@ -125,7 +130,7 @@ func (cr *containerReference) Start(attach bool) common.Executor {
func (cr *containerReference) Pull(forcePull bool) common.Executor { func (cr *containerReference) Pull(forcePull bool) common.Executor {
return common. return common.
NewInfoExecutor("%sdocker pull image=%s platform=%s username=%s forcePull=%t", logPrefix, cr.input.Image, cr.input.Platform, cr.input.Username, forcePull). NewInfoExecutor("docker pull image=%s platform=%s username=%s forcePull=%t", cr.input.Image, cr.input.Platform, cr.input.Username, forcePull).
Then( Then(
NewDockerPullExecutor(NewDockerPullExecutorInput{ NewDockerPullExecutor(NewDockerPullExecutorInput{
Image: cr.input.Image, Image: cr.input.Image,
@@ -147,7 +152,9 @@ func (cr *containerReference) Copy(destPath string, files ...*FileEntry) common.
func (cr *containerReference) CopyDir(destPath, srcPath string, useGitIgnore bool) common.Executor { func (cr *containerReference) CopyDir(destPath, srcPath string, useGitIgnore bool) common.Executor {
return common.NewPipelineExecutor( return common.NewPipelineExecutor(
common.NewInfoExecutor("%sdocker cp src=%s dst=%s", logPrefix, srcPath, destPath), common.NewInfoExecutor("docker cp src=%s dst=%s", srcPath, destPath),
cr.connect(),
cr.find(),
cr.copyDir(destPath, srcPath, useGitIgnore), cr.copyDir(destPath, srcPath, useGitIgnore),
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
@@ -163,8 +170,21 @@ func (cr *containerReference) GetContainerArchive(ctx context.Context, srcPath s
if common.Dryrun(ctx) { if common.Dryrun(ctx) {
return nil, errors.New("DRYRUN is not supported in GetContainerArchive") return nil, errors.New("DRYRUN is not supported in GetContainerArchive")
} }
a, _, err := cr.cli.CopyFromContainer(ctx, cr.id, srcPath) // Direct entry point (no pipeline) — revalidate cr.id ourselves.
return a, err if err := cr.connect()(ctx); err != nil {
return nil, err
}
if err := cr.find()(ctx); err != nil {
return nil, err
}
if cr.id == "" {
return nil, cr.missingContainerError("get archive %s", srcPath)
}
result, err := cr.cli.CopyFromContainer(ctx, cr.id, client.CopyFromContainerOptions{SourcePath: srcPath})
if err != nil {
return nil, err
}
return result.Content, nil
} }
func (cr *containerReference) UpdateFromEnv(srcPath string, env *map[string]string) common.Executor { func (cr *containerReference) UpdateFromEnv(srcPath string, env *map[string]string) common.Executor {
@@ -177,7 +197,7 @@ func (cr *containerReference) UpdateFromImageEnv(env *map[string]string) common.
func (cr *containerReference) Exec(command []string, env map[string]string, user, workdir string) common.Executor { func (cr *containerReference) Exec(command []string, env map[string]string, user, workdir string) common.Executor {
return common.NewPipelineExecutor( return common.NewPipelineExecutor(
common.NewInfoExecutor("%sdocker exec cmd=[%s] user=%s workdir=%s", logPrefix, strings.Join(command, " "), user, workdir), common.NewInfoExecutor("docker exec cmd=[%s] user=%s workdir=%s", strings.Join(command, " "), user, workdir),
cr.connect(), cr.connect(),
cr.find(), cr.find(),
cr.exec(command, env, user, workdir), cr.exec(command, env, user, workdir),
@@ -222,22 +242,27 @@ func GetDockerClient(ctx context.Context) (cli client.APIClient, err error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
cli, err = client.NewClientWithOpts( cli, err = client.New(
client.WithHost(helper.Host), client.WithHost(helper.Host),
client.WithDialContext(helper.Dialer), client.WithDialContext(helper.Dialer),
) )
} else { } else {
cli, err = client.NewClientWithOpts(client.FromEnv) cli, err = client.New(client.FromEnv)
} }
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to connect to docker daemon: %w", err) return nil, fmt.Errorf("failed to connect to docker daemon: %w", err)
} }
cli.NegotiateAPIVersion(ctx) // Best-effort API version negotiation, matching the old client.NegotiateAPIVersion
// behaviour. Ping failures here are non-fatal: the connection is exercised again on
// the first real call, and the client falls back to the daemon's default API version.
if _, err := cli.Ping(ctx, client.PingOptions{NegotiateAPIVersion: true}); err != nil {
common.Logger(ctx).Warnf("docker daemon ping during version negotiation failed, continuing: %v", err)
}
return cli, nil return cli, nil
} }
func GetHostInfo(ctx context.Context) (info types.Info, err error) { //nolint:staticcheck // pre-existing issue from nektos/act func GetHostInfo(ctx context.Context) (info system.Info, err error) {
var cli client.APIClient var cli client.APIClient
cli, err = GetDockerClient(ctx) cli, err = GetDockerClient(ctx)
if err != nil { if err != nil {
@@ -245,12 +270,12 @@ func GetHostInfo(ctx context.Context) (info types.Info, err error) { //nolint:st
} }
defer cli.Close() defer cli.Close()
info, err = cli.Info(ctx) result, err := cli.Info(ctx, client.InfoOptions{})
if err != nil { if err != nil {
return info, err return info, err
} }
return info, nil return result.Info, nil
} }
// Arch fetches values from docker info and translates architecture to // Arch fetches values from docker info and translates architecture to
@@ -302,19 +327,31 @@ func (cr *containerReference) Close() common.Executor {
} }
} }
// missingContainerError is the shared "container X does not exist" error
// used by ops that need a live cr.id.
func (cr *containerReference) missingContainerError(format string, args ...any) error {
return fmt.Errorf("container %q does not exist; cannot "+format, append([]any{cr.input.Name}, args...)...)
}
func (cr *containerReference) find() common.Executor { func (cr *containerReference) find() common.Executor {
return func(ctx context.Context) error { return func(ctx context.Context) error {
if cr.id != "" { if cr.id != "" {
return nil // Validate cached id; clear only on definitive NotFound so a
// transient daemon error doesn't abort cleanup pipelines.
_, err := cr.cli.ContainerInspect(ctx, cr.id, client.ContainerInspectOptions{})
if !cerrdefs.IsNotFound(err) {
return nil
}
cr.id = ""
} }
containers, err := cr.cli.ContainerList(ctx, types.ContainerListOptions{ //nolint:staticcheck // pre-existing issue from nektos/act containers, err := cr.cli.ContainerList(ctx, client.ContainerListOptions{
All: true, All: true,
}) })
if err != nil { if err != nil {
return fmt.Errorf("failed to list containers: %w", err) return fmt.Errorf("failed to list containers: %w", err)
} }
for _, c := range containers { for _, c := range containers.Items {
for _, name := range c.Names { for _, name := range c.Names {
if name[1:] == cr.input.Name { if name[1:] == cr.input.Name {
cr.id = c.ID cr.id = c.ID
@@ -323,7 +360,6 @@ func (cr *containerReference) find() common.Executor {
} }
} }
cr.id = ""
return nil return nil
} }
} }
@@ -335,7 +371,7 @@ func (cr *containerReference) remove() common.Executor {
} }
logger := common.Logger(ctx) logger := common.Logger(ctx)
err := cr.cli.ContainerRemove(ctx, cr.id, types.ContainerRemoveOptions{ //nolint:staticcheck // pre-existing issue from nektos/act _, err := cr.cli.ContainerRemove(ctx, cr.id, client.ContainerRemoveOptions{
RemoveVolumes: true, RemoveVolumes: true,
Force: true, Force: true,
}) })
@@ -438,15 +474,22 @@ func (cr *containerReference) create(capAdd, capDrop []string) common.Executor {
return nil return nil
} }
logger := common.Logger(ctx) logger := common.Logger(ctx)
isTerminal := term.IsTerminal(int(os.Stdout.Fd()))
input := cr.input input := cr.input
exposedPorts, err := convertPortSet(input.ExposedPorts)
if err != nil {
return err
}
portBindings, err := convertPortMap(input.PortBindings)
if err != nil {
return err
}
config := &container.Config{ config := &container.Config{
Image: input.Image, Image: input.Image,
WorkingDir: input.WorkingDir, WorkingDir: input.WorkingDir,
Env: input.Env, Env: input.Env,
ExposedPorts: input.ExposedPorts, ExposedPorts: exposedPorts,
Tty: isTerminal, Tty: input.AllocatePTY,
} }
// For Gitea, reduce log noise // For Gitea, reduce log noise
// logger.Debugf("Common container.Config ==> %+v", config) // logger.Debugf("Common container.Config ==> %+v", config)
@@ -470,15 +513,9 @@ func (cr *containerReference) create(capAdd, capDrop []string) common.Executor {
var platSpecs *specs.Platform var platSpecs *specs.Platform
if supportsContainerImagePlatform(ctx, cr.cli) && cr.input.Platform != "" { if supportsContainerImagePlatform(ctx, cr.cli) && cr.input.Platform != "" {
desiredPlatform := strings.SplitN(cr.input.Platform, `/`, 2) platSpecs, err = parsePlatform(cr.input.Platform)
if err != nil {
if len(desiredPlatform) != 2 { return err
return fmt.Errorf("incorrect container platform option '%s'", cr.input.Platform)
}
platSpecs = &specs.Platform{
Architecture: desiredPlatform[1],
OS: desiredPlatform[0],
} }
} }
@@ -490,13 +527,13 @@ func (cr *containerReference) create(capAdd, capDrop []string) common.Executor {
NetworkMode: container.NetworkMode(input.NetworkMode), NetworkMode: container.NetworkMode(input.NetworkMode),
Privileged: input.Privileged, Privileged: input.Privileged,
UsernsMode: container.UsernsMode(input.UsernsMode), UsernsMode: container.UsernsMode(input.UsernsMode),
PortBindings: input.PortBindings, PortBindings: portBindings,
AutoRemove: input.AutoRemove, AutoRemove: input.AutoRemove,
} }
// For Gitea, reduce log noise // For Gitea, reduce log noise
// logger.Debugf("Common container.HostConfig ==> %+v", hostConfig) // logger.Debugf("Common container.HostConfig ==> %+v", hostConfig)
config, hostConfig, err := cr.mergeContainerConfigs(ctx, config, hostConfig) config, hostConfig, err = cr.mergeContainerConfigs(ctx, config, hostConfig)
if err != nil { if err != nil {
return err return err
} }
@@ -520,7 +557,13 @@ func (cr *containerReference) create(capAdd, capDrop []string) common.Executor {
} }
} }
resp, err := cr.cli.ContainerCreate(ctx, config, hostConfig, networkingConfig, platSpecs, input.Name) resp, err := cr.cli.ContainerCreate(ctx, client.ContainerCreateOptions{
Config: config,
HostConfig: hostConfig,
NetworkingConfig: networkingConfig,
Platform: platSpecs,
Name: input.Name,
})
if err != nil { if err != nil {
return fmt.Errorf("failed to create container: '%w'", err) return fmt.Errorf("failed to create container: '%w'", err)
} }
@@ -538,7 +581,7 @@ func (cr *containerReference) extractFromImageEnv(env *map[string]string) common
return func(ctx context.Context) error { return func(ctx context.Context) error {
logger := common.Logger(ctx) logger := common.Logger(ctx)
inspect, _, err := cr.cli.ImageInspectWithRaw(ctx, cr.input.Image) inspect, err := cr.cli.ImageInspect(ctx, cr.input.Image)
if err != nil { if err != nil {
logger.Error(err) logger.Error(err)
return fmt.Errorf("inspect image: %w", err) return fmt.Errorf("inspect image: %w", err)
@@ -573,6 +616,9 @@ func (cr *containerReference) extractFromImageEnv(env *map[string]string) common
func (cr *containerReference) exec(cmd []string, env map[string]string, user, workdir string) common.Executor { func (cr *containerReference) exec(cmd []string, env map[string]string, user, workdir string) common.Executor {
return func(ctx context.Context) error { return func(ctx context.Context) error {
if cr.id == "" {
return cr.missingContainerError("exec %v", cmd)
}
logger := common.Logger(ctx) logger := common.Logger(ctx)
// Fix slashes when running on Windows // Fix slashes when running on Windows
if runtime.GOOS == "windows" { if runtime.GOOS == "windows" {
@@ -584,7 +630,7 @@ func (cr *containerReference) exec(cmd []string, env map[string]string, user, wo
} }
logger.Debugf("Exec command '%s'", cmd) logger.Debugf("Exec command '%s'", cmd)
isTerminal := term.IsTerminal(int(os.Stdout.Fd())) isTerminal := cr.input.AllocatePTY
envList := make([]string, 0) envList := make([]string, 0)
for k, v := range env { for k, v := range env {
envList = append(envList, fmt.Sprintf("%s=%s", k, v)) envList = append(envList, fmt.Sprintf("%s=%s", k, v))
@@ -602,12 +648,12 @@ func (cr *containerReference) exec(cmd []string, env map[string]string, user, wo
} }
logger.Debugf("Working directory '%s'", wd) logger.Debugf("Working directory '%s'", wd)
idResp, err := cr.cli.ContainerExecCreate(ctx, cr.id, types.ExecConfig{ idResp, err := cr.cli.ExecCreate(ctx, cr.id, client.ExecCreateOptions{
User: user, User: user,
Cmd: cmd, Cmd: cmd,
WorkingDir: wd, WorkingDir: wd,
Env: envList, Env: envList,
Tty: isTerminal, TTY: isTerminal,
AttachStderr: true, AttachStderr: true,
AttachStdout: true, AttachStdout: true,
}) })
@@ -615,20 +661,20 @@ func (cr *containerReference) exec(cmd []string, env map[string]string, user, wo
return fmt.Errorf("failed to create exec: %w", err) return fmt.Errorf("failed to create exec: %w", err)
} }
resp, err := cr.cli.ContainerExecAttach(ctx, idResp.ID, types.ExecStartCheck{ resp, err := cr.cli.ExecAttach(ctx, idResp.ID, client.ExecAttachOptions{
Tty: isTerminal, TTY: isTerminal,
}) })
if err != nil { if err != nil {
return fmt.Errorf("failed to attach to exec: %w", err) return fmt.Errorf("failed to attach to exec: %w", err)
} }
defer resp.Close() defer resp.Close()
err = cr.waitForCommand(ctx, isTerminal, resp, idResp, user, workdir) err = cr.waitForCommand(ctx, isTerminal, resp.HijackedResponse, idResp, user, workdir)
if err != nil { if err != nil {
return err return err
} }
inspectResp, err := cr.cli.ContainerExecInspect(ctx, idResp.ID) inspectResp, err := cr.cli.ExecInspect(ctx, idResp.ID, client.ExecInspectOptions{})
if err != nil { if err != nil {
return fmt.Errorf("failed to inspect exec: %w", err) return fmt.Errorf("failed to inspect exec: %w", err)
} }
@@ -642,7 +688,7 @@ func (cr *containerReference) exec(cmd []string, env map[string]string, user, wo
func (cr *containerReference) tryReadID(opt string, cbk func(id int)) common.Executor { func (cr *containerReference) tryReadID(opt string, cbk func(id int)) common.Executor {
return func(ctx context.Context) error { return func(ctx context.Context) error {
idResp, err := cr.cli.ContainerExecCreate(ctx, cr.id, types.ExecConfig{ idResp, err := cr.cli.ExecCreate(ctx, cr.id, client.ExecCreateOptions{
Cmd: []string{"id", opt}, Cmd: []string{"id", opt},
AttachStdout: true, AttachStdout: true,
AttachStderr: true, AttachStderr: true,
@@ -651,7 +697,7 @@ func (cr *containerReference) tryReadID(opt string, cbk func(id int)) common.Exe
return nil return nil
} }
resp, err := cr.cli.ContainerExecAttach(ctx, idResp.ID, types.ExecStartCheck{}) resp, err := cr.cli.ExecAttach(ctx, idResp.ID, client.ExecAttachOptions{})
if err != nil { if err != nil {
return nil return nil
} }
@@ -681,7 +727,7 @@ func (cr *containerReference) tryReadGID() common.Executor {
return cr.tryReadID("-g", func(id int) { cr.GID = id }) return cr.tryReadID("-g", func(id int) { cr.GID = id })
} }
func (cr *containerReference) waitForCommand(ctx context.Context, isTerminal bool, resp types.HijackedResponse, _ types.IDResponse, _, _ string) error { func (cr *containerReference) waitForCommand(ctx context.Context, isTerminal bool, resp client.HijackedResponse, _ client.ExecCreateResult, _, _ string) error {
logger := common.Logger(ctx) logger := common.Logger(ctx)
cmdResponse := make(chan error) cmdResponse := make(chan error)
@@ -727,6 +773,9 @@ func (cr *containerReference) waitForCommand(ctx context.Context, isTerminal boo
} }
func (cr *containerReference) CopyTarStream(ctx context.Context, destPath string, tarStream io.Reader) error { func (cr *containerReference) CopyTarStream(ctx context.Context, destPath string, tarStream io.Reader) error {
if cr.id == "" {
return cr.missingContainerError("copy to %s", destPath)
}
// Mkdir // Mkdir
buf := &bytes.Buffer{} buf := &bytes.Buffer{}
tw := tar.NewWriter(buf) tw := tar.NewWriter(buf)
@@ -736,12 +785,18 @@ func (cr *containerReference) CopyTarStream(ctx context.Context, destPath string
Typeflag: tar.TypeDir, Typeflag: tar.TypeDir,
}) })
tw.Close() tw.Close()
err := cr.cli.CopyToContainer(ctx, cr.id, "/", buf, types.CopyToContainerOptions{}) _, err := cr.cli.CopyToContainer(ctx, cr.id, client.CopyToContainerOptions{
DestinationPath: "/",
Content: buf,
})
if err != nil { if err != nil {
return fmt.Errorf("failed to mkdir to copy content to container: %w", err) return fmt.Errorf("failed to mkdir to copy content to container: %w", err)
} }
// Copy Content // Copy Content
err = cr.cli.CopyToContainer(ctx, cr.id, destPath, tarStream, types.CopyToContainerOptions{}) _, err = cr.cli.CopyToContainer(ctx, cr.id, client.CopyToContainerOptions{
DestinationPath: destPath,
Content: tarStream,
})
if err != nil { if err != nil {
return fmt.Errorf("failed to copy content to container: %w", err) return fmt.Errorf("failed to copy content to container: %w", err)
} }
@@ -754,6 +809,9 @@ func (cr *containerReference) CopyTarStream(ctx context.Context, destPath string
func (cr *containerReference) copyDir(dstPath, srcPath string, useGitIgnore bool) common.Executor { func (cr *containerReference) copyDir(dstPath, srcPath string, useGitIgnore bool) common.Executor {
return func(ctx context.Context) error { return func(ctx context.Context) error {
if cr.id == "" {
return cr.missingContainerError("copy directory to %s", dstPath)
}
logger := common.Logger(ctx) logger := common.Logger(ctx)
tarFile, err := os.CreateTemp("", "act") tarFile, err := os.CreateTemp("", "act")
if err != nil { if err != nil {
@@ -815,7 +873,10 @@ func (cr *containerReference) copyDir(dstPath, srcPath string, useGitIgnore bool
if err != nil { if err != nil {
return fmt.Errorf("failed to seek tar archive: %w", err) return fmt.Errorf("failed to seek tar archive: %w", err)
} }
err = cr.cli.CopyToContainer(ctx, cr.id, "/", tarFile, types.CopyToContainerOptions{}) _, err = cr.cli.CopyToContainer(ctx, cr.id, client.CopyToContainerOptions{
DestinationPath: "/",
Content: tarFile,
})
if err != nil { if err != nil {
return fmt.Errorf("failed to copy content to container: %w", err) return fmt.Errorf("failed to copy content to container: %w", err)
} }
@@ -825,6 +886,9 @@ func (cr *containerReference) copyDir(dstPath, srcPath string, useGitIgnore bool
func (cr *containerReference) copyContent(dstPath string, files ...*FileEntry) common.Executor { func (cr *containerReference) copyContent(dstPath string, files ...*FileEntry) common.Executor {
return func(ctx context.Context) error { return func(ctx context.Context) error {
if cr.id == "" {
return cr.missingContainerError("copy to %s", dstPath)
}
logger := common.Logger(ctx) logger := common.Logger(ctx)
var buf bytes.Buffer var buf bytes.Buffer
tw := tar.NewWriter(&buf) tw := tar.NewWriter(&buf)
@@ -849,7 +913,10 @@ func (cr *containerReference) copyContent(dstPath string, files ...*FileEntry) c
} }
logger.Debugf("Extracting content to '%s'", dstPath) logger.Debugf("Extracting content to '%s'", dstPath)
err := cr.cli.CopyToContainer(ctx, cr.id, dstPath, &buf, types.CopyToContainerOptions{}) _, err := cr.cli.CopyToContainer(ctx, cr.id, client.CopyToContainerOptions{
DestinationPath: dstPath,
Content: &buf,
})
if err != nil { if err != nil {
return fmt.Errorf("failed to copy content to container: %w", err) return fmt.Errorf("failed to copy content to container: %w", err)
} }
@@ -859,7 +926,7 @@ func (cr *containerReference) copyContent(dstPath string, files ...*FileEntry) c
func (cr *containerReference) attach() common.Executor { func (cr *containerReference) attach() common.Executor {
return func(ctx context.Context) error { return func(ctx context.Context) error {
out, err := cr.cli.ContainerAttach(ctx, cr.id, types.ContainerAttachOptions{ //nolint:staticcheck // pre-existing issue from nektos/act out, err := cr.cli.ContainerAttach(ctx, cr.id, client.ContainerAttachOptions{
Stream: true, Stream: true,
Stdout: true, Stdout: true,
Stderr: true, Stderr: true,
@@ -867,7 +934,7 @@ func (cr *containerReference) attach() common.Executor {
if err != nil { if err != nil {
return fmt.Errorf("failed to attach to container: %w", err) return fmt.Errorf("failed to attach to container: %w", err)
} }
isTerminal := term.IsTerminal(int(os.Stdout.Fd())) isTerminal := cr.input.AllocatePTY
var outWriter io.Writer var outWriter io.Writer
outWriter = cr.input.Stdout outWriter = cr.input.Stdout
@@ -897,7 +964,7 @@ func (cr *containerReference) start() common.Executor {
logger := common.Logger(ctx) logger := common.Logger(ctx)
logger.Debugf("Starting container: %v", cr.id) logger.Debugf("Starting container: %v", cr.id)
if err := cr.cli.ContainerStart(ctx, cr.id, types.ContainerStartOptions{}); err != nil { //nolint:staticcheck // pre-existing issue from nektos/act if _, err := cr.cli.ContainerStart(ctx, cr.id, client.ContainerStartOptions{}); err != nil {
return fmt.Errorf("failed to start container: %w", err) return fmt.Errorf("failed to start container: %w", err)
} }
@@ -909,14 +976,16 @@ func (cr *containerReference) start() common.Executor {
func (cr *containerReference) wait() common.Executor { func (cr *containerReference) wait() common.Executor {
return func(ctx context.Context) error { return func(ctx context.Context) error {
logger := common.Logger(ctx) logger := common.Logger(ctx)
statusCh, errCh := cr.cli.ContainerWait(ctx, cr.id, container.WaitConditionNotRunning) waitResult := cr.cli.ContainerWait(ctx, cr.id, client.ContainerWaitOptions{
Condition: container.WaitConditionNotRunning,
})
var statusCode int64 var statusCode int64
select { select {
case err := <-errCh: case err := <-waitResult.Error:
if err != nil { if err != nil {
return fmt.Errorf("failed to wait for container: %w", err) return fmt.Errorf("failed to wait for container: %w", err)
} }
case status := <-statusCh: case status := <-waitResult.Result:
statusCode = status.StatusCode statusCode = status.StatusCode
} }
@@ -936,22 +1005,7 @@ func (cr *containerReference) sanitizeConfig(ctx context.Context, config *contai
logger := common.Logger(ctx) logger := common.Logger(ctx)
if len(cr.input.ValidVolumes) > 0 { if len(cr.input.ValidVolumes) > 0 {
globs := make([]glob.Glob, 0, len(cr.input.ValidVolumes)) matcher := newValidVolumeMatcher(ctx, cr.input.ValidVolumes)
for _, v := range cr.input.ValidVolumes {
if g, err := glob.Compile(v); err != nil {
logger.Errorf("create glob from %s error: %v", v, err)
} else {
globs = append(globs, g)
}
}
isValid := func(v string) bool {
for _, g := range globs {
if g.Match(v) {
return true
}
}
return false
}
// sanitize binds // sanitize binds
sanitizedBinds := make([]string, 0, len(hostConfig.Binds)) sanitizedBinds := make([]string, 0, len(hostConfig.Binds))
for _, bind := range hostConfig.Binds { for _, bind := range hostConfig.Binds {
@@ -965,7 +1019,7 @@ func (cr *containerReference) sanitizeConfig(ctx context.Context, config *contai
sanitizedBinds = append(sanitizedBinds, bind) sanitizedBinds = append(sanitizedBinds, bind)
continue continue
} }
if isValid(parsed.Source) { if matcher.isValid(parsed.Source, mount.Type(parsed.Type)) {
sanitizedBinds = append(sanitizedBinds, bind) sanitizedBinds = append(sanitizedBinds, bind)
} else { } else {
logger.Warnf("[%s] is not a valid volume, will be ignored", parsed.Source) logger.Warnf("[%s] is not a valid volume, will be ignored", parsed.Source)
@@ -975,7 +1029,7 @@ func (cr *containerReference) sanitizeConfig(ctx context.Context, config *contai
// sanitize mounts // sanitize mounts
sanitizedMounts := make([]mount.Mount, 0, len(hostConfig.Mounts)) sanitizedMounts := make([]mount.Mount, 0, len(hostConfig.Mounts))
for _, mt := range hostConfig.Mounts { for _, mt := range hostConfig.Mounts {
if isValid(mt.Source) { if matcher.isValid(mt.Source, mt.Type) {
sanitizedMounts = append(sanitizedMounts, mt) sanitizedMounts = append(sanitizedMounts, mt)
} else { } else {
logger.Warnf("[%s] is not a valid volume, will be ignored", mt.Source) logger.Warnf("[%s] is not a valid volume, will be ignored", mt.Source)
@@ -989,3 +1043,129 @@ func (cr *containerReference) sanitizeConfig(ctx context.Context, config *contai
return config, hostConfig return config, hostConfig
} }
type validVolumeMatcher struct {
allowAll bool
named []glob.Glob
host []glob.Glob
}
func newValidVolumeMatcher(ctx context.Context, validVolumes []string) validVolumeMatcher {
logger := common.Logger(ctx)
ret := validVolumeMatcher{
named: make([]glob.Glob, 0, len(validVolumes)),
host: make([]glob.Glob, 0, len(validVolumes)),
}
for _, v := range validVolumes {
if v == "**" {
ret.allowAll = true
continue
}
if !isHostVolumePattern(v) {
if g, err := glob.Compile(v); err != nil {
logger.Errorf("create glob from %s error: %v", v, err)
} else {
ret.named = append(ret.named, g)
}
continue
}
normalized, err := normalizeHostVolumePath(v)
if err != nil {
logger.Errorf("normalize volume pattern %s error: %v", v, err)
continue
}
if g, err := glob.Compile(normalized); err != nil {
logger.Errorf("create glob from %s error: %v", normalized, err)
} else {
ret.host = append(ret.host, g)
}
}
return ret
}
func (m validVolumeMatcher) isValid(source string, sourceType mount.Type) bool {
if m.allowAll {
return true
}
if isHostVolumeSource(source, sourceType) {
normalized, err := normalizeHostVolumePath(source)
if err != nil {
return false
}
for _, g := range m.host {
if g.Match(normalized) {
return true
}
}
return false
}
for _, g := range m.named {
if g.Match(source) {
return true
}
}
return false
}
func isHostVolumePattern(pattern string) bool {
return filepath.IsAbs(pattern) ||
strings.HasPrefix(pattern, "."+string(filepath.Separator)) ||
strings.HasPrefix(pattern, ".."+string(filepath.Separator)) ||
strings.Contains(pattern, "/") ||
strings.Contains(pattern, `\`)
}
func isHostVolumeSource(source string, sourceType mount.Type) bool {
if sourceType == mount.TypeBind {
return true
}
if sourceType == mount.TypeVolume {
return false
}
return isHostVolumePattern(source)
}
func normalizeHostVolumePath(path string) (string, error) {
abs, err := filepath.Abs(path)
if err != nil {
return "", err
}
return evalSymlinksExistingPrefix(abs)
}
func evalSymlinksExistingPrefix(path string) (string, error) {
resolved, err := filepath.EvalSymlinks(path)
if err == nil {
return filepath.Clean(resolved), nil
}
if !errors.Is(err, os.ErrNotExist) {
return "", err
}
current := path
var missing []string
for {
_, err := os.Lstat(current)
if err == nil {
resolved, err := filepath.EvalSymlinks(current)
if err != nil {
return "", err
}
for _, name := range slices.Backward(missing) {
resolved = filepath.Join(resolved, name)
}
return filepath.Clean(resolved), nil
}
if !errors.Is(err, os.ErrNotExist) {
return "", err
}
parent := filepath.Dir(current)
if parent == current {
return filepath.Clean(path), nil
}
missing = append(missing, filepath.Base(current))
current = parent
}
}

View File

@@ -11,15 +11,17 @@ import (
"errors" "errors"
"io" "io"
"net" "net"
"os"
"path/filepath"
"strings" "strings"
"testing" "testing"
"time" "time"
"gitea.com/gitea/runner/act/common" "gitea.com/gitea/runner/act/common"
"github.com/docker/docker/api/types" cerrdefs "github.com/containerd/errdefs"
"github.com/docker/docker/api/types/container" "github.com/moby/moby/api/types/container"
"github.com/docker/docker/client" mobyclient "github.com/moby/moby/client"
"github.com/sirupsen/logrus/hooks/test" "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"
@@ -27,9 +29,10 @@ import (
) )
func TestDocker(t *testing.T) { func TestDocker(t *testing.T) {
requireDocker(t)
ctx := context.Background() ctx := context.Background()
client, err := GetDockerClient(ctx) client, err := GetDockerClient(ctx)
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act require.NoError(t, err)
defer client.Close() defer client.Close()
dockerBuild := NewDockerBuildExecutor(NewDockerBuildExecutorInput{ dockerBuild := NewDockerBuildExecutor(NewDockerBuildExecutorInput{
@@ -67,33 +70,43 @@ func TestDocker(t *testing.T) {
} }
type mockDockerClient struct { type mockDockerClient struct {
client.APIClient mobyclient.APIClient
mock.Mock mock.Mock
} }
func (m *mockDockerClient) ContainerExecCreate(ctx context.Context, id string, opts types.ExecConfig) (types.IDResponse, error) { func (m *mockDockerClient) ExecCreate(ctx context.Context, id string, opts mobyclient.ExecCreateOptions) (mobyclient.ExecCreateResult, error) {
args := m.Called(ctx, id, opts) args := m.Called(ctx, id, opts)
return args.Get(0).(types.IDResponse), args.Error(1) return args.Get(0).(mobyclient.ExecCreateResult), args.Error(1)
} }
func (m *mockDockerClient) ContainerExecAttach(ctx context.Context, id string, opts types.ExecStartCheck) (types.HijackedResponse, error) { func (m *mockDockerClient) ExecAttach(ctx context.Context, id string, opts mobyclient.ExecAttachOptions) (mobyclient.ExecAttachResult, error) {
args := m.Called(ctx, id, opts) args := m.Called(ctx, id, opts)
return args.Get(0).(types.HijackedResponse), args.Error(1) return args.Get(0).(mobyclient.ExecAttachResult), args.Error(1)
} }
func (m *mockDockerClient) ContainerExecInspect(ctx context.Context, execID string) (types.ContainerExecInspect, error) { func (m *mockDockerClient) ExecInspect(ctx context.Context, execID string, opts mobyclient.ExecInspectOptions) (mobyclient.ExecInspectResult, error) {
args := m.Called(ctx, execID) args := m.Called(ctx, execID, opts)
return args.Get(0).(types.ContainerExecInspect), args.Error(1) return args.Get(0).(mobyclient.ExecInspectResult), args.Error(1)
} }
func (m *mockDockerClient) ContainerWait(ctx context.Context, containerID string, condition container.WaitCondition) (<-chan container.WaitResponse, <-chan error) { func (m *mockDockerClient) ContainerWait(ctx context.Context, containerID string, opts mobyclient.ContainerWaitOptions) mobyclient.ContainerWaitResult {
args := m.Called(ctx, containerID, condition) args := m.Called(ctx, containerID, opts)
return args.Get(0).(<-chan container.WaitResponse), args.Get(1).(<-chan error) return args.Get(0).(mobyclient.ContainerWaitResult)
} }
func (m *mockDockerClient) CopyToContainer(ctx context.Context, id, path string, content io.Reader, options types.CopyToContainerOptions) error { func (m *mockDockerClient) CopyToContainer(ctx context.Context, id string, options mobyclient.CopyToContainerOptions) (mobyclient.CopyToContainerResult, error) {
args := m.Called(ctx, id, path, content, options) args := m.Called(ctx, id, options)
return args.Error(0) return args.Get(0).(mobyclient.CopyToContainerResult), args.Error(1)
}
func (m *mockDockerClient) ContainerInspect(ctx context.Context, id string, opts mobyclient.ContainerInspectOptions) (mobyclient.ContainerInspectResult, error) {
args := m.Called(ctx, id, opts)
return args.Get(0).(mobyclient.ContainerInspectResult), args.Error(1)
}
func (m *mockDockerClient) ContainerList(ctx context.Context, opts mobyclient.ContainerListOptions) (mobyclient.ContainerListResult, error) {
args := m.Called(ctx, opts)
return args.Get(0).(mobyclient.ContainerListResult), args.Error(1)
} }
type endlessReader struct { type endlessReader struct {
@@ -125,10 +138,12 @@ func TestDockerExecAbort(t *testing.T) {
conn.On("Write", mock.AnythingOfType("[]uint8")).Return(1, nil) conn.On("Write", mock.AnythingOfType("[]uint8")).Return(1, nil)
client := &mockDockerClient{} client := &mockDockerClient{}
client.On("ContainerExecCreate", ctx, "123", mock.AnythingOfType("types.ExecConfig")).Return(types.IDResponse{ID: "id"}, nil) client.On("ExecCreate", ctx, "123", mock.AnythingOfType("client.ExecCreateOptions")).Return(mobyclient.ExecCreateResult{ID: "id"}, nil)
client.On("ContainerExecAttach", ctx, "id", mock.AnythingOfType("types.ExecStartCheck")).Return(types.HijackedResponse{ client.On("ExecAttach", ctx, "id", mock.AnythingOfType("client.ExecAttachOptions")).Return(mobyclient.ExecAttachResult{
Conn: conn, HijackedResponse: mobyclient.HijackedResponse{
Reader: bufio.NewReader(endlessReader{}), Conn: conn,
Reader: bufio.NewReader(endlessReader{}),
},
}, nil) }, nil)
cr := &containerReference{ cr := &containerReference{
@@ -162,12 +177,14 @@ func TestDockerExecFailure(t *testing.T) {
conn := &mockConn{} conn := &mockConn{}
client := &mockDockerClient{} client := &mockDockerClient{}
client.On("ContainerExecCreate", ctx, "123", mock.AnythingOfType("types.ExecConfig")).Return(types.IDResponse{ID: "id"}, nil) client.On("ExecCreate", ctx, "123", mock.AnythingOfType("client.ExecCreateOptions")).Return(mobyclient.ExecCreateResult{ID: "id"}, nil)
client.On("ContainerExecAttach", ctx, "id", mock.AnythingOfType("types.ExecStartCheck")).Return(types.HijackedResponse{ client.On("ExecAttach", ctx, "id", mock.AnythingOfType("client.ExecAttachOptions")).Return(mobyclient.ExecAttachResult{
Conn: conn, HijackedResponse: mobyclient.HijackedResponse{
Reader: bufio.NewReader(strings.NewReader("output")), Conn: conn,
Reader: bufio.NewReader(strings.NewReader("output")),
},
}, nil) }, nil)
client.On("ContainerExecInspect", ctx, "id").Return(types.ContainerExecInspect{ client.On("ExecInspect", ctx, "id", mobyclient.ExecInspectOptions{}).Return(mobyclient.ExecInspectResult{
ExitCode: 1, ExitCode: 1,
}, nil) }, nil)
@@ -197,8 +214,11 @@ func TestDockerWaitFailure(t *testing.T) {
errCh := make(chan error, 1) errCh := make(chan error, 1)
client := &mockDockerClient{} client := &mockDockerClient{}
client.On("ContainerWait", ctx, "123", container.WaitConditionNotRunning). client.On("ContainerWait", ctx, "123", mobyclient.ContainerWaitOptions{Condition: container.WaitConditionNotRunning}).
Return((<-chan container.WaitResponse)(statusCh), (<-chan error)(errCh)) Return(mobyclient.ContainerWaitResult{
Result: (<-chan container.WaitResponse)(statusCh),
Error: (<-chan error)(errCh),
})
cr := &containerReference{ cr := &containerReference{
id: "123", id: "123",
@@ -220,11 +240,13 @@ func TestDockerWaitFailure(t *testing.T) {
func TestDockerCopyTarStream(t *testing.T) { func TestDockerCopyTarStream(t *testing.T) {
ctx := context.Background() ctx := context.Background()
conn := &mockConn{}
client := &mockDockerClient{} client := &mockDockerClient{}
client.On("CopyToContainer", ctx, "123", "/", mock.Anything, mock.AnythingOfType("types.CopyToContainerOptions")).Return(nil) client.On("CopyToContainer", ctx, "123", mock.MatchedBy(func(opts mobyclient.CopyToContainerOptions) bool {
client.On("CopyToContainer", ctx, "123", "/var/run/act", mock.Anything, mock.AnythingOfType("types.CopyToContainerOptions")).Return(nil) return opts.DestinationPath == "/" && opts.Content != nil
})).Return(mobyclient.CopyToContainerResult{}, nil)
client.On("CopyToContainer", ctx, "123", mock.MatchedBy(func(opts mobyclient.CopyToContainerOptions) bool {
return opts.DestinationPath == "/var/run/act" && opts.Content != nil
})).Return(mobyclient.CopyToContainerResult{}, nil)
cr := &containerReference{ cr := &containerReference{
id: "123", id: "123",
cli: client, cli: client,
@@ -235,20 +257,18 @@ func TestDockerCopyTarStream(t *testing.T) {
_ = cr.CopyTarStream(ctx, "/var/run/act", &bytes.Buffer{}) _ = cr.CopyTarStream(ctx, "/var/run/act", &bytes.Buffer{})
conn.AssertExpectations(t)
client.AssertExpectations(t) client.AssertExpectations(t)
} }
func TestDockerCopyTarStreamErrorInCopyFiles(t *testing.T) { func TestDockerCopyTarStreamErrorInCopyFiles(t *testing.T) {
ctx := context.Background() ctx := context.Background()
conn := &mockConn{}
merr := errors.New("Failure") merr := errors.New("Failure")
client := &mockDockerClient{} client := &mockDockerClient{}
client.On("CopyToContainer", ctx, "123", "/", mock.Anything, mock.AnythingOfType("types.CopyToContainerOptions")).Return(merr) client.On("CopyToContainer", ctx, "123", mock.MatchedBy(func(opts mobyclient.CopyToContainerOptions) bool {
client.On("CopyToContainer", ctx, "123", "/", mock.Anything, mock.AnythingOfType("types.CopyToContainerOptions")).Return(merr) return opts.DestinationPath == "/" && opts.Content != nil
})).Return(mobyclient.CopyToContainerResult{}, merr)
cr := &containerReference{ cr := &containerReference{
id: "123", id: "123",
cli: client, cli: client,
@@ -260,20 +280,21 @@ func TestDockerCopyTarStreamErrorInCopyFiles(t *testing.T) {
err := cr.CopyTarStream(ctx, "/var/run/act", &bytes.Buffer{}) err := cr.CopyTarStream(ctx, "/var/run/act", &bytes.Buffer{})
assert.ErrorIs(t, err, merr) //nolint:testifylint // pre-existing issue from nektos/act assert.ErrorIs(t, err, merr) //nolint:testifylint // pre-existing issue from nektos/act
conn.AssertExpectations(t)
client.AssertExpectations(t) client.AssertExpectations(t)
} }
func TestDockerCopyTarStreamErrorInMkdir(t *testing.T) { func TestDockerCopyTarStreamErrorInMkdir(t *testing.T) {
ctx := context.Background() ctx := context.Background()
conn := &mockConn{}
merr := errors.New("Failure") merr := errors.New("Failure")
client := &mockDockerClient{} client := &mockDockerClient{}
client.On("CopyToContainer", ctx, "123", "/", mock.Anything, mock.AnythingOfType("types.CopyToContainerOptions")).Return(nil) client.On("CopyToContainer", ctx, "123", mock.MatchedBy(func(opts mobyclient.CopyToContainerOptions) bool {
client.On("CopyToContainer", ctx, "123", "/var/run/act", mock.Anything, mock.AnythingOfType("types.CopyToContainerOptions")).Return(merr) return opts.DestinationPath == "/" && opts.Content != nil
})).Return(mobyclient.CopyToContainerResult{}, nil)
client.On("CopyToContainer", ctx, "123", mock.MatchedBy(func(opts mobyclient.CopyToContainerOptions) bool {
return opts.DestinationPath == "/var/run/act" && opts.Content != nil
})).Return(mobyclient.CopyToContainerResult{}, merr)
cr := &containerReference{ cr := &containerReference{
id: "123", id: "123",
cli: client, cli: client,
@@ -285,10 +306,137 @@ func TestDockerCopyTarStreamErrorInMkdir(t *testing.T) {
err := cr.CopyTarStream(ctx, "/var/run/act", &bytes.Buffer{}) err := cr.CopyTarStream(ctx, "/var/run/act", &bytes.Buffer{})
assert.ErrorIs(t, err, merr) //nolint:testifylint // pre-existing issue from nektos/act assert.ErrorIs(t, err, merr) //nolint:testifylint // pre-existing issue from nektos/act
conn.AssertExpectations(t)
client.AssertExpectations(t) client.AssertExpectations(t)
} }
// find() must drop a stale cached id so later Copy/Exec don't hit the
// daemon with a torn-down container.
func TestFindRevalidatesStaleID(t *testing.T) {
ctx := context.Background()
notFound := cerrdefs.ErrNotFound.WithMessage("No such container")
boom := errors.New("daemon unreachable")
newCR := func(id string) (*containerReference, *mockDockerClient) {
client := &mockDockerClient{}
return &containerReference{id: id, cli: client, input: &NewContainerInput{Name: "job-1"}}, client
}
listOpts := mobyclient.ContainerListOptions{All: true}
inspectOpts := mobyclient.ContainerInspectOptions{}
t.Run("stale id cleared, name lookup empty", func(t *testing.T) {
cr, client := newCR("stale")
client.On("ContainerInspect", ctx, "stale", inspectOpts).Return(mobyclient.ContainerInspectResult{}, notFound)
client.On("ContainerList", ctx, listOpts).Return(mobyclient.ContainerListResult{}, nil)
require.NoError(t, cr.find()(ctx))
assert.Empty(t, cr.id)
client.AssertExpectations(t)
})
t.Run("stale id cleared, name lookup repopulates", func(t *testing.T) {
cr, client := newCR("stale")
client.On("ContainerInspect", ctx, "stale", inspectOpts).Return(mobyclient.ContainerInspectResult{}, notFound)
client.On("ContainerList", ctx, listOpts).Return(mobyclient.ContainerListResult{Items: []container.Summary{
{ID: "other", Names: []string{"/somebody-else"}},
{ID: "fresh", Names: []string{"/job-1"}},
}}, nil)
require.NoError(t, cr.find()(ctx))
assert.Equal(t, "fresh", cr.id)
client.AssertExpectations(t)
})
t.Run("live id kept", func(t *testing.T) {
cr, client := newCR("live")
client.On("ContainerInspect", ctx, "live", inspectOpts).Return(mobyclient.ContainerInspectResult{}, nil)
require.NoError(t, cr.find()(ctx))
assert.Equal(t, "live", cr.id)
client.AssertExpectations(t)
})
t.Run("transient inspect error trusts cache", func(t *testing.T) {
cr, client := newCR("live")
client.On("ContainerInspect", ctx, "live", inspectOpts).Return(mobyclient.ContainerInspectResult{}, boom)
require.NoError(t, cr.find()(ctx))
assert.Equal(t, "live", cr.id)
client.AssertExpectations(t)
})
t.Run("list error propagates", func(t *testing.T) {
cr, client := newCR("")
client.On("ContainerList", ctx, listOpts).Return(mobyclient.ContainerListResult{}, boom)
require.ErrorIs(t, cr.find()(ctx), boom)
client.AssertExpectations(t)
})
}
// Every daemon entry point fails fast with a clear, container-named
// error when no live cr.id is known.
func TestRejectsMissingContainer(t *testing.T) {
ctx := context.Background()
client := &mockDockerClient{}
client.On("ContainerList", ctx, mobyclient.ContainerListOptions{All: true}).Return(mobyclient.ContainerListResult{}, nil)
cr := &containerReference{cli: client, input: &NewContainerInput{Name: "job-1"}}
check := func(op string, err error) {
t.Helper()
require.Error(t, err, 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("copyDir", cr.copyDir("/var/run/act", "/src", false)(ctx))
check("CopyTarStream", cr.CopyTarStream(ctx, "/var/run/act", &bytes.Buffer{}))
check("exec", cr.exec([]string{"echo"}, nil, "", "")(ctx))
_, err := cr.GetContainerArchive(ctx, "/var/run/act/x")
check("GetContainerArchive", err)
}
// End-to-end: a stale cr.id is cleared, repopulated from name lookup,
// and the Copy completes against the fresh id.
func TestPublicCopyPipelineHandlesStaleID(t *testing.T) {
ctx := context.Background()
client := &mockDockerClient{}
client.On("ContainerInspect", ctx, "stale", mobyclient.ContainerInspectOptions{}).
Return(mobyclient.ContainerInspectResult{}, cerrdefs.ErrNotFound.WithMessage("gone"))
client.On("ContainerList", ctx, mobyclient.ContainerListOptions{All: true}).
Return(mobyclient.ContainerListResult{Items: []container.Summary{
{ID: "fresh", Names: []string{"/job-1"}},
}}, nil)
client.On("CopyToContainer", ctx, "fresh", mock.MatchedBy(func(opts mobyclient.CopyToContainerOptions) bool {
return opts.DestinationPath == "/var/run/act"
})).Return(mobyclient.CopyToContainerResult{}, nil)
cr := &containerReference{id: "stale", cli: client, input: &NewContainerInput{Name: "job-1"}}
require.NoError(t, cr.Copy("/var/run/act", &FileEntry{Name: "x", Mode: 0o644})(ctx))
assert.Equal(t, "fresh", cr.id)
client.AssertExpectations(t)
}
// TestDockerCopyToSymlinkPath is a regression test for gitea/runner#981. Most base images
// symlink /var/run to /run, so copying into /var/run/act traverses that symlink. The broken
// docker 29.5.1 daemon fails the extraction with "mkdirat var/run: file exists" (fixed in
// 29.5.2). Running against the daemon shipped in the dind image, this catches a bad bump.
func TestDockerCopyToSymlinkPath(t *testing.T) {
requireDocker(t)
ctx := context.Background()
rc := NewContainer(&NewContainerInput{
Image: "alpine:latest",
Entrypoint: []string{"sleep", "30"},
Name: "act-test-symlink-" + time.Now().Format("20060102150405.000000"),
AutoRemove: true,
})
require.NoError(t, rc.Pull(false)(ctx))
require.NoError(t, rc.Create(nil, nil)(ctx))
require.NoError(t, rc.Start(false)(ctx))
t.Cleanup(func() {
_ = rc.Remove()(ctx)
_ = rc.Close()(ctx)
})
// CopyTarStream first creates the destination directory by extracting a tar at "/",
// which makes the daemon mkdir var, then var/run (the symlink), then act — the exact
// step that fails on the broken daemon.
err := rc.CopyTarStream(ctx, "/var/run/act", &bytes.Buffer{})
require.NoError(t, err)
}
// Type assert containerReference implements ExecutionsEnvironment // Type assert containerReference implements ExecutionsEnvironment
var _ ExecutionsEnvironment = &containerReference{} var _ ExecutionsEnvironment = &containerReference{}
@@ -364,3 +512,40 @@ func TestCheckVolumes(t *testing.T) {
}) })
} }
} }
func TestCheckVolumesRejectsEscapingHostPaths(t *testing.T) {
logger, _ := test.NewNullLogger()
ctx := common.WithLogger(context.Background(), logger)
base := t.TempDir()
allowed := filepath.Join(base, "allowed")
denied := filepath.Join(base, "denied")
require.NoError(t, os.MkdirAll(allowed, 0o700))
require.NoError(t, os.MkdirAll(denied, 0o700))
cr := &containerReference{
input: &NewContainerInput{
ValidVolumes: []string{filepath.Join(allowed, "**")},
},
}
escapingPath := allowed + string(filepath.Separator) + ".." + string(filepath.Separator) + "denied"
_, hostConf := cr.sanitizeConfig(ctx, &container.Config{}, &container.HostConfig{
Binds: []string{escapingPath + ":/mnt"},
})
assert.Empty(t, hostConf.Binds)
linkPath := filepath.Join(allowed, "link")
if err := os.Symlink(denied, linkPath); err != nil {
t.Skipf("cannot create symlink: %v", err)
}
_, hostConf = cr.sanitizeConfig(ctx, &container.Config{}, &container.HostConfig{
Binds: []string{linkPath + ":/mnt"},
})
assert.Empty(t, hostConf.Binds)
_, hostConf = cr.sanitizeConfig(ctx, &container.Config{}, &container.HostConfig{
Binds: []string{filepath.Join(linkPath, "missing") + ":/mnt"},
})
assert.Empty(t, hostConf.Binds)
}

View File

@@ -18,9 +18,19 @@ func init() {
var originalCommonSocketLocations = CommonSocketLocations var originalCommonSocketLocations = CommonSocketLocations
func isolateSocketEnv(t *testing.T) {
t.Helper()
t.Cleanup(func() { CommonSocketLocations = originalCommonSocketLocations })
if host, ok := os.LookupEnv("DOCKER_HOST"); ok {
t.Setenv("DOCKER_HOST", host)
} else {
t.Cleanup(func() { os.Unsetenv("DOCKER_HOST") })
}
}
func TestGetSocketAndHostWithSocket(t *testing.T) { func TestGetSocketAndHostWithSocket(t *testing.T) {
// Arrange // Arrange
CommonSocketLocations = originalCommonSocketLocations isolateSocketEnv(t)
dockerHost := "unix:///my/docker/host.sock" dockerHost := "unix:///my/docker/host.sock"
socketURI := "/path/to/my.socket" socketURI := "/path/to/my.socket"
t.Setenv("DOCKER_HOST", dockerHost) t.Setenv("DOCKER_HOST", dockerHost)
@@ -48,9 +58,9 @@ func TestGetSocketAndHostNoSocket(t *testing.T) {
func TestGetSocketAndHostOnlySocket(t *testing.T) { func TestGetSocketAndHostOnlySocket(t *testing.T) {
// Arrange // Arrange
isolateSocketEnv(t)
socketURI := "/path/to/my.socket" socketURI := "/path/to/my.socket"
os.Unsetenv("DOCKER_HOST") os.Unsetenv("DOCKER_HOST")
CommonSocketLocations = originalCommonSocketLocations
defaultSocket, defaultSocketFound := socketLocation() defaultSocket, defaultSocketFound := socketLocation()
// Act // Act
@@ -65,7 +75,7 @@ func TestGetSocketAndHostOnlySocket(t *testing.T) {
func TestGetSocketAndHostDontMount(t *testing.T) { func TestGetSocketAndHostDontMount(t *testing.T) {
// Arrange // Arrange
CommonSocketLocations = originalCommonSocketLocations isolateSocketEnv(t)
dockerHost := "unix:///my/docker/host.sock" dockerHost := "unix:///my/docker/host.sock"
t.Setenv("DOCKER_HOST", dockerHost) t.Setenv("DOCKER_HOST", dockerHost)
@@ -79,7 +89,7 @@ func TestGetSocketAndHostDontMount(t *testing.T) {
func TestGetSocketAndHostNoHostNoSocket(t *testing.T) { func TestGetSocketAndHostNoHostNoSocket(t *testing.T) {
// Arrange // Arrange
CommonSocketLocations = originalCommonSocketLocations isolateSocketEnv(t)
os.Unsetenv("DOCKER_HOST") os.Unsetenv("DOCKER_HOST")
defaultSocket, found := socketLocation() defaultSocket, found := socketLocation()
@@ -97,6 +107,7 @@ func TestGetSocketAndHostNoHostNoSocket(t *testing.T) {
// > This happens if neither DOCKER_HOST nor --container-daemon-socket has a value, but socketLocation() returns a URI // > This happens if neither DOCKER_HOST nor --container-daemon-socket has a value, but socketLocation() returns a URI
func TestGetSocketAndHostNoHostNoSocketDefaultLocation(t *testing.T) { func TestGetSocketAndHostNoHostNoSocketDefaultLocation(t *testing.T) {
// Arrange // Arrange
isolateSocketEnv(t)
mySocketFile, tmpErr := os.CreateTemp(t.TempDir(), "act-*.sock") mySocketFile, tmpErr := os.CreateTemp(t.TempDir(), "act-*.sock")
mySocket := mySocketFile.Name() mySocket := mySocketFile.Name()
unixSocket := "unix://" + mySocket unixSocket := "unix://" + mySocket
@@ -119,6 +130,7 @@ func TestGetSocketAndHostNoHostNoSocketDefaultLocation(t *testing.T) {
func TestGetSocketAndHostNoHostInvalidSocket(t *testing.T) { func TestGetSocketAndHostNoHostInvalidSocket(t *testing.T) {
// Arrange // Arrange
isolateSocketEnv(t)
os.Unsetenv("DOCKER_HOST") os.Unsetenv("DOCKER_HOST")
mySocket := "/my/socket/path.sock" mySocket := "/my/socket/path.sock"
CommonSocketLocations = []string{"/unusual", "/socket", "/location"} CommonSocketLocations = []string{"/unusual", "/socket", "/location"}
@@ -136,6 +148,7 @@ func TestGetSocketAndHostNoHostInvalidSocket(t *testing.T) {
func TestGetSocketAndHostOnlySocketValidButUnusualLocation(t *testing.T) { func TestGetSocketAndHostOnlySocketValidButUnusualLocation(t *testing.T) {
// Arrange // Arrange
isolateSocketEnv(t)
socketURI := "unix:///path/to/my.socket" socketURI := "unix:///path/to/my.socket"
CommonSocketLocations = []string{"/unusual", "/location"} CommonSocketLocations = []string{"/unusual", "/location"}
os.Unsetenv("DOCKER_HOST") os.Unsetenv("DOCKER_HOST")

View File

@@ -12,7 +12,7 @@ import (
"gitea.com/gitea/runner/act/common" "gitea.com/gitea/runner/act/common"
"github.com/docker/docker/api/types" "github.com/moby/moby/api/types/system"
"github.com/pkg/errors" "github.com/pkg/errors"
) )
@@ -51,8 +51,8 @@ func RunnerArch(ctx context.Context) string {
return runtime.GOOS return runtime.GOOS
} }
func GetHostInfo(ctx context.Context) (info types.Info, err error) { func GetHostInfo(ctx context.Context) (info system.Info, err error) {
return types.Info{}, nil return system.Info{}, nil
} }
func NewDockerVolumeRemoveExecutor(volume string, force bool) common.Executor { func NewDockerVolumeRemoveExecutor(volume string, force bool) common.Executor {

View File

@@ -11,8 +11,7 @@ import (
"gitea.com/gitea/runner/act/common" "gitea.com/gitea/runner/act/common"
"github.com/docker/docker/api/types/filters" "github.com/moby/moby/client"
"github.com/docker/docker/api/types/volume"
) )
func NewDockerVolumeRemoveExecutor(volumeName string, force bool) common.Executor { func NewDockerVolumeRemoveExecutor(volumeName string, force bool) common.Executor {
@@ -23,12 +22,12 @@ func NewDockerVolumeRemoveExecutor(volumeName string, force bool) common.Executo
} }
defer cli.Close() defer cli.Close()
list, err := cli.VolumeList(ctx, volume.ListOptions{Filters: filters.NewArgs()}) list, err := cli.VolumeList(ctx, client.VolumeListOptions{})
if err != nil { if err != nil {
return err return err
} }
for _, vol := range list.Volumes { for _, vol := range list.Items {
if vol.Name == volumeName { if vol.Name == volumeName {
return removeExecutor(volumeName, force)(ctx) return removeExecutor(volumeName, force)(ctx)
} }
@@ -42,7 +41,7 @@ func NewDockerVolumeRemoveExecutor(volumeName string, force bool) common.Executo
func removeExecutor(volume string, force bool) common.Executor { func removeExecutor(volume string, force bool) common.Executor {
return func(ctx context.Context) error { return func(ctx context.Context) error {
logger := common.Logger(ctx) logger := common.Logger(ctx)
logger.Debugf("%sdocker volume rm %s", logPrefix, volume) logger.Debugf("docker volume rm %s", volume)
if common.Dryrun(ctx) { if common.Dryrun(ctx) {
return nil return nil
@@ -54,6 +53,7 @@ func removeExecutor(volume string, force bool) common.Executor {
} }
defer cli.Close() defer cli.Close()
return cli.VolumeRemove(ctx, volume, force) _, err = cli.VolumeRemove(ctx, volume, client.VolumeRemoveOptions{Force: force})
return err
} }
} }

View File

@@ -0,0 +1,27 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package container
import (
"context"
"testing"
mobyclient "github.com/moby/moby/client"
)
// requireDocker skips the test unless a reachable docker daemon is available.
// GetDockerClient succeeds even without a running daemon (its ping is best-effort),
// so the daemon has to be pinged explicitly here to decide whether to skip.
func requireDocker(t *testing.T) {
t.Helper()
ctx := context.Background()
cli, err := GetDockerClient(ctx)
if err != nil {
t.Skipf("skipping: docker client unavailable: %v", err)
}
defer cli.Close()
if _, err := cli.Ping(ctx, mobyclient.PingOptions{}); err != nil {
t.Skipf("skipping: docker daemon unreachable: %v", err)
}
}

View File

@@ -16,9 +16,8 @@ import (
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"runtime" "runtime"
"strconv"
"strings" "strings"
"sync" "sync/atomic"
"time" "time"
"gitea.com/gitea/runner/act/common" "gitea.com/gitea/runner/act/common"
@@ -36,15 +35,13 @@ type HostEnvironment struct {
TmpDir string TmpDir string
ToolCache string ToolCache string
Workdir string Workdir string
// BindWorkdir is true when the app runner mounts the workspace on the host and // CleanWorkdir means teardown owns Workdir and may delete it. Leave false
// deletes the task directory after the job; host teardown must not remove Workdir. // when Workdir points at a caller-owned checkout (e.g. `act` local mode).
BindWorkdir bool CleanWorkdir bool
ActPath string ActPath string
CleanUp func() CleanUp func()
StdOut io.Writer StdOut io.Writer
AllocatePTY bool // allocate a pseudo-TTY for each step's process
mu sync.Mutex
runningPIDs map[int]struct{}
} }
func (e *HostEnvironment) Create(_, _ []string) common.Executor { func (e *HostEnvironment) Create(_, _ []string) common.Executor {
@@ -200,12 +197,12 @@ func (e *HostEnvironment) Start(_ bool) common.Executor {
type ptyWriter struct { type ptyWriter struct {
Out io.Writer Out io.Writer
AutoStop bool AutoStop atomic.Bool
dirtyLine bool dirtyLine bool
} }
func (w *ptyWriter) Write(buf []byte) (int, error) { func (w *ptyWriter) Write(buf []byte) (int, error) {
if w.AutoStop && len(buf) > 0 && buf[len(buf)-1] == 4 { if w.AutoStop.Load() && len(buf) > 0 && buf[len(buf)-1] == 4 {
n, err := w.Out.Write(buf[:len(buf)-1]) n, err := w.Out.Write(buf[:len(buf)-1])
if err != nil { if err != nil {
return n, err return n, err
@@ -325,6 +322,30 @@ func (e *HostEnvironment) exec(ctx context.Context, command []string, cmdline st
cmd.Stderr = e.StdOut cmd.Stderr = e.StdOut
cmd.Dir = wd cmd.Dir = wd
cmd.SysProcAttr = getSysProcAttr(cmdline, false) cmd.SysProcAttr = getSysProcAttr(cmdline, false)
// On Windows a step often launches a process tree (a shell that starts a
// child which spawns further GUI or background processes). The default
// context cancellation only kills the direct child, leaving the rest of the
// tree running; and because the orphans inherit cmd's stdout/stderr pipe,
// cmd.Wait() would block forever, hanging the runner. Kill the whole tree
// via a Job Object on cancellation, and bound the wait so a leftover pipe
// writer can never hang Wait indefinitely.
var killer atomic.Pointer[processKiller]
if runtime.GOOS == "windows" {
cmd.Cancel = func() error {
if k := killer.Load(); k != nil {
return k.Kill()
}
if cmd.Process != nil {
return cmd.Process.Kill()
}
return nil
}
// Once the step process has exited, give its I/O pipes at most this long
// to drain before Wait force-closes them and returns (Go's WaitDelay).
cmd.WaitDelay = 10 * time.Second
}
var ppty *os.File var ppty *os.File
var tty *os.File var tty *os.File
defer func() { defer func() {
@@ -335,40 +356,36 @@ func (e *HostEnvironment) exec(ctx context.Context, command []string, cmdline st
tty.Close() tty.Close()
} }
}() }()
if true /* allocate Terminal */ { if e.AllocatePTY {
var err error var err error
ppty, tty, err = setupPty(cmd, cmdline) ppty, tty, err = setupPty(cmd, cmdline)
if err != nil { if err != nil {
common.Logger(ctx).Debugf("Failed to setup Pty %v\n", err.Error()) common.Logger(ctx).Debugf("Failed to setup Pty %v\n", err.Error())
} }
} }
writer := &ptyWriter{Out: e.StdOut} var writer *ptyWriter
logctx, finishLog := context.WithCancel(context.Background()) var logctx context.Context
if ppty != nil { if ppty != nil {
writer = &ptyWriter{Out: e.StdOut}
var finishLog context.CancelFunc
logctx, finishLog = context.WithCancel(context.Background())
go copyPtyOutput(writer, ppty, finishLog) go copyPtyOutput(writer, ppty, finishLog)
} else {
finishLog()
}
if ppty != nil {
go writeKeepAlive(ppty) go writeKeepAlive(ppty)
} }
// Split Start/Wait so the PID can be registered before the process can exit;
// cmd.Run() would block until exit, by which time the PID may have been reused.
if err := cmd.Start(); err != nil { if err := cmd.Start(); err != nil {
return err return err
} }
if cmd.Process != nil { if runtime.GOOS == "windows" {
e.mu.Lock() // Assign the started process to a Job Object so cmd.Cancel can kill the
if e.runningPIDs == nil { // whole descendant tree. Children spawned afterwards are auto-included.
e.runningPIDs = map[int]struct{}{} // On failure (e.g. nested-job restrictions) we fall back to the default
// single-process kill; WaitDelay + end-of-job cleanup still apply.
if k, kerr := newProcessKiller(cmd.Process); kerr != nil {
common.Logger(ctx).Warnf("process tree kill setup failed, falling back to single-process kill: %v", kerr)
} else {
killer.Store(k)
defer k.Close()
} }
e.runningPIDs[cmd.Process.Pid] = struct{}{}
e.mu.Unlock()
defer func(pid int) {
e.mu.Lock()
delete(e.runningPIDs, pid)
e.mu.Unlock()
}(cmd.Process.Pid)
} }
err = cmd.Wait() err = cmd.Wait()
if err != nil { if err != nil {
@@ -379,14 +396,11 @@ func (e *HostEnvironment) exec(ctx context.Context, command []string, cmdline st
return err return err
} }
if tty != nil { if tty != nil {
writer.AutoStop = true writer.AutoStop.Store(true)
if _, err := tty.WriteString("\x04"); err != nil { if _, err := tty.WriteString("\x04"); err != nil {
common.Logger(ctx).Debug("Failed to write EOT") common.Logger(ctx).Debug("Failed to write EOT")
} }
} <-logctx.Done()
<-logctx.Done()
if ppty != nil {
ppty.Close() ppty.Close()
ppty = nil ppty = nil
} }
@@ -442,30 +456,80 @@ func removePathWithRetry(ctx context.Context, path string) error {
return lastErr return lastErr
} }
// buildWindowsWorkspaceKillScript builds a PowerShell command that `taskkill
// /T /F`s every process tree whose ExecutablePath or CommandLine references one
// of the given absolute workspace dirs, releasing file handles for cleanup.
//
// Win32_Process is used because it exposes both ExecutablePath and CommandLine
// (Get-Process doesn't, wmic is deprecated). Both match the dir+separator
// prefix, so a sibling dir sharing a name prefix (job1 vs job10) is spared.
// Ordinal String methods, not -like, so path metacharacters ([ ] ? *) stay
// literal.
//
// Pure function so the quote-escaping can be unit-tested without PowerShell.
func buildWindowsWorkspaceKillScript(dirs []string) string {
quoted := make([]string, len(dirs))
for i, d := range dirs {
// Single-quoted PowerShell literal; escape ' by doubling it.
quoted[i] = "'" + strings.ReplaceAll(d, "'", "''") + "'"
}
return `$paths = @(` + strings.Join(quoted, ",") + `)
$selfPid = $PID
Get-CimInstance Win32_Process -ErrorAction SilentlyContinue | Where-Object {
if ($_.ProcessId -eq $selfPid) { return $false }
foreach ($p in $paths) {
$prefix = $p + '\'
if ($_.ExecutablePath -and $_.ExecutablePath.StartsWith($prefix, [System.StringComparison]::OrdinalIgnoreCase)) { return $true }
if ($_.CommandLine -and $_.CommandLine.IndexOf($prefix, [System.StringComparison]::OrdinalIgnoreCase) -ge 0) { return $true }
}
return $false
} | ForEach-Object {
& taskkill.exe /PID $_.ProcessId /T /F 2>$null | Out-Null
}
`
}
func (e *HostEnvironment) terminateRunningProcesses(ctx context.Context) { func (e *HostEnvironment) terminateRunningProcesses(ctx context.Context) {
if runtime.GOOS != "windows" { if runtime.GOOS != "windows" {
return return
} }
e.mu.Lock()
pids := make([]int, 0, len(e.runningPIDs))
for pid := range e.runningPIDs {
pids = append(pids, pid)
}
e.mu.Unlock()
if len(pids) == 0 { // Detached: exec.CommandContext won't start on a cancelled ctx, and a
// server cancel has already cancelled the parent ctx.
killCtx, killCancel := context.WithTimeout(context.Background(), 30*time.Second)
defer killCancel()
logger := common.Logger(ctx)
// Workspace dirs we own. Any process running from or referencing one is a
// leftover job process. ToolCache is shared across jobs; Workdir only when
// we own it (else it's a caller-provided checkout, e.g. act local mode).
owned := []string{e.Path, e.TmpDir}
if e.CleanWorkdir {
owned = append(owned, e.Workdir)
}
dirs := make([]string, 0, len(owned))
for _, d := range owned {
if d == "" {
continue
}
abs, err := filepath.Abs(d)
if err != nil {
continue
}
dirs = append(dirs, abs)
}
if len(dirs) == 0 {
return return
} }
logger := common.Logger(ctx) script := buildWindowsWorkspaceKillScript(dirs)
for _, pid := range pids {
// Best-effort: forcibly terminate process tree to release file handles cmd := exec.CommandContext(killCtx, "powershell.exe", "-NoProfile", "-NonInteractive", "-Command", script)
// so that workspace cleanup can succeed on Windows. out, err := cmd.CombinedOutput()
cmd := exec.CommandContext(ctx, "taskkill", "/PID", strconv.Itoa(pid), "/T", "/F") if err != nil {
out, err := cmd.CombinedOutput() logger.Debugf("workspace process-tree kill via PowerShell failed: %v output=%s", err, strings.TrimSpace(string(out)))
if err != nil {
logger.Debugf("taskkill failed for pid=%d: %v output=%s", pid, err, strings.TrimSpace(string(out)))
}
} }
} }
@@ -479,14 +543,20 @@ func (e *HostEnvironment) Remove() common.Executor {
if e.CleanUp != nil { if e.CleanUp != nil {
e.CleanUp() e.CleanUp()
} }
// Detach: a cancelled ctx would skip removePathWithRetry's retries,
// which absorb Windows file-handle release lag after the kill above.
rmCtx, rmCancel := context.WithTimeout(context.Background(), 30*time.Second)
defer rmCancel()
logger := common.Logger(ctx) logger := common.Logger(ctx)
var errs []error var errs []error
if err := removePathWithRetry(ctx, e.Path); err != nil { if err := removePathWithRetry(rmCtx, e.Path); err != nil {
logger.Warnf("failed to remove host misc state %s: %v", e.Path, err) logger.Warnf("failed to remove host misc state %s: %v", e.Path, err)
errs = append(errs, err) errs = append(errs, err)
} }
if !e.BindWorkdir && e.Workdir != "" { if e.CleanWorkdir {
if err := removePathWithRetry(ctx, e.Workdir); err != nil { if err := removePathWithRetry(rmCtx, e.Workdir); err != nil {
logger.Warnf("failed to remove host workspace %s: %v", e.Workdir, err) logger.Warnf("failed to remove host workspace %s: %v", e.Workdir, err)
errs = append(errs, err) errs = append(errs, err)
} }

View File

@@ -6,12 +6,14 @@ package container
import ( import (
"archive/tar" "archive/tar"
"bytes"
"context" "context"
"io" "io"
"os" "os"
"path" "path"
"path/filepath" "path/filepath"
"runtime" "runtime"
"strings"
"testing" "testing"
"gitea.com/gitea/runner/act/common" "gitea.com/gitea/runner/act/common"
@@ -100,7 +102,46 @@ func TestHostEnvironmentExecExitCode(t *testing.T) {
assert.Equal(t, "Process completed with exit code 3.", err.Error()) assert.Equal(t, "Process completed with exit code 3.", err.Error())
} }
func TestHostEnvironmentRemoveCleansWorkdir(t *testing.T) { func TestHostEnvironmentAllocatePTY(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("uses POSIX shell")
}
for _, tc := range []struct {
name string
allocPTY bool
expect string
}{
{name: "off", allocPTY: false, expect: "NOTTY"},
{name: "on", allocPTY: true, expect: "TTY"},
} {
t.Run(tc.name, func(t *testing.T) {
dir := t.TempDir()
buf := &bytes.Buffer{}
e := &HostEnvironment{
Path: filepath.Join(dir, "path"),
TmpDir: filepath.Join(dir, "tmp"),
ToolCache: filepath.Join(dir, "tool_cache"),
ActPath: filepath.Join(dir, "act_path"),
StdOut: buf,
Workdir: filepath.Join(dir, "path"),
AllocatePTY: tc.allocPTY,
}
for _, p := range []string{e.Path, e.TmpDir, e.ToolCache, e.ActPath} {
require.NoError(t, os.MkdirAll(p, 0o700))
}
err := e.Exec(
[]string{"sh", "-c", "[ -t 1 ] && printf TTY || printf NOTTY"},
map[string]string{"PATH": os.Getenv("PATH")}, "", "",
)(context.Background())
require.NoError(t, err)
got := strings.TrimSpace(strings.ReplaceAll(buf.String(), "\r", ""))
assert.Equal(t, tc.expect, got)
})
}
}
func TestHostEnvironmentRemovePreservesWorkdirByDefault(t *testing.T) {
logger := logrus.New() logger := logrus.New()
ctx := common.WithLogger(context.Background(), logrus.NewEntry(logger)) ctx := common.WithLogger(context.Background(), logrus.NewEntry(logger))
base := t.TempDir() base := t.TempDir()
@@ -111,9 +152,8 @@ func TestHostEnvironmentRemoveCleansWorkdir(t *testing.T) {
require.NoError(t, os.MkdirAll(workdir, 0o700)) require.NoError(t, os.MkdirAll(workdir, 0o700))
e := &HostEnvironment{ e := &HostEnvironment{
Path: path, Path: path,
Workdir: workdir, Workdir: workdir,
BindWorkdir: false,
CleanUp: func() { CleanUp: func() {
_ = os.RemoveAll(miscRoot) _ = os.RemoveAll(miscRoot)
}, },
@@ -121,10 +161,10 @@ func TestHostEnvironmentRemoveCleansWorkdir(t *testing.T) {
} }
require.NoError(t, e.Remove()(ctx)) require.NoError(t, e.Remove()(ctx))
_, err := os.Stat(workdir) _, err := os.Stat(workdir)
assert.ErrorIs(t, err, os.ErrNotExist) require.NoError(t, err)
} }
func TestHostEnvironmentRemoveSkipsWorkdirWhenBindWorkdir(t *testing.T) { func TestHostEnvironmentRemoveCleansWorkdirWhenOwned(t *testing.T) {
logger := logrus.New() logger := logrus.New()
ctx := common.WithLogger(context.Background(), logrus.NewEntry(logger)) ctx := common.WithLogger(context.Background(), logrus.NewEntry(logger))
base := t.TempDir() base := t.TempDir()
@@ -135,9 +175,9 @@ func TestHostEnvironmentRemoveSkipsWorkdirWhenBindWorkdir(t *testing.T) {
require.NoError(t, os.MkdirAll(workdir, 0o700)) require.NoError(t, os.MkdirAll(workdir, 0o700))
e := &HostEnvironment{ e := &HostEnvironment{
Path: path, Path: path,
Workdir: workdir, Workdir: workdir,
BindWorkdir: true, CleanWorkdir: true,
CleanUp: func() { CleanUp: func() {
_ = os.RemoveAll(miscRoot) _ = os.RemoveAll(miscRoot)
}, },
@@ -145,5 +185,66 @@ func TestHostEnvironmentRemoveSkipsWorkdirWhenBindWorkdir(t *testing.T) {
} }
require.NoError(t, e.Remove()(ctx)) require.NoError(t, e.Remove()(ctx))
_, err := os.Stat(workdir) _, err := os.Stat(workdir)
require.NoError(t, err) assert.ErrorIs(t, err, os.ErrNotExist)
}
func TestBuildWindowsWorkspaceKillScript(t *testing.T) {
t.Run("single dir", func(t *testing.T) {
s := buildWindowsWorkspaceKillScript([]string{`C:\workspace\job1`})
assert.Contains(t, s, `$paths = @('C:\workspace\job1')`)
// Self-PID guard is essential — without it the script could taskkill
// the PowerShell process running it.
assert.Contains(t, s, "$selfPid = $PID")
assert.Contains(t, s, "$_.ProcessId -eq $selfPid")
// Must match both ExecutablePath (binaries from the workspace) and
// CommandLine (system binaries invoked with workspace paths in args),
// both bounded by dir+separator so a name-prefix sibling is spared.
assert.Contains(t, s, `$prefix = $p + '\'`)
assert.Contains(t, s, "$_.ExecutablePath.StartsWith($prefix")
assert.Contains(t, s, "$_.CommandLine.IndexOf($prefix")
// Each matched PID must be tree-killed, not just stopped.
assert.Contains(t, s, "taskkill.exe /PID $_.ProcessId /T /F")
})
t.Run("multiple dirs comma-separated", func(t *testing.T) {
s := buildWindowsWorkspaceKillScript([]string{
`C:\work\path`,
`C:\work\workdir`,
`C:\Users\runner\AppData\Local\Temp\job-42`,
})
assert.Contains(t, s, `'C:\work\path'`)
assert.Contains(t, s, `'C:\work\workdir'`)
assert.Contains(t, s, `'C:\Users\runner\AppData\Local\Temp\job-42'`)
// Commas between entries — no trailing comma, no leading comma.
assert.Contains(t, s, `'C:\work\path','C:\work\workdir',`)
})
t.Run("path with single quote is escaped", func(t *testing.T) {
// In PowerShell single-quoted strings the only special char is the
// quote itself, escaped by doubling. A workspace path that ever
// contained `'` would inject a command into the script otherwise.
s := buildWindowsWorkspaceKillScript([]string{`C:\work\it's\path`})
assert.Contains(t, s, `'C:\work\it''s\path'`)
// And it must NOT appear unescaped — otherwise the quote would
// terminate the literal early.
assert.NotContains(t, s, `'C:\work\it's\path'`)
})
t.Run("path with wildcard metacharacters is matched literally", func(t *testing.T) {
// A path containing [ ] ? * must be embedded verbatim and matched with
// ordinal String methods, not -like, otherwise the metacharacters would
// be interpreted as wildcards and the leftover process could escape.
s := buildWindowsWorkspaceKillScript([]string{`C:\work\[job]?1`})
assert.Contains(t, s, `'C:\work\[job]?1'`)
assert.NotContains(t, s, "-like")
assert.Contains(t, s, "StartsWith")
assert.Contains(t, s, "IndexOf")
})
t.Run("empty dir list still produces a valid script", func(t *testing.T) {
s := buildWindowsWorkspaceKillScript(nil)
// Empty array literal — script runs, matches nothing, is a no-op.
assert.Contains(t, s, "$paths = @()")
assert.Contains(t, s, "Get-CimInstance Win32_Process")
})
} }

View File

@@ -29,6 +29,8 @@ func parseEnvFile(e Container, srcPath string, env *map[string]string) common.Ex
return err return err
} }
s := bufio.NewScanner(reader) s := bufio.NewScanner(reader)
// Default 64 KiB max token size is too small for realistic env-file lines; allow up to 16 MiB.
s.Buffer(make([]byte, 0, 64*1024), 16*1024*1024)
for s.Scan() { for s.Scan() {
line := s.Text() line := s.Text()
singleLineEnv := strings.Index(line, "=") singleLineEnv := strings.Index(line, "=")
@@ -50,6 +52,9 @@ func parseEnvFile(e Container, srcPath string, env *map[string]string) common.Ex
} }
multiLineEnvContent += content multiLineEnvContent += content
} }
if err := s.Err(); err != nil {
return fmt.Errorf("reading env file: %w", err)
}
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)
} }
@@ -58,6 +63,9 @@ func parseEnvFile(e Container, srcPath string, env *map[string]string) common.Ex
return fmt.Errorf("invalid format '%v', expected a line with '=' or '<<'", line) return fmt.Errorf("invalid format '%v', expected a line with '=' or '<<'", line)
} }
} }
if err := s.Err(); err != nil {
return fmt.Errorf("reading env file: %w", err)
}
env = &localEnv env = &localEnv
return nil return nil
} }

View File

@@ -0,0 +1,75 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package container
import (
"bufio"
"context"
"os"
"path/filepath"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func newTestHostEnv(t *testing.T) (*HostEnvironment, string) {
t.Helper()
e := &HostEnvironment{Path: t.TempDir()}
return e, filepath.Join(e.Path, "envfile")
}
func TestParseEnvFileSingleLine(t *testing.T) {
e, envPath := newTestHostEnv(t)
require.NoError(t, os.WriteFile(envPath, []byte("FOO=bar\nBAZ=qux\n"), 0o600))
env := map[string]string{}
require.NoError(t, parseEnvFile(e, envPath, &env)(context.Background()))
assert.Equal(t, "bar", env["FOO"])
assert.Equal(t, "qux", env["BAZ"])
}
func TestParseEnvFileMultiLine(t *testing.T) {
e, envPath := newTestHostEnv(t)
content := "FOO<<EOF\nline1\nline2\nEOF\n"
require.NoError(t, os.WriteFile(envPath, []byte(content), 0o600))
env := map[string]string{}
require.NoError(t, parseEnvFile(e, envPath, &env)(context.Background()))
assert.Equal(t, "line1\nline2", env["FOO"])
}
func TestParseEnvFileLargeValueWithinLimit(t *testing.T) {
e, envPath := newTestHostEnv(t)
big := strings.Repeat("x", 2*1024*1024)
content := "FOO<<EOF\n" + big + "\nEOF\n"
require.NoError(t, os.WriteFile(envPath, []byte(content), 0o600))
env := map[string]string{}
require.NoError(t, parseEnvFile(e, envPath, &env)(context.Background()))
assert.Equal(t, big, env["FOO"])
}
func TestParseEnvFileLineExceedsBufferReportsScannerError(t *testing.T) {
e, envPath := newTestHostEnv(t)
tooBig := strings.Repeat("x", 17*1024*1024) // over the 16 MiB cap
content := "FOO<<EOF\n" + tooBig + "\nEOF\n"
require.NoError(t, os.WriteFile(envPath, []byte(content), 0o600))
env := map[string]string{}
err := parseEnvFile(e, envPath, &env)(context.Background())
require.ErrorIs(t, err, bufio.ErrTooLong)
assert.Contains(t, err.Error(), "reading env file")
}
func TestParseEnvFileMissingDelimiter(t *testing.T) {
e, envPath := newTestHostEnv(t)
require.NoError(t, os.WriteFile(envPath, []byte("FOO<<EOF\nline1\nline2\n"), 0o600))
env := map[string]string{}
err := parseEnvFile(e, envPath, &env)(context.Background())
require.Error(t, err)
assert.Contains(t, err.Error(), "delimiter")
}

View File

@@ -0,0 +1,19 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
//go:build !windows
package container
import "os"
// processKiller is a no-op on non-Windows platforms. The Job Object based
// tree-kill is only wired in on Windows (see exec()); elsewhere the default
// exec.CommandContext cancellation and Setpgid handling apply.
type processKiller struct{}
func newProcessKiller(_ *os.Process) (*processKiller, error) { return &processKiller{}, nil }
func (k *processKiller) Kill() error { return nil }
func (k *processKiller) Close() error { return nil }

View File

@@ -0,0 +1,71 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package container
import (
"os"
"golang.org/x/sys/windows"
)
// processKiller terminates a step process together with its entire descendant
// tree via a Windows Job Object.
//
// Background: a step often launches a process tree (a shell that starts a
// child which in turn spawns further GUI or background processes). The default
// exec.CommandContext cancellation only kills the direct child, so cancelling a
// job left the rest of the tree running. Because those orphans inherited the
// step's stdout/stderr pipe, cmd.Wait() also blocked forever and the runner hung.
//
// Assigning the step process to a Job Object lets us kill the whole tree
// atomically on cancellation (TerminateJobObject), which also closes the
// inherited pipe handles so cmd.Wait() can return.
type processKiller struct {
job windows.Handle
}
// newProcessKiller creates a Job Object and assigns p (an already-started
// process) to it. Children spawned by p afterwards are automatically part of
// the job. The job does NOT use JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, so closing
// the handle on normal completion does not kill legitimate background
// processes; the tree is only torn down by an explicit Kill (cancellation).
func newProcessKiller(p *os.Process) (*processKiller, error) {
job, err := windows.CreateJobObject(nil, nil)
if err != nil {
return nil, err
}
h, err := windows.OpenProcess(windows.PROCESS_SET_QUOTA|windows.PROCESS_TERMINATE, false, uint32(p.Pid))
if err != nil {
windows.CloseHandle(job)
return nil, err
}
defer windows.CloseHandle(h)
if err := windows.AssignProcessToJobObject(job, h); err != nil {
windows.CloseHandle(job)
return nil, err
}
return &processKiller{job: job}, nil
}
// Kill terminates every process currently assigned to the job (the step process
// and all of its descendants).
func (k *processKiller) Kill() error {
if k == nil || k.job == 0 {
return nil
}
return windows.TerminateJobObject(k.job, 1)
}
// Close releases the job handle. It does not terminate the processes.
func (k *processKiller) Close() error {
if k == nil || k.job == 0 {
return nil
}
h := k.job
k.job = 0
return windows.CloseHandle(h)
}

View File

@@ -0,0 +1,78 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package container
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
"github.com/stretchr/testify/require"
"golang.org/x/sys/windows"
)
// processAlive reports whether pid refers to a still-running process.
func processAlive(pid int) bool {
h, err := windows.OpenProcess(windows.PROCESS_QUERY_LIMITED_INFORMATION, false, uint32(pid))
if err != nil {
return false
}
defer windows.CloseHandle(h)
var code uint32
if err := windows.GetExitCodeProcess(h, &code); err != nil {
return false
}
const stillActive = 259 // STILL_ACTIVE
return code == stillActive
}
// TestProcessKillerKillsTree verifies that a process assigned to the Job Object
// is terminated together with a child it spawns afterwards. This mirrors a step
// that launches a child which spawns further processes, where cancelling the
// job must take down the whole tree, not just the direct child.
func TestProcessKillerKillsTree(t *testing.T) {
dir := t.TempDir()
pidFile := filepath.Join(dir, "child.pid")
// Parent powershell spawns a detached, long-lived child powershell (writing
// its PID to a file) and then sleeps. The child is launched AFTER the parent
// has been assigned to the job, so it must be captured by the job too.
script := fmt.Sprintf(
`$c = Start-Process powershell -PassThru -ArgumentList '-NoProfile','-Command','Start-Sleep -Seconds 600'; `+
`Set-Content -LiteralPath %q -Value $c.Id; Start-Sleep -Seconds 600`, pidFile)
cmd := exec.Command("powershell.exe", "-NoProfile", "-Command", script)
require.NoError(t, cmd.Start())
t.Cleanup(func() { _ = cmd.Process.Kill() })
killer, err := newProcessKiller(cmd.Process)
require.NoError(t, err)
defer killer.Close()
// Wait for the child PID to be reported.
var childPID int
require.Eventually(t, func() bool {
b, e := os.ReadFile(pidFile)
if e != nil {
return false
}
s := strings.TrimSpace(string(b))
if s == "" {
return false
}
childPID, _ = strconv.Atoi(s)
return childPID > 0 && processAlive(childPID)
}, 20*time.Second, 200*time.Millisecond, "child process should start")
// Killing the job must terminate both the parent and the detached child.
require.NoError(t, killer.Kill())
require.Eventually(t, func() bool {
return !processAlive(cmd.Process.Pid) && !processAlive(childPID)
}, 20*time.Second, 200*time.Millisecond, "parent and child should both be terminated")
}

View File

@@ -251,7 +251,7 @@ func (impl *interperterImpl) evaluateArrayDeref(arrayDerefNode *actionlint.Array
func (impl *interperterImpl) getPropertyValue(left reflect.Value, property string) (value any, err error) { func (impl *interperterImpl) getPropertyValue(left reflect.Value, property string) (value any, err error) {
switch left.Kind() { switch left.Kind() {
case reflect.Ptr: case reflect.Pointer:
return impl.getPropertyValue(left.Elem(), property) return impl.getPropertyValue(left.Elem(), property)
case reflect.Struct: case reflect.Struct:
@@ -321,7 +321,7 @@ func (impl *interperterImpl) getPropertyValue(left reflect.Value, property strin
} }
func (impl *interperterImpl) getMapValue(value reflect.Value) (any, error) { func (impl *interperterImpl) getMapValue(value reflect.Value) (any, error) {
if value.Kind() == reflect.Ptr { if value.Kind() == reflect.Pointer {
return impl.getMapValue(value.Elem()) return impl.getMapValue(value.Elem())
} }

View File

@@ -73,10 +73,16 @@ func (cc *CopyCollector) WriteFile(fpath string, fi fs.FileInfo, linkName string
if err := os.MkdirAll(filepath.Dir(fdestpath), 0o777); err != nil { if err := os.MkdirAll(filepath.Dir(fdestpath), 0o777); err != nil {
return err return err
} }
// Remove any existing destination so we can overwrite read-only files
// (e.g. git pack files at mode 0444 trip EACCES on macOS and "Access is
// denied" on Windows when reopened with O_WRONLY) and so os.Symlink does
// not fail with EEXIST. os.Remove clears the Windows read-only attribute
// internally; on Unix unlink only needs write permission on the parent.
_ = os.Remove(fdestpath)
if f == nil { if f == nil {
return os.Symlink(linkName, fdestpath) return os.Symlink(linkName, fdestpath)
} }
df, err := os.OpenFile(fdestpath, os.O_CREATE|os.O_WRONLY, fi.Mode()) df, err := os.OpenFile(fdestpath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, fi.Mode())
if err != nil { if err != nil {
return err return err
} }

View File

@@ -8,7 +8,9 @@ import (
"archive/tar" "archive/tar"
"context" "context"
"io" "io"
"os"
"path/filepath" "path/filepath"
"runtime"
"strings" "strings"
"testing" "testing"
@@ -20,6 +22,7 @@ import (
"github.com/go-git/go-git/v5/plumbing/format/index" "github.com/go-git/go-git/v5/plumbing/format/index"
"github.com/go-git/go-git/v5/storage/filesystem" "github.com/go-git/go-git/v5/storage/filesystem"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
) )
type memoryFs struct { type memoryFs struct {
@@ -174,3 +177,47 @@ func TestSymlinks(t *testing.T) {
assert.Equal(t, ".env", files["test.env"].Linkname) assert.Equal(t, ".env", files["test.env"].Linkname)
assert.ErrorIs(t, err, io.EOF, "tar must be read cleanly to EOF") assert.ErrorIs(t, err, io.EOF, "tar must be read cleanly to EOF")
} }
// Regression for https://gitea.com/gitea/runner/issues/876 and /941:
// re-copying an action directory must overwrite a pre-existing read-only
// file (e.g. a git pack .idx at mode 0444) instead of failing with EACCES
// on macOS or "Access is denied" on Windows.
func TestCopyCollectorWriteFileOverwritesReadOnlyFile(t *testing.T) {
dst := t.TempDir()
target := filepath.Join(dst, "sub", "pack.idx")
require.NoError(t, os.MkdirAll(filepath.Dir(target), 0o755))
require.NoError(t, os.WriteFile(target, []byte("old"), 0o444))
src := filepath.Join(t.TempDir(), "pack.idx")
require.NoError(t, os.WriteFile(src, []byte("new"), 0o444))
fi, err := os.Stat(src)
require.NoError(t, err)
cc := &CopyCollector{DstDir: dst}
require.NoError(t, cc.WriteFile("sub/pack.idx", fi, "", strings.NewReader("new")))
got, err := os.ReadFile(target)
require.NoError(t, err)
assert.Equal(t, "new", string(got))
}
// Without the destination removal, os.Symlink fails with EEXIST when the
// path already holds a regular file from an earlier copy of the action.
func TestCopyCollectorWriteFileOverwritesFileWithSymlink(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("creating symlinks requires elevated privileges on Windows")
}
dst := t.TempDir()
target := filepath.Join(dst, "link")
require.NoError(t, os.WriteFile(target, []byte("stale"), 0o644))
fi, err := os.Lstat(target)
require.NoError(t, err)
cc := &CopyCollector{DstDir: dst}
require.NoError(t, cc.WriteFile("link", fi, "target", nil))
resolved, err := os.Readlink(target)
require.NoError(t, err)
assert.Equal(t, "target", resolved)
}

View File

@@ -4,18 +4,6 @@
package lookpath package lookpath
import "os"
type Env interface { type Env interface {
Getenv(name string) string Getenv(name string) string
} }
type defaultEnv struct{}
func (*defaultEnv) Getenv(name string) string {
return os.Getenv(name)
}
func LookPath(file string) (string, error) {
return LookPath2(file, &defaultEnv{})
}

View File

@@ -325,14 +325,20 @@ func (j *Job) Needs() []string {
// RunsOn list for Job // RunsOn list for Job
func (j *Job) RunsOn() []string { func (j *Job) RunsOn() []string {
switch j.RawRunsOn.Kind { return RunsOnFromNode(j.RawRunsOn)
}
// RunsOnFromNode parses the runs-on labels from a raw runs-on node, so callers can evaluate a
// copy of the node (avoiding mutation of the shared Job) before reading the labels.
func RunsOnFromNode(rawRunsOn yaml.Node) []string {
switch rawRunsOn.Kind {
case yaml.MappingNode: case yaml.MappingNode:
var val struct { var val struct {
Group string Group string
Labels yaml.Node Labels yaml.Node
} }
if !decodeNode(j.RawRunsOn, &val) { if !decodeNode(rawRunsOn, &val) {
return nil return nil
} }
@@ -344,7 +350,7 @@ func (j *Job) RunsOn() []string {
return labels return labels
default: default:
return nodeAsStringSlice(j.RawRunsOn) return nodeAsStringSlice(rawRunsOn)
} }
} }
@@ -645,6 +651,33 @@ type Step struct {
TimeoutMinutes string `yaml:"timeout-minutes"` TimeoutMinutes string `yaml:"timeout-minutes"`
} }
// Clone returns a deep copy safe to mutate independently of s. Job steps are shared across
// parallel matrix runs, which mutate per-job fields (ID, Number, Shell) and evaluate the If/Env
// yaml.Nodes in place, so each job must own its copy.
func (s *Step) Clone() *Step {
clone := *s
clone.If = CloneYamlNode(s.If)
clone.Env = CloneYamlNode(s.Env)
clone.With = maps.Clone(s.With)
return &clone
}
// CloneYamlNode returns a deep copy of a yaml.Node so callers can evaluate it in place without
// mutating a node shared across parallel jobs.
func CloneYamlNode(n yaml.Node) yaml.Node {
clone := n
if n.Content != nil {
clone.Content = make([]*yaml.Node, len(n.Content))
for i, child := range n.Content {
if child != nil {
childClone := CloneYamlNode(*child)
clone.Content[i] = &childClone
}
}
}
return clone
}
// String gets the name of step // String gets the name of step
func (s *Step) String() string { func (s *Step) String() string {
if s.Name != "" { if s.Name != "" {

View File

@@ -9,9 +9,29 @@ import (
"testing" "testing"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.yaml.in/yaml/v4" "go.yaml.in/yaml/v4"
) )
// TestStepCloneIsolatesMutableFields guards the parallel-matrix race fix: combinations share the
// job's *Step, and Clone() must hand each a copy whose If/Env nodes and With map can be mutated
// independently. A shallow copy would share Env.Content's backing array (and the With map) and
// leak writes across combinations.
func TestStepCloneIsolatesMutableFields(t *testing.T) {
var orig Step
require.NoError(t, yaml.Unmarshal([]byte("if: ${{ env.X == 'a' }}\nenv:\n KEY: original\nwith:\n arg: original\n"), &orig))
require.Len(t, orig.Env.Content, 2) // [key, value]
clone := orig.Clone()
clone.If.Value = "changed"
clone.Env.Content[1].Value = "changed"
clone.With["arg"] = "changed"
assert.Equal(t, "${{ env.X == 'a' }}", orig.If.Value, "If must not be shared with the clone")
assert.Equal(t, "original", orig.Env.Content[1].Value, "Env nodes must not be shared with the clone")
assert.Equal(t, "original", orig.With["arg"], "With map must not be shared with the clone")
}
func TestReadWorkflow_ScheduleEvent(t *testing.T) { func TestReadWorkflow_ScheduleEvent(t *testing.T) {
yaml := ` yaml := `
name: local-action-docker-url name: local-action-docker-url

View File

@@ -19,6 +19,7 @@ import (
"strings" "strings"
"gitea.com/gitea/runner/act/common" "gitea.com/gitea/runner/act/common"
"gitea.com/gitea/runner/act/common/git"
"gitea.com/gitea/runner/act/container" "gitea.com/gitea/runner/act/container"
"gitea.com/gitea/runner/act/model" "gitea.com/gitea/runner/act/model"
@@ -44,6 +45,11 @@ type runAction func(step actionStep, actionDir string, remoteAction *remoteActio
//go:embed res/trampoline.js //go:embed res/trampoline.js
var trampoline embed.FS var trampoline embed.FS
var (
ContainerImageExistsLocally = container.ImageExistsLocally
ContainerNewDockerBuildExecutor = container.NewDockerBuildExecutor
)
func readActionImpl(ctx context.Context, step *model.Step, actionDir, actionPath string, readFile actionYamlReader, writeFile fileWriter) (*model.Action, error) { func readActionImpl(ctx context.Context, step *model.Step, actionDir, actionPath string, readFile actionYamlReader, writeFile fileWriter) (*model.Action, error) {
logger := common.Logger(ctx) logger := common.Logger(ctx)
allErrors := []error{} allErrors := []error{}
@@ -148,6 +154,8 @@ func maybeCopyToActionDir(ctx context.Context, step actionStep, actionDir, actio
return rc.JobContainer.CopyTarStream(ctx, containerActionDirCopy, ta) return rc.JobContainer.CopyTarStream(ctx, containerActionDirCopy, ta)
} }
defer git.AcquireCloneLock(actionDir)()
if err := removeGitIgnore(ctx, actionDir); err != nil { if err := removeGitIgnore(ctx, actionDir); err != nil {
return err return err
} }
@@ -197,7 +205,7 @@ func runActionImpl(step actionStep, actionDir string, remoteAction *remoteAction
if remoteAction == nil { if remoteAction == nil {
location = containerActionDir location = containerActionDir
} }
return execAsDocker(ctx, step, actionName, location, remoteAction == nil) return execAsDocker(ctx, step, actionName, actionDir, location, remoteAction == nil)
case x.IsComposite(): case x.IsComposite():
if err := maybeCopyToActionDir(ctx, step, actionDir, actionPath, containerActionDir); err != nil { if err := maybeCopyToActionDir(ctx, step, actionDir, actionPath, containerActionDir); err != nil {
return err return err
@@ -265,7 +273,7 @@ func removeGitIgnore(ctx context.Context, directory string) error {
} }
// TODO: break out parts of function to reduce complexicity // TODO: break out parts of function to reduce complexicity
func execAsDocker(ctx context.Context, step actionStep, actionName, basedir string, localAction bool) error { func execAsDocker(ctx context.Context, step actionStep, actionName, actionDir, basedir string, localAction bool) error {
logger := common.Logger(ctx) logger := common.Logger(ctx)
rc := step.getRunContext() rc := step.getRunContext()
action := step.getActionModel() action := step.getActionModel()
@@ -284,12 +292,12 @@ func execAsDocker(ctx context.Context, step actionStep, actionName, basedir stri
image = strings.ToLower(image) image = strings.ToLower(image)
contextDir, fileName := filepath.Split(filepath.Join(basedir, action.Runs.Image)) contextDir, fileName := filepath.Split(filepath.Join(basedir, action.Runs.Image))
anyArchExists, err := container.ImageExistsLocally(ctx, image, "any") anyArchExists, err := ContainerImageExistsLocally(ctx, image, "any")
if err != nil { if err != nil {
return err return err
} }
correctArchExists, err := container.ImageExistsLocally(ctx, image, rc.Config.ContainerArchitecture) correctArchExists, err := ContainerImageExistsLocally(ctx, image, rc.Config.ContainerArchitecture)
if err != nil { if err != nil {
return err return err
} }
@@ -321,13 +329,21 @@ func execAsDocker(ctx context.Context, step actionStep, actionName, basedir stri
} }
defer buildContext.Close() defer buildContext.Close()
} }
prepImage = container.NewDockerBuildExecutor(container.NewDockerBuildExecutorInput{ prepImage = ContainerNewDockerBuildExecutor(container.NewDockerBuildExecutorInput{
ContextDir: contextDir, ContextDir: contextDir,
Dockerfile: fileName, Dockerfile: fileName,
ImageTag: image, ImageTag: image,
BuildContext: buildContext, BuildContext: buildContext,
Platform: rc.Config.ContainerArchitecture, Platform: rc.Config.ContainerArchitecture,
}) })
if buildContext == nil {
// Held across the whole build: the daemon drains contextDir lazily.
inner := prepImage
prepImage = func(ctx context.Context) error {
defer git.AcquireCloneLock(actionDir)()
return inner(ctx)
}
}
} else { } else {
logger.Debugf("image '%s' for architecture '%s' already exists", image, rc.Config.ContainerArchitecture) logger.Debugf("image '%s' for architecture '%s' already exists", image, rc.Config.ContainerArchitecture)
} }
@@ -420,13 +436,11 @@ func newStepContainer(ctx context.Context, step step, image string, cmd, entrypo
if rc.IsHostEnv(ctx) { if rc.IsHostEnv(ctx) {
networkMode = "default" networkMode = "default"
} }
stepContainer := container.NewContainer(&container.NewContainerInput{ stepContainer := ContainerNewContainer(&container.NewContainerInput{
Cmd: cmd, Cmd: cmd,
Entrypoint: entrypoint, Entrypoint: entrypoint,
WorkingDir: rc.JobContainer.ToContainerPath(rc.Config.Workdir), WorkingDir: rc.JobContainer.ToContainerPath(rc.Config.Workdir),
Image: image, Image: image,
Username: rc.Config.Secrets["DOCKER_USERNAME"],
Password: rc.Config.Secrets["DOCKER_PASSWORD"],
Name: createContainerName(rc.jobContainerName(), "STEP-"+stepModel.ID), Name: createContainerName(rc.jobContainerName(), "STEP-"+stepModel.ID),
Env: envList, Env: envList,
Mounts: mounts, Mounts: mounts,
@@ -439,7 +453,8 @@ func newStepContainer(ctx context.Context, step step, image string, cmd, entrypo
Platform: rc.Config.ContainerArchitecture, Platform: rc.Config.ContainerArchitecture,
Options: rc.Config.ContainerOptions, Options: rc.Config.ContainerOptions,
AutoRemove: rc.Config.AutoRemove, AutoRemove: rc.Config.AutoRemove,
ValidVolumes: rc.Config.ValidVolumes, ValidVolumes: rc.validVolumes(),
AllocatePTY: rc.Config.AllocatePTY,
}) })
return stepContainer return stepContainer
} }

View File

@@ -1,45 +0,0 @@
// Copyright 2024 The Gitea Authors. All rights reserved.
// Copyright 2024 The nektos/act Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package runner
import (
"context"
"io"
"path"
git "github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/plumbing"
)
type GoGitActionCacheOfflineMode struct {
Parent GoGitActionCache
}
func (c GoGitActionCacheOfflineMode) Fetch(ctx context.Context, cacheDir, url, ref, token string) (string, error) {
sha, fetchErr := c.Parent.Fetch(ctx, cacheDir, url, ref, token)
gitPath := path.Join(c.Parent.Path, safeFilename(cacheDir)+".git")
gogitrepo, err := git.PlainOpen(gitPath)
if err != nil {
return "", fetchErr
}
refName := plumbing.ReferenceName("refs/action-cache-offline/" + ref)
r, err := gogitrepo.Reference(refName, true)
if fetchErr == nil {
if err != nil || sha != r.Hash().String() {
if err == nil {
refName = r.Name()
}
ref := plumbing.NewHashReference(refName, plumbing.NewHash(sha))
_ = gogitrepo.Storer.SetReference(ref)
}
} else if err == nil {
return r.Hash().String(), nil
}
return sha, fetchErr
}
func (c GoGitActionCacheOfflineMode) GetTarArchive(ctx context.Context, cacheDir, sha, includePrefix string) (io.ReadCloser, error) {
return c.Parent.GetTarArchive(ctx, cacheDir, sha, includePrefix)
}

View File

@@ -8,64 +8,139 @@ import (
"archive/tar" "archive/tar"
"bytes" "bytes"
"context" "context"
"fmt"
"io" "io"
"os"
"os/exec"
"path/filepath"
"strings"
"testing" "testing"
"time"
"gitea.com/gitea/runner/act/common"
"gitea.com/gitea/runner/act/model"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
) )
func TestActionCache(t *testing.T) { func runGit(t *testing.T, dir string, args ...string) {
if testing.Short() { t.Helper()
t.Skip("skipping integration test") if dir != "" {
args = append([]string{"-C", dir}, args...)
} }
cmd := exec.Command("git", args...)
// Fixed identity and host-config isolation so commits succeed offline regardless of the
// host's git config (mirrors gitCmd in act/common/git).
cmd.Env = append(os.Environ(),
"GIT_AUTHOR_NAME=test", "GIT_AUTHOR_EMAIL=test@example.com",
"GIT_COMMITTER_NAME=test", "GIT_COMMITTER_EMAIL=test@example.com",
"GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null",
)
out, err := cmd.CombinedOutput()
require.NoError(t, err, string(out))
}
// TestShortShaActionRejected verifies a `uses` ref that is a shortened commit SHA is rejected
// with a clear error. The action is resolved from a local repo (via DefaultActionInstance) so
// this runs offline.
func TestShortShaActionRejected(t *testing.T) {
// a local "remote" action repo at <root>/actions/hello-world-docker-action
actionRoot := t.TempDir()
repo := filepath.Join(actionRoot, "actions", "hello-world-docker-action")
require.NoError(t, os.MkdirAll(repo, 0o755))
runGit(t, "", "init", "--initial-branch=main", repo)
require.NoError(t, os.WriteFile(filepath.Join(repo, "action.yml"),
[]byte("name: hello\nruns:\n using: node24\n main: index.js\n"), 0o644))
runGit(t, repo, "add", ".")
runGit(t, repo, "commit", "-m", "initial")
out, err := exec.Command("git", "-C", repo, "rev-parse", "HEAD").Output()
require.NoError(t, err)
shortSha := strings.TrimSpace(string(out))[:7]
// a workflow that uses the action at the short SHA
wfDir := filepath.Join(t.TempDir(), "wf")
require.NoError(t, os.MkdirAll(wfDir, 0o755))
wf := fmt.Sprintf("on: push\njobs:\n test:\n runs-on: ubuntu-latest\n steps:\n - uses: actions/hello-world-docker-action@%s\n", shortSha)
require.NoError(t, os.WriteFile(filepath.Join(wfDir, "push.yml"), []byte(wf), 0o644))
runner, err := New(&Config{
Workdir: wfDir,
EventName: "push",
Platforms: map[string]string{"ubuntu-latest": baseImage},
GitHubInstance: "github.com",
DefaultActionInstance: actionRoot,
ContainerMaxLifetime: time.Hour,
})
require.NoError(t, err)
planner, err := model.NewWorkflowPlanner(wfDir, true)
require.NoError(t, err)
plan, err := planner.PlanEvent("push")
require.NoError(t, err)
err = runner.NewPlanExecutor(plan)(common.WithDryrun(context.Background(), true))
require.Error(t, err)
assert.Contains(t, err.Error(), "shortened version of a commit SHA")
}
func TestActionCache(t *testing.T) {
a := assert.New(t) a := assert.New(t)
ctx := context.Background()
// Build a local bare repo with a `js` action dir so this runs offline (formerly cloned
// github.com/nektos/act-test-actions over the network). allowAnySHA1InWant lets the
// "Fetch Sha" case fetch a commit hash directly.
remoteDir := t.TempDir()
runGit(t, "", "init", "--bare", "--initial-branch=main", remoteDir)
runGit(t, remoteDir, "config", "uploadpack.allowAnySHA1InWant", "true")
workDir := t.TempDir()
runGit(t, "", "clone", remoteDir, workDir)
require.NoError(t, os.MkdirAll(filepath.Join(workDir, "js"), 0o755))
require.NoError(t, os.WriteFile(filepath.Join(workDir, "js", "action.yml"),
[]byte("name: js\nruns:\n using: node24\n main: index.js\n"), 0o644))
require.NoError(t, os.WriteFile(filepath.Join(workDir, "js", "index.js"),
[]byte("console.log('hello');\n"), 0o644))
runGit(t, workDir, "add", ".")
runGit(t, workDir, "commit", "-m", "initial")
runGit(t, workDir, "push", "-u", "origin", "main")
out, err := exec.Command("git", "-C", workDir, "rev-parse", "main").Output()
require.NoError(t, err)
fullSha := strings.TrimSpace(string(out))
cache := &GoGitActionCache{ cache := &GoGitActionCache{
Path: t.TempDir(), Path: t.TempDir(),
} }
ctx := context.Background() cacheDir := "local/act-test-actions"
cacheDir := "nektos/act-test-actions"
repo := "https://github.com/nektos/act-test-actions"
refs := []struct { refs := []struct {
Name string Name string
CacheDir string Ref string
Repo string
Ref string
}{ }{
{ {Name: "Fetch Branch Name", Ref: "main"},
Name: "Fetch Branch Name", {Name: "Fetch Branch Name Absolutely", Ref: "refs/heads/main"},
CacheDir: cacheDir, {Name: "Fetch HEAD", Ref: "HEAD"},
Repo: repo, {Name: "Fetch Sha", Ref: fullSha},
Ref: "main",
},
{
Name: "Fetch Branch Name Absolutely",
CacheDir: cacheDir,
Repo: repo,
Ref: "refs/heads/main",
},
{
Name: "Fetch HEAD",
CacheDir: cacheDir,
Repo: repo,
Ref: "HEAD",
},
{
Name: "Fetch Sha",
CacheDir: cacheDir,
Repo: repo,
Ref: "de984ca37e4df4cb9fd9256435a3b82c4a2662b1",
},
} }
for _, c := range refs { for _, c := range refs {
t.Run(c.Name, func(t *testing.T) { t.Run(c.Name, func(t *testing.T) {
sha, err := cache.Fetch(ctx, c.CacheDir, c.Repo, c.Ref, "") sha, err := cache.Fetch(ctx, cacheDir, remoteDir, c.Ref, "")
if !a.NoError(err) || !a.NotEmpty(sha) { //nolint:testifylint // pre-existing issue from nektos/act if !a.NoError(err) || !a.NotEmpty(sha) { //nolint:testifylint // pre-existing issue from nektos/act
return return
} }
atar, err := cache.GetTarArchive(ctx, c.CacheDir, sha, "js") atar, err := cache.GetTarArchive(ctx, cacheDir, sha, "js")
if !a.NoError(err) || !a.NotEmpty(atar) { //nolint:testifylint // pre-existing issue from nektos/act // NotNil, not NotEmpty: atar is a live io.PipeReader whose producer goroutine is
// writing concurrently; NotEmpty deep-reflects over its internals and races.
if !a.NoError(err) || !a.NotNil(atar) { //nolint:testifylint // pre-existing issue from nektos/act
return return
} }
// GetTarArchive streams from a background goroutine walking the shared repo.
// Drain and close so it finishes before the next subtest fetches into the same
// repo; otherwise the lingering walk races with that fetch.
defer func() {
_, _ = io.Copy(io.Discard, atar)
_ = atar.Close()
}()
mytar := tar.NewReader(atar) mytar := tar.NewReader(atar)
th, err := mytar.Next() th, err := mytar.Next()
if !a.NoError(err) || !a.NotEqual(0, th.Size) { //nolint:testifylint // pre-existing issue from nektos/act if !a.NoError(err) || !a.NotEqual(0, th.Size) { //nolint:testifylint // pre-existing issue from nektos/act

View File

@@ -9,8 +9,13 @@ import (
"io" "io"
"io/fs" "io/fs"
"strings" "strings"
"sync"
"testing" "testing"
"time"
"gitea.com/gitea/runner/act/common"
"gitea.com/gitea/runner/act/common/git"
"gitea.com/gitea/runner/act/container"
"gitea.com/gitea/runner/act/model" "gitea.com/gitea/runner/act/model"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
@@ -252,3 +257,201 @@ func TestActionRunner(t *testing.T) {
}) })
} }
} }
func TestNewStepContainerDoesNotUseDockerSecrets(t *testing.T) {
cm := &containerMock{}
var captured *container.NewContainerInput
origContainerNewContainer := ContainerNewContainer
ContainerNewContainer = func(input *container.NewContainerInput) container.ExecutionsEnvironment {
captured = input
return cm
}
defer func() {
ContainerNewContainer = origContainerNewContainer
}()
ctx := context.Background()
rc := &RunContext{
Name: "job",
Config: &Config{
Secrets: map[string]string{
"DOCKER_USERNAME": "docker-user",
"DOCKER_PASSWORD": "docker-password",
},
},
Run: &model.Run{
JobID: "job",
Workflow: &model.Workflow{
Name: "test",
Jobs: map[string]*model.Job{
"job": {},
},
},
},
JobContainer: cm,
StepResults: map[string]*model.StepResult{},
}
env := map[string]string{}
step := &stepMock{}
step.On("getRunContext").Return(rc)
step.On("getStepModel").Return(&model.Step{ID: "action"})
step.On("getEnv").Return(&env)
_ = newStepContainer(ctx, step, "registry.example.com/action:tag", nil, nil)
// DOCKER_USERNAME/DOCKER_PASSWORD should not be injected as pull credentials for docker action containers.
assert.Empty(t, captured.Username)
assert.Empty(t, captured.Password)
step.AssertExpectations(t)
}
func TestMaybeCopyToActionDirHoldsCloneLock(t *testing.T) {
ctx := context.Background()
actionDir := t.TempDir()
releaseCopy := make(chan struct{})
release := sync.OnceFunc(func() { close(releaseCopy) })
defer release()
copyEntered := make(chan struct{})
cm := &containerMock{}
cm.On("CopyDir", "/var/run/act/actions/", actionDir+"/", false).Return(func(ctx context.Context) error {
close(copyEntered)
<-releaseCopy
return nil
})
step := &stepActionRemote{
Step: &model.Step{Uses: "remote/action@v1"},
RunContext: &RunContext{
Config: &Config{},
JobContainer: cm,
},
}
copyDone := make(chan error, 1)
go func() {
copyDone <- maybeCopyToActionDir(ctx, step, actionDir, "", "/var/run/act/actions/")
}()
select {
case <-copyEntered:
case err := <-copyDone:
t.Fatalf("maybeCopyToActionDir returned before CopyDir was entered: %v", err)
case <-time.After(time.Second):
t.Fatal("CopyDir was not entered within 1 second")
}
peerAcquired := make(chan struct{})
go func() {
unlock := git.AcquireCloneLock(actionDir)
close(peerAcquired)
unlock()
}()
select {
case <-peerAcquired:
t.Fatal("peer AcquireCloneLock returned while CopyDir was running")
case <-time.After(50 * time.Millisecond):
}
release()
select {
case err := <-copyDone:
if err != nil {
t.Fatalf("maybeCopyToActionDir returned error: %v", err)
}
case <-time.After(time.Second):
t.Fatal("maybeCopyToActionDir did not return after CopyDir was unblocked")
}
select {
case <-peerAcquired:
case <-time.After(time.Second):
t.Fatal("peer AcquireCloneLock did not proceed after lock released")
}
cm.AssertExpectations(t)
}
func TestExecAsDockerHoldsCloneLockForRemoteUncached(t *testing.T) {
actionDir := t.TempDir()
unlockOnce := sync.OnceFunc(git.AcquireCloneLock(actionDir))
defer unlockOnce()
innerEntered := make(chan struct{})
releaseInner := make(chan struct{})
releaseOnce := sync.OnceFunc(func() { close(releaseInner) })
defer releaseOnce()
origImageExists := ContainerImageExistsLocally
ContainerImageExistsLocally = func(_ context.Context, _, _ string) (bool, error) {
return false, nil
}
defer func() { ContainerImageExistsLocally = origImageExists }()
origBuildExec := ContainerNewDockerBuildExecutor
ContainerNewDockerBuildExecutor = func(_ container.NewDockerBuildExecutorInput) common.Executor {
return func(_ context.Context) error {
close(innerEntered)
<-releaseInner
return nil
}
}
defer func() { ContainerNewDockerBuildExecutor = origBuildExec }()
step := &stepActionRemote{
Step: &model.Step{ID: "1", Uses: "remote/action@v1", With: map[string]string{}},
RunContext: &RunContext{
Config: &Config{},
Run: &model.Run{
JobID: "1",
Workflow: &model.Workflow{
Name: "wf",
Jobs: map[string]*model.Job{"1": {}},
},
},
JobContainer: &containerMock{},
},
action: &model.Action{Runs: model.ActionRuns{Using: "docker", Image: "Dockerfile"}},
env: map[string]string{},
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
done := make(chan error, 1)
go func() { done <- execAsDocker(ctx, step, "test-action", actionDir, actionDir, false) }()
select {
case <-innerEntered:
t.Fatal("inner build executor ran before clone lock was released")
case err := <-done:
t.Fatalf("execAsDocker returned before inner was entered: %v", err)
case <-time.After(50 * time.Millisecond):
}
unlockOnce()
select {
case <-innerEntered:
case err := <-done:
t.Fatalf("execAsDocker returned without entering inner: %v", err)
case <-time.After(time.Second):
t.Fatal("inner build executor not entered after lock released")
}
cancel()
releaseOnce()
select {
case <-done:
case <-time.After(time.Second):
t.Fatal("execAsDocker did not return after inner was released and ctx was canceled")
}
}

View File

@@ -51,7 +51,7 @@ func (rc *RunContext) commandHandler(ctx context.Context) common.LineHandler {
logger.Infof("%s", line) logger.Infof("%s", line)
return false return false
} }
arg = unescapeCommandData(arg) arg = UnescapeCommandData(arg)
kvPairs = unescapeKvPairs(kvPairs) kvPairs = unescapeKvPairs(kvPairs)
switch command { switch command {
case "set-env": case "set-env":
@@ -120,7 +120,7 @@ func (rc *RunContext) setOutput(ctx context.Context, kvPairs map[string]string,
result, ok := rc.StepResults[stepID] result, ok := rc.StepResults[stepID]
if !ok { if !ok {
logger.Infof(" \U00002757 no outputs used step '%s'", stepID) logger.Infof("No outputs registered for step '%s'", stepID)
return return
} }
@@ -151,7 +151,7 @@ func parseKeyValuePairs(kvPairs, separator string) map[string]string {
return rtn return rtn
} }
func unescapeCommandData(arg string) string { func UnescapeCommandData(arg string) string {
escapeMap := map[string]string{ escapeMap := map[string]string{
"%25": "%", "%25": "%",
"%0D": "\r", "%0D": "\r",

View File

@@ -562,15 +562,15 @@ func getWorkflowSecrets(ctx context.Context, rc *RunContext) map[string]string {
secrets = rc.caller.runContext.Config.Secrets secrets = rc.caller.runContext.Config.Secrets
} }
if secrets == nil { // Interpolate into a new map. secrets may be the shared Config.Secrets (or the job's
secrets = map[string]string{} // 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 { for k, v := range secrets {
secrets[k] = rc.caller.runContext.ExprEval.Interpolate(ctx, v) interpolated[k] = rc.caller.runContext.ExprEval.Interpolate(ctx, v)
} }
return secrets return interpolated
} }
return rc.Config.Secrets return rc.Config.Secrets

View File

@@ -0,0 +1,66 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package runner
import (
"context"
"net"
"os/exec"
"runtime"
"testing"
"time"
"gitea.com/gitea/runner/act/container"
mobyclient "github.com/moby/moby/client"
)
// requireLinuxDocker skips on non-Linux hosts. Some integration workflows need Docker features
// that only a Linux daemon provides (host networking, host /proc bind mounts); Docker Desktop
// on macOS/Windows does not, so those tests can only run on Linux.
func requireLinuxDocker(t *testing.T) {
t.Helper()
if runtime.GOOS != "linux" {
t.Skip("skipping: requires a Linux Docker host")
}
}
// requireDocker skips the test unless a reachable docker daemon is available.
// GetDockerClient succeeds even without a running daemon (its ping is best-effort),
// so the daemon has to be pinged explicitly here to decide whether to skip.
func requireDocker(t *testing.T) {
t.Helper()
ctx := context.Background()
cli, err := container.GetDockerClient(ctx)
if err != nil {
t.Skipf("skipping: docker client unavailable: %v", err)
}
defer cli.Close()
if _, err := cli.Ping(ctx, mobyclient.PingOptions{}); err != nil {
t.Skipf("skipping: docker daemon unreachable: %v", err)
}
}
// requireNetwork skips the test unless github.com is reachable. A few tests exercise behaviour
// that inherently needs the network (force-pulling an image, resolving a remote short-sha ref);
// gating lets the rest of the suite run offline without these failing.
func requireNetwork(t *testing.T) {
t.Helper()
conn, err := net.DialTimeout("tcp", "github.com:443", 3*time.Second)
if err != nil {
t.Skipf("skipping: network unavailable: %v", err)
}
_ = conn.Close()
}
// requireHostTools skips the test unless every named executable is on PATH. Used by the
// self-hosted (host environment) suite, which runs steps directly on the host.
func requireHostTools(t *testing.T, tools ...string) {
t.Helper()
for _, tool := range tools {
if _, err := exec.LookPath(tool); err != nil {
t.Skipf("skipping: required host tool %q not found: %v", tool, err)
}
}
}

View File

@@ -5,15 +5,46 @@
package runner package runner
import ( import (
"archive/tar"
"bytes"
"context" "context"
"encoding/base64"
"encoding/json"
"fmt" "fmt"
"io"
"net/http"
"path"
"slices"
"strconv" "strconv"
"strings"
"time" "time"
"unicode"
"gitea.com/gitea/runner/act/common" "gitea.com/gitea/runner/act/common"
"gitea.com/gitea/runner/act/container"
"gitea.com/gitea/runner/act/model" "gitea.com/gitea/runner/act/model"
) )
const maxJobSummaryBytes = 1024 * 1024
// jobSummaryTruncationMarker is appended to a summary that exceeded the size limit
// so the rendered output makes the truncation visible instead of silently cutting off.
const jobSummaryTruncationMarker = "\n\n---\n\n*Job summary truncated: it exceeded the maximum allowed size.*\n"
var (
jobSummaryUploadRetryDelay = time.Second
// jobSummaryUploadRequestTimeout bounds a single step upload request. It is kept
// below jobSummaryUploadPhaseTimeout so one slow or unreachable request times out
// and lets the remaining steps still upload within the phase budget, instead of a
// single stuck request consuming the whole phase.
jobSummaryUploadRequestTimeout = 5 * time.Second
// jobSummaryUploadPhaseTimeout bounds the total time spent uploading all step
// summaries. The uploads run inside the job cleanup budget that is also used to
// stop and remove the container, so a slow or unreachable endpoint must not be
// allowed to consume it; this keeps the remaining budget available for teardown.
jobSummaryUploadPhaseTimeout = 15 * time.Second
)
type jobInfo interface { type jobInfo interface {
matrix() map[string]any matrix() map[string]any
steps() []*model.Step steps() []*model.Step
@@ -80,8 +111,10 @@ func newJobExecutor(info jobInfo, sf stepFactory, rc *RunContext) common.Executo
return common.NewErrorExecutor(err) return common.NewErrorExecutor(err)
} }
stepIdx := stepModel.Number
preExec := step.pre() preExec := step.pre()
preSteps = append(preSteps, useStepLogger(rc, stepModel, stepStagePre, func(ctx context.Context) error { preSteps = append(preSteps, useStepLogger(rc, stepModel, stepStagePre, func(ctx context.Context) error {
rc.CurrentStepIndex = stepIdx
preErr := preExec(ctx) preErr := preExec(ctx)
if preErr != nil { if preErr != nil {
reportStepError(ctx, preErr) reportStepError(ctx, preErr)
@@ -93,6 +126,7 @@ func newJobExecutor(info jobInfo, sf stepFactory, rc *RunContext) common.Executo
stepExec := step.main() stepExec := step.main()
steps = append(steps, useStepLogger(rc, stepModel, stepStageMain, func(ctx context.Context) error { steps = append(steps, useStepLogger(rc, stepModel, stepStageMain, func(ctx context.Context) error {
rc.CurrentStepIndex = stepIdx
err := stepExec(ctx) err := stepExec(ctx)
if err != nil { if err != nil {
reportStepError(ctx, err) reportStepError(ctx, err)
@@ -104,6 +138,7 @@ func newJobExecutor(info jobInfo, sf stepFactory, rc *RunContext) common.Executo
postFn := step.post() postFn := step.post()
postExec := useStepLogger(rc, stepModel, stepStagePost, func(ctx context.Context) error { postExec := useStepLogger(rc, stepModel, stepStagePost, func(ctx context.Context) error {
rc.CurrentStepIndex = stepIdx
err := postFn(ctx) err := postFn(ctx)
if err != nil { if err != nil {
reportStepError(ctx, err) reportStepError(ctx, err)
@@ -129,6 +164,7 @@ func newJobExecutor(info jobInfo, sf stepFactory, rc *RunContext) common.Executo
defer cancel() defer cancel()
logger := common.Logger(ctx) logger := common.Logger(ctx)
tryUploadJobSummary(ctx, rc)
// For Gitea // For Gitea
// We don't need to call `stopServiceContainers` here since it will be called by following `info.stopContainer` // We don't need to call `stopServiceContainers` here since it will be called by following `info.stopContainer`
// logger.Infof("Cleaning up services for job %s", rc.JobName) // logger.Infof("Cleaning up services for job %s", rc.JobName)
@@ -183,18 +219,25 @@ func newJobExecutor(info jobInfo, sf stepFactory, rc *RunContext) common.Executo
func setJobResult(ctx context.Context, info jobInfo, rc *RunContext, success bool) { func setJobResult(ctx context.Context, info jobInfo, rc *RunContext, success bool) {
logger := common.Logger(ctx) logger := common.Logger(ctx)
jobResult := "success" // Matrix combinations share one *model.Job and run in parallel; serialize the
// we have only one result for a whole matrix build, so we need // read-modify-write of the job result so a failing combination is not lost-updated by a
// to keep an existing result state if we run a matrix // concurrent succeeding one.
if len(info.matrix()) > 0 && rc.Run.Job().Result != "" { job := rc.Run.Job()
jobResult = rc.Run.Job().Result jobResult := func() string {
} defer lockJob(job)()
result := "success"
// we have only one result for a whole matrix build, so we need
// to keep an existing result state if we run a matrix
if len(info.matrix()) > 0 && job.Result != "" {
result = job.Result
}
if !success {
result = "failure"
}
info.result(result)
return result
}()
if !success {
jobResult = "failure"
}
info.result(jobResult)
if rc.caller != nil { if rc.caller != nil {
// set reusable workflow job result // set reusable workflow job result
rc.caller.setReusedWorkflowJobResult(rc.JobName, jobResult) // For Gitea rc.caller.setReusedWorkflowJobResult(rc.JobName, jobResult) // For Gitea
@@ -220,10 +263,188 @@ func setJobOutputs(ctx context.Context, rc *RunContext) {
callerOutputs[k] = ee.Interpolate(ctx, ee.Interpolate(ctx, v.Value)) callerOutputs[k] = ee.Interpolate(ctx, ee.Interpolate(ctx, v.Value))
} }
rc.caller.runContext.Run.Job().Outputs = callerOutputs // 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
} }
} }
func tryUploadJobSummary(ctx context.Context, rc *RunContext) {
if rc == nil || rc.JobContainer == nil || rc.Config == nil {
return
}
// Bound the whole upload phase so a slow or unreachable endpoint cannot consume
// the job cleanup budget reserved for stopping and removing the container.
ctx, cancel := context.WithTimeout(ctx, jobSummaryUploadPhaseTimeout)
defer cancel()
env := rc.GetEnv()
caps := strings.TrimSpace(env["GITEA_ACTIONS_CAPABILITIES"])
if !hasJobSummaryCapability(caps) {
// Server did not advertise support. Do not attempt upload.
return
}
runtimeURL := strings.TrimSpace(env["ACTIONS_RUNTIME_URL"])
runtimeToken := strings.TrimSpace(env["ACTIONS_RUNTIME_TOKEN"])
runID := strings.TrimSpace(env["GITEA_RUN_ID"])
if runtimeURL == "" || runtimeToken == "" || runID == "" {
return
}
if rc.Run == nil || rc.Run.Job() == nil {
return
}
// The numeric ActionRunJob ID is not exposed in the proto Task message or task context,
// but the server signs it into the ACTIONS_RUNTIME_TOKEN JWT claims. We decode the
// unverified claims to retrieve it; the server re-verifies the token on the request.
jobID := extractJobIDFromRuntimeToken(runtimeToken)
if jobID <= 0 {
return
}
base := strings.TrimRight(runtimeURL, "/") + "/_apis/pipelines/workflows/" + runID +
"/jobs/" + strconv.FormatInt(jobID, 10) + "/steps/"
actPath := rc.JobContainer.GetActPath()
// Reuse a single client across all step uploads so connections can be pooled.
client := &http.Client{Timeout: jobSummaryUploadRequestTimeout}
for i := range rc.Run.Job().Steps {
summaryPath := path.Join(actPath, "workflow", "step-summary-"+strconv.Itoa(i)+".md")
body, ok := readSingleFileFromContainerArchive(ctx, rc.JobContainer, summaryPath, maxJobSummaryBytes)
if !ok || len(body) == 0 {
continue
}
uploadJobSummary(ctx, client, base+strconv.Itoa(i)+"/summary", runtimeToken, body)
}
}
// extractJobIDFromRuntimeToken returns the JobID claim from an ACTIONS_RUNTIME_TOKEN JWT
// without verifying its signature. Returns 0 if the token is unparseable or has no JobID.
func extractJobIDFromRuntimeToken(token string) int64 {
parts := strings.Split(token, ".")
if len(parts) != 3 {
return 0
}
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
return 0
}
var claims struct {
JobID int64 `json:"JobID"`
}
if err := json.Unmarshal(payload, &claims); err != nil {
return 0
}
return claims.JobID
}
func hasJobSummaryCapability(caps string) bool {
return slices.Contains(strings.FieldsFunc(caps, func(r rune) bool {
return r == ',' || unicode.IsSpace(r)
}), "job-summary")
}
func uploadJobSummary(ctx context.Context, client *http.Client, url, runtimeToken string, body []byte) {
logger := common.Logger(ctx)
var lastStatus int
var lastErr error
for attempt := 0; attempt < 2; attempt++ {
status, err := putJobSummary(ctx, client, url, runtimeToken, body)
if err == nil && status/100 == 2 {
return
}
lastStatus = status
lastErr = err
if attempt == 1 || !isTransientJobSummaryUploadFailure(status, err) {
break
}
timer := time.NewTimer(jobSummaryUploadRetryDelay)
select {
case <-ctx.Done():
timer.Stop()
lastErr = ctx.Err()
attempt = 1
case <-timer.C:
}
}
// Best-effort only; do not fail job, but log because capability was advertised.
if lastErr != nil {
logger.WithError(lastErr).Warn("job summary upload failed")
return
}
logger.Warnf("job summary upload failed: status=%d", lastStatus)
}
func putJobSummary(ctx context.Context, client *http.Client, url, runtimeToken string, body []byte) (int, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodPut, url, bytes.NewReader(body))
if err != nil {
return 0, err
}
req.Header.Set("Authorization", "Bearer "+runtimeToken)
req.Header.Set("Content-Type", "text/markdown; charset=utf-8")
resp, err := client.Do(req)
if err != nil {
return 0, err
}
defer resp.Body.Close()
_, _ = io.Copy(io.Discard, resp.Body)
return resp.StatusCode, nil
}
func isTransientJobSummaryUploadFailure(status int, err error) bool {
return err != nil || status == http.StatusRequestTimeout || status == http.StatusTooManyRequests || status/100 == 5
}
func readSingleFileFromContainerArchive(ctx context.Context, env container.ExecutionsEnvironment, p string, maxBytes int64) ([]byte, bool) {
rc, err := env.GetContainerArchive(ctx, p)
if err != nil {
return nil, false
}
defer rc.Close()
tr := tar.NewReader(rc)
for {
header, err := tr.Next()
if err == io.EOF {
return nil, false
}
if err != nil {
return nil, false
}
if header.Typeflag != tar.TypeReg {
continue
}
if !archiveEntryMatchesPath(header.Name, p) {
continue
}
// Summaries larger than the limit are truncated rather than dropped, so the
// user still gets the leading content (mirroring how GitHub caps oversized
// step summaries instead of discarding them). Read one extra byte so an
// over-limit file is detected from the actual stream rather than trusting
// header.Size, then cap the returned content at maxBytes.
b, err := io.ReadAll(io.LimitReader(tr, maxBytes+1))
if err != nil {
return nil, false
}
if int64(len(b)) > maxBytes {
// Reserve room for the marker so the marked-up result still fits in maxBytes.
marker := []byte(jobSummaryTruncationMarker)
keep := max(maxBytes-int64(len(marker)), 0)
b = append(b[:keep], marker...)
common.Logger(ctx).Warnf("job summary truncated: path=%s max=%d", p, maxBytes)
}
return b, true
}
}
func archiveEntryMatchesPath(entryName, requestedPath string) bool {
entryName = path.Clean(strings.TrimPrefix(entryName, "/"))
requestedPath = path.Clean(strings.TrimPrefix(requestedPath, "/"))
return entryName == requestedPath || entryName == path.Base(requestedPath)
}
func useStepLogger(rc *RunContext, stepModel *model.Step, stage stepStage, executor common.Executor) common.Executor { func useStepLogger(rc *RunContext, stepModel *model.Step, stage stepStage, executor common.Executor) common.Executor {
return func(ctx context.Context) error { return func(ctx context.Context) error {
ctx = withStepLogger(ctx, stepModel.Number, stepModel.ID, rc.ExprEval.Interpolate(ctx, stepModel.String()), stage.String()) ctx = withStepLogger(ctx, stepModel.Number, stepModel.ID, rc.ExprEval.Interpolate(ctx, stepModel.String()), stage.String())

View File

@@ -5,34 +5,39 @@
package runner package runner
import ( import (
"archive/tar"
"bytes"
"context" "context"
"encoding/base64"
"errors" "errors"
"fmt" "fmt"
"io" "io"
"net/http"
"net/http/httptest"
"slices" "slices"
"strconv"
"strings"
"testing" "testing"
"time"
"gitea.com/gitea/runner/act/common" "gitea.com/gitea/runner/act/common"
"gitea.com/gitea/runner/act/container" "gitea.com/gitea/runner/act/container"
"gitea.com/gitea/runner/act/model" "gitea.com/gitea/runner/act/model"
logrustest "github.com/sirupsen/logrus/hooks/test"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock" "github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
) )
func TestJobExecutor(t *testing.T) { func TestJobExecutor(t *testing.T) {
if testing.Short() { // Dryrun only checks syntax/planning; all cases resolve locally, so this runs offline.
t.Skip("skipping integration test")
}
tables := []TestJobFileInfo{ tables := []TestJobFileInfo{
{workdir, "uses-and-run-in-one-step", "push", "Invalid run/uses syntax for job:test step:Test", platforms, secrets}, {workdir, "uses-and-run-in-one-step", "push", "Invalid run/uses syntax for job:test step:Test", platforms, secrets},
{workdir, "uses-github-empty", "push", "Expected format {org}/{repo}[/path]@ref", platforms, secrets}, {workdir, "uses-github-empty", "push", "Expected format {org}/{repo}[/path]@ref", platforms, secrets},
{workdir, "uses-github-noref", "push", "Expected format {org}/{repo}[/path]@ref", platforms, secrets}, {workdir, "uses-github-noref", "push", "Expected format {org}/{repo}[/path]@ref", platforms, secrets},
{workdir, "uses-github-root", "push", "", platforms, secrets}, {workdir, "uses-github-root", "push", "", platforms, secrets},
{workdir, "uses-github-path", "push", "", platforms, secrets},
{workdir, "uses-docker-url", "push", "", platforms, secrets}, {workdir, "uses-docker-url", "push", "", platforms, secrets},
{workdir, "uses-github-full-sha", "push", "", platforms, secrets},
{workdir, "uses-github-short-sha", "push", "Unable to resolve action `actions/hello-world-docker-action@b136eb8`, the provided ref `b136eb8` is the shortened version of a commit SHA, which is not supported. Please use the full commit SHA `b136eb8894c5cb1dd5807da824be97ccdf9b5423` instead", platforms, secrets},
{workdir, "job-nil-step", "push", "invalid Step 0: missing run or uses key", platforms, secrets}, {workdir, "job-nil-step", "push", "invalid Step 0: missing run or uses key", platforms, secrets},
} }
// These tests are sufficient to only check syntax. // These tests are sufficient to only check syntax.
@@ -341,3 +346,331 @@ func TestNewJobExecutor(t *testing.T) {
}) })
} }
} }
func TestHasJobSummaryCapability(t *testing.T) {
assert.True(t, hasJobSummaryCapability("cache,job-summary artifacts"))
assert.True(t, hasJobSummaryCapability("cache,\njob-summary\tartifacts"))
assert.False(t, hasJobSummaryCapability("not-job-summary,job-summary-v2"))
}
// fakeRuntimeToken builds a JWT-shaped string whose middle (claims) segment encodes
// the given JobID. The header and signature segments are filler — the runner does not
// verify the signature; the server does.
func fakeRuntimeToken(jobID int64) string {
header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"HS256","typ":"JWT"}`))
claims := base64.RawURLEncoding.EncodeToString(fmt.Appendf(nil, `{"JobID":%d}`, jobID))
sig := base64.RawURLEncoding.EncodeToString([]byte("sig"))
return header + "." + claims + "." + sig
}
func newJobSummaryRC(env map[string]string, jobContainer container.ExecutionsEnvironment, stepCount int) *RunContext {
steps := make([]*model.Step, stepCount)
for i := range steps {
steps[i] = &model.Step{ID: strconv.Itoa(i)}
}
return &RunContext{
Config: &Config{},
JobContainer: jobContainer,
Env: env,
Run: &model.Run{
JobID: "test",
Workflow: &model.Workflow{
Jobs: map[string]*model.Job{
"test": {Steps: steps},
},
},
},
}
}
func TestTryUploadJobSummaryRetriesTransientFailure(t *testing.T) {
oldDelay := jobSummaryUploadRetryDelay
jobSummaryUploadRetryDelay = 0
defer func() {
jobSummaryUploadRetryDelay = oldDelay
}()
runtimeToken := fakeRuntimeToken(34)
requests := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests++
assert.Equal(t, http.MethodPut, r.Method)
assert.Equal(t, "/_apis/pipelines/workflows/12/jobs/34/steps/0/summary", r.URL.Path)
assert.Equal(t, "Bearer "+runtimeToken, r.Header.Get("Authorization"))
assert.Equal(t, "text/markdown; charset=utf-8", r.Header.Get("Content-Type"))
body, err := io.ReadAll(r.Body)
assert.NoError(t, err)
assert.Equal(t, []byte("# summary"), body)
if requests == 1 {
w.WriteHeader(http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}))
defer server.Close()
ctx := context.Background()
cm := &containerMock{}
cm.On("GetContainerArchive", mock.Anything, "/var/run/act/workflow/step-summary-0.md").Return(
io.NopCloser(bytes.NewReader(tarArchive(t, tarEntry{name: "step-summary-0.md", body: "# summary"}))),
nil,
).Once()
rc := newJobSummaryRC(map[string]string{
"GITEA_ACTIONS_CAPABILITIES": "cache, job-summary",
"ACTIONS_RUNTIME_URL": server.URL,
"ACTIONS_RUNTIME_TOKEN": runtimeToken,
"GITEA_RUN_ID": "12",
}, cm, 1)
tryUploadJobSummary(ctx, rc)
assert.Equal(t, 2, requests)
cm.AssertExpectations(t)
}
func TestTryUploadJobSummaryStopsAtPhaseTimeout(t *testing.T) {
oldPhase := jobSummaryUploadPhaseTimeout
jobSummaryUploadPhaseTimeout = 100 * time.Millisecond
defer func() {
jobSummaryUploadPhaseTimeout = oldPhase
}()
runtimeToken := fakeRuntimeToken(34)
// The server blocks until either the request context is cancelled (the behaviour
// under test: the phase timeout aborts the in-flight upload) or the test tears it
// down. Without the phase timeout the upload would hang until the 30s client
// timeout instead of releasing the cleanup budget. The release channel guarantees
// the handler always returns so server.Close() cannot itself hang.
release := make(chan struct{})
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
select {
case <-r.Context().Done():
case <-release:
}
}))
defer server.Close()
defer close(release)
ctx := context.Background()
cm := &containerMock{}
cm.On("GetContainerArchive", mock.Anything, "/var/run/act/workflow/step-summary-0.md").Return(
io.NopCloser(bytes.NewReader(tarArchive(t, tarEntry{name: "step-summary-0.md", body: "# summary"}))),
nil,
).Once()
rc := newJobSummaryRC(map[string]string{
"GITEA_ACTIONS_CAPABILITIES": "job-summary",
"ACTIONS_RUNTIME_URL": server.URL,
"ACTIONS_RUNTIME_TOKEN": runtimeToken,
"GITEA_RUN_ID": "12",
}, cm, 1)
done := make(chan struct{})
go func() {
defer close(done)
tryUploadJobSummary(ctx, rc)
}()
select {
case <-done:
case <-time.After(5 * time.Second):
t.Fatal("tryUploadJobSummary did not honour the phase timeout")
}
cm.AssertExpectations(t)
}
func TestTryUploadJobSummaryUploadsEachStepIndependently(t *testing.T) {
runtimeToken := fakeRuntimeToken(34)
type upload struct {
path string
body string
}
var got []upload
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
assert.NoError(t, err)
got = append(got, upload{r.URL.Path, string(body)})
w.WriteHeader(http.StatusNoContent)
}))
defer server.Close()
ctx := context.Background()
cm := &containerMock{}
// Three steps: 0 has content, 1 has empty content (skipped), 2 has content.
cm.On("GetContainerArchive", mock.Anything, "/var/run/act/workflow/step-summary-0.md").Return(
io.NopCloser(bytes.NewReader(tarArchive(t, tarEntry{name: "step-summary-0.md", body: "first"}))),
nil,
).Once()
cm.On("GetContainerArchive", mock.Anything, "/var/run/act/workflow/step-summary-1.md").Return(
io.NopCloser(bytes.NewReader(tarArchive(t, tarEntry{name: "step-summary-1.md", body: ""}))),
nil,
).Once()
cm.On("GetContainerArchive", mock.Anything, "/var/run/act/workflow/step-summary-2.md").Return(
io.NopCloser(bytes.NewReader(tarArchive(t, tarEntry{name: "step-summary-2.md", body: "third"}))),
nil,
).Once()
rc := newJobSummaryRC(map[string]string{
"GITEA_ACTIONS_CAPABILITIES": "job-summary",
"ACTIONS_RUNTIME_URL": server.URL,
"ACTIONS_RUNTIME_TOKEN": runtimeToken,
"GITEA_RUN_ID": "12",
}, cm, 3)
tryUploadJobSummary(ctx, rc)
assert.Equal(t, []upload{
{"/_apis/pipelines/workflows/12/jobs/34/steps/0/summary", "first"},
{"/_apis/pipelines/workflows/12/jobs/34/steps/2/summary", "third"},
}, got)
cm.AssertExpectations(t)
}
func TestTryUploadJobSummaryRequiresExactCapability(t *testing.T) {
requests := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests++
w.WriteHeader(http.StatusNoContent)
}))
defer server.Close()
rc := newJobSummaryRC(map[string]string{
"GITEA_ACTIONS_CAPABILITIES": "not-job-summary,job-summary-v2",
"ACTIONS_RUNTIME_URL": server.URL,
"ACTIONS_RUNTIME_TOKEN": fakeRuntimeToken(34),
"GITEA_RUN_ID": "12",
}, &containerMock{}, 1)
tryUploadJobSummary(context.Background(), rc)
assert.Equal(t, 0, requests)
}
func TestTryUploadJobSummarySkipsWhenJobIDMissingFromToken(t *testing.T) {
requests := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests++
w.WriteHeader(http.StatusNoContent)
}))
defer server.Close()
rc := newJobSummaryRC(map[string]string{
"GITEA_ACTIONS_CAPABILITIES": "job-summary",
"ACTIONS_RUNTIME_URL": server.URL,
"ACTIONS_RUNTIME_TOKEN": "not-a-jwt",
"GITEA_RUN_ID": "12",
}, &containerMock{}, 1)
tryUploadJobSummary(context.Background(), rc)
assert.Equal(t, 0, requests)
}
func TestExtractJobIDFromRuntimeToken(t *testing.T) {
assert.Equal(t, int64(42), extractJobIDFromRuntimeToken(fakeRuntimeToken(42)))
assert.Equal(t, int64(0), extractJobIDFromRuntimeToken("not-a-jwt"))
assert.Equal(t, int64(0), extractJobIDFromRuntimeToken("a.b.c"))
assert.Equal(t, int64(0), extractJobIDFromRuntimeToken(""))
}
func TestReadSingleFileFromContainerArchiveFindsMatchingRegularFile(t *testing.T) {
ctx := context.Background()
cm := &containerMock{}
cm.On("GetContainerArchive", ctx, "/var/run/act/workflow/SUMMARY.md").Return(
io.NopCloser(bytes.NewReader(tarArchive(t,
tarEntry{name: "workflow", typeflag: tar.TypeDir},
tarEntry{name: "other.md", body: "wrong"},
tarEntry{name: "SUMMARY.md", body: "right"},
))),
nil,
).Once()
body, ok := readSingleFileFromContainerArchive(ctx, cm, "/var/run/act/workflow/SUMMARY.md", 1024)
assert.True(t, ok)
assert.Equal(t, []byte("right"), body)
cm.AssertExpectations(t)
}
func TestReadSingleFileFromContainerArchiveTruncatesWhenTooLarge(t *testing.T) {
logger, hook := logrustest.NewNullLogger()
ctx := common.WithLogger(context.Background(), logger)
cm := &containerMock{}
content := strings.Repeat("a", 300)
cm.On("GetContainerArchive", ctx, "/var/run/act/workflow/SUMMARY.md").Return(
io.NopCloser(bytes.NewReader(tarArchive(t, tarEntry{name: "SUMMARY.md", body: content}))),
nil,
).Once()
const maxBytes = 200
body, ok := readSingleFileFromContainerArchive(ctx, cm, "/var/run/act/workflow/SUMMARY.md", maxBytes)
// Oversized summaries are truncated to the limit (reserving room for the marker)
// rather than dropped entirely, and the truncation marker is appended.
assert.True(t, ok)
assert.LessOrEqual(t, len(body), maxBytes)
keep := maxBytes - len(jobSummaryTruncationMarker)
assert.Equal(t, []byte(content[:keep]+jobSummaryTruncationMarker), body)
if assert.Len(t, hook.Entries, 1) {
assert.Contains(t, hook.Entries[0].Message, "job summary truncated")
}
cm.AssertExpectations(t)
}
func TestReadSingleFileFromContainerArchiveKeepsExactLimitWithoutWarning(t *testing.T) {
logger, hook := logrustest.NewNullLogger()
ctx := common.WithLogger(context.Background(), logger)
cm := &containerMock{}
cm.On("GetContainerArchive", ctx, "/var/run/act/workflow/SUMMARY.md").Return(
io.NopCloser(bytes.NewReader(tarArchive(t, tarEntry{name: "SUMMARY.md", body: "abc"}))),
nil,
).Once()
body, ok := readSingleFileFromContainerArchive(ctx, cm, "/var/run/act/workflow/SUMMARY.md", 3)
// A summary that is exactly at the limit is kept whole and not flagged as truncated.
assert.True(t, ok)
assert.Equal(t, []byte("abc"), body)
assert.Empty(t, hook.Entries)
cm.AssertExpectations(t)
}
type tarEntry struct {
name string
body string
typeflag byte
}
func tarArchive(t *testing.T, entries ...tarEntry) []byte {
t.Helper()
buf := &bytes.Buffer{}
tw := tar.NewWriter(buf)
for _, entry := range entries {
typeflag := entry.typeflag
if typeflag == 0 {
typeflag = tar.TypeReg
}
header := &tar.Header{
Name: entry.name,
Typeflag: typeflag,
Mode: 0o644,
Size: int64(len(entry.body)),
}
if typeflag == tar.TypeDir {
header.Mode = 0o755
header.Size = 0
}
require.NoError(t, tw.WriteHeader(header))
if typeflag == tar.TypeReg {
_, err := tw.Write([]byte(entry.body))
require.NoError(t, err)
}
}
require.NoError(t, tw.Close())
return buf.Bytes()
}

View File

@@ -10,6 +10,7 @@ import (
"fmt" "fmt"
"io" "io"
"os" "os"
"slices"
"strings" "strings"
"sync" "sync"
@@ -166,9 +167,29 @@ 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
func AppendSecretMasker(oldnew []string, v string) []string {
ret := oldnew
for l := range strings.SplitSeq(v, "\n") {
tm := strings.TrimSpace(l)
// formatted JSON secrets could otherwise mask {,[,],} everywhere
if len(tm) > 1 {
ret = append(ret, tm, "***")
}
}
return ret
}
// valueMasker applies secrets and ::add-mask:: patterns to every log entry, including // valueMasker applies secrets and ::add-mask:: patterns to every log entry, including
// raw_output (command/stream) lines; there is no bypass by field. // raw_output (command/stream) lines; there is no bypass by field.
func valueMasker(insecureSecrets bool, secrets map[string]string) entryProcessor { func valueMasker(insecureSecrets bool, secrets map[string]string) entryProcessor {
var oldnew []string
for _, v := range secrets {
oldnew = AppendSecretMasker(oldnew, v)
}
oldnew = slices.Clip(oldnew)
defReplacer := strings.NewReplacer(oldnew...)
return func(entry *logrus.Entry) *logrus.Entry { return func(entry *logrus.Entry) *logrus.Entry {
if insecureSecrets { if insecureSecrets {
return entry return entry
@@ -176,16 +197,16 @@ func valueMasker(insecureSecrets bool, secrets map[string]string) entryProcessor
masks := Masks(entry.Context) masks := Masks(entry.Context)
for _, v := range secrets { if len(*masks) == 0 {
if v != "" { entry.Message = defReplacer.Replace(entry.Message)
entry.Message = strings.ReplaceAll(entry.Message, v, "***") } else {
} cmasker := oldnew
}
for _, v := range *masks { for _, v := range *masks {
if v != "" { cmasker = AppendSecretMasker(cmasker, v)
entry.Message = strings.ReplaceAll(entry.Message, v, "***")
} }
entry.Message = strings.NewReplacer(cmasker...).Replace(entry.Message)
} }
return entry return entry

52
act/runner/logger_test.go Normal file
View File

@@ -0,0 +1,52 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package runner
import (
"strings"
"testing"
"github.com/sirupsen/logrus"
"github.com/stretchr/testify/assert"
)
func TestValueMasker(t *testing.T) {
table := []struct {
name string
lines string
secrets map[string]string
masks []string
disallowed []string
}{
{
name: "Multiline Private Key",
lines: "cat << EOF > private.key\nPRIVATE_KEY_BEGIN\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\nPRIVATE_KEY_END\nEOF",
secrets: map[string]string{
"PRIVATE_KEY": "PRIVATE_KEY_BEGIN\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\nPRIVATE_KEY_END",
},
disallowed: []string{"KEY", "dsdfseffefsefes", "PRIVATE_KEY_END"},
},
{
name: "Multiline Private Key in masks",
lines: "cat << EOF > private.key\nPRIVATE_KEY_BEGIN\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\nPRIVATE_KEY_END\nEOF",
masks: []string{"PRIVATE_KEY_BEGIN\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\ndsdfseffefsefes\nPRIVATE_KEY_END"},
disallowed: []string{"KEY", "dsdfseffefsefes", "PRIVATE_KEY_END"},
},
}
for _, entry := range table {
t.Run(entry.name, func(t *testing.T) {
ctx := WithMasks(t.Context(), &entry.masks)
masker := valueMasker(false, entry.secrets)
for line := range strings.SplitSeq(entry.lines, "\n") {
lentry := masker(&logrus.Entry{
Context: ctx,
Message: line,
})
for _, line := range entry.disallowed {
assert.NotContains(t, lentry.Message, line)
}
}
})
}
}

View File

@@ -10,6 +10,7 @@ import (
"fmt" "fmt"
"net/url" "net/url"
"path" "path"
"path/filepath"
"regexp" "regexp"
"strings" "strings"
@@ -27,7 +28,9 @@ func newLocalReusableWorkflowExecutor(rc *RunContext) common.Executor {
workflowDir = strings.TrimPrefix(workflowDir, "./") workflowDir = strings.TrimPrefix(workflowDir, "./")
return common.NewPipelineExecutor( return common.NewPipelineExecutor(
newReusableWorkflowExecutor(rc, workflowDir, fileName), // resolve the local workflow against the workspace root, not the process
// working directory, so it is found regardless of where the runner is invoked
newReusableWorkflowExecutor(rc, filepath.Join(rc.Config.Workdir, workflowDir), fileName),
) )
} }
@@ -142,9 +145,16 @@ func cloneRemoteReusableWorkflow(rc *RunContext, cloneURL, ref, targetDirectory,
} }
} }
var modelNewWorkflowPlanner = model.NewWorkflowPlanner
func newReusableWorkflowExecutor(rc *RunContext, directory, workflow string) common.Executor { func newReusableWorkflowExecutor(rc *RunContext, directory, workflow string) common.Executor {
return func(ctx context.Context) error { return func(ctx context.Context) error {
planner, err := model.NewWorkflowPlanner(path.Join(directory, workflow), true) // Scoped to the yaml read so concurrent invocations don't serialize
// on the whole job run.
planner, err := func() (model.WorkflowPlanner, error) {
defer git.AcquireCloneLock(directory)()
return modelNewWorkflowPlanner(path.Join(directory, workflow), true)
}()
if err != nil { if err != nil {
return err return err
} }
@@ -277,8 +287,12 @@ func setReusedWorkflowCallerResult(rc *RunContext, runner Runner) common.Executo
if rc.caller != nil { if rc.caller != nil {
rc.caller.setReusedWorkflowJobResult(rc.JobName, reusedWorkflowJobResult) rc.caller.setReusedWorkflowJobResult(rc.JobName, reusedWorkflowJobResult)
} else { } else {
// Serialize this shared Job.Result write against the other matrix combos
// and setJobResult (same lockJob key).
unlock := lockJob(rc.Run.Job())
rc.result(reusedWorkflowJobResult) rc.result(reusedWorkflowJobResult)
logger.WithField("jobResult", reusedWorkflowJobResult).Infof("\U0001F3C1 Job %s", reusedWorkflowJobResultMessage) unlock()
logger.WithField("jobResult", reusedWorkflowJobResult).Infof("Job %s", reusedWorkflowJobResultMessage)
} }
} }
@@ -301,6 +315,11 @@ func getGitCloneToken(conf *Config, cloneURL string) string {
// 1. cloneURL is from the same Gitea instance that the runner is registered to // 1. cloneURL is from the same Gitea instance that the runner is registered to
// 2. the cloneURL does not have basic auth embedded // 2. the cloneURL does not have basic auth embedded
func shouldCloneURLUseToken(instanceURL, cloneURL string) bool { func shouldCloneURLUseToken(instanceURL, cloneURL string) bool {
if !strings.HasPrefix(instanceURL, "http://") &&
!strings.HasPrefix(instanceURL, "https://") {
instanceURL = "https://" + instanceURL
}
u1, err1 := url.Parse(instanceURL) u1, err1 := url.Parse(instanceURL)
u2, err2 := url.Parse(cloneURL) u2, err2 := url.Parse(cloneURL)
if err1 != nil || err2 != nil { if err1 != nil || err2 != nil {

View File

@@ -5,11 +5,15 @@ package runner
import ( import (
"context" "context"
"errors"
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"sync"
"testing" "testing"
"time"
"gitea.com/gitea/runner/act/common/git"
"gitea.com/gitea/runner/act/model" "gitea.com/gitea/runner/act/model"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
@@ -71,6 +75,113 @@ func TestReusableWorkflowCachedBranchRefRefreshes(t *testing.T) {
require.Equal(t, tmpl("v2"), string(got), "cached workflow file must reflect the updated branch tip") require.Equal(t, tmpl("v2"), string(got), "cached workflow file must reflect the updated branch tip")
} }
func TestNewReusableWorkflowExecutorHoldsCloneLock(t *testing.T) {
workflowDir := t.TempDir()
unlockOnce := sync.OnceFunc(git.AcquireCloneLock(workflowDir))
defer unlockOnce()
plannerCalled := make(chan struct{})
origPlanner := modelNewWorkflowPlanner
modelNewWorkflowPlanner = func(string, bool) (model.WorkflowPlanner, error) {
close(plannerCalled)
return nil, errors.New("stop")
}
defer func() { modelNewWorkflowPlanner = origPlanner }()
rc := &RunContext{
Config: &Config{},
Run: &model.Run{Workflow: &model.Workflow{Jobs: map[string]*model.Job{}}},
}
exec := newReusableWorkflowExecutor(rc, workflowDir, "reusable.yml")
done := make(chan error, 1)
go func() { done <- exec(context.Background()) }()
select {
case <-plannerCalled:
t.Fatal("planner ran while clone lock was held")
case err := <-done:
t.Fatalf("executor returned before planner was reached: %v", err)
case <-time.After(50 * time.Millisecond):
}
unlockOnce()
select {
case <-plannerCalled:
case <-time.After(time.Second):
t.Fatal("planner not called after lock was released")
}
select {
case err := <-done:
require.Error(t, err)
case <-time.After(time.Second):
t.Fatal("executor did not return after planner ran")
}
}
func TestGetGitCloneTokenWithSchemalessGiteaInstance(t *testing.T) {
conf := &Config{
GitHubInstance: "gitea.example.net",
Secrets: map[string]string{
"GITEA_TOKEN": "token-value",
},
}
token := getGitCloneToken(conf, "https://gitea.example.net/actions/tools")
require.Equal(t, "token-value", token)
}
func TestShouldCloneURLUseToken(t *testing.T) {
tests := []struct {
name string
instanceURL string
cloneURL string
want bool
}{
{
name: "same host with schemaless instance",
instanceURL: "gitea.example.net",
cloneURL: "https://gitea.example.net/actions/tools",
want: true,
},
{
name: "same host with schemaless instance and port",
instanceURL: "gitea.example.net:3000",
cloneURL: "https://gitea.example.net:3000/actions/tools",
want: true,
},
{
name: "different host",
instanceURL: "gitea.example.net",
cloneURL: "https://github.com/actions/tools",
want: false,
},
{
name: "embedded basic auth",
instanceURL: "gitea.example.net",
cloneURL: "https://user:pass@gitea.example.net/actions/tools",
want: false,
},
{
name: "invalid clone URL",
instanceURL: "gitea.example.net",
cloneURL: "://gitea.example.net/actions/tools",
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
require.Equal(t, tt.want, shouldCloneURLUseToken(tt.instanceURL, tt.cloneURL))
})
}
}
func gitMust(t *testing.T, dir string, args ...string) { func gitMust(t *testing.T, dir string, args ...string) {
t.Helper() t.Helper()
cmd := exec.Command("git", args...) cmd := exec.Command("git", args...)

View File

@@ -20,7 +20,9 @@ import (
"path/filepath" "path/filepath"
"regexp" "regexp"
"runtime" "runtime"
"slices"
"strings" "strings"
"sync"
"time" "time"
"gitea.com/gitea/runner/act/common" "gitea.com/gitea/runner/act/common"
@@ -34,15 +36,19 @@ import (
// RunContext contains info about current job // RunContext contains info about current job
type RunContext struct { type RunContext struct {
Name string Name string
Config *Config Config *Config
Matrix map[string]any Matrix map[string]any
Run *model.Run Run *model.Run
EventJSON string EventJSON string
Env map[string]string Env map[string]string
GlobalEnv map[string]string // to pass env changes of GITHUB_ENV and set-env correctly, due to dirty Env field GlobalEnv map[string]string // to pass env changes of GITHUB_ENV and set-env correctly, due to dirty Env field
ExtraPath []string ExtraPath []string
CurrentStep string CurrentStep string
// CurrentStepIndex is the index of the top-level job step currently executing
// (model.Step.Number). Composite sub-steps inherit the outer step's index by
// walking the Parent chain; see topLevelRunContext.
CurrentStepIndex int
StepResults map[string]*model.StepResult StepResults map[string]*model.StepResult
IntraActionState map[string]map[string]string IntraActionState map[string]map[string]string
ExprEval ExpressionEvaluator ExprEval ExpressionEvaluator
@@ -55,6 +61,18 @@ type RunContext struct {
Masks []string Masks []string
cleanUpJobContainer common.Executor cleanUpJobContainer common.Executor
caller *caller // job calling this RunContext (reusable workflows) caller *caller // job calling this RunContext (reusable workflows)
// summaryFileInitialized tracks which per-step summary files (workflow/step-summary-N.md)
// have already been created on the JobContainer. The runner sets up file-command files
// via JobContainer.Copy at the start of every phase, which truncates them — fine for
// GITHUB_ENV/OUTPUT/STATE/PATH (consumed per phase) but wrong for GITHUB_STEP_SUMMARY,
// which has accumulating semantics. We initialize each step's summary file exactly once
// so writes from later phases and from composite sub-steps append to the same file.
// Only populated on the top-level RunContext; child RCs walk Parent via topLevelRunContext.
summaryFileInitialized map[int]bool
// outputTemplate is this combination's pristine snapshot of the job's output expressions,
// captured before execution so each matrix combo interpolates from the originals rather
// than from a sibling's already-resolved values written into the shared Job.Outputs.
outputTemplate map[string]string
} }
func (rc *RunContext) AddMask(mask string) { func (rc *RunContext) AddMask(mask string) {
@@ -130,17 +148,34 @@ func getDockerDaemonSocketMountPath(daemonPath string) string {
return daemonPath return daemonPath
} }
// containerDaemonSocket returns the configured Docker daemon socket, applying the default
// without mutating the shared Config. Parallel jobs in a plan share one *Config, so a job
// must never write to it.
func (rc *RunContext) containerDaemonSocket() string {
if rc.Config.ContainerDaemonSocket == "" {
return "/var/run/docker.sock"
}
return rc.Config.ContainerDaemonSocket
}
// validVolumes returns the volumes allowed on this job's containers: the configured base
// plus the volumes the runner mounts automatically. It derives a fresh slice every call and
// never mutates the shared Config (see containerDaemonSocket).
func (rc *RunContext) validVolumes() []string {
name := rc.jobContainerName()
volumes := slices.Clone(rc.Config.ValidVolumes)
// TODO: add a new configuration to control whether the docker daemon can be mounted
return append(volumes, "act-toolcache", name, name+"-env",
getDockerDaemonSocketMountPath(rc.containerDaemonSocket()))
}
// 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) {
name := rc.jobContainerName() name := rc.jobContainerName()
if rc.Config.ContainerDaemonSocket == "" {
rc.Config.ContainerDaemonSocket = "/var/run/docker.sock"
}
binds := []string{} binds := []string{}
if rc.Config.ContainerDaemonSocket != "-" { if daemonSocket := rc.containerDaemonSocket(); daemonSocket != "-" {
daemonPath := getDockerDaemonSocketMountPath(rc.Config.ContainerDaemonSocket) daemonPath := getDockerDaemonSocketMountPath(daemonSocket)
binds = append(binds, fmt.Sprintf("%s:%s", daemonPath, "/var/run/docker.sock")) binds = append(binds, fmt.Sprintf("%s:%s", daemonPath, "/var/run/docker.sock"))
} }
@@ -179,21 +214,13 @@ func (rc *RunContext) GetBindsAndMounts() ([]string, map[string]string) {
mounts[name] = ext.ToContainerPath(rc.Config.Workdir) mounts[name] = ext.ToContainerPath(rc.Config.Workdir)
} }
// For Gitea
// add some default binds and mounts to ValidVolumes
rc.Config.ValidVolumes = append(rc.Config.ValidVolumes, "act-toolcache")
rc.Config.ValidVolumes = append(rc.Config.ValidVolumes, name)
rc.Config.ValidVolumes = append(rc.Config.ValidVolumes, name+"-env")
// TODO: add a new configuration to control whether the docker daemon can be mounted
rc.Config.ValidVolumes = append(rc.Config.ValidVolumes, getDockerDaemonSocketMountPath(rc.Config.ContainerDaemonSocket))
return binds, mounts return binds, mounts
} }
func (rc *RunContext) startHostEnvironment() common.Executor { func (rc *RunContext) startHostEnvironment() common.Executor {
return func(ctx context.Context) error { return func(ctx context.Context) error {
logger := common.Logger(ctx) logger := common.Logger(ctx)
rawLogger := logger.WithField("raw_output", true) rawLogger := logger.WithField(rawOutputField, true)
logWriter := common.NewLineWriter(rc.commandHandler(ctx), func(s string) bool { logWriter := common.NewLineWriter(rc.commandHandler(ctx), func(s string) bool {
if rc.Config.LogOutput { if rc.Config.LogOutput {
rawLogger.Infof("%s", s) rawLogger.Infof("%s", s)
@@ -220,16 +247,17 @@ func (rc *RunContext) startHostEnvironment() common.Executor {
} }
toolCache := filepath.Join(cacheDir, "tool_cache") toolCache := filepath.Join(cacheDir, "tool_cache")
rc.JobContainer = &container.HostEnvironment{ rc.JobContainer = &container.HostEnvironment{
Path: path, Path: path,
TmpDir: runnerTmp, TmpDir: runnerTmp,
ToolCache: toolCache, ToolCache: toolCache,
Workdir: rc.Config.Workdir, Workdir: rc.Config.Workdir,
BindWorkdir: rc.Config.BindWorkdir, CleanWorkdir: rc.Config.CleanWorkdir,
ActPath: actPath, ActPath: actPath,
CleanUp: func() { CleanUp: func() {
os.RemoveAll(miscpath) os.RemoveAll(miscpath)
}, },
StdOut: logWriter, StdOut: logWriter,
AllocatePTY: rc.Config.AllocatePTY,
} }
rc.cleanUpJobContainer = rc.JobContainer.Remove() rc.cleanUpJobContainer = rc.JobContainer.Remove()
for k, v := range rc.JobContainer.GetRunnerContext(ctx) { for k, v := range rc.JobContainer.GetRunnerContext(ctx) {
@@ -260,11 +288,24 @@ func (rc *RunContext) startHostEnvironment() common.Executor {
} }
} }
// printStartJobContainerGroup mirrors actions/runner's "Starting job container"
// section: emit the group header and summary, return a closer for ::endgroup::.
func printStartJobContainerGroup(ctx context.Context, image, name, network string) func() {
rawLogger := common.Logger(ctx).WithField(rawOutputField, true)
rawLogger.Infof("::group::Starting job container")
rawLogger.Infof("image: %s", image)
rawLogger.Infof("name: %s", name)
rawLogger.Infof("network: %s", network)
return func() {
rawLogger.Infof("::endgroup::")
}
}
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(ctx)
rawLogger := logger.WithField("raw_output", true) rawLogger := logger.WithField(rawOutputField, true)
logWriter := common.NewLineWriter(rc.commandHandler(ctx), func(s string) bool { logWriter := common.NewLineWriter(rc.commandHandler(ctx), func(s string) bool {
if rc.Config.LogOutput { if rc.Config.LogOutput {
rawLogger.Infof("%s", s) rawLogger.Infof("%s", s)
@@ -279,7 +320,6 @@ func (rc *RunContext) startJobContainer() common.Executor {
return fmt.Errorf("failed to handle credentials: %s", err) return fmt.Errorf("failed to handle credentials: %s", err)
} }
logger.Infof("\U0001f680 Start image=%s", image)
name := rc.jobContainerName() name := rc.jobContainerName()
// 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.
@@ -359,6 +399,7 @@ func (rc *RunContext) startJobContainer() common.Executor {
NetworkAliases: []string{serviceID}, NetworkAliases: []string{serviceID},
ExposedPorts: exposedPorts, ExposedPorts: exposedPorts,
PortBindings: portBindings, PortBindings: portBindings,
AllocatePTY: rc.Config.AllocatePTY,
}) })
rc.ServiceContainers = append(rc.ServiceContainers, c) rc.ServiceContainers = append(rc.ServiceContainers, c)
} }
@@ -418,12 +459,14 @@ func (rc *RunContext) startJobContainer() common.Executor {
Platform: rc.Config.ContainerArchitecture, Platform: rc.Config.ContainerArchitecture,
Options: rc.options(ctx), Options: rc.options(ctx),
AutoRemove: rc.Config.AutoRemove, AutoRemove: rc.Config.AutoRemove,
ValidVolumes: rc.Config.ValidVolumes, ValidVolumes: rc.validVolumes(),
AllocatePTY: rc.Config.AllocatePTY,
}) })
if rc.JobContainer == nil { if rc.JobContainer == nil {
return errors.New("Failed to create job container") return errors.New("Failed to create job container")
} }
defer printStartJobContainerGroup(ctx, image, name, networkName)()
return common.NewPipelineExecutor( return common.NewPipelineExecutor(
rc.pullServicesImages(rc.Config.ForcePull), rc.pullServicesImages(rc.Config.ForcePull),
rc.JobContainer.Pull(rc.Config.ForcePull), rc.JobContainer.Pull(rc.Config.ForcePull),
@@ -570,14 +613,29 @@ func (rc *RunContext) ActionCacheDir() string {
} }
// Interpolate outputs after a job is done // Interpolate outputs after a job is done
// jobMutexes serializes per-job result/output aggregation across the matrix combinations that
// share one *model.Job and run in parallel. Keyed by the shared *model.Job (mirrors the
// per-directory AcquireCloneLock pattern).
var jobMutexes sync.Map // key: *model.Job; value: *sync.Mutex
func lockJob(job *model.Job) func() {
v, _ := jobMutexes.LoadOrStore(job, &sync.Mutex{})
mu := v.(*sync.Mutex)
mu.Lock()
return mu.Unlock
}
func (rc *RunContext) interpolateOutputs() common.Executor { func (rc *RunContext) interpolateOutputs() common.Executor {
return func(ctx context.Context) error { return func(ctx context.Context) error {
ee := rc.NewExpressionEvaluator(ctx) ee := rc.NewExpressionEvaluator(ctx)
for k, v := range rc.Run.Job().Outputs { job := rc.Run.Job()
interpolated := ee.Interpolate(ctx, v) // Matrix combinations share this Job and its Outputs map. Interpolate from this combo's
if v != interpolated { // pristine snapshot (outputTemplate) and write under the lock, so each combo overwrites
rc.Run.Job().Outputs[k] = interpolated // 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.
defer lockJob(job)()
for k, v := range rc.outputTemplate {
job.Outputs[k] = ee.Interpolate(ctx, v)
} }
return nil return nil
} }
@@ -585,10 +643,34 @@ func (rc *RunContext) interpolateOutputs() common.Executor {
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
if rc.IsHostEnv(ctx) { if rc.IsHostEnv(ctx) {
return rc.startHostEnvironment()(ctx) err = rc.startHostEnvironment()(ctx)
} else {
err = rc.startJobContainer()(ctx)
} }
return rc.startJobContainer()(ctx) if err != nil {
// The job executor's teardown only runs after a successful start, so a failed
// start would otherwise leak the per-job network and container.
rc.cleanupFailedStart(ctx)
}
return err
}
}
func (rc *RunContext) cleanupFailedStart(ctx context.Context) {
if rc.cleanUpJobContainer == nil {
return
}
cleanCtx := ctx
if ctx.Err() != nil {
// the start likely failed because ctx was cancelled, detach so teardown still runs
var cancel context.CancelFunc
cleanCtx, cancel = context.WithTimeout(common.WithLogger(context.Background(), common.Logger(ctx)), time.Minute)
defer cancel()
}
if err := rc.cleanUpJobContainer(cleanCtx); err != nil {
common.Logger(ctx).Errorf("Error while cleaning up after failed container start for job %s: %v", rc.JobName, err)
} }
} }
@@ -620,7 +702,29 @@ func (rc *RunContext) result(result string) {
} }
func (rc *RunContext) steps() []*model.Step { func (rc *RunContext) steps() []*model.Step {
return rc.Run.Job().Steps // Return per-job copies of the steps. Matrix combinations run in parallel and share the
// workflow model, but step execution mutates per-job fields and evaluates the If/Env nodes
// in place, so the *model.Step instances must not be shared across jobs (see Step.Clone).
shared := rc.Run.Job().Steps
steps := make([]*model.Step, len(shared))
for i, step := range shared {
if step == nil {
continue
}
steps[i] = step.Clone()
}
return steps
}
// topLevelRunContext walks the Parent chain to the outermost RunContext. Composite
// actions create child RunContexts whose sub-steps need to share the outer job step's
// summary file path so that nested writes accumulate under the right step_index.
func (rc *RunContext) topLevelRunContext() *RunContext {
top := rc
for top.Parent != nil {
top = top.Parent
}
return top
} }
// Executor returns a pipeline executor for all the steps in the job // Executor returns a pipeline executor for all the steps in the job
@@ -697,12 +801,15 @@ func (rc *RunContext) runsOnPlatformNames(ctx context.Context) []string {
return []string{} return []string{}
} }
if err := rc.ExprEval.EvaluateYamlNode(ctx, &job.RawRunsOn); err != nil { // Evaluate a copy: RawRunsOn is shared across parallel matrix jobs, so interpolating it in
// place would race and leak one matrix combination's runs-on into the others.
rawRunsOn := model.CloneYamlNode(job.RawRunsOn)
if err := rc.ExprEval.EvaluateYamlNode(ctx, &rawRunsOn); err != nil {
common.Logger(ctx).Errorf("Error while evaluating runs-on: %v", err) common.Logger(ctx).Errorf("Error while evaluating runs-on: %v", err)
return []string{} return []string{}
} }
return job.RunsOn() return model.RunsOnFromNode(rawRunsOn)
} }
func (rc *RunContext) platformImage(ctx context.Context) string { func (rc *RunContext) platformImage(ctx context.Context) string {
@@ -753,7 +860,7 @@ func (rc *RunContext) isEnabled(ctx context.Context) (bool, error) {
img := rc.platformImage(ctx) img := rc.platformImage(ctx)
if img == "" { if img == "" {
for _, platformName := range rc.runsOnPlatformNames(ctx) { for _, platformName := range rc.runsOnPlatformNames(ctx) {
l.Infof("\U0001F6A7 Skipping unsupported platform -- Try running with `-P %+v=...`", platformName) l.Infof("Skipping unsupported platform -- Try running with `-P %+v=...`", platformName)
} }
return false, nil return false, nil
} }
@@ -1068,21 +1175,18 @@ func setActionRuntimeVars(rc *RunContext, env map[string]string) {
} }
func (rc *RunContext) handleCredentials(ctx context.Context) (string, string, error) { func (rc *RunContext) handleCredentials(ctx context.Context) (string, string, error) {
// TODO: remove below 2 lines when we can release act with breaking changes
username := rc.Config.Secrets["DOCKER_USERNAME"]
password := rc.Config.Secrets["DOCKER_PASSWORD"]
container := rc.Run.Job().Container() container := rc.Run.Job().Container()
if container == nil || container.Credentials == nil { if container == nil || container.Credentials == nil {
return username, password, nil return "", "", nil
} }
if container.Credentials != nil && len(container.Credentials) != 2 { if len(container.Credentials) != 2 {
err := errors.New("invalid property count for key 'credentials:'") err := errors.New("invalid property count for key 'credentials:'")
return "", "", err return "", "", err
} }
ee := rc.NewExpressionEvaluator(ctx) ee := rc.NewExpressionEvaluator(ctx)
var username, password string
if username = ee.Interpolate(ctx, container.Credentials["username"]); username == "" { if username = ee.Interpolate(ctx, container.Credentials["username"]); username == "" {
err := errors.New("failed to interpolate container.credentials.username") err := errors.New("failed to interpolate container.credentials.username")
return "", "", err return "", "", err
@@ -1125,12 +1229,9 @@ func (rc *RunContext) handleServiceCredentials(ctx context.Context, creds map[st
// GetServiceBindsAndMounts returns the binds and mounts for the service container, resolving paths as appopriate // GetServiceBindsAndMounts returns the binds and mounts for the service container, resolving paths as appopriate
func (rc *RunContext) GetServiceBindsAndMounts(svcVolumes []string) ([]string, map[string]string) { func (rc *RunContext) GetServiceBindsAndMounts(svcVolumes []string) ([]string, map[string]string) {
if rc.Config.ContainerDaemonSocket == "" {
rc.Config.ContainerDaemonSocket = "/var/run/docker.sock"
}
binds := []string{} binds := []string{}
if rc.Config.ContainerDaemonSocket != "-" { if daemonSocket := rc.containerDaemonSocket(); daemonSocket != "-" {
daemonPath := getDockerDaemonSocketMountPath(rc.Config.ContainerDaemonSocket) daemonPath := getDockerDaemonSocketMountPath(daemonSocket)
binds = append(binds, fmt.Sprintf("%s:%s", daemonPath, "/var/run/docker.sock")) binds = append(binds, fmt.Sprintf("%s:%s", daemonPath, "/var/run/docker.sock"))
} }

View File

@@ -5,6 +5,7 @@
package runner package runner
import ( import (
"bytes"
"context" "context"
"fmt" "fmt"
"os" "os"
@@ -12,11 +13,13 @@ import (
"strings" "strings"
"testing" "testing"
"gitea.com/gitea/runner/act/common"
"gitea.com/gitea/runner/act/exprparser" "gitea.com/gitea/runner/act/exprparser"
"gitea.com/gitea/runner/act/model" "gitea.com/gitea/runner/act/model"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
assert "github.com/stretchr/testify/assert" assert "github.com/stretchr/testify/assert"
require "github.com/stretchr/testify/require"
yaml "go.yaml.in/yaml/v4" yaml "go.yaml.in/yaml/v4"
) )
@@ -167,6 +170,38 @@ func TestRunContext_EvalBool(t *testing.T) {
} }
} }
func TestRunContextHandleCredentialsDoesNotUseDockerSecrets(t *testing.T) {
workflow, err := model.ReadWorkflow(strings.NewReader(`
name: test
on: push
jobs:
job:
runs-on: ubuntu-latest
steps: []
`))
require.NoError(t, err)
rc := &RunContext{
Config: &Config{
Secrets: map[string]string{
"DOCKER_USERNAME": "docker-user",
"DOCKER_PASSWORD": "docker-password",
},
Env: map[string]string{},
},
Run: &model.Run{
JobID: "job",
Workflow: workflow,
},
}
// DOCKER_USERNAME/DOCKER_PASSWORD secrets should not be used as implicit job container pull credentials.
username, password, err := rc.handleCredentials(t.Context())
require.NoError(t, err)
assert.Empty(t, username)
assert.Empty(t, password)
}
func TestRunContext_GetBindsAndMounts(t *testing.T) { func TestRunContext_GetBindsAndMounts(t *testing.T) {
rctemplate := &RunContext{ rctemplate := &RunContext{
Name: "TestRCName", Name: "TestRCName",
@@ -278,6 +313,44 @@ func TestRunContext_GetBindsAndMounts(t *testing.T) {
}) })
} }
func TestRunContextValidVolumes(t *testing.T) {
rc := &RunContext{
Name: "job",
Run: &model.Run{Workflow: &model.Workflow{Name: "wf"}},
Config: &Config{ValidVolumes: []string{"my-vol", "/host/path"}},
}
name := rc.jobContainerName()
got := rc.validVolumes()
// the configured volumes plus the four the runner mounts automatically
assert.Subset(t, got, []string{"my-vol", "/host/path", "act-toolcache", name, name + "-env", "/var/run/docker.sock"})
// deriving the list must never mutate or grow the shared Config slice: parallel matrix
// combinations share one *Config, and the previous in-place append was a data race.
assert.Equal(t, []string{"my-vol", "/host/path"}, rc.Config.ValidVolumes)
assert.Len(t, rc.validVolumes(), len(got), "repeated calls must be stable, not accumulate")
}
// TestInterpolateOutputsIsPerMatrixCombo guards the matrix-output fix: combinations share one
// *model.Job, so each must interpolate from its own pristine snapshot. Otherwise the first
// combo's resolved value freezes the shared template and later combos can't resolve their own.
func TestInterpolateOutputsIsPerMatrixCombo(t *testing.T) {
job := &model.Job{Outputs: map[string]string{"o": "${{ matrix.v }}"}}
run := &model.Run{JobID: "j", Workflow: &model.Workflow{Name: "w", Jobs: map[string]*model.Job{"j": job}}}
r := &runnerImpl{config: &Config{}}
ctx := context.Background()
rcA := r.newRunContext(ctx, run, map[string]any{"v": "a"})
rcB := r.newRunContext(ctx, run, map[string]any{"v": "b"})
require.NoError(t, rcA.interpolateOutputs()(ctx))
require.NoError(t, rcB.interpolateOutputs()(ctx))
// Last combo wins (matching GitHub) instead of being frozen to combo A's "a".
require.Equal(t, "b", job.Outputs["o"])
}
func TestGetGitHubContext(t *testing.T) { func TestGetGitHubContext(t *testing.T) {
log.SetLevel(log.DebugLevel) log.SetLevel(log.DebugLevel)
@@ -635,3 +708,75 @@ func TestCreateContainerNameBoundedForLongMatrixInput(t *testing.T) {
assert.LessOrEqual(t, len(name+"-network"), 255) assert.LessOrEqual(t, len(name+"-network"), 255)
assert.LessOrEqual(t, len(name+"-job1234567890"), 255) assert.LessOrEqual(t, len(name+"-job1234567890"), 255)
} }
func TestPrintStartJobContainerGroupGolden(t *testing.T) {
buf := &bytes.Buffer{}
logger := log.New()
logger.SetOutput(buf)
logger.SetLevel(log.InfoLevel)
logger.SetFormatter(&jobLogFormatter{color: cyan})
entry := logger.WithFields(log.Fields{"job": "j1"})
ctx := common.WithLogger(context.Background(), entry)
printStartJobContainerGroup(ctx, "node:20", "GITEA-WORKFLOW-build-JOB-test", "gitea-runner-network")()
want := strings.Join([]string{
"[j1] | ::group::Starting job container",
"[j1] | image: node:20",
"[j1] | name: GITEA-WORKFLOW-build-JOB-test",
"[j1] | network: gitea-runner-network",
"[j1] | ::endgroup::",
"",
}, "\n")
assert.Equal(t, want, buf.String())
}
func TestRunContext_cleanupFailedStart(t *testing.T) {
type ctxKey string
const sentinel = ctxKey("sentinel")
// the fresh context is cancelled via defer on return, so capture state inside the stub
type capture struct {
calls int
err error
sentinel any
}
newRC := func(c *capture) *RunContext {
return &RunContext{
JobName: "job",
cleanUpJobContainer: func(ctx context.Context) error {
c.calls++
c.err = ctx.Err()
c.sentinel = ctx.Value(sentinel)
return nil
},
}
}
t.Run("runs teardown on the live context", func(t *testing.T) {
var c capture
ctx := context.WithValue(context.Background(), sentinel, "v")
newRC(&c).cleanupFailedStart(ctx)
assert.Equal(t, 1, c.calls)
require.NoError(t, c.err)
assert.Equal(t, "v", c.sentinel)
})
t.Run("falls back to a fresh context when the input is done", func(t *testing.T) {
var c capture
ctx, cancel := context.WithCancel(context.WithValue(context.Background(), sentinel, "v"))
cancel()
newRC(&c).cleanupFailedStart(ctx)
assert.Equal(t, 1, c.calls)
require.NoError(t, c.err)
assert.Nil(t, c.sentinel)
})
t.Run("no-op when there is nothing to clean up", func(t *testing.T) {
assert.NotPanics(t, func() { (&RunContext{}).cleanupFailedStart(context.Background()) })
})
}

View File

@@ -8,6 +8,7 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"maps"
"os" "os"
"runtime" "runtime"
"sync" "sync"
@@ -16,7 +17,7 @@ import (
"gitea.com/gitea/runner/act/common" "gitea.com/gitea/runner/act/common"
"gitea.com/gitea/runner/act/model" "gitea.com/gitea/runner/act/model"
docker_container "github.com/docker/docker/api/types/container" docker_container "github.com/moby/moby/api/types/container"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
) )
@@ -30,7 +31,7 @@ type Config struct {
Actor string // the user that triggered the event Actor string // the user that triggered the event
Workdir string // path to working directory Workdir string // path to working directory
ActionCacheDir string // path used for caching action contents ActionCacheDir string // path used for caching action contents
ActionOfflineMode bool // when offline, use caching action contents ActionOfflineMode bool // when offline, use cached action contents
BindWorkdir bool // bind the workdir to the job container BindWorkdir bool // bind the workdir to the job container
EventName string // name of event to run EventName string // name of event to run
EventPath string // path to JSON file to use for event.json in containers EventPath string // path to JSON file to use for event.json in containers
@@ -73,12 +74,14 @@ type Config struct {
EventJSON string // the content of JSON file to use for event.json in containers, overrides EventPath EventJSON string // the content of JSON file to use for event.json in containers, overrides EventPath
ContainerNamePrefix string // the prefix of container name ContainerNamePrefix string // the prefix of container name
ContainerMaxLifetime time.Duration // the max lifetime of job containers ContainerMaxLifetime time.Duration // the max lifetime of job containers
CleanWorkdir bool // remove host executor workdir on teardown
DefaultActionInstance string // the default actions web site DefaultActionInstance string // the default actions web site
PlatformPicker func(labels []string) string // platform picker, it will take precedence over Platforms if isn't nil PlatformPicker func(labels []string) string // platform picker, it will take precedence over Platforms if isn't nil
JobLoggerLevel *log.Level // the level of job logger JobLoggerLevel *log.Level // the level of job logger
ValidVolumes []string // only volumes (and bind mounts) in this slice can be mounted on the job container or service containers ValidVolumes []string // only volumes (and bind mounts) in this slice can be mounted on the job container or service containers
InsecureSkipTLS bool // whether to skip verifying TLS certificate of the Gitea instance InsecureSkipTLS bool // whether to skip verifying TLS certificate of the Gitea instance
MaxParallel int // max parallel jobs to run across all workflows (0 = no limit, uses CPU count) MaxParallel int // max parallel jobs to run across all workflows (0 = no limit, uses CPU count)
AllocatePTY bool // allocate a pseudo-TTY for each step's process
} }
// GetToken: Adapt to Gitea // GetToken: Adapt to Gitea
@@ -90,6 +93,17 @@ func (c Config) GetToken() string {
return token return token
} }
// DefaultActionURL returns the host used for implicit remote actions.
func (c Config) DefaultActionURL() string {
if c.DefaultActionInstance != "" {
return c.DefaultActionInstance
}
if c.GitHubInstance != "" {
return c.GitHubInstance
}
return "github.com"
}
type caller struct { type caller struct {
runContext *RunContext runContext *RunContext
@@ -237,7 +251,14 @@ func (runner *runnerImpl) NewPlanExecutor(plan *model.Plan) common.Executor {
return executor(common.WithJobErrorContainer(WithJobLogger(ctx, rc.Run.JobID, jobName, rc.Config, &rc.Masks, matrix))) return executor(common.WithJobErrorContainer(WithJobLogger(ctx, rc.Run.JobID, jobName, rc.Config, &rc.Masks, matrix)))
}) })
} }
pipeline = append(pipeline, common.NewParallelExecutor(maxParallel, stageExecutor...)) // Run all matrix combinations of this job, then drop its aggregation mutex: the
// combos are the only users of it, so once they finish the jobMutexes entry can be
// released, keeping the map from growing unbounded over a long-lived runner.
stageParallel := common.NewParallelExecutor(maxParallel, stageExecutor...)
pipeline = append(pipeline, func(ctx context.Context) error {
defer jobMutexes.Delete(job)
return stageParallel(ctx)
})
} }
// For pipeline execution: // For pipeline execution:
@@ -321,6 +342,11 @@ func (runner *runnerImpl) newRunContext(ctx context.Context, run *model.Run, mat
} }
rc.ExprEval = rc.NewExpressionEvaluator(ctx) rc.ExprEval = rc.NewExpressionEvaluator(ctx)
rc.Name = rc.ExprEval.Interpolate(ctx, run.String()) rc.Name = rc.ExprEval.Interpolate(ctx, run.String())
// Snapshot the job's pristine output expressions now, before any matrix combo runs and
// rewrites the shared Job.Outputs (see interpolateOutputs).
if job := run.Job(); job != nil {
rc.outputTemplate = maps.Clone(job.Outputs)
}
return rc return rc
} }

View File

@@ -15,6 +15,7 @@ import (
"runtime" "runtime"
"strings" "strings"
"testing" "testing"
"time"
"gitea.com/gitea/runner/act/common" "gitea.com/gitea/runner/act/common"
"gitea.com/gitea/runner/act/model" "gitea.com/gitea/runner/act/model"
@@ -187,13 +188,17 @@ func (j *TestJobFileInfo) runTest(ctx context.Context, t *testing.T, cfg *Config
EventPath: cfg.EventPath, EventPath: cfg.EventPath,
Platforms: j.platforms, Platforms: j.platforms,
ReuseContainers: false, ReuseContainers: false,
ForceRebuild: true,
Env: cfg.Env, Env: cfg.Env,
Secrets: cfg.Secrets, Secrets: cfg.Secrets,
Inputs: cfg.Inputs, Inputs: cfg.Inputs,
GitHubInstance: "github.com", GitHubInstance: "github.com",
DefaultActionInstance: cfg.DefaultActionInstance,
ContainerArchitecture: cfg.ContainerArchitecture, ContainerArchitecture: cfg.ContainerArchitecture,
ContainerMaxLifetime: time.Hour,
Matrix: cfg.Matrix, Matrix: cfg.Matrix,
ActionCache: cfg.ActionCache, ActionCache: cfg.ActionCache,
ValidVolumes: []string{"**"}, // allow workflow-declared volumes (e.g. container-volumes)
} }
runner, err := New(runnerConfig) runner, err := New(runnerConfig)
@@ -221,18 +226,14 @@ type TestConfig struct {
} }
func TestRunEvent(t *testing.T) { func TestRunEvent(t *testing.T) {
if testing.Short() { requireDocker(t)
t.Skip("skipping integration test")
}
ctx := context.Background() ctx := context.Background()
tables := []TestJobFileInfo{ tables := []TestJobFileInfo{
// Shells // Shells
{workdir, "shells/defaults", "push", "", platforms, secrets}, {workdir, "shells/defaults", "push", "", platforms, secrets},
{workdir, "shells/pwsh", "push", "", map[string]string{"ubuntu-latest": "catthehacker/ubuntu:pwsh-latest"}, secrets}, // custom image with pwsh
{workdir, "shells/bash", "push", "", platforms, secrets}, {workdir, "shells/bash", "push", "", platforms, secrets},
{workdir, "shells/python", "push", "", map[string]string{"ubuntu-latest": "node:24-bookworm"}, secrets}, // slim doesn't have python
{workdir, "shells/sh", "push", "", platforms, secrets}, {workdir, "shells/sh", "push", "", platforms, secrets},
// Local action // Local action
@@ -244,11 +245,6 @@ func TestRunEvent(t *testing.T) {
// Uses // Uses
{workdir, "uses-composite", "push", "", platforms, secrets}, {workdir, "uses-composite", "push", "", platforms, secrets},
{workdir, "uses-composite-with-error", "push", "Job 'failing-composite-action' failed", platforms, secrets}, {workdir, "uses-composite-with-error", "push", "Job 'failing-composite-action' failed", platforms, secrets},
{workdir, "uses-nested-composite", "push", "", platforms, secrets},
{workdir, "remote-action-composite-js-pre-with-defaults", "push", "", platforms, secrets},
{workdir, "remote-action-composite-action-ref", "push", "", platforms, secrets},
{workdir, "uses-workflow", "push", "", platforms, map[string]string{"secret": "keep_it_private"}},
{workdir, "uses-workflow", "pull_request", "", platforms, map[string]string{"secret": "keep_it_private"}},
{workdir, "uses-docker-url", "push", "", platforms, secrets}, {workdir, "uses-docker-url", "push", "", platforms, secrets},
{workdir, "act-composite-env-test", "push", "", platforms, secrets}, {workdir, "act-composite-env-test", "push", "", platforms, secrets},
@@ -258,21 +254,15 @@ func TestRunEvent(t *testing.T) {
{workdir, "evalmatrixneeds2", "push", "", platforms, secrets}, {workdir, "evalmatrixneeds2", "push", "", platforms, secrets},
{workdir, "evalmatrix-merge-map", "push", "", platforms, secrets}, {workdir, "evalmatrix-merge-map", "push", "", platforms, secrets},
{workdir, "evalmatrix-merge-array", "push", "", platforms, secrets}, {workdir, "evalmatrix-merge-array", "push", "", platforms, secrets},
{workdir, "issue-1195", "push", "", platforms, secrets},
{workdir, "basic", "push", "", platforms, secrets}, {workdir, "basic", "push", "", platforms, secrets},
{workdir, "fail", "push", "exit with `FAILURE`: 1", platforms, secrets}, {workdir, "fail", "push", "exit with `FAILURE`: 1", platforms, secrets},
{workdir, "runs-on", "push", "", platforms, secrets},
{workdir, "checkout", "push", "", platforms, secrets}, {workdir, "checkout", "push", "", platforms, secrets},
{workdir, "job-container", "push", "", platforms, secrets}, {workdir, "job-container", "push", "", platforms, secrets},
{workdir, "job-container-non-root", "push", "", platforms, secrets}, {workdir, "job-container-non-root", "push", "", platforms, secrets},
{workdir, "job-container-invalid-credentials", "push", "failed to handle credentials: failed to interpolate container.credentials.password", platforms, secrets}, {workdir, "job-container-invalid-credentials", "push", "failed to handle credentials: failed to interpolate container.credentials.password", platforms, secrets},
{workdir, "container-hostname", "push", "", platforms, secrets}, {workdir, "container-hostname", "push", "", platforms, secrets},
{workdir, "remote-action-docker", "push", "", platforms, secrets},
{workdir, "remote-action-js", "push", "", platforms, secrets},
{workdir, "remote-action-js-node-user", "push", "", platforms, secrets}, // Test if this works with non root container
{workdir, "matrix", "push", "", platforms, secrets}, {workdir, "matrix", "push", "", platforms, secrets},
{workdir, "matrix-include-exclude", "push", "", platforms, secrets},
{workdir, "matrix-exitcode", "push", "Job 'test' failed", platforms, secrets}, {workdir, "matrix-exitcode", "push", "Job 'test' failed", platforms, secrets},
{workdir, "commands", "push", "", platforms, secrets}, {workdir, "commands", "push", "", platforms, secrets},
{workdir, "workdir", "push", "", platforms, secrets}, {workdir, "workdir", "push", "", platforms, secrets},
@@ -293,7 +283,6 @@ func TestRunEvent(t *testing.T) {
{workdir, "job-status-check", "push", "job 'fail' failed", platforms, secrets}, {workdir, "job-status-check", "push", "job 'fail' failed", platforms, secrets},
{workdir, "if-expressions", "push", "Job 'mytest' failed", platforms, secrets}, {workdir, "if-expressions", "push", "Job 'mytest' failed", platforms, secrets},
{workdir, "actions-environment-and-context-tests", "push", "", platforms, secrets}, {workdir, "actions-environment-and-context-tests", "push", "", platforms, secrets},
{workdir, "uses-action-with-pre-and-post-step", "push", "", platforms, secrets},
{workdir, "evalenv", "push", "", platforms, secrets}, {workdir, "evalenv", "push", "", platforms, secrets},
{workdir, "docker-action-custom-path", "push", "", platforms, secrets}, {workdir, "docker-action-custom-path", "push", "", platforms, secrets},
{workdir, "GITHUB_ENV-use-in-env-ctx", "push", "", platforms, secrets}, {workdir, "GITHUB_ENV-use-in-env-ctx", "push", "", platforms, secrets},
@@ -304,7 +293,6 @@ func TestRunEvent(t *testing.T) {
{workdir, "workflow_dispatch-scalar", "workflow_dispatch", "", platforms, secrets}, {workdir, "workflow_dispatch-scalar", "workflow_dispatch", "", platforms, secrets},
{workdir, "workflow_dispatch-scalar-composite-action", "workflow_dispatch", "", platforms, secrets}, {workdir, "workflow_dispatch-scalar-composite-action", "workflow_dispatch", "", platforms, secrets},
{workdir, "job-needs-context-contains-result", "push", "", platforms, secrets}, {workdir, "job-needs-context-contains-result", "push", "", platforms, secrets},
{"../model/testdata", "strategy", "push", "", platforms, secrets}, // TODO: move all testdata into pkg so we can validate it with planner and runner
{"../model/testdata", "container-volumes", "push", "", platforms, secrets}, {"../model/testdata", "container-volumes", "push", "", platforms, secrets},
{workdir, "path-handling", "push", "", platforms, secrets}, {workdir, "path-handling", "push", "", platforms, secrets},
{workdir, "do-not-leak-step-env-in-composite", "push", "", platforms, secrets}, {workdir, "do-not-leak-step-env-in-composite", "push", "", platforms, secrets},
@@ -314,7 +302,6 @@ func TestRunEvent(t *testing.T) {
// services // services
{workdir, "services", "push", "", platforms, secrets}, {workdir, "services", "push", "", platforms, secrets},
{workdir, "services-host-network", "push", "", platforms, secrets},
{workdir, "services-with-container", "push", "", platforms, secrets}, {workdir, "services-with-container", "push", "", platforms, secrets},
// local remote action overrides // local remote action overrides
@@ -323,6 +310,11 @@ func TestRunEvent(t *testing.T) {
for _, table := range tables { for _, table := range tables {
t.Run(table.workflowPath, func(t *testing.T) { t.Run(table.workflowPath, func(t *testing.T) {
if table.workflowPath == "container-volumes" {
// host /proc bind mounts are Linux-Docker-only
requireLinuxDocker(t)
}
config := &Config{ config := &Config{
Secrets: table.secrets, Secrets: table.secrets,
} }
@@ -354,9 +346,12 @@ func TestRunEvent(t *testing.T) {
} }
func TestRunEventHostEnvironment(t *testing.T) { func TestRunEventHostEnvironment(t *testing.T) {
if testing.Short() { // Runs steps directly on the host (the "-self-hosted" platform), so it needs the shells
t.Skip("skipping integration test") // and tools the workflows invoke. No network gate: every action these workflows reference
} // is a local `./` fixture or the skipped actions/checkout, so the suite runs offline (same
// as TestRunEvent). Only the broadly-used interpreters are required up front; the pwsh- and
// nix-specific cases gate on their own tool below so a missing pwsh/nix skips just those.
requireHostTools(t, "bash", "node")
ctx := context.Background() ctx := context.Background()
@@ -372,7 +367,6 @@ func TestRunEventHostEnvironment(t *testing.T) {
{workdir, "shells/defaults", "push", "", platforms, secrets}, {workdir, "shells/defaults", "push", "", platforms, secrets},
{workdir, "shells/pwsh", "push", "", platforms, secrets}, {workdir, "shells/pwsh", "push", "", platforms, secrets},
{workdir, "shells/bash", "push", "", platforms, secrets}, {workdir, "shells/bash", "push", "", platforms, secrets},
{workdir, "shells/python", "push", "", platforms, secrets},
{workdir, "shells/sh", "push", "", platforms, secrets}, {workdir, "shells/sh", "push", "", platforms, secrets},
// Local action // Local action
@@ -381,7 +375,6 @@ func TestRunEventHostEnvironment(t *testing.T) {
// Uses // Uses
{workdir, "uses-composite", "push", "", platforms, secrets}, {workdir, "uses-composite", "push", "", platforms, secrets},
{workdir, "uses-composite-with-error", "push", "Job 'failing-composite-action' failed", platforms, secrets}, {workdir, "uses-composite-with-error", "push", "Job 'failing-composite-action' failed", platforms, secrets},
{workdir, "uses-nested-composite", "push", "", platforms, secrets},
{workdir, "act-composite-env-test", "push", "", platforms, secrets}, {workdir, "act-composite-env-test", "push", "", platforms, secrets},
// Eval // Eval
@@ -390,14 +383,10 @@ func TestRunEventHostEnvironment(t *testing.T) {
{workdir, "evalmatrixneeds2", "push", "", platforms, secrets}, {workdir, "evalmatrixneeds2", "push", "", platforms, secrets},
{workdir, "evalmatrix-merge-map", "push", "", platforms, secrets}, {workdir, "evalmatrix-merge-map", "push", "", platforms, secrets},
{workdir, "evalmatrix-merge-array", "push", "", platforms, secrets}, {workdir, "evalmatrix-merge-array", "push", "", platforms, secrets},
{workdir, "issue-1195", "push", "", platforms, secrets},
{workdir, "fail", "push", "exit with `FAILURE`: 1", platforms, secrets}, {workdir, "fail", "push", "exit with `FAILURE`: 1", platforms, secrets},
{workdir, "runs-on", "push", "", platforms, secrets},
{workdir, "checkout", "push", "", platforms, secrets}, {workdir, "checkout", "push", "", platforms, secrets},
{workdir, "remote-action-js", "push", "", platforms, secrets},
{workdir, "matrix", "push", "", platforms, secrets}, {workdir, "matrix", "push", "", platforms, secrets},
{workdir, "matrix-include-exclude", "push", "", platforms, secrets},
{workdir, "commands", "push", "", platforms, secrets}, {workdir, "commands", "push", "", platforms, secrets},
{workdir, "defaults-run", "push", "", platforms, secrets}, {workdir, "defaults-run", "push", "", platforms, secrets},
{workdir, "composite-fail-with-output", "push", "", platforms, secrets}, {workdir, "composite-fail-with-output", "push", "", platforms, secrets},
@@ -411,7 +400,6 @@ func TestRunEventHostEnvironment(t *testing.T) {
{workdir, "steps-context/outcome", "push", "", platforms, secrets}, {workdir, "steps-context/outcome", "push", "", platforms, secrets},
{workdir, "job-status-check", "push", "job 'fail' failed", platforms, secrets}, {workdir, "job-status-check", "push", "job 'fail' failed", platforms, secrets},
{workdir, "if-expressions", "push", "Job 'mytest' failed", platforms, secrets}, {workdir, "if-expressions", "push", "Job 'mytest' failed", platforms, secrets},
{workdir, "uses-action-with-pre-and-post-step", "push", "", platforms, secrets},
{workdir, "evalenv", "push", "", platforms, secrets}, {workdir, "evalenv", "push", "", platforms, secrets},
{workdir, "ensure-post-steps", "push", "Job 'second-post-step-should-fail' failed", platforms, secrets}, {workdir, "ensure-post-steps", "push", "Job 'second-post-step-should-fail' failed", platforms, secrets},
}...) }...)
@@ -444,24 +432,26 @@ func TestRunEventHostEnvironment(t *testing.T) {
for _, table := range tables { for _, table := range tables {
t.Run(table.workflowPath, func(t *testing.T) { t.Run(table.workflowPath, func(t *testing.T) {
switch table.workflowPath {
case "shells/pwsh":
requireHostTools(t, "pwsh")
case "nix-prepend-path":
requireHostTools(t, "nix")
}
table.runTest(ctx, t, &Config{}) table.runTest(ctx, t, &Config{})
}) })
} }
} }
func TestDryrunEvent(t *testing.T) { func TestDryrunEvent(t *testing.T) {
if testing.Short() { // Dryrun plans without containers or network (shells and local actions only).
t.Skip("skipping integration test")
}
ctx := common.WithDryrun(context.Background(), true) ctx := common.WithDryrun(context.Background(), true)
tables := []TestJobFileInfo{ tables := []TestJobFileInfo{
// Shells // Shells
{workdir, "shells/defaults", "push", "", platforms, secrets}, {workdir, "shells/defaults", "push", "", platforms, secrets},
{workdir, "shells/pwsh", "push", "", map[string]string{"ubuntu-latest": "catthehacker/ubuntu:pwsh-latest"}, secrets}, // custom image with pwsh {workdir, "shells/pwsh", "push", "", platforms, secrets},
{workdir, "shells/bash", "push", "", platforms, secrets}, {workdir, "shells/bash", "push", "", platforms, secrets},
{workdir, "shells/python", "push", "", map[string]string{"ubuntu-latest": "node:24-bookworm"}, secrets}, // slim doesn't have python
{workdir, "shells/sh", "push", "", platforms, secrets}, {workdir, "shells/sh", "push", "", platforms, secrets},
// Local action // Local action
@@ -478,10 +468,18 @@ func TestDryrunEvent(t *testing.T) {
} }
} }
// TestReusableWorkflowCaller exercises the reusable-workflow caller path against a local
// reusable workflow (typed inputs, secrets as both a map and `inherit`, and reading the called
// workflow's outputs via `needs`).
func TestReusableWorkflowCaller(t *testing.T) {
requireDocker(t)
table := TestJobFileInfo{workdir, "uses-workflow", "push", "", platforms, map[string]string{"secret": "keep_it_private"}}
table.runTest(context.Background(), t, &Config{Secrets: table.secrets})
}
func TestDockerActionForcePullForceRebuild(t *testing.T) { func TestDockerActionForcePullForceRebuild(t *testing.T) {
if testing.Short() { requireDocker(t)
t.Skip("skipping integration test") requireNetwork(t) // force-pulls a docker action image
}
ctx := context.Background() ctx := context.Background()
@@ -502,22 +500,6 @@ func TestDockerActionForcePullForceRebuild(t *testing.T) {
} }
} }
func TestRunDifferentArchitecture(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test")
}
tjfi := TestJobFileInfo{
workdir: workdir,
workflowPath: "basic",
eventName: "push",
errorMessage: "",
platforms: platforms,
}
tjfi.runTest(context.Background(), t, &Config{ContainerArchitecture: "linux/arm64"})
}
type maskJobLoggerFactory struct { type maskJobLoggerFactory struct {
Output bytes.Buffer Output bytes.Buffer
} }
@@ -538,9 +520,7 @@ func TestMaskValues(t *testing.T) {
assert.False(t, strings.Contains(text, "composite secret")) //nolint:testifylint // pre-existing issue from nektos/act assert.False(t, strings.Contains(text, "composite secret")) //nolint:testifylint // pre-existing issue from nektos/act
} }
if testing.Short() { requireDocker(t)
t.Skip("skipping integration test")
}
log.SetLevel(log.DebugLevel) log.SetLevel(log.DebugLevel)
@@ -561,9 +541,7 @@ func TestMaskValues(t *testing.T) {
} }
func TestRunEventSecrets(t *testing.T) { func TestRunEventSecrets(t *testing.T) {
if testing.Short() { requireDocker(t)
t.Skip("skipping integration test")
}
workflowPath := "secrets" workflowPath := "secrets"
tjfi := TestJobFileInfo{ tjfi := TestJobFileInfo{
@@ -583,9 +561,7 @@ func TestRunEventSecrets(t *testing.T) {
} }
func TestRunWithService(t *testing.T) { func TestRunWithService(t *testing.T) {
if testing.Short() { requireDocker(t)
t.Skip("skipping integration test")
}
log.SetLevel(log.DebugLevel) log.SetLevel(log.DebugLevel)
ctx := context.Background() ctx := context.Background()
@@ -601,10 +577,11 @@ func TestRunWithService(t *testing.T) {
assert.NoError(t, err, workflowPath) //nolint:testifylint // pre-existing issue from nektos/act assert.NoError(t, err, workflowPath) //nolint:testifylint // pre-existing issue from nektos/act
runnerConfig := &Config{ runnerConfig := &Config{
Workdir: workdir, Workdir: workdir,
EventName: eventName, EventName: eventName,
Platforms: platforms, Platforms: platforms,
ReuseContainers: false, ReuseContainers: false,
ContainerMaxLifetime: time.Hour, // otherwise the job container is `sleep 0` and exits at once
} }
runner, err := New(runnerConfig) runner, err := New(runnerConfig)
assert.NoError(t, err, workflowPath) //nolint:testifylint // pre-existing issue from nektos/act assert.NoError(t, err, workflowPath) //nolint:testifylint // pre-existing issue from nektos/act
@@ -620,9 +597,7 @@ func TestRunWithService(t *testing.T) {
} }
func TestRunActionInputs(t *testing.T) { func TestRunActionInputs(t *testing.T) {
if testing.Short() { requireDocker(t)
t.Skip("skipping integration test")
}
workflowPath := "input-from-cli" workflowPath := "input-from-cli"
tjfi := TestJobFileInfo{ tjfi := TestJobFileInfo{
@@ -641,9 +616,7 @@ func TestRunActionInputs(t *testing.T) {
} }
func TestRunEventPullRequest(t *testing.T) { func TestRunEventPullRequest(t *testing.T) {
if testing.Short() { requireDocker(t)
t.Skip("skipping integration test")
}
workflowPath := "pull-request" workflowPath := "pull-request"
@@ -659,9 +632,7 @@ func TestRunEventPullRequest(t *testing.T) {
} }
func TestRunMatrixWithUserDefinedInclusions(t *testing.T) { func TestRunMatrixWithUserDefinedInclusions(t *testing.T) {
if testing.Short() { requireDocker(t)
t.Skip("skipping integration test")
}
workflowPath := "matrix-with-user-inclusions" workflowPath := "matrix-with-user-inclusions"
tjfi := TestJobFileInfo{ tjfi := TestJobFileInfo{

View File

@@ -124,7 +124,12 @@ func runStepExecutor(step step, stage stepStage, executor common.Executor) commo
envFileCommand := path.Join("workflow", "envs.txt") envFileCommand := path.Join("workflow", "envs.txt")
(*step.getEnv())["GITHUB_ENV"] = path.Join(actPath, envFileCommand) (*step.getEnv())["GITHUB_ENV"] = path.Join(actPath, envFileCommand)
summaryFileCommand := path.Join("workflow", "SUMMARY.md") // Per-step summary file. Composite sub-steps share the outer job step's index
// via the Parent chain so all writes from within a composite action accumulate
// in the same file and upload under the outer step_index.
topRC := rc.topLevelRunContext()
stepSummaryIndex := topRC.CurrentStepIndex
summaryFileCommand := path.Join("workflow", "step-summary-"+strconv.Itoa(stepSummaryIndex)+".md")
(*step.getEnv())["GITHUB_STEP_SUMMARY"] = path.Join(actPath, summaryFileCommand) (*step.getEnv())["GITHUB_STEP_SUMMARY"] = path.Join(actPath, summaryFileCommand)
{ {
@@ -136,22 +141,23 @@ func runStepExecutor(step step, stage stepStage, executor common.Executor) commo
(*step.getEnv())["GITEA_STEP_SUMMARY"] = (*step.getEnv())["GITHUB_STEP_SUMMARY"] (*step.getEnv())["GITEA_STEP_SUMMARY"] = (*step.getEnv())["GITHUB_STEP_SUMMARY"]
} }
_ = rc.JobContainer.Copy(actPath, &container.FileEntry{ // Reset the per-phase file-command files. GITHUB_STEP_SUMMARY is intentionally
Name: outputFileCommand, // excluded here and initialized below at most once per step so writes from later
Mode: 0o666, // phases and from composite sub-steps accumulate instead of being truncated.
}, &container.FileEntry{ files := []*container.FileEntry{
Name: stateFileCommand, {Name: outputFileCommand, Mode: 0o666},
Mode: 0o666, {Name: stateFileCommand, Mode: 0o666},
}, &container.FileEntry{ {Name: pathFileCommand, Mode: 0o666},
Name: pathFileCommand, {Name: envFileCommand, Mode: 0o666},
Mode: 0o666, }
}, &container.FileEntry{ if topRC.summaryFileInitialized == nil {
Name: envFileCommand, topRC.summaryFileInitialized = map[int]bool{}
Mode: 0o666, }
}, &container.FileEntry{ if !topRC.summaryFileInitialized[stepSummaryIndex] {
Name: summaryFileCommand, files = append(files, &container.FileEntry{Name: summaryFileCommand, Mode: 0o666})
Mode: 0o666, topRC.summaryFileInitialized[stepSummaryIndex] = true
})(ctx) }
_ = rc.JobContainer.Copy(actPath, files...)(ctx)
timeoutctx, cancelTimeOut := evaluateStepTimeout(ctx, rc.ExprEval, stepModel) timeoutctx, cancelTimeOut := evaluateStepTimeout(ctx, rc.ExprEval, stepModel)
defer cancelTimeOut() defer cancelTimeOut()

View File

@@ -113,9 +113,10 @@ func (sar *stepActionRemote) prepareActionExecutor() common.Executor {
} }
actionDir := fmt.Sprintf("%s/%s", sar.RunContext.ActionCacheDir(), sar.Step.UsesHash()) actionDir := fmt.Sprintf("%s/%s", sar.RunContext.ActionCacheDir(), sar.Step.UsesHash())
token := getGitCloneToken(sar.getRunContext().Config, sar.remoteAction.CloneURL(sar.RunContext.Config.DefaultActionInstance)) defaultActionURL := sar.RunContext.Config.DefaultActionURL()
token := getGitCloneToken(sar.getRunContext().Config, sar.remoteAction.CloneURL(defaultActionURL))
gitClone := stepActionRemoteNewCloneExecutor(git.NewGitCloneExecutorInput{ gitClone := stepActionRemoteNewCloneExecutor(git.NewGitCloneExecutorInput{
URL: sar.remoteAction.CloneURL(sar.RunContext.Config.DefaultActionInstance), URL: sar.remoteAction.CloneURL(defaultActionURL),
Ref: sar.remoteAction.Ref, Ref: sar.remoteAction.Ref,
Dir: actionDir, Dir: actionDir,
Token: token, Token: token,
@@ -145,6 +146,7 @@ func (sar *stepActionRemote) prepareActionExecutor() common.Executor {
return common.NewPipelineExecutor( return common.NewPipelineExecutor(
ntErr, ntErr,
func(ctx context.Context) error { func(ctx context.Context) error {
defer git.AcquireCloneLock(actionDir)()
actionModel, err := sar.readAction(ctx, sar.Step, actionDir, sar.remoteAction.Path, remoteReader(ctx), os.WriteFile) actionModel, err := sar.readAction(ctx, sar.Step, actionDir, sar.remoteAction.Path, remoteReader(ctx), os.WriteFile)
sar.action = actionModel sar.action = actionModel
return err return err
@@ -273,7 +275,7 @@ func (sar *stepActionRemote) cloneSkipTLS() bool {
if sar.remoteAction.URL == "" { if sar.remoteAction.URL == "" {
// Empty URL means the default action instance should be used // Empty URL means the default action instance should be used
// Return true if the URL of the Gitea instance is the same as the URL of the default action instance // Return true if the URL of the Gitea instance is the same as the URL of the default action instance
return sar.RunContext.Config.DefaultActionInstance == sar.RunContext.Config.GitHubInstance return sar.RunContext.Config.DefaultActionURL() == sar.RunContext.Config.GitHubInstance
} }
// Return true if the URL of the remote action is the same as the URL of the Gitea instance // Return true if the URL of the remote action is the same as the URL of the Gitea instance
return sar.remoteAction.URL == sar.RunContext.Config.GitHubInstance return sar.remoteAction.URL == sar.RunContext.Config.GitHubInstance
@@ -289,7 +291,9 @@ type remoteAction struct {
func (ra *remoteAction) CloneURL(u string) string { func (ra *remoteAction) CloneURL(u string) string {
if ra.URL == "" { if ra.URL == "" {
if !strings.HasPrefix(u, "http://") && !strings.HasPrefix(u, "https://") { // keep an absolute local path as-is (used by tests to resolve actions from a local
// repo); only bare host names get the https:// scheme prepended
if !strings.HasPrefix(u, "http://") && !strings.HasPrefix(u, "https://") && !filepath.IsAbs(u) {
u = "https://" + u u = "https://" + u
} }
} else { } else {

View File

@@ -20,6 +20,7 @@ import (
"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"
"go.yaml.in/yaml/v4" "go.yaml.in/yaml/v4"
) )
@@ -434,6 +435,57 @@ func TestStepActionRemotePreThroughActionToken(t *testing.T) {
} }
} }
func TestStepActionRemoteUsesGitHubInstanceWhenDefaultActionInstanceEmpty(t *testing.T) {
ctx := context.Background()
var actualURL string
sarm := &stepActionRemoteMocks{}
origStepAtionRemoteNewCloneExecutor := stepActionRemoteNewCloneExecutor
stepActionRemoteNewCloneExecutor = func(input git.NewGitCloneExecutorInput) common.Executor {
return func(ctx context.Context) error {
actualURL = input.URL
return nil
}
}
defer func() {
stepActionRemoteNewCloneExecutor = origStepAtionRemoteNewCloneExecutor
}()
sar := &stepActionRemote{
Step: &model.Step{
Uses: "actions/setup-go@v4",
},
RunContext: &RunContext{
Config: &Config{
GitHubInstance: "gitea.example",
DefaultActionInstance: "",
ActionCacheDir: t.TempDir(),
},
Run: &model.Run{
JobID: "1",
Workflow: &model.Workflow{
Jobs: map[string]*model.Job{
"1": {},
},
},
},
},
readAction: sarm.readAction,
}
suffixMatcher := func(suffix string) any {
return mock.MatchedBy(func(actionDir string) bool {
return strings.HasSuffix(actionDir, suffix)
})
}
sarm.On("readAction", sar.Step, suffixMatcher(sar.Step.UsesHash()), "", mock.Anything, mock.Anything).Return(&model.Action{}, nil)
require.NoError(t, sar.prepareActionExecutor()(ctx))
assert.Equal(t, "https://gitea.example/actions/setup-go", actualURL)
sarm.AssertExpectations(t)
}
func TestStepActionRemotePost(t *testing.T) { func TestStepActionRemotePost(t *testing.T) {
table := []struct { table := []struct {
name string name string

View File

@@ -125,8 +125,6 @@ func (sd *stepDocker) newStepContainer(ctx context.Context, image string, cmd, e
Entrypoint: entrypoint, Entrypoint: entrypoint,
WorkingDir: rc.JobContainer.ToContainerPath(rc.Config.Workdir), WorkingDir: rc.JobContainer.ToContainerPath(rc.Config.Workdir),
Image: image, Image: image,
Username: rc.Config.Secrets["DOCKER_USERNAME"],
Password: rc.Config.Secrets["DOCKER_PASSWORD"],
Name: createContainerName(rc.jobContainerName(), "STEP-"+step.ID), Name: createContainerName(rc.jobContainerName(), "STEP-"+step.ID),
Env: envList, Env: envList,
Mounts: mounts, Mounts: mounts,
@@ -138,7 +136,8 @@ func (sd *stepDocker) newStepContainer(ctx context.Context, image string, cmd, e
UsernsMode: rc.Config.UsernsMode, UsernsMode: rc.Config.UsernsMode,
Platform: rc.Config.ContainerArchitecture, Platform: rc.Config.ContainerArchitecture,
AutoRemove: rc.Config.AutoRemove, AutoRemove: rc.Config.AutoRemove,
ValidVolumes: rc.Config.ValidVolumes, ValidVolumes: rc.validVolumes(),
AllocatePTY: rc.Config.AllocatePTY,
}) })
return stepContainer return stepContainer
} }

View File

@@ -38,7 +38,12 @@ func TestStepDockerMain(t *testing.T) {
sd := &stepDocker{ sd := &stepDocker{
RunContext: &RunContext{ RunContext: &RunContext{
StepResults: map[string]*model.StepResult{}, StepResults: map[string]*model.StepResult{},
Config: &Config{}, Config: &Config{
Secrets: map[string]string{
"DOCKER_USERNAME": "docker-user",
"DOCKER_PASSWORD": "docker-password",
},
},
Run: &model.Run{ Run: &model.Run{
JobID: "1", JobID: "1",
Workflow: &model.Workflow{ Workflow: &model.Workflow{
@@ -106,9 +111,62 @@ func TestStepDockerMain(t *testing.T) {
assert.Equal(t, "node:14", input.Image) assert.Equal(t, "node:14", input.Image)
// DOCKER_USERNAME/DOCKER_PASSWORD secrets should not be used as implicit pull credentials for docker:// action containers.
assert.Empty(t, input.Username)
assert.Empty(t, input.Password)
cm.AssertExpectations(t) cm.AssertExpectations(t)
} }
func TestStepDockerNewStepContainerAllocatePTY(t *testing.T) {
for _, tc := range []struct {
name string
allocPTY bool
}{
{name: "off", allocPTY: false},
{name: "on", allocPTY: true},
} {
t.Run(tc.name, func(t *testing.T) {
cm := &containerMock{}
var captured *container.NewContainerInput
origContainerNewContainer := ContainerNewContainer
ContainerNewContainer = func(input *container.NewContainerInput) container.ExecutionsEnvironment {
captured = input
return cm
}
defer func() {
ContainerNewContainer = origContainerNewContainer
}()
ctx := context.Background()
sd := &stepDocker{
RunContext: &RunContext{
StepResults: map[string]*model.StepResult{},
Config: &Config{
AllocatePTY: tc.allocPTY,
PlatformPicker: func(_ []string) string {
return "node:14"
},
},
Run: &model.Run{
JobID: "1",
Workflow: &model.Workflow{
Jobs: map[string]*model.Job{"1": {}},
},
},
JobContainer: cm,
},
Step: &model.Step{ID: "1", Uses: "docker://node:14"},
}
sd.RunContext.ExprEval = sd.RunContext.NewExpressionEvaluator(ctx)
_ = sd.newStepContainer(ctx, "node:14", []string{"echo", "hi"}, nil)
assert.Equal(t, tc.allocPTY, captured.AllocatePTY)
})
}
}
func TestStepDockerPrePost(t *testing.T) { func TestStepDockerPrePost(t *testing.T) {
ctx := context.Background() ctx := context.Background()
sd := &stepDocker{} sd := &stepDocker{}

View File

@@ -0,0 +1,34 @@
name: local-reusable-workflow
on:
workflow_call:
inputs:
string_required:
required: true
type: string
bool_required:
required: true
type: boolean
number_required:
required: true
type: number
secrets:
secret:
required: true
outputs:
output:
value: ${{ jobs.reusable.outputs.output }}
jobs:
reusable:
runs-on: ubuntu-latest
outputs:
output: ${{ steps.gen.outputs.output }}
steps:
- name: check inputs and secret arrived
run: |
[ "${{ inputs.string_required }}" = "string" ]
[ "${{ inputs.bool_required }}" = "true" ]
[ "${{ inputs.number_required }}" = "1" ]
[ "${{ secrets.secret }}" = "keep_it_private" ]
- id: gen
run: echo "output=${{ inputs.string_required }}" >> $GITHUB_OUTPUT

View File

@@ -5,10 +5,11 @@ jobs:
env: env:
MYGLOBALENV3: myglobalval3 MYGLOBALENV3: myglobalval3
steps: steps:
- uses: actions/checkout@v4
- run: | - run: |
echo MYGLOBALENV1=myglobalval1 > $GITHUB_ENV echo MYGLOBALENV1=myglobalval1 > $GITHUB_ENV
echo "::set-env name=MYGLOBALENV2::myglobalval2" echo "::set-env name=MYGLOBALENV2::myglobalval2"
- uses: nektos/act-test-actions/script@main - uses: ./actions/script
with: with:
main: | main: |
env env

View File

@@ -1,48 +1,31 @@
on: push on: push
jobs: jobs:
# State saved in main (via the $GITHUB_STATE file and the ::save-state command) must surface
# as $STATE_* in the action's post step.
_: _:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: nektos/act-test-actions/script@main - uses: actions/checkout@v4
- uses: ./actions/script
with: with:
pre: |
env
echo mystate0=mystateval > $GITHUB_STATE
echo "::save-state name=mystate1::mystateval"
main: | main: |
env
echo mystate2=mystateval > $GITHUB_STATE echo mystate2=mystateval > $GITHUB_STATE
echo "::save-state name=mystate3::mystateval" echo "::save-state name=mystate3::mystateval"
post: | post: |
env
[ "$STATE_mystate0" = "mystateval" ]
[ "$STATE_mystate1" = "mystateval" ]
[ "$STATE_mystate2" = "mystateval" ] [ "$STATE_mystate2" = "mystateval" ]
[ "$STATE_mystate3" = "mystateval" ] [ "$STATE_mystate3" = "mystateval" ]
# State must be isolated per action instance even when two steps use the same action.
test-id-collision-bug: test-id-collision-bug:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: nektos/act-test-actions/script@main - uses: actions/checkout@v4
- uses: ./actions/script
id: script id: script
with: with:
pre: | main: echo mystate=val1 > $GITHUB_STATE
env post: '[ "$STATE_mystate" = "val1" ]'
echo mystate0=mystateval > $GITHUB_STATE - uses: ./actions/script
echo "::save-state name=mystate1::mystateval"
main: |
env
echo mystate2=mystateval > $GITHUB_STATE
echo "::save-state name=mystate3::mystateval"
post: |
env
[ "$STATE_mystate0" = "mystateval" ]
[ "$STATE_mystate1" = "mystateval" ]
[ "$STATE_mystate2" = "mystateval" ]
[ "$STATE_mystate3" = "mystateval" ]
- uses: nektos/act-test-actions/script@main
id: pre-script id: pre-script
with: with:
main: | main: echo mystate=val2 > $GITHUB_STATE
env post: '[ "$STATE_mystate" = "val2" ]'
echo mystate0=mystateerror > $GITHUB_STATE
echo "::save-state name=mystate1::mystateerror"

View File

@@ -1,4 +1,4 @@
FROM alpine:3 FROM alpine:3.23
COPY entrypoint.sh /entrypoint.sh COPY entrypoint.sh /entrypoint.sh

View File

@@ -9,7 +9,3 @@ jobs:
- uses: actions/checkout@v3 - uses: actions/checkout@v3
- uses: './actions-environment-and-context-tests/js' - uses: './actions-environment-and-context-tests/js'
- uses: './actions-environment-and-context-tests/docker' - uses: './actions-environment-and-context-tests/docker'
- uses: 'nektos/act-test-actions/js@main'
- uses: 'nektos/act-test-actions/docker@main'
- uses: 'nektos/act-test-actions/docker-file@main'
- uses: 'nektos/act-test-actions/docker-relative-context/action@main'

View File

@@ -10,4 +10,4 @@ outputs:
description: 'The time we greeted you' description: 'The time we greeted you'
runs: runs:
using: 'node24' using: 'node24'
main: 'dist/index.js' main: 'index.js'

View File

@@ -1,11 +1,14 @@
import {getInput, setOutput, setFailed} from '@actions/core'; import {appendFileSync, readFileSync} from 'node:fs';
import {context} from '@actions/github';
try { const nameToGreet = process.env['INPUT_WHO-TO-GREET'] || 'World';
const nameToGreet = getInput('who-to-greet'); console.log(`Hello ${nameToGreet}!`);
console.log(`Hello ${nameToGreet}!`);
setOutput('time', (new Date()).toTimeString()); if (process.env.GITHUB_OUTPUT) {
console.log(`The event payload: ${JSON.stringify(context.payload, undefined, 2)}`); appendFileSync(process.env.GITHUB_OUTPUT, `time=${new Date().toTimeString()}\n`);
} catch (error) {
setFailed(error.message);
} }
let payload = {};
if (process.env.GITHUB_EVENT_PATH) {
payload = JSON.parse(readFileSync(process.env.GITHUB_EVENT_PATH, 'utf8'));
}
console.log(`The event payload: ${JSON.stringify(payload, undefined, 2)}`);

View File

@@ -1,21 +1,5 @@
{ {
"name": "node24", "name": "node24",
"version": "1.0.0", "private": true,
"description": "", "type": "module"
"main": "index.js",
"type": "module",
"scripts": {
"build": "ncc build index.js"
},
"license": "ISC",
"dependencies": {
"@actions/core": "^3.0.1",
"@actions/github": "^9.1.1"
},
"devDependencies": {
"@vercel/ncc": "^0.38.4"
},
"engines": {
"node": ">=24"
}
} }

View File

@@ -0,0 +1,15 @@
name: 'script'
description: 'Run the shell scripts passed as inputs across the pre/main/post lifecycle'
inputs:
main:
description: 'shell script to run in the main step'
required: false
default: ''
post:
description: 'shell script to run in the post step'
required: false
default: ''
runs:
using: 'node24'
main: 'index.js'
post: 'post.js'

View File

@@ -0,0 +1,9 @@
import {execFileSync} from 'node:child_process';
// Run the `main` input as a bash script; its stdout (workflow commands like
// ::set-output / ::save-state) and $GITHUB_ENV / $GITHUB_STATE writes are
// processed by the runner, exactly like the remote script action this replaces.
const script = process.env.INPUT_MAIN;
if (script) {
execFileSync('bash', ['-eo', 'pipefail', '-c', script], {stdio: 'inherit'});
}

View File

@@ -0,0 +1,5 @@
{
"name": "script",
"private": true,
"type": "module"
}

View File

@@ -0,0 +1,6 @@
import {execFileSync} from 'node:child_process';
const script = process.env.INPUT_POST;
if (script) {
execFileSync('bash', ['-eo', 'pipefail', '-c', script], {stdio: 'inherit'});
}

View File

@@ -4,7 +4,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- run: | - run: |
FROM ubuntu:latest FROM node:24-bookworm-slim
ENV PATH="/opt/texlive/texdir/bin/x86_64-linuxmusl:${PATH}" ENV PATH="/opt/texlive/texdir/bin/x86_64-linuxmusl:${PATH}"
ENV ORG_PATH="${PATH}" ENV ORG_PATH="${PATH}"
ENTRYPOINT [ "bash", "-c", "echo \"PATH=$PATH\" && echo \"ORG_PATH=$ORG_PATH\" && [[ \"$PATH\" = \"$ORG_PATH\" ]]" ] ENTRYPOINT [ "bash", "-c", "echo \"PATH=$PATH\" && echo \"ORG_PATH=$ORG_PATH\" && [[ \"$PATH\" = \"$ORG_PATH\" ]]" ]

View File

@@ -1,13 +0,0 @@
on: push
env:
variable: "${{ github.repository_owner }}"
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: print env.variable
run: |
echo ${{ env.variable }}
exit ${{ (env.variable == 'nektos') && '0' || '1'}}

View File

@@ -9,24 +9,13 @@ jobs:
steps: steps:
- name: My first false step - name: My first false step
if: "endsWith('Should not', 'o1')" if: "endsWith('Should not', 'o1')"
uses: actions/checkout@v2.0.0 run: exit 1
with:
ref: refs/pull/${{github.event.pull_request.number}}/merge
fetch-depth: 5
- name: My first true step - name: My first true step
if: ${{endsWith('Hello world', 'ld')}} if: ${{endsWith('Hello world', 'ld')}}
uses: actions/hello-world-javascript-action@main run: echo "Renst the Octocat"
with:
who-to-greet: "Renst the Octocat"
- name: My second false step - name: My second false step
if: "endsWith('Should not evaluate', 'o2')" if: "endsWith('Should not evaluate', 'o2')"
uses: actions/checkout@v2.0.0 run: exit 1
with:
ref: refs/pull/${{github.event.pull_request.number}}/merge
fetch-depth: 5
- name: My third false step - name: My third false step
if: ${{endsWith('Should not evaluate', 'o3')}} if: ${{endsWith('Should not evaluate', 'o3')}}
uses: actions/checkout@v2.0.0 run: exit 1
with:
ref: refs/pull/${{github.event.pull_request.number}}/merge
fetch-depth: 5

View File

@@ -9,23 +9,13 @@ jobs:
steps: steps:
- name: My first false step - name: My first false step
if: "endsWith('Hello world', 'o1')" if: "endsWith('Hello world', 'o1')"
uses: actions/hello-world-javascript-action@main run: exit 1
with:
who-to-greet: 'Mona the Octocat'
- name: My first true step - name: My first true step
if: "!endsWith('Hello world', 'od')" if: "!endsWith('Hello world', 'od')"
uses: actions/hello-world-javascript-action@main run: echo "Renst the Octocat"
with:
who-to-greet: "Renst the Octocat"
- name: My second false step - name: My second false step
if: "endsWith('Hello world', 'o2')" if: "endsWith('Hello world', 'o2')"
uses: actions/hello-world-javascript-action@main run: exit 1
with:
who-to-greet: 'Act the Octocat'
- name: My third false step - name: My third false step
if: "endsWith('Hello world', 'o2')" if: "endsWith('Hello world', 'o2')"
uses: actions/hello-world-javascript-action@main run: exit 1
with:
who-to-greet: 'Git the Octocat'

View File

@@ -5,6 +5,7 @@ jobs:
test: test:
runs-on: ubuntu-latest runs-on: ubuntu-latest
container: container:
image: catthehacker/ubuntu:runner-latest # image with user 'runner:runner' built on tag 'act-latest' image: node:24-bookworm-slim
options: --user 1000
steps: steps:
- run: echo PASS - run: echo PASS

View File

@@ -24,4 +24,3 @@ jobs:
args: ${{format('"{0}"', 'Mona is not the Octocat') }} args: ${{format('"{0}"', 'Mona is not the Octocat') }}
who-to-greet: 'Mona the Octocat' who-to-greet: 'Mona the Octocat'
- run: '[[ "${{ env.SOMEVAR }}" == "Mona is not the Octocat" ]]' - run: '[[ "${{ env.SOMEVAR }}" == "Mona is not the Octocat" ]]'
- uses: ./localdockerimagetest_

View File

@@ -30,11 +30,6 @@ runs:
who-to-greet: ${{inputs.who-to-greet}} who-to-greet: ${{inputs.who-to-greet}}
- run: '[[ "${{ env.SOMEVAR }}" == "Mona is not the Octocat" ]]' - run: '[[ "${{ env.SOMEVAR }}" == "Mona is not the Octocat" ]]'
shell: bash shell: bash
- uses: ./localdockerimagetest_
# Also test a remote docker action here
- uses: actions/hello-world-docker-action@v2
with:
who-to-greet: 'Mona the Octocat'
# Test if GITHUB_ACTION_PATH is set correctly after all steps # Test if GITHUB_ACTION_PATH is set correctly after all steps
- run: stat $GITHUB_ACTION_PATH/push.yml - run: stat $GITHUB_ACTION_PATH/push.yml
shell: bash shell: bash

View File

@@ -5,5 +5,5 @@ jobs:
build: build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: nektos/test-override@a - uses: https://github.com/nektos/test-override@a
- uses: nektos/test-override@b - uses: nektos/test-override@b

View File

@@ -1,31 +0,0 @@
name: matrix-include-exclude
on: push
jobs:
build:
name: PHP ${{ matrix.os }} ${{ matrix.node}}
runs-on: ${{ matrix.os }}
steps:
- run: echo ${NODE_VERSION} | grep ${{ matrix.node }}
env:
NODE_VERSION: ${{ matrix.node }}
strategy:
matrix:
os: [ubuntu-18.04, macos-latest]
node: [4, 6, 8, 10]
exclude:
- os: macos-latest
node: 4
include:
- os: ubuntu-16.04
node: 10
test:
runs-on: ubuntu-latest
strategy:
matrix:
node: [8.x, 10.x, 12.x, 13.x]
steps:
- run: echo ${NODE_VERSION} | grep ${{ matrix.node }}
env:
NODE_VERSION: ${{ matrix.node }}

View File

@@ -19,11 +19,3 @@ jobs:
using: composite using: composite
shell: cp {0} action.yml shell: cp {0} action.yml
- uses: ./ - uses: ./
remote-invalid-step:
runs-on: ubuntu-latest
steps:
- uses: nektos/act-test-actions/invalid-composite-action/invalid-step@main
remote-missing-steps:
runs-on: ubuntu-latest
steps:
- uses: nektos/act-test-actions/invalid-composite-action/missing-steps@main

View File

@@ -27,7 +27,7 @@ jobs:
exit 1 exit 1
fi fi
- uses: nektos/act-test-actions/composite@main - uses: ./path-handling/
with: with:
input: some input input: some input

View File

@@ -1,8 +0,0 @@
name: remote-action-composite-action-ref
on: push
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: nektos/act-test-actions/composite-assert-action-ref-action@main

View File

@@ -1,23 +0,0 @@
name: remote-action-composite-js-pre-with-defaults
on: push
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: nektos/act-test-actions/composite-js-pre-with-defaults/js@main
with:
in: nix
- uses: nektos/act-test-actions/composite-js-pre-with-defaults@main
with:
in: secretval
- uses: nektos/act-test-actions/composite-js-pre-with-defaults@main
with:
in: secretval
- uses: nektos/act-test-actions/composite-js-pre-with-defaults/js@main
with:
pre: "true"
in: nix
- uses: nektos/act-test-actions/composite-js-pre-with-defaults/js@main
with:
in: nix

Some files were not shown because too many files have changed in this diff Show More