23 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
110 changed files with 2780 additions and 1350 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

@@ -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,7 @@ 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 ARG VERSION=dev
@@ -37,7 +37,7 @@ 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 ARG VERSION=dev
@@ -63,7 +63,7 @@ ENTRYPOINT ["s6-svscan","/etc/s6"]
### BASIC VARIANT ### BASIC VARIANT
# #
# #
FROM alpine AS basic FROM alpine:3.23 AS basic
ARG VERSION=dev ARG VERSION=dev

View File

@@ -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

@@ -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

@@ -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

@@ -66,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(
@@ -99,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
} }
@@ -174,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"
} }
@@ -247,10 +265,25 @@ type NewGitCloneExecutorInput struct {
func CloneIfRequired(ctx context.Context, refName plumbing.ReferenceName, input NewGitCloneExecutorInput, logger log.FieldLogger) (*git.Repository, bool, error) { func CloneIfRequired(ctx context.Context, refName plumbing.ReferenceName, input NewGitCloneExecutorInput, logger log.FieldLogger) (*git.Repository, bool, error) {
r, err := git.PlainOpen(input.Dir) r, err := git.PlainOpen(input.Dir)
if err == nil { if err == nil {
// Verify the cached clone still points to the resolved URL before reusing it.
remote, err := r.Remote("origin")
if err == nil && len(remote.Config().URLs) > 0 && remote.Config().URLs[0] == input.URL {
// Reuse existing clone // Reuse existing clone
return r, true, nil return r, true, nil
} }
if err != nil {
logger.Debugf("Removing cached clone at %s because origin cannot be read: %v", input.Dir, err)
} else if len(remote.Config().URLs) == 0 {
logger.Debugf("Removing cached clone at %s because origin has no URL", input.Dir)
} else {
logger.Debugf("Removing cached clone at %s because origin URL changed from %s to %s", input.Dir, remote.Config().URLs[0], input.URL)
}
if err := os.RemoveAll(input.Dir); err != nil {
return nil, false, fmt.Errorf("remove cached clone %s: %w", input.Dir, err)
}
}
var progressWriter io.Writer var progressWriter io.Writer
if isatty.IsTerminal(os.Stdout.Fd()) || isatty.IsCygwinTerminal(os.Stdout.Fd()) { if isatty.IsTerminal(os.Stdout.Fd()) || isatty.IsCygwinTerminal(os.Stdout.Fd()) {
if entry, ok := logger.(*log.Entry); ok { if entry, ok := logger.(*log.Entry); ok {

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))
@@ -280,8 +335,6 @@ func TestGitCloneExecutorNonFastForwardRef(t *testing.T) {
} }
func TestGitCloneExecutorOfflineMode(t *testing.T) { func TestGitCloneExecutorOfflineMode(t *testing.T) {
gitConfig()
// Build a local "remote" with a single commit on main. // Build a local "remote" with a single commit on main.
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))
@@ -327,22 +380,21 @@ func TestGitCloneExecutorOfflineMode(t *testing.T) {
}) })
} }
func gitConfig() {
if os.Getenv("GITHUB_ACTIONS") == "true" {
var err error
if err = gitCmd("config", "--global", "user.email", "test@test.com"); err != nil {
log.Error(err)
}
if err = gitCmd("config", "--global", "user.name", "Unit Test"); err != nil {
log.Error(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 {

View File

@@ -13,7 +13,6 @@ import (
"github.com/distribution/reference" "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/moby/moby/api/types/registry"
) )
@@ -26,10 +25,6 @@ func LoadDockerAuthConfig(ctx context.Context, image string) (registry.AuthConfi
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
} }
if !cfg.ContainsAuth() {
cfg.CredentialsStore = credentials.DetectDefaultStore(cfg.CredentialsStore)
}
registryKey := registryAuthConfigKey("docker.io") registryKey := registryAuthConfigKey("docker.io")
if image != "" { if image != "" {
if registryRef, refErr := reference.ParseNormalizedNamed(image); refErr != nil { if registryRef, refErr := reference.ParseNormalizedNamed(image); refErr != nil {
@@ -55,10 +50,6 @@ func LoadDockerAuthConfigs(ctx context.Context) map[string]registry.AuthConfig {
logger.Warnf("Could not load docker config: %v", err) logger.Warnf("Could not load docker config: %v", err)
return nil return nil
} }
if !cfg.ContainsAuth() {
cfg.CredentialsStore = credentials.DetectDefaultStore(cfg.CredentialsStore)
}
creds, err := cfg.GetAllCredentials() creds, err := cfg.GetAllCredentials()
if err != nil { if err != nil {
logger.Warnf("Could not get docker auth configs: %v", err) logger.Warnf("Could not get docker auth configs: %v", err)

View File

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

@@ -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

@@ -26,6 +26,7 @@ import (
"dario.cat/mergo" "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/go-git/go-billy/v5/helper/polyfill" "github.com/go-git/go-billy/v5/helper/polyfill"
@@ -152,6 +153,8 @@ 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("docker cp src=%s dst=%s", 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
@@ -167,6 +170,16 @@ 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")
} }
// Direct entry point (no pipeline) — revalidate cr.id ourselves.
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}) result, err := cr.cli.CopyFromContainer(ctx, cr.id, client.CopyFromContainerOptions{SourcePath: srcPath})
if err != nil { if err != nil {
return nil, err return nil, err
@@ -314,11 +327,23 @@ 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 != "" {
// 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 return nil
} }
cr.id = ""
}
containers, err := cr.cli.ContainerList(ctx, client.ContainerListOptions{ containers, err := cr.cli.ContainerList(ctx, client.ContainerListOptions{
All: true, All: true,
}) })
@@ -335,7 +360,6 @@ func (cr *containerReference) find() common.Executor {
} }
} }
cr.id = ""
return nil return nil
} }
} }
@@ -592,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" {
@@ -746,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)
@@ -779,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 {
@@ -853,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)

View File

@@ -19,6 +19,7 @@ import (
"gitea.com/gitea/runner/act/common" "gitea.com/gitea/runner/act/common"
cerrdefs "github.com/containerd/errdefs"
"github.com/moby/moby/api/types/container" "github.com/moby/moby/api/types/container"
mobyclient "github.com/moby/moby/client" mobyclient "github.com/moby/moby/client"
"github.com/sirupsen/logrus/hooks/test" "github.com/sirupsen/logrus/hooks/test"
@@ -28,14 +29,10 @@ import (
) )
func TestDocker(t *testing.T) { func TestDocker(t *testing.T) {
if testing.Short() { requireDocker(t)
t.Skip("skipping integration test")
}
ctx := context.Background() ctx := context.Background()
client, err := GetDockerClient(ctx) client, err := GetDockerClient(ctx)
if err != nil { require.NoError(t, err)
t.Skipf("skipping integration test: %v", err)
}
defer client.Close() defer client.Close()
dockerBuild := NewDockerBuildExecutor(NewDockerBuildExecutorInput{ dockerBuild := NewDockerBuildExecutor(NewDockerBuildExecutorInput{
@@ -102,6 +99,16 @@ func (m *mockDockerClient) CopyToContainer(ctx context.Context, id string, optio
return args.Get(0).(mobyclient.CopyToContainerResult), args.Error(1) 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 {
io.Reader io.Reader
} }
@@ -302,6 +309,134 @@ func TestDockerCopyTarStreamErrorInMkdir(t *testing.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{}

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

@@ -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,7 @@ import (
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"runtime" "runtime"
"strconv"
"strings" "strings"
"sync"
"sync/atomic" "sync/atomic"
"time" "time"
@@ -44,9 +42,6 @@ type HostEnvironment struct {
CleanUp func() CleanUp func()
StdOut io.Writer StdOut io.Writer
AllocatePTY bool // allocate a pseudo-TTY for each step's process 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 {
@@ -327,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() {
@@ -353,23 +372,20 @@ func (e *HostEnvironment) exec(ctx context.Context, command []string, cmdline st
go copyPtyOutput(writer, ppty, finishLog) go copyPtyOutput(writer, ppty, finishLog)
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 {
@@ -440,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.
cmd := exec.CommandContext(ctx, "taskkill", "/PID", strconv.Itoa(pid), "/T", "/F")
out, err := cmd.CombinedOutput() out, err := cmd.CombinedOutput()
if err != nil { if err != nil {
logger.Debugf("taskkill failed for pid=%d: %v output=%s", pid, err, strings.TrimSpace(string(out))) logger.Debugf("workspace process-tree kill via PowerShell failed: %v output=%s", err, strings.TrimSpace(string(out)))
}
} }
} }
@@ -477,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.CleanWorkdir { 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

@@ -187,3 +187,64 @@ func TestHostEnvironmentRemoveCleansWorkdirWhenOwned(t *testing.T) {
_, err := os.Stat(workdir) _, err := os.Stat(workdir)
assert.ErrorIs(t, err, os.ErrNotExist) 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

@@ -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

@@ -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

@@ -436,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,
@@ -455,7 +453,7 @@ 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, AllocatePTY: rc.Config.AllocatePTY,
}) })
return stepContainer return stepContainer

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
Repo string
Ref 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

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

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":
@@ -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
// read-modify-write of the job result so a failing combination is not lost-updated by a
// concurrent succeeding one.
job := rc.Run.Job()
jobResult := func() string {
defer lockJob(job)()
result := "success"
// we have only one result for a whole matrix build, so we need // we have only one result for a whole matrix build, so we need
// to keep an existing result state if we run a matrix // to keep an existing result state if we run a matrix
if len(info.matrix()) > 0 && rc.Run.Job().Result != "" { if len(info.matrix()) > 0 && job.Result != "" {
jobResult = rc.Run.Job().Result result = job.Result
} }
if !success { if !success {
jobResult = "failure" result = "failure"
} }
info.result(result)
return result
}()
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),
) )
} }
@@ -284,7 +287,11 @@ 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)
unlock()
logger.WithField("jobResult", reusedWorkflowJobResult).Infof("Job %s", reusedWorkflowJobResultMessage) logger.WithField("jobResult", reusedWorkflowJobResult).Infof("Job %s", reusedWorkflowJobResultMessage)
} }
} }

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"
@@ -43,6 +45,10 @@ type RunContext struct {
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,14 +214,6 @@ 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
} }
@@ -432,7 +459,7 @@ 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, AllocatePTY: rc.Config.AllocatePTY,
}) })
if rc.JobContainer == nil { if rc.JobContainer == nil {
@@ -586,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
} }
@@ -601,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)
} }
} }
@@ -636,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
@@ -713,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 {
@@ -1084,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
@@ -1141,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

@@ -19,6 +19,7 @@ import (
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"
) )
@@ -169,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",
@@ -280,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)
@@ -659,3 +730,53 @@ func TestPrintStartJobContainerGroupGolden(t *testing.T) {
}, "\n") }, "\n")
assert.Equal(t, want, buf.String()) 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"
@@ -250,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:
@@ -334,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,11 +468,19 @@ func TestDryrunEvent(t *testing.T) {
} }
} }
func TestDockerActionForcePullForceRebuild(t *testing.T) { // TestReusableWorkflowCaller exercises the reusable-workflow caller path against a local
if testing.Short() { // reusable workflow (typed inputs, secrets as both a map and `inherit`, and reading the called
t.Skip("skipping integration test") // 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) {
requireDocker(t)
requireNetwork(t) // force-pulls a docker action image
ctx := context.Background() ctx := context.Background()
config := &Config{ config := &Config{
@@ -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()
@@ -605,6 +581,7 @@ func TestRunWithService(t *testing.T) {
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

@@ -291,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

@@ -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,7 @@ 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, 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,6 +111,10 @@ 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)
} }

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());
console.log(`The event payload: ${JSON.stringify(context.payload, undefined, 2)}`); if (process.env.GITHUB_OUTPUT) {
} catch (error) { appendFileSync(process.env.GITHUB_OUTPUT, `time=${new Date().toTimeString()}\n`);
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

View File

@@ -1,10 +0,0 @@
name: remote-action-docker
on: push
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/hello-world-docker-action@v1
with:
who-to-greet: 'Mona the Octocat'

View File

@@ -1,30 +0,0 @@
name: remote-action-js
on: push
jobs:
test:
runs-on: ubuntu-latest
container:
image: node:24-bookworm-slim
options: --user node
steps:
- name: check permissions of env files
id: test
run: |
echo "USER: $(id -un) expected: node"
[[ "$(id -un)" = "node" ]]
echo "TEST=Value" >> $GITHUB_OUTPUT
shell: bash
- name: check if file command worked
if: steps.test.outputs.test != 'Value'
run: |
echo "steps.test.outputs.test=${{ steps.test.outputs.test || 'missing value!' }}"
exit 1
shell: bash
- uses: actions/hello-world-javascript-action@v1
with:
who-to-greet: 'Mona the Octocat'
- uses: cloudposse/actions/github/slash-command-dispatch@0.14.0

View File

@@ -1,12 +0,0 @@
name: remote-action-js
on: push
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/hello-world-javascript-action@v1
with:
who-to-greet: 'Mona the Octocat'
- uses: cloudposse/actions/github/slash-command-dispatch@0.14.0

View File

@@ -1,24 +0,0 @@
name: runs-on
on: push
jobs:
test:
runs-on: ubuntu-latest
steps:
- run: env
- run: echo ${GITHUB_ACTOR}
- run: echo ${GITHUB_ACTOR} | grep nektos/act
many:
runs-on: [ubuntu-latest]
steps:
- run: env
- run: echo ${GITHUB_ACTOR}
- run: echo ${GITHUB_ACTOR} | grep nektos/act
selfmany:
runs-on: [self-hosted, ubuntu-latest]
steps:
- run: env
- run: echo ${GITHUB_ACTOR}
- run: echo ${GITHUB_ACTOR} | grep nektos/act

2
act/runner/testdata/secrets/.env vendored Normal file
View File

@@ -0,0 +1,2 @@
HELLO=WORLD
MULTILINE_ENV="foo\nbar\nbaz"

View File

@@ -1,14 +0,0 @@
name: services-host-network
on: push
jobs:
services-host-network:
runs-on: ubuntu-latest
services:
nginx:
image: "nginx:latest"
ports:
- "8080:80"
steps:
- run: apt-get -qq update && apt-get -yqq install --no-install-recommends curl net-tools
- run: netstat -tlpen
- run: curl -v http://localhost:8080

View File

@@ -5,12 +5,11 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
# https://docs.github.com/en/actions/using-containerized-services/about-service-containers#running-jobs-in-a-container # https://docs.github.com/en/actions/using-containerized-services/about-service-containers#running-jobs-in-a-container
container: container:
image: "ubuntu:latest" image: "node:24-bookworm-slim"
services: services:
nginx: nginx:
image: "nginx:latest" image: "nginx:alpine"
ports:
- "8080:80"
steps: steps:
- run: apt-get -qq update && apt-get -yqq install --no-install-recommends curl - run: apt-get -qq update && apt-get -yqq install --no-install-recommends curl
# reach the service over the shared job network by its alias, no host port needed
- run: curl -v http://nginx:80 - run: curl -v http://nginx:80

View File

@@ -6,18 +6,9 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
services: services:
postgres: postgres:
image: postgres:12 image: nginx:alpine
env:
POSTGRES_USER: runner
POSTGRES_PASSWORD: mysecretdbpass
POSTGRES_DB: mydb
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports: ports:
- 5432:5432 - 80
steps: steps:
- name: Echo the Postgres service ID / Network / Ports - name: Echo the Postgres service ID / Network / Ports
run: | run: |

View File

@@ -8,13 +8,6 @@ jobs:
- shell: ${{ env.MY_SHELL }} - shell: ${{ env.MY_SHELL }}
run: | run: |
$PSVersionTable $PSVersionTable
check-container:
runs-on: ubuntu-latest
container: catthehacker/ubuntu:pwsh-latest
steps:
- shell: ${{ env.MY_SHELL }}
run: |
$PSVersionTable
check-job-default: check-job-default:
runs-on: ubuntu-latest runs-on: ubuntu-latest
defaults: defaults:

View File

@@ -1,28 +0,0 @@
on: push
env:
MY_SHELL: python
jobs:
check:
runs-on: ubuntu-latest
steps:
- shell: ${{ env.MY_SHELL }}
run: |
import platform
print(platform.python_version())
check-container:
runs-on: ubuntu-latest
container: node:24-bookworm
steps:
- shell: ${{ env.MY_SHELL }}
run: |
import platform
print(platform.python_version())
check-job-default:
runs-on: ubuntu-latest
defaults:
run:
shell: ${{ env.MY_SHELL }}
steps:
- run: |
import platform
print(platform.python_version())

View File

@@ -1,7 +0,0 @@
name: "last action check"
description: "last action check"
runs:
using: "node24"
main: main.js
post: post.js

View File

@@ -1,17 +0,0 @@
const pre = process.env['ACTION_OUTPUT_PRE'];
const main = process.env['ACTION_OUTPUT_MAIN'];
const post = process.env['ACTION_OUTPUT_POST'];
console.log({pre, main, post});
if (pre !== 'pre') {
throw new Error(`Expected 'pre' but got '${pre}'`);
}
if (main !== 'main') {
throw new Error(`Expected 'main' but got '${main}'`);
}
if (post !== 'post') {
throw new Error(`Expected 'post' but got '${post}'`);
}

View File

@@ -1,15 +0,0 @@
name: uses-action-with-pre-and-post-step
on: push
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: ./uses-action-with-pre-and-post-step/last-action
- uses: nektos/act-test-actions/js-with-pre-and-post-step@main
with:
pre: true
post: true
- run: |
cat $GITHUB_ENV

View File

@@ -1,7 +0,0 @@
name: uses-github-root
on: push
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/hello-world-docker-action@b136eb8894c5cb1dd5807da824be97ccdf9b5423

View File

@@ -1,7 +0,0 @@
name: uses-github-path
on: push
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: sergioramos/yarn-actions/install@v6

View File

@@ -1,7 +0,0 @@
name: uses-github-root
on: push
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/hello-world-docker-action@b136eb8

View File

@@ -1,63 +0,0 @@
---
name: "Test Composite Action"
description: "Test action uses composite"
inputs:
test_input_optional:
description: Test
runs:
using: "composite"
steps:
- uses: actions/setup-node@v6
with:
node-version: '24'
- run: |
console.log(process.version);
console.log("Hi from node");
console.log("${{ inputs.test_input_optional }}");
if("${{ inputs.test_input_optional }}" !== "Test") {
console.log("Invalid input test_input_optional expected \"Test\" as value");
process.exit(1);
}
if(!process.version.startsWith('v16')) {
console.log("Expected node v16, but got " + process.version);
process.exit(1);
}
shell: node {0}
- uses: ./uses-composite/composite_action
id: composite
with:
test_input_required: 'test_input_required_value'
test_input_optional: 'test_input_optional_value'
test_input_optional_with_default_overriden: 'test_input_optional_with_default_overriden'
test_input_required_with_default: 'test_input_optional_value'
test_input_required_with_default_overriden: 'test_input_required_with_default_overriden'
secret_input: ${{inputs.test_input_optional}}
env:
secret_input: ${{inputs.test_input_optional}}
- run: |
echo "steps.composite.outputs.test_output=${{ steps.composite.outputs.test_output }}"
[[ "${{steps.composite.outputs.test_output == 'test_output_value'}}" = "true" ]] || exit 1
shell: bash
- run: |
echo "steps.composite.outputs.secret_output=${{ steps.composite.outputs.secret_output }}"
[[ "${{steps.composite.outputs.secret_output == format('{0}/{0}', inputs.test_input_optional)}}" = "true" ]] || exit 1
shell: bash
# Now test again with default values
- name: ./uses-composite/composite_action with defaults
uses: ./uses-composite/composite_action
id: composite2
with:
test_input_required: 'test_input_required_value'
test_input_optional_with_default_overriden: 'test_input_optional_with_default_overriden'
test_input_required_with_default_overriden: 'test_input_required_with_default_overriden'
- run: |
echo "steps.composite2.outputs.test_output=${{ steps.composite2.outputs.test_output }}"
[[ "${{steps.composite2.outputs.test_output == 'test_output_value'}}" = "true" ]] || exit 1
shell: bash
- run: |
echo "steps.composite.outputs.secret_output=$COMPOSITE_ACTION_ENV_OUTPUT"
[[ "${{env.COMPOSITE_ACTION_ENV_OUTPUT == 'my test value' }}" = "true" ]] || exit 1
shell: bash

View File

@@ -1,15 +0,0 @@
name: uses-docker-url
on: push
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: ./uses-nested-composite/composite_action2
with:
test_input_optional: Test
- run: |
echo "steps.composite.outputs.secret_output=$COMPOSITE_ACTION_ENV_OUTPUT"
[[ "${{env.COMPOSITE_ACTION_ENV_OUTPUT == 'my test value' }}" = "true" ]] || exit 1
shell: bash

View File

@@ -1,42 +0,0 @@
name: local-reusable-workflows
on: pull_request
jobs:
reusable-workflow:
uses: ./.github/workflows/local-reusable-workflow.yml
with:
string_required: string
bool_required: ${{ true }}
number_required: 1
secrets:
secret: keep_it_private
reusable-workflow-with-inherited-secrets:
uses: ./.github/workflows/local-reusable-workflow.yml
with:
string_required: string
bool_required: ${{ true }}
number_required: 1
secrets: inherit
reusable-workflow-with-on-string-notation:
uses: ./.github/workflows/local-reusable-workflow-no-inputs-string.yml
reusable-workflow-with-on-array-notation:
uses: ./.github/workflows/local-reusable-workflow-no-inputs-array.yml
output-test:
runs-on: ubuntu-latest
needs:
- reusable-workflow
- reusable-workflow-with-inherited-secrets
steps:
- name: output with secrets map
run: |
echo reusable-workflow.output=${{ needs.reusable-workflow.outputs.output }}
[[ "${{ needs.reusable-workflow.outputs.output == 'string' }}" = "true" ]] || exit 1
- name: output with inherited secrets
run: |
echo reusable-workflow-with-inherited-secrets.output=${{ needs.reusable-workflow-with-inherited-secrets.outputs.output }}
[[ "${{ needs.reusable-workflow-with-inherited-secrets.outputs.output == 'string' }}" = "true" ]] || exit 1

View File

@@ -1,8 +1,11 @@
on: push on: push
# Exercises the reusable-workflow caller path against a local reusable workflow: passing typed
# inputs and secrets (both an explicit map and `inherit`), and reading the called workflow's
# outputs back through `needs`.
jobs: jobs:
reusable-workflow: reusable-workflow:
uses: nektos/act-test-actions/.github/workflows/reusable-workflow.yml@main uses: ./.github/workflows/local-reusable-workflow.yml
with: with:
string_required: string string_required: string
bool_required: ${{ true }} bool_required: ${{ true }}
@@ -11,7 +14,7 @@ jobs:
secret: keep_it_private secret: keep_it_private
reusable-workflow-with-inherited-secrets: reusable-workflow-with-inherited-secrets:
uses: nektos/act-test-actions/.github/workflows/reusable-workflow.yml@main uses: ./.github/workflows/local-reusable-workflow.yml
with: with:
string_required: string string_required: string
bool_required: ${{ true }} bool_required: ${{ true }}
@@ -24,12 +27,5 @@ jobs:
- reusable-workflow - reusable-workflow
- reusable-workflow-with-inherited-secrets - reusable-workflow-with-inherited-secrets
steps: steps:
- name: output with secrets map - run: '[[ "${{ needs.reusable-workflow.outputs.output == ''string'' }}" = "true" ]] || exit 1'
run: | - run: '[[ "${{ needs.reusable-workflow-with-inherited-secrets.outputs.output == ''string'' }}" = "true" ]] || exit 1'
echo reusable-workflow.output=${{ needs.reusable-workflow.outputs.output }}
[[ "${{ needs.reusable-workflow.outputs.output == 'string' }}" = "true" ]] || exit 1
- name: output with inherited secrets
run: |
echo reusable-workflow-with-inherited-secrets.output=${{ needs.reusable-workflow-with-inherited-secrets.outputs.output }}
[[ "${{ needs.reusable-workflow-with-inherited-secrets.outputs.output == 'string' }}" = "true" ]] || exit 1

8
go.mod
View File

@@ -3,15 +3,15 @@ module gitea.com/gitea/runner
go 1.26.0 go 1.26.0
require ( require (
code.gitea.io/actions-proto-go v0.4.1
connectrpc.com/connect v1.20.0 connectrpc.com/connect v1.20.0
dario.cat/mergo v1.0.2 dario.cat/mergo v1.0.2
gitea.dev/actions-proto-go v0.5.0
github.com/Masterminds/semver v1.5.0 github.com/Masterminds/semver v1.5.0
github.com/avast/retry-go/v5 v5.0.0 github.com/avast/retry-go/v5 v5.0.0
github.com/containerd/errdefs v1.0.0 github.com/containerd/errdefs v1.0.0
github.com/creack/pty v1.1.24 github.com/creack/pty v1.1.24
github.com/distribution/reference v0.6.0 github.com/distribution/reference v0.6.0
github.com/docker/cli v29.5.2+incompatible github.com/docker/cli v29.5.3+incompatible
github.com/docker/go-connections v0.7.0 github.com/docker/go-connections v0.7.0
github.com/go-git/go-billy/v5 v5.9.0 github.com/go-git/go-billy/v5 v5.9.0
github.com/go-git/go-git/v5 v5.19.1 github.com/go-git/go-git/v5 v5.19.1
@@ -26,7 +26,7 @@ require (
github.com/moby/moby/client v0.4.1 github.com/moby/moby/client v0.4.1
github.com/moby/patternmatcher v0.6.1 github.com/moby/patternmatcher v0.6.1
github.com/opencontainers/image-spec v1.1.1 github.com/opencontainers/image-spec v1.1.1
github.com/opencontainers/selinux v1.14.1 github.com/opencontainers/selinux v1.15.1
github.com/pkg/errors v0.9.1 github.com/pkg/errors v0.9.1
github.com/prometheus/client_golang v1.23.2 github.com/prometheus/client_golang v1.23.2
github.com/rhysd/actionlint v1.7.12 github.com/rhysd/actionlint v1.7.12
@@ -37,6 +37,7 @@ require (
github.com/timshannon/bolthold v0.0.0-20240314194003-30aac6950928 github.com/timshannon/bolthold v0.0.0-20240314194003-30aac6950928
go.etcd.io/bbolt v1.4.3 go.etcd.io/bbolt v1.4.3
go.yaml.in/yaml/v4 v4.0.0-rc.3 go.yaml.in/yaml/v4 v4.0.0-rc.3
golang.org/x/sys v0.46.0
golang.org/x/term v0.43.0 golang.org/x/term v0.43.0
google.golang.org/protobuf v1.36.11 google.golang.org/protobuf v1.36.11
gotest.tools/v3 v3.5.2 gotest.tools/v3 v3.5.2
@@ -106,7 +107,6 @@ require (
golang.org/x/crypto v0.50.0 // indirect golang.org/x/crypto v0.50.0 // indirect
golang.org/x/net v0.53.0 // indirect golang.org/x/net v0.53.0 // indirect
golang.org/x/sync v0.20.0 // indirect golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.44.0 // indirect
gopkg.in/warnings.v0 v0.1.2 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
) )

18
go.sum
View File

@@ -1,13 +1,11 @@
code.gitea.io/actions-proto-go v0.4.1 h1:l0EYhjsgpUe/1VABo2eK7zcoNX2W44WOnb0MSLrKfls=
code.gitea.io/actions-proto-go v0.4.1/go.mod h1:mn7Wkqz6JbnTOHQpot3yDeHx+O5C9EGhMEE+htvHBas=
connectrpc.com/connect v1.19.2 h1:McQ83FGdzL+t60peksi0gXC7MQ/iLKgLduAnThbM0mo=
connectrpc.com/connect v1.19.2/go.mod h1:tN20fjdGlewnSFeZxLKb0xwIZ6ozc3OQs2hTXy4du9w=
connectrpc.com/connect v1.20.0 h1:6TNDAB+WeNd2uolWNlYczB5E0KNNaVMNUEx8JEUsPmQ= connectrpc.com/connect v1.20.0 h1:6TNDAB+WeNd2uolWNlYczB5E0KNNaVMNUEx8JEUsPmQ=
connectrpc.com/connect v1.20.0/go.mod h1:A2ygJrukXwWy32vkCAAHNVguZrqZ+jeZ9rGRnGR4dN4= connectrpc.com/connect v1.20.0/go.mod h1:A2ygJrukXwWy32vkCAAHNVguZrqZ+jeZ9rGRnGR4dN4=
cyphar.com/go-pathrs v0.2.3 h1:0pH8gep37wB0BgaXrEaN1OtZhUMeS7VvaejSr6i822o= cyphar.com/go-pathrs v0.2.3 h1:0pH8gep37wB0BgaXrEaN1OtZhUMeS7VvaejSr6i822o=
cyphar.com/go-pathrs v0.2.3/go.mod h1:y8f1EMG7r+hCuFf/rXsKqMJrJAUoADZGNh5/vZPKcGc= cyphar.com/go-pathrs v0.2.3/go.mod h1:y8f1EMG7r+hCuFf/rXsKqMJrJAUoADZGNh5/vZPKcGc=
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
gitea.dev/actions-proto-go v0.5.0 h1:Fc3DI4Fm3B3JBRXFUjegql+usoNAjjAw1cxMansfA2I=
gitea.dev/actions-proto-go v0.5.0/go.mod h1:p4RX+D9oqiEEzzkPMXscw2CmaGuYFPWFc6xIOmDNDqs=
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk= github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk=
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8=
github.com/Masterminds/semver v1.5.0 h1:H65muMkzWKEuNDnfl9d70GUjFniHKHRbFPGBuZ3QEww= github.com/Masterminds/semver v1.5.0 h1:H65muMkzWKEuNDnfl9d70GUjFniHKHRbFPGBuZ3QEww=
@@ -51,6 +49,8 @@ github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5Qvfr
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/docker/cli v29.5.2+incompatible h1:ubykJ1Y8LmNRGJ2BuMQ0kHOt/RO1YzGNswqWMJgivuQ= github.com/docker/cli v29.5.2+incompatible h1:ubykJ1Y8LmNRGJ2BuMQ0kHOt/RO1YzGNswqWMJgivuQ=
github.com/docker/cli v29.5.2+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= github.com/docker/cli v29.5.2+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
github.com/docker/cli v29.5.3+incompatible h1:nbEFfz774vBwQ5KRYv7c/AghjReqnGISvrRhzjV0evs=
github.com/docker/cli v29.5.3+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
github.com/docker/docker-credential-helpers v0.9.6 h1:cT2PbRPSlnMmNTfT2TDMXRyQ1KMWHG7xoTLBcn1ZNv0= github.com/docker/docker-credential-helpers v0.9.6 h1:cT2PbRPSlnMmNTfT2TDMXRyQ1KMWHG7xoTLBcn1ZNv0=
github.com/docker/docker-credential-helpers v0.9.6/go.mod h1:v1S+hepowrQXITkEfw6o4+BMbGot02wiKpzWhGUZK6c= github.com/docker/docker-credential-helpers v0.9.6/go.mod h1:v1S+hepowrQXITkEfw6o4+BMbGot02wiKpzWhGUZK6c=
github.com/docker/go-connections v0.7.0 h1:6SsRfJddP22WMrCkj19x9WKjEDTB+ahsdiGYf0mN39c= github.com/docker/go-connections v0.7.0 h1:6SsRfJddP22WMrCkj19x9WKjEDTB+ahsdiGYf0mN39c=
@@ -149,8 +149,10 @@ github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
github.com/opencontainers/selinux v1.14.1 h1:a7XlXV/nN/l5zFP1FWZYoExpClu1QOPMfWUV2CZ8kEQ= github.com/opencontainers/selinux v1.15.0 h1:4Gs40e/R2FvM8PC1HPaPncLLaDor8Y2WDfk5gjU9o5M=
github.com/opencontainers/selinux v1.14.1/go.mod h1:LenyElirjUHszfxrjuFqC85HIeXZKumHcKMQtnaDlQQ= github.com/opencontainers/selinux v1.15.0/go.mod h1:LenyElirjUHszfxrjuFqC85HIeXZKumHcKMQtnaDlQQ=
github.com/opencontainers/selinux v1.15.1 h1:ERxeh5caJvCzNAKdI8WQbJmB1LDTn4BuaAg8wihLBpA=
github.com/opencontainers/selinux v1.15.1/go.mod h1:LenyElirjUHszfxrjuFqC85HIeXZKumHcKMQtnaDlQQ=
github.com/pjbgf/sha1cd v0.6.0 h1:3WJ8Wz8gvDz29quX1OcEmkAlUg9diU4GxJHqs0/XiwU= github.com/pjbgf/sha1cd v0.6.0 h1:3WJ8Wz8gvDz29quX1OcEmkAlUg9diU4GxJHqs0/XiwU=
github.com/pjbgf/sha1cd v0.6.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM= github.com/pjbgf/sha1cd v0.6.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
@@ -254,6 +256,10 @@ golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=

View File

@@ -148,6 +148,7 @@ func runDaemon(ctx context.Context, daemArgs *daemonArgs, configFile *string) fu
log.Infof("runner: %s, with version: %s, with labels: %v, declare successfully", log.Infof("runner: %s, with version: %s, with labels: %v, declare successfully",
resp.Msg.Runner.Name, resp.Msg.Runner.Version, resp.Msg.Runner.Labels) resp.Msg.Runner.Name, resp.Msg.Runner.Version, resp.Msg.Runner.Labels)
} }
runner.SetCapabilitiesFromDeclare(resp)
if cfg.Metrics.Enabled { if cfg.Metrics.Enabled {
metrics.Init() metrics.Init()

View File

@@ -19,9 +19,9 @@ import (
"gitea.com/gitea/runner/internal/pkg/labels" "gitea.com/gitea/runner/internal/pkg/labels"
"gitea.com/gitea/runner/internal/pkg/ver" "gitea.com/gitea/runner/internal/pkg/ver"
pingv1 "code.gitea.io/actions-proto-go/ping/v1"
runnerv1 "code.gitea.io/actions-proto-go/runner/v1"
"connectrpc.com/connect" "connectrpc.com/connect"
pingv1 "gitea.dev/actions-proto-go/ping/v1"
runnerv1 "gitea.dev/actions-proto-go/runner/v1"
"github.com/mattn/go-isatty" "github.com/mattn/go-isatty"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
"github.com/spf13/cobra" "github.com/spf13/cobra"

View File

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

View File

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

View File

@@ -31,8 +31,8 @@ import (
"gitea.com/gitea/runner/internal/pkg/report" "gitea.com/gitea/runner/internal/pkg/report"
"gitea.com/gitea/runner/internal/pkg/ver" "gitea.com/gitea/runner/internal/pkg/ver"
runnerv1 "code.gitea.io/actions-proto-go/runner/v1"
"connectrpc.com/connect" "connectrpc.com/connect"
runnerv1 "gitea.dev/actions-proto-go/runner/v1"
"github.com/moby/moby/api/types/container" "github.com/moby/moby/api/types/container"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
) )
@@ -47,6 +47,7 @@ type Runner struct {
labels labels.Labels labels labels.Labels
envs map[string]string envs map[string]string
cacheHandler *artifactcache.Handler cacheHandler *artifactcache.Handler
capabilities string
runningTasks sync.Map runningTasks sync.Map
runningCount atomic.Int64 runningCount atomic.Int64
@@ -185,6 +186,14 @@ func (r *Runner) cleanupStaleTaskDirs(ctx context.Context, workdirRoot string) {
} }
} }
func (r *Runner) SetCapabilitiesFromDeclare(resp *connect.Response[runnerv1.DeclareResponse]) {
if resp == nil {
return
}
// Capability negotiation is done via response headers to avoid a hard proto bump.
r.capabilities = strings.TrimSpace(resp.Header().Get("X-Gitea-Actions-Capabilities"))
}
func (r *Runner) Run(ctx context.Context, task *runnerv1.Task) error { func (r *Runner) Run(ctx context.Context, task *runnerv1.Task) error {
if _, ok := r.runningTasks.Load(task.Id); ok { if _, ok := r.runningTasks.Load(task.Id); ok {
return fmt.Errorf("task %d is already running", task.Id) return fmt.Errorf("task %d is already running", task.Id)
@@ -219,9 +228,10 @@ func (r *Runner) Run(ctx context.Context, task *runnerv1.Task) error {
} }
func (r *Runner) cloneEnvs() map[string]string { func (r *Runner) cloneEnvs() map[string]string {
// +3 reserves space for the per-task keys injected by run(): // Reserve space for the per-task keys injected by run():
// ACTIONS_ID_TOKEN_REQUEST_URL, ACTIONS_ID_TOKEN_REQUEST_TOKEN, ACTIONS_RUNTIME_TOKEN. // ACTIONS_ID_TOKEN_REQUEST_URL, ACTIONS_ID_TOKEN_REQUEST_TOKEN, ACTIONS_RUNTIME_TOKEN,
envs := make(map[string]string, len(r.envs)+3) // GITEA_ACTIONS_CAPABILITIES, GITEA_RUN_ID.
envs := make(map[string]string, len(r.envs)+5)
maps.Copy(envs, r.envs) maps.Copy(envs, r.envs)
return envs return envs
} }
@@ -261,6 +271,13 @@ func (r *Runner) run(ctx context.Context, task *runnerv1.Task, reporter *report.
taskContext := task.Context.Fields taskContext := task.Context.Fields
envs := r.cloneEnvs() envs := r.cloneEnvs()
if r.capabilities != "" {
envs["GITEA_ACTIONS_CAPABILITIES"] = r.capabilities
}
if v := taskContext["run_id"].GetStringValue(); v != "" {
envs["GITEA_RUN_ID"] = v
}
log.Infof("task %v repo is %v %v %v", task.Id, taskContext["repository"].GetStringValue(), log.Infof("task %v repo is %v %v %v", task.Id, taskContext["repository"].GetStringValue(),
r.getDefaultActionsURL(task), r.getDefaultActionsURL(task),
r.client.Address()) r.client.Address())

View File

@@ -11,7 +11,7 @@ import (
"gitea.com/gitea/runner/act/model" "gitea.com/gitea/runner/act/model"
runnerv1 "code.gitea.io/actions-proto-go/runner/v1" runnerv1 "gitea.dev/actions-proto-go/runner/v1"
"go.yaml.in/yaml/v4" "go.yaml.in/yaml/v4"
) )

View File

@@ -8,7 +8,7 @@ import (
"gitea.com/gitea/runner/act/model" "gitea.com/gitea/runner/act/model"
runnerv1 "code.gitea.io/actions-proto-go/runner/v1" runnerv1 "gitea.dev/actions-proto-go/runner/v1"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"go.yaml.in/yaml/v4" "go.yaml.in/yaml/v4"
"gotest.tools/v3/assert" "gotest.tools/v3/assert"

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