Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 17 additions & 2 deletions .github/workflows/csharp-ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,19 @@ on:
required: false
type: string
default: ''
matrix-mode:
description: >-
Cost-routing override for the build-and-test matrix. `cheap`
collapses the matrix to a single FREE self-hosted Hetzner shard
(`["self-hosted","hetzner"]`, coverage on), ignoring `os-list` and
`build-matrix` — use it to route paid GitHub-hosted legs onto idle
self-hosted runners. `full` forces the normal matrix (visibility
default, `os-list`, or `build-matrix`). Empty (the default) defers
to the org/repo variable `CI_MATRIX_MODE`. Precedence: this input
over `CI_MATRIX_MODE` over the normal matrix.
required: false
type: string
default: ''
test-project:
description: >-
Optional path (relative to working-directory) of a specific test
Expand Down Expand Up @@ -206,17 +219,19 @@ jobs:
GH_TOKEN: ${{ github.token }}
BUILD_MATRIX: ${{ inputs.build-matrix }}
OS_LIST: ${{ inputs.os-list }}
MATRIX_MODE: ${{ inputs.matrix-mode != '' && inputs.matrix-mode || vars.CI_MATRIX_MODE }}
run: |
set -euo pipefail
mode=$(printf '%s' "$MATRIX_MODE" | tr '[:upper:]' '[:lower:]')
visibility=""
if [ -z "$BUILD_MATRIX" ] && [ -z "$OS_LIST" ]; then
if [ "$mode" = "cheap" ] || { [ -z "$BUILD_MATRIX" ] && [ -z "$OS_LIST" ]; }; then
visibility=$(gh api "repos/${GITHUB_REPOSITORY}" --jq '.visibility')
if [ -z "$visibility" ]; then
echo "::error::could not determine repository visibility (empty result from gh api)" >&2
exit 1
fi
fi
matrix=$(.github-actions-helpers/scripts/normalize-ci-matrix.sh "$BUILD_MATRIX" "$OS_LIST" "$visibility")
matrix=$(.github-actions-helpers/scripts/normalize-ci-matrix.sh "$BUILD_MATRIX" "$OS_LIST" "$visibility" "$MATRIX_MODE")
echo "matrix=$matrix" >> "$GITHUB_OUTPUT"

build-and-test:
Expand Down
19 changes: 17 additions & 2 deletions .github/workflows/scala-ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,19 @@ on:
required: false
type: string
default: ''
matrix-mode:
description: >-
Cost-routing override for the build-and-test matrix. `cheap`
collapses the matrix to a single FREE self-hosted Hetzner shard
(`["self-hosted","hetzner"]`, coverage on), ignoring `os-list` and
`build-matrix` — use it to route paid GitHub-hosted legs onto idle
self-hosted runners. `full` forces the normal matrix (visibility
default, `os-list`, or `build-matrix`). Empty (the default) defers
to the org/repo variable `CI_MATRIX_MODE`. Precedence: this input
over `CI_MATRIX_MODE` over the normal matrix.
required: false
type: string
default: ''
cobertura-path:
description: >-
Path (relative to `working-directory`) of the Cobertura XML
Expand Down Expand Up @@ -164,17 +177,19 @@ jobs:
GH_TOKEN: ${{ github.token }}
BUILD_MATRIX: ${{ inputs.build-matrix }}
OS_LIST: ${{ inputs.os-list }}
MATRIX_MODE: ${{ inputs.matrix-mode != '' && inputs.matrix-mode || vars.CI_MATRIX_MODE }}
run: |
set -euo pipefail
mode=$(printf '%s' "$MATRIX_MODE" | tr '[:upper:]' '[:lower:]')
visibility=""
if [ -z "$BUILD_MATRIX" ] && [ -z "$OS_LIST" ]; then
if [ "$mode" = "cheap" ] || { [ -z "$BUILD_MATRIX" ] && [ -z "$OS_LIST" ]; }; then
visibility=$(gh api "repos/${GITHUB_REPOSITORY}" --jq '.visibility')
if [ -z "$visibility" ]; then
echo "::error::could not determine repository visibility (empty result from gh api)" >&2
exit 1
fi
fi
matrix=$(.github-actions-helpers/scripts/normalize-ci-matrix.sh "$BUILD_MATRIX" "$OS_LIST" "$visibility")
matrix=$(.github-actions-helpers/scripts/normalize-ci-matrix.sh "$BUILD_MATRIX" "$OS_LIST" "$visibility" "$MATRIX_MODE")
echo "matrix=$matrix" >> "$GITHUB_OUTPUT"

build-and-test:
Expand Down
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- Add a `matrix-mode` input to `csharp-ci.yaml` and `scala-ci.yaml` plus a new org/repo Actions variable `CI_MATRIX_MODE` for cheap CI matrix routing: on private/internal repos `cheap` collapses the build/test matrix to a single free self-hosted Hetzner shard (`["self-hosted","hetzner"]`, coverage on), overriding any `os-list` / `build-matrix`; `full` forces the normal matrix; empty defers to `CI_MATRIX_MODE`. Precedence is input > variable > normal matrix, so the org variable can flip every consumer onto idle self-hosted runners while a single caller opts out with `matrix-mode: full`. For safety, `cheap` is ignored (with a warning) on public repositories, since self-hosted runners must not run untrusted public/fork-PR workloads. (#27)

## [2.2.0] - 2026-06-14

### Added
Expand Down
43 changes: 43 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,7 @@ choosing which shard carries coverage — use `build-matrix` instead (see
| `working-directory` | `.` | Path of the .NET workspace. |
| `os-list` | *(by visibility)* | JSON array of runner labels for the build-and-test matrix. Ignored when `build-matrix` is set. Empty + empty build-matrix → matrix by repo visibility (public → ubuntu/windows/macos; private/internal → Hetzner). |
| `build-matrix` | *(empty)* | JSON array of `{ name, runner, coverage }` shards. Overrides `os-list`. See [Selecting runners](#selecting-runners). |
| `matrix-mode` | *(empty → `CI_MATRIX_MODE`)* | on private/internal repos, `cheap` collapses the matrix to a single free self-hosted Hetzner shard, overriding `os-list` / `build-matrix` (ignored with a warning on public repos); `full` forces the normal matrix; empty defers to the org/repo variable `CI_MATRIX_MODE`. See [Cheap matrix mode](#cheap-matrix-mode). |
| `test-project` | *(empty)* | Specific test project / solution path; empty runs the workspace default `.sln` / `.slnx`. |
| `test-filter` | *(empty)* | Forwarded to `dotnet test --filter`. |
| `coverage-pr-comment-header` | `csharp-coverage` | Sticky-comment header (make unique per workspace if a repo calls this workflow more than once). |
Expand Down Expand Up @@ -247,6 +248,7 @@ coverage — use `build-matrix` instead (see
| `java-distribution` | `temurin` | Passed to `actions/setup-java`. |
| `os-list` | *(by visibility)* | JSON array of runner labels for the build-and-test matrix. Ignored when `build-matrix` is set. Empty + empty build-matrix → matrix by repo visibility (public → ubuntu/windows/macos; private/internal → Hetzner). |
| `build-matrix` | *(empty)* | JSON array of `{ name, runner, coverage }` shards. Overrides `os-list`. See [Selecting runners](#selecting-runners). |
| `matrix-mode` | *(empty → `CI_MATRIX_MODE`)* | on private/internal repos, `cheap` collapses the matrix to a single free self-hosted Hetzner shard, overriding `os-list` / `build-matrix` (ignored with a warning on public repos); `full` forces the normal matrix; empty defers to the org/repo variable `CI_MATRIX_MODE`. See [Cheap matrix mode](#cheap-matrix-mode). |
| `cobertura-path` | `target/scala-2.13/coverage-report/cobertura.xml` | Path (relative to `working-directory`) of the Cobertura XML emitted by `sbt coverageReport`. Override for non-2.13 Scala majors. |
| `coverage-pr-comment-header` | `scala-coverage` | Hidden HTML-comment dedup key for the sticky PR comment. Make unique per language / per repo if multiple coverage comments coexist. |
| `coverage-artifact-name` | `scala-coverage` | Name of the uploaded Cobertura artifact. Must not collide with other coverage artifacts uploaded by sibling jobs in the same run. |
Expand Down Expand Up @@ -529,6 +531,47 @@ jobs:
empty) to keep the `os-list` behaviour: each label becomes a shard, and the
`ubuntu-latest` shard — if present — carries coverage.

### Cheap matrix mode

`csharp-ci` and `scala-ci` accept a `matrix-mode` input that routes paid
GitHub-hosted matrix legs onto idle free self-hosted runners — handy during a
refactor when CI runs constantly:

- `cheap` — collapse the whole matrix to a single shard on the free
self-hosted Hetzner pool (`["self-hosted", "hetzner"]`, coverage on),
ignoring `os-list` and `build-matrix`. **Private/internal repositories
only** — see the security note below.
- `full` — force the normal matrix (visibility default, `os-list`, or
`build-matrix`).
- *(empty, the default)* — defer to the org/repo variable `CI_MATRIX_MODE`.

> **Security: `cheap` is ignored on public repositories.** Self-hosted
> runners must never execute untrusted public- or fork-PR workloads, so on a
> public repo `cheap` is dropped (with a warning) and the normal
> GitHub-hosted matrix runs instead. Scope the `CI_MATRIX_MODE` org variable
> to **Private repositories** to match this guarantee.

Set `CI_MATRIX_MODE` as an org- or repo-level **Actions variable** (Settings →
Secrets and variables → Actions → Variables) to flip every consumer at once:

```
CI_MATRIX_MODE = cheap # all callers collapse to the Hetzner leg
CI_MATRIX_MODE = full # all callers run the normal matrix
# unset # callers run the normal matrix
```

Precedence is **input `matrix-mode` > variable `CI_MATRIX_MODE` > normal
matrix**, so a single caller can opt out with `matrix-mode: full` even while
the org variable is `cheap`:

```yaml
jobs:
csharp-ci:
uses: peacefulstudio/github-actions/.github/workflows/csharp-ci.yaml@v2
with:
matrix-mode: full
```

## Pinning

Pin to a major version tag (`@v1`) for stability. Float to the major tag
Expand Down
24 changes: 24 additions & 0 deletions scripts/normalize-ci-matrix.sh
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,34 @@ set -euo pipefail
build_matrix="${1:-}"
os_list="${2:-}"
visibility="${3:-}"
matrix_mode="${4:-}"

public_default='[{"name":"ubuntu-latest","runner":"ubuntu-latest","coverage":true},{"name":"windows-latest","runner":"windows-latest","coverage":false},{"name":"macos-latest","runner":"macos-latest","coverage":false}]'
private_default='[{"name":"linux","runner":["self-hosted","hetzner"],"coverage":true}]'

case "$(printf '%s' "$matrix_mode" | tr '[:upper:]' '[:lower:]')" in
cheap)
case "$visibility" in
private|internal)
build_matrix="$private_default"
os_list=""
;;
public)
echo "::warning::matrix-mode=cheap ignored on public repo (self-hosted runners must not run public/fork workloads); using normal matrix" >&2
;;
*)
echo "::error::matrix-mode=cheap requires a known repo visibility (private or internal); got '$visibility' — refusing to route to self-hosted runners" >&2
exit 1
;;
esac
;;
""|full) ;;
*)
echo "::error::unexpected matrix-mode '$matrix_mode' (expected cheap, full, or empty)" >&2
exit 1
;;
esac

transform() {
local program="$1" input="$2" what="$3"
local errfile out
Expand Down
48 changes: 48 additions & 0 deletions test/normalize-ci-matrix_test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -148,4 +148,52 @@ run "" "" "bogus"
check_rc "unexpected visibility exit 1" 1 "$rc"
check_contains "unexpected visibility annotated" "::error::unexpected repo visibility 'bogus'" "$err"

hetzner_leg='[{"coverage":true,"name":"linux","runner":["self-hosted","hetzner"]}]'
public_default='[{"coverage":true,"name":"ubuntu-latest","runner":"ubuntu-latest"},{"coverage":false,"name":"windows-latest","runner":"windows-latest"},{"coverage":false,"name":"macos-latest","runner":"macos-latest"}]'

run '[{"name":"a","runner":"ubuntu-latest","coverage":true}]' '["windows-latest"]' "private" "cheap"
check_rc "cheap overrides explicit build-matrix exit 0" 0 "$rc"
check "cheap overrides explicit build-matrix" "$hetzner_leg" "$out"

run "" "" "internal" "cheap"
check_rc "cheap on internal repo exit 0" 0 "$rc"
check "cheap on internal resolves hetzner leg" "$hetzner_leg" "$out"

run "" "" "" "cheap"
check_rc "cheap with unknown visibility fails closed exit 1" 1 "$rc"
check_contains "cheap with unknown visibility refuses self-hosted" "::error::matrix-mode=cheap requires a known repo visibility" "$err"

run "" "" "private" "CHEAP"
check_rc "cheap is case-insensitive exit 0" 0 "$rc"
check "cheap is case-insensitive" "$hetzner_leg" "$out"

run "" "" "public" "cheap"
check_rc "cheap ignored on public repo exit 0" 0 "$rc"
check "cheap ignored on public falls back to public default" "$public_default" "$out"
check_contains "cheap ignored on public warns" "::warning::matrix-mode=cheap ignored on public repo" "$err"

run "" "" "public" "CHEAP"
check_rc "cheap uppercase still guarded on public exit 0" 0 "$rc"
check "cheap uppercase still falls back to public default" "$public_default" "$out"
check_contains "cheap uppercase still warns on public" "::warning::matrix-mode=cheap ignored on public repo" "$err"

run '[{"name":"a","runner":"ubuntu-latest","coverage":true}]' "" "public" "cheap"
check_rc "cheap on public keeps explicit build-matrix exit 0" 0 "$rc"
check "cheap on public keeps explicit build-matrix" '[{"coverage":true,"name":"a","runner":"ubuntu-latest"}]' "$out"

run "" "" "public" "full"
check_rc "full preserves visibility default exit 0" 0 "$rc"
check "full preserves public visibility default" \
'[{"coverage":true,"name":"ubuntu-latest","runner":"ubuntu-latest"},{"coverage":false,"name":"windows-latest","runner":"windows-latest"},{"coverage":false,"name":"macos-latest","runner":"macos-latest"}]' \
"$out"

run "" '["macos-latest"]' "" ""
check_rc "empty mode preserves os-list behaviour exit 0" 0 "$rc"
check "empty mode preserves os-list behaviour" \
'[{"coverage":false,"name":"macos-latest","runner":"macos-latest"}]' "$out"

run "" "" "public" "bogus"
check_rc "unknown matrix-mode exit 1" 1 "$rc"
check_contains "unknown matrix-mode annotated" "::error::unexpected matrix-mode 'bogus'" "$err"

exit $fail