# Matrix Strategy: Support for Scalar Values and Template Expressions
## 🎯 Overview
This Pull Request implements support for **scalar values and template expressions** in matrix strategies. Previously, every matrix value had to be declared as a YAML array — a bare scalar like `os: ubuntu-latest` or `version: ${{ fromJSON(...) }}` caused silent failures. Now, scalars are automatically wrapped into single-element arrays, enabling the `${{ fromJSON(...) }}` pattern that is commonly used in GitHub Actions workflows for dynamic matrix generation.
## ⚠️ Semantic Changes
Two subtle behavior changes are introduced that are worth understanding:
### 1. Scalar matrix values now produce a 1-element matrix expansion
**Before this PR:** A scalar value like
```yaml
strategy:
matrix:
os: ubuntu-latest # no brackets
```
failed to decode as `map[string][]interface{}`, so `Matrix()` returned `nil`. The job ran **once, without any matrix context** (`matrix.os` was undefined).
**After this PR:** The same input is wrapped to `["ubuntu-latest"]`. The job runs **one matrix iteration** with `matrix.os = "ubuntu-latest"` set.
> **Impact on existing workflows:** Workflows that accidentally used bare scalars instead of arrays will now run with a matrix context where they previously did not. The functional result is usually the same, but `matrix.*` variables are now populated. This is considered an improvement, but is a semantic change.
---
### 2. Unevaluated template expressions fall back to a literal string value
The actual resolution of `${{ fromJSON(needs.job1.outputs.versions) }}` into an array happens **before** `Matrix()` is called — in `EvaluateYamlNode` inside `pkg/runner/runner.go`. This PR does not change that evaluation path.
If evaluation succeeds → the YAML node already contains a proper array → `Matrix()` decodes it normally.
If evaluation **fails or is skipped** (e.g., expression not yet resolved) → the scalar string is now wrapped as `["${{ fromJSON(...) }}"]` → the job runs **one iteration with the literal unexpanded string** as the matrix value.
> **Note:** The test `TestJobMatrix/matrix_with_template_expression` covers this fallback path, not the successful resolution path. The literal-wrap behavior is the fallback, not the primary mechanism for template expression support.
---
## 🔑 Key Changes
### 1. Flexible Matrix Decoding (`pkg/model/workflow.go`)
#### New Helper Function: `normalizeMatrixValue`
```go
func normalizeMatrixValue(key string, val interface{}) ([]interface{}, error)
```
- Converts a single matrix value to the expected array format
- Handles three cases:
1. **Arrays**: Pass through unchanged
2. **Scalars**: Wrap in single-element array (including template expressions)
3. **Invalid types** (maps, etc.): Return error to detect misconfigurations early
**Supported scalar types:**
- `string` — including unevaluated template expressions
- `int`, `float64` — numeric values
- `bool` — boolean values
- `nil` — null values
#### Enhanced `Matrix()` Method
**Before:**
```go
func (j *Job) Matrix() map[string][]interface{} {
if j.Strategy.RawMatrix.Kind == yaml.MappingNode {
var val map[string][]interface{}
if !decodeNode(j.Strategy.RawMatrix, &val) {
return nil
}
return val
}
return nil
}
```
**After:**
```go
func (j *Job) Matrix() map[string][]interface{} {
if j.Strategy == nil || j.Strategy.RawMatrix.Kind != yaml.MappingNode {
return nil
}
// First decode to flexible map[string]interface{} to handle scalars and arrays
var flexVal map[string]interface{}
err := j.Strategy.RawMatrix.Decode(&flexVal)
if err != nil {
// If that fails, try the strict array format
var val map[string][]interface{}
if !decodeNode(j.Strategy.RawMatrix, &val) {
return nil
}
return val
}
// Convert flexible format to expected format with validation
val := make(map[string][]interface{})
for k, v := range flexVal {
normalized, err := normalizeMatrixValue(k, v)
if err != nil {
log.Errorf("matrix validation error: %v", err)
return nil
}
val[k] = normalized
}
return val
}
```
**Key improvements:**
- Handles unevaluated template expressions as scalar strings
- Automatic conversion to array format for consistent processing
- Validation to catch nested maps and invalid configurations
- Fallback to strict array format decoding for backward compatibility
- Early nil check for strategy to prevent nil pointer panics
---
## 🎁 Use Cases
### Example 1: Template Expression in Matrix (primary motivation)
```yaml
jobs:
setup:
runs-on: ubuntu-latest
outputs:
versions: ${{ steps.version-map.outputs.versions }}
steps:
- id: version-map
run: echo "versions=[\"1.17\",\"1.18\",\"1.19\"]" >> $GITHUB_OUTPUT
test:
needs: setup
strategy:
matrix:
# EvaluateYamlNode resolves this to an array before Matrix() is called
version: ${{ fromJSON(needs.setup.outputs.versions) }}
runs-on: ubuntu-latest
steps:
- run: echo "Testing version ${{ matrix.version }}"
```
### Example 2: Mixed Scalar and Array Values
```yaml
jobs:
build:
strategy:
matrix:
os: [ubuntu-latest, windows-latest] # Array — unchanged
go-version: 1.19 # Scalar — wrapped to [1.19]
node: [14, 16, 18] # Array — unchanged
runs-on: ${{ matrix.os }}
```
### Example 3: Single Value Matrix
```yaml
jobs:
deploy:
strategy:
matrix:
environment: production # Scalar — wrapped to ["production"]
runs-on: ubuntu-latest
```
---
## ✅ Benefits
- ✨ **Feature Parity with GitHub Actions**: Supports template expressions in matrix strategies
- 🔒 **Robust Validation**: Detects misconfigured matrices (nested maps) early
- 🔄 **Backward Compatible**: Existing array-based workflows continue to work
- 📝 **Clear Error Messages**: Helpful validation messages for debugging
- 🛡️ **Type Safety**: Handles all YAML scalar types correctly
---
## 🚀 Breaking Changes
**Technically none in a strictly backward-incompatible sense**, but two semantic changes exist (see [⚠️ Semantic Changes](#️-semantic-changes-read-before-merging) above):
1. Scalar matrix values now iterate (previously they caused a `nil` matrix / no iteration)
2. Unevaluated template strings now produce a literal iteration instead of skipping
Both changes are improvements in practice, but downstream users should be aware.
---
## 📚 Related Work
- Related Gitea Server PR: [go-gitea/gitea#36564](https://github.com/go-gitea/gitea/pull/36564)
- Enables dynamic matrix generation from job outputs
- Supports advanced CI/CD patterns used in GitHub Actions
## 🔗 References
- [GitHub Actions: Matrix strategies](https://docs.github.com/en/actions/using-jobs/using-a-matrix-for-your-jobs)
- [GitHub Actions: Expression syntax](https://docs.github.com/en/actions/learn-github-actions/expressions)
- [GitHub Actions: `fromJSON` in matrix context](https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstrategymatrix)
Reviewed-on: https://gitea.com/gitea/act/pulls/149
Reviewed-by: silverwind <2021+silverwind@noreply.gitea.com>
Co-authored-by: Pascal Zimmermann <pascal.zimmermann@theiotstudio.com>
Co-committed-by: Pascal Zimmermann <pascal.zimmermann@theiotstudio.com>
Use `golangci-lint fmt` to format code, upgrading `.golangci.yml` to v2 and mirroring the linter configuration used by https://github.com/go-gitea/gitea. `gci` now handles import ordering into standard, project-local, blank, and default groups.
Mirrors https://github.com/go-gitea/gitea/pull/37194.
Changes:
- Upgrade `.golangci.yml` to v2 format with the same linter set as gitea (minus `prealloc`, `unparam`, `testifylint`, `nilnil` which produced too many pre-existing issues)
- Add path-based exclusions (`bodyclose`, `gosec` in tests; `gosec:G115`/`G117` globally)
- Run lint via `make lint-go` in CI instead of `golangci/golangci-lint-action`, matching the pattern used by other Gitea repos
- Apply safe auto-fixes (`modernize`, `perfsprint`, `usetesting`, etc.)
- Add explanations to existing `//nolint` directives
- Remove dead code (unused `newRemoteReusableWorkflow` and `networkName`), duplicate imports, and shadowed `max` builtins
- Replace deprecated `docker/distribution/reference` with `distribution/reference`
- Fix `Deprecated:` comment casing and simplify nil/len checks
---
This PR was written with the help of Claude Opus 4.7
Reviewed-on: https://gitea.com/gitea/act/pulls/163
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: silverwind <me@silverwind.io>
Co-committed-by: silverwind <me@silverwind.io>
Cannot guarantee that all noisy logs can be removed at once.
Comment them instead of removing them to make it easier to merge upstream.
What have been removed in this PR are those that are very very long and almost unreadable logs, like
<img width="839" alt="image" src="/attachments/b59e1dcc-4edd-4f81-b939-83dcc45f2ed2">
Reviewed-on: https://gitea.com/gitea/act/pulls/108
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
The current code overrides the container's entrypoint with `sleep`. Unfortunately, that prevents initialization scripts, such as to initialize Docker-in-Docker, from running.
The change simply moves the `sleep` to the command, rather than entrypoint, directive.
For most containers of this sort, the entrypoint script performs initialization, and then ends with `$@` to execute whatever command is passed.
If the container has no entrypoint, the command is executed directly. As a result, this should be a transparent change for most use cases, while allowing the container's entrypoint to be used when present.
Reviewed-on: https://gitea.com/gitea/act/pulls/86
Reviewed-by: Jason Song <i@wolfogre.com>
Co-authored-by: Thomas E Lackey <telackey@bozemanpass.com>
Co-committed-by: Thomas E Lackey <telackey@bozemanpass.com>
The toolcache on GitHub Actions need to be in
/opt/hostedtoolcache. This is the case for all
environment variables set by act, but it's not the
case for the volume mounted into the container.
Co-authored-by: Björn Brauer <zaubernerd@zaubernerd.de>
Co-authored-by: ChristopherHX <christopher.homberger@web.de>
Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com>
- include repoURL and repoRef in error
- map NoErrAlreadyUptodate to `couldn't find remote ref` for branchOrtag
fetch request
Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com>
Move the logging back up a level to fix a minor logging issue introduced in #2088
`RunContext`s for composite actions have dummy/blank `Job`s with no `runs-on`,
meaning their calls to `withGithubEnv` would result in an inaccurate log message
complaining that `'runs-on' key not defined in ...`
Co-authored-by: Jason Song <i@wolfogre.com>
Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com>
* Support array expressions in runs-on
* Simplify appproach to use EvaluateYamlNode, fix case-sensitivity bug
---------
Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com>
* Support services (#42)
Removed createSimpleContainerName and AutoRemove flag
Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: Jason Song <i@wolfogre.com>
Reviewed-on: https://gitea.com/gitea/act/pulls/42
Reviewed-by: Jason Song <i@wolfogre.com>
Co-authored-by: Zettat123 <zettat123@gmail.com>
Co-committed-by: Zettat123 <zettat123@gmail.com>
* Support services options (#45)
Reviewed-on: https://gitea.com/gitea/act/pulls/45
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: Zettat123 <zettat123@gmail.com>
Co-committed-by: Zettat123 <zettat123@gmail.com>
* Support intepolation for `env` of `services` (#47)
Reviewed-on: https://gitea.com/gitea/act/pulls/47
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: Zettat123 <zettat123@gmail.com>
Co-committed-by: Zettat123 <zettat123@gmail.com>
* Support services `credentials` (#51)
If a service's image is from a container registry requires authentication, `act_runner` will need `credentials` to pull the image, see [documentation](https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idservicesservice_idcredentials).
Currently, `act_runner` incorrectly uses the `credentials` of `containers` to pull services' images and the `credentials` of services won't be used, see the related code: 0c1f2edb99/pkg/runner/run_context.go (L228-L269)
Co-authored-by: Jason Song <i@wolfogre.com>
Reviewed-on: https://gitea.com/gitea/act/pulls/51
Reviewed-by: Jason Song <i@wolfogre.com>
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: Zettat123 <zettat123@gmail.com>
Co-committed-by: Zettat123 <zettat123@gmail.com>
* Add ContainerMaxLifetime and ContainerNetworkMode options
from: b9c20dcaa4
* Fix container network issue (#56)
Follow: https://gitea.com/gitea/act_runner/pulls/184
Close https://gitea.com/gitea/act_runner/issues/177
- `act` create new networks only if the value of `NeedCreateNetwork` is true, and remove these networks at last. `NeedCreateNetwork` is passed by `act_runner`. 'NeedCreateNetwork' is true only if `container.network` in the configuration file of the `act_runner` is empty.
- In the `docker create` phase, specify the network to which containers will connect. Because, if not specify , container will connect to `bridge` network which is created automatically by Docker.
- If the network is user defined network ( the value of `container.network` is empty or `<custom-network>`. Because, the network created by `act` is also user defined network.), will also specify alias by `--network-alias`. The alias of service is `<service-id>`. So we can be access service container by `<service-id>:<port>` in the steps of job.
- Won't try to `docker network connect ` network after `docker start` any more.
- Because on the one hand, `docker network connect` applies only to user defined networks, if try to `docker network connect host <container-name>` will return error.
- On the other hand, we just specify network in the stage of `docker create`, the same effect can be achieved.
- Won't try to remove containers and networks berfore the stage of `docker start`, because the name of these containers and netwoks won't be repeat.
Co-authored-by: Jason Song <i@wolfogre.com>
Reviewed-on: https://gitea.com/gitea/act/pulls/56
Reviewed-by: Jason Song <i@wolfogre.com>
Co-authored-by: sillyguodong <gedong_1994@163.com>
Co-committed-by: sillyguodong <gedong_1994@163.com>
* Check volumes (#60)
This PR adds a `ValidVolumes` config. Users can specify the volumes (including bind mounts) that can be mounted to containers by this config.
Options related to volumes:
- [jobs.<job_id>.container.volumes](https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idcontainervolumes)
- [jobs.<job_id>.services.<service_id>.volumes](https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idservicesservice_idvolumes)
In addition, volumes specified by `options` will also be checked.
Currently, the following default volumes (see a72822b3f8/pkg/runner/run_context.go (L116-L166)) will be added to `ValidVolumes`:
- `act-toolcache`
- `<container-name>` and `<container-name>-env`
- `/var/run/docker.sock` (We need to add a new configuration to control whether the docker daemon can be mounted)
Co-authored-by: Jason Song <i@wolfogre.com>
Reviewed-on: https://gitea.com/gitea/act/pulls/60
Reviewed-by: Jason Song <i@wolfogre.com>
Co-authored-by: Zettat123 <zettat123@gmail.com>
Co-committed-by: Zettat123 <zettat123@gmail.com>
* Remove ContainerMaxLifetime; fix lint
* Remove unused ValidVolumes
* Remove ConnectToNetwork
* Add docker stubs
* Close docker clients to prevent file descriptor leaks
* Fix the error when removing network in self-hosted mode (#69)
Fixes https://gitea.com/gitea/act_runner/issues/255
Reviewed-on: https://gitea.com/gitea/act/pulls/69
Co-authored-by: Zettat123 <zettat123@gmail.com>
Co-committed-by: Zettat123 <zettat123@gmail.com>
* Move service container and network cleanup to rc.cleanUpJobContainer
* Add --network flag; default to host if not using service containers or set explicitly
* Correctly close executor to prevent fd leak
* Revert to tail instead of full path
* fix network duplication
* backport networkingConfig for aliaes
* don't hardcode netMode host
* Convert services test to table driven tests
* Add failing tests for services
* Expose service container ports onto the host
* Set container network mode in artifacts server test to host mode
* Log container network mode when creating/starting a container
* fix: Correctly handle ContainerNetworkMode
* fix: missing service container network
* Always remove service containers
Although we usually keep containers running if the workflow errored
(unless `--rm` is given) in order to facilitate debugging and we have
a flag (`--reuse`) to always keep containers running in order to speed
up repeated `act` invocations, I believe that these should only apply
to job containers and not service containers, because changing the
network settings on a service container requires re-creating it anyway.
* Remove networks only if no active endpoints exist
* Ensure job containers are stopped before starting a new job
* fix: go build -tags WITHOUT_DOCKER
---------
Co-authored-by: Zettat123 <zettat123@gmail.com>
Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: Jason Song <i@wolfogre.com>
Co-authored-by: sillyguodong <gedong_1994@163.com>
Co-authored-by: ChristopherHX <christopher.homberger@web.de>
Co-authored-by: ZauberNerd <zaubernerd@zaubernerd.de>
* fix: use actions/runner hashfiles in container
Previously hashfiles ran on the host,
this don't work for container generated content
* fix: lint
* fix: lint
* fix assign follow symlink flag
Co-authored-by: Jason Song <i@wolfogre.com>
---------
Co-authored-by: Jason Song <i@wolfogre.com>
Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com>
From the Go docs:
"For a nil slice, the number of iterations is 0" [1]
Therefore, an additional nil check for `job.RunsOn()` before the loop is
unnecessary because `job.RunsOn()` returns a `[]string`.
[1]: https://go.dev/ref/spec#For_range
Signed-off-by: Eng Zer Jun <engzerjun@gmail.com>