# 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>
* 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>
* throw if `uses` is invalid
* update JobType to return error
* lint
* put //nolint:dupl on wrong test
* update error message to remove end punctuation
* lint
* update remote job type check
* move if statement
* rm nolint:dupl ... we'll see how that goes
---------
Co-authored-by: Casey Lee <cplee@nektos.com>
Adds an option to specify the directory below which actions
and host workspaces will be stored. If left empty the previous
location at $XDG_CACHE_HOME/act or $HOME/.cache/act will be used
respectively.
Co-authored-by: Casey Lee <cplee@nektos.com>
* feature: Add support for github action variables
* add --var flag for github variables
* unitests: Updated unittests to cover vars context.
* Remove syntax extension for vars and correct unit tests
* Update pkg/runner/expression.go
Co-authored-by: ChristopherHX <christopher.homberger@web.de>
---------
Co-authored-by: kuanyong-wong-partior <kuanyong.wong@partior.com>
Co-authored-by: ChristopherHX <christopher.homberger@web.de>
* Log incoming jobs.
Log the full contents of the job protobuf to make debugging jobs easier
* Ensure that the parallel executor always uses at least one thread.
The caller may mis-calculate the number of CPUs as zero, in which case
ensure that at least one thread is spawned.
* Use runtime.NumCPU for CPU counts.
For hosts without docker, GetHostInfo() returns a blank struct which
has zero CPUs and causes downstream trouble.
---------
Co-authored-by: Paul Armstrong <psa@users.noreply.gitea.com>
Co-authored-by: Jason Song <i@wolfogre.com>
Follow: https://gitea.com/gitea/act_runner/pulls/184
Close https://gitea.com/gitea/act_runner/issues/177
#### changes:
- `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>
* Prior to this change, the artifact server always binds to the detected
"outbound IP", breaks functionality when that IP is unroutable.
For example, Zscaler assigns the host a local CGNAT address,
100.64.0.1, which is unreachable from Docker Desktop.
* Add the `--artifact-server-addr` flag to allow override of the address
to which the artifact server binds, defaulting to the existing
behaviour.
Fixes: #1559
* added input flags
* added input as part of the action event and added test cases
* updated readme
Co-authored-by: ChristopherHX <christopher.homberger@web.de>
* feat: allow to spawn and run a local reusable workflow
This change contains the ability to parse/plan/run a local
reusable workflow.
There are still numerous things missing:
- inputs
- secrets
- outputs
* feat: add workflow_call inputs
* test: improve inputs test
* feat: add input defaults
* feat: allow expressions in inputs
* feat: use context specific expression evaluator
* refactor: prepare for better re-usability
* feat: add secrets for reusable workflows
* test: use secrets during test run
* feat: handle reusable workflow outputs
Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com>
* refactor: move autoremove into the jobexecutor
breaking: docker container are removed after job exit
* reduce complexity
* remove linter exception
* reduce cyclic complexity
* fix: always allow 1 min for stopping and removing the runner, even if we were cancelled
Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com>
The io/ioutil package has been deprecated as of Go 1.16 [1]. This commit
replaces the existing io/ioutil functions with their new definitions in
io and os packages.
[1]: https://golang.org/doc/go1.16#ioutil
Signed-off-by: Eng Zer Jun <engzerjun@gmail.com>
Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com>
For log processing of the JSON logs, we want to be able to know which
keys/values of the matrices were used.
This commit adds the current matrix map to the job logger.
Co-authored-by: Markus Wolf <markus.wolf@new-work.se>
Co-authored-by: Markus Wolf <markus.wolf@new-work.se>
* Made env interpolated instead of evaluated.
* [skip ci] Add Test Workflow file
* Activate Test
* fix Test
Co-authored-by: ChristopherHX <christopher.homberger@web.de>
* feat(#1161): add --through-action to assigned actions from GitHub
* docs(flags): add --through-action and --through-action-token flags description
* test(action, remote): add test case for ThroughAction
* refactor(command): rename command from --through-action to --actions-from-github
* refactor(command): rename command from --actions-from-github to --replace-ghe-action-with-github-com
* feat: use logger from context wherever possible
Co-authored-by: Markus Wolf <markus.wolf@new-work.se>
* feat: add step/job id and results to json logs
Co-authored-by: Markus Wolf <markus.wolf@new-work.se>
* test: value to be masked should not be hard-coded in the action
Co-authored-by: Markus Wolf <markus.wolf@new-work.se>
* fix: replace values following ::add-mask:: in evaluated strings
Co-authored-by: Markus Wolf <markus.wolf@new-work.se>
* feat: [DEBUG] identifier for debug logs to distinguish them
Co-authored-by: Markus Wolf <markus.wolf@new-work.se>
* feat: replace logger with step logger
The container gets injected a job logger, but during the time that steps
are run, we want to use the step logger.
This commit wraps pre/main/post steps in an executor that replaces the
job logger with a step logger.
Co-authored-by: Markus Wolf <markus.wolf@new-work.se>
* feat: add pre/post stage identifier fields to json log output
Co-authored-by: Markus Wolf <markus.wolf@new-work.se>
* feat: add job/step result status to skipped steps/jobs
Co-authored-by: Markus Wolf <markus.wolf@new-work.se>
Co-authored-by: Markus Wolf <markus.wolf@new-work.se>
Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com>
There might be use cases where users want to use GitHub's variables in
the environment variables, which is a valid use case.
This commits adds support for replacement of GitHub's env with GitHub's
values.
Signed-off-by: André Martins <aanm90@gmail.com>
* feat: handle context cancelation during docker exec
To allow interrupting docker exec (which could be long running)
we process the log output in a go routine and handle
context cancelation as well as command result.
In case of context cancelation a CTRL+C is written into the docker
container. This should be enough to terminate the running
command.
To make sure we do not get stuck during cleanup, we do
set the cleanup contexts with a timeout of 5 minutes
Co-authored-by: Björn Brauer <bjoern.brauer@new-work.se>
Co-authored-by: Philipp Hinrichsen <philipp.hinrichsen@new-work.se>
* feat: handle SIGTERM signal and abort run
* test: on context cancel, abort running command
This test makes sure that whenever the act Context was canceled, the
currently running docker exec is sent a 0x03 (ctrl+c).
Co-authored-by: Björn Brauer <zaubernerd@zaubernerd.de>
* test: make sure the exec funcction handles command exit code
This test makes sure that the exec function does handle
docker command error results
Co-authored-by: Björn Brauer <bjoern.brauer@new-work.se>
Co-authored-by: Philipp Hinrichsen <philipp.hinrichsen@new-work.se>
Co-authored-by: Björn Brauer <zaubernerd@zaubernerd.de>