From 76947191265007533c2d5a272cdc80a33a92a455 Mon Sep 17 00:00:00 2001 From: monsieurleberre Date: Wed, 17 Jun 2026 08:02:05 +0100 Subject: [PATCH 1/4] feat(ci): cheap matrix mode via matrix-mode input + CI_MATRIX_MODE variable Add a `matrix-mode` input to csharp-ci.yaml and scala-ci.yaml and a new org/repo Actions variable CI_MATRIX_MODE. `cheap` collapses the build/test matrix to a single free self-hosted Hetzner leg, overriding os-list and build-matrix; `full` forces the normal matrix; empty defers to the variable. Precedence: input > CI_MATRIX_MODE > normal matrix. normalize-ci-matrix.sh gains a 4th positional arg reusing private_default as the single source of the Hetzner leg, with loud failure on unknown modes. --- .github/workflows/csharp-ci.yaml | 18 ++++++++++++++-- .github/workflows/scala-ci.yaml | 18 ++++++++++++++-- CHANGELOG.md | 4 ++++ README.md | 36 ++++++++++++++++++++++++++++++++ scripts/normalize-ci-matrix.sh | 14 +++++++++++++ test/normalize-ci-matrix_test.sh | 29 +++++++++++++++++++++++++ 6 files changed, 115 insertions(+), 4 deletions(-) diff --git a/.github/workflows/csharp-ci.yaml b/.github/workflows/csharp-ci.yaml index f83d90e..49d5484 100644 --- a/.github/workflows/csharp-ci.yaml +++ b/.github/workflows/csharp-ci.yaml @@ -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 @@ -206,17 +219,18 @@ 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 visibility="" - if [ -z "$BUILD_MATRIX" ] && [ -z "$OS_LIST" ]; then + if [ "$MATRIX_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: diff --git a/.github/workflows/scala-ci.yaml b/.github/workflows/scala-ci.yaml index 5780b32..b3717aa 100644 --- a/.github/workflows/scala-ci.yaml +++ b/.github/workflows/scala-ci.yaml @@ -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 @@ -164,17 +177,18 @@ 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 visibility="" - if [ -z "$BUILD_MATRIX" ] && [ -z "$OS_LIST" ]; then + if [ "$MATRIX_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: diff --git a/CHANGELOG.md b/CHANGELOG.md index b68d253..38e7959 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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: `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`. (#PR) + ## [2.2.0] - 2026-06-14 ### Added diff --git a/README.md b/README.md index 2b4e6f2..eaff175 100644 --- a/README.md +++ b/README.md @@ -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`)* | `cheap` collapses the matrix to a single free self-hosted Hetzner shard, overriding `os-list` / `build-matrix`; `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). | @@ -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`)* | `cheap` collapses the matrix to a single free self-hosted Hetzner shard, overriding `os-list` / `build-matrix`; `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. | @@ -529,6 +531,40 @@ 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`. +- `full` — force the normal matrix (visibility default, `os-list`, or + `build-matrix`). +- *(empty, the default)* — defer to the org/repo variable `CI_MATRIX_MODE`. + +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@v1 + with: + matrix-mode: cheap +``` + ## Pinning Pin to a major version tag (`@v1`) for stability. Float to the major tag diff --git a/scripts/normalize-ci-matrix.sh b/scripts/normalize-ci-matrix.sh index aa32c54..63558e4 100755 --- a/scripts/normalize-ci-matrix.sh +++ b/scripts/normalize-ci-matrix.sh @@ -6,10 +6,24 @@ 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) + build_matrix="$private_default" + os_list="" + visibility="" + ;; + ""|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 diff --git a/test/normalize-ci-matrix_test.sh b/test/normalize-ci-matrix_test.sh index bc5f06f..b01efa8 100755 --- a/test/normalize-ci-matrix_test.sh +++ b/test/normalize-ci-matrix_test.sh @@ -148,4 +148,33 @@ 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"]}]' + +run '[{"name":"a","runner":"ubuntu-latest","coverage":true}]' '["windows-latest"]' "public" "cheap" +check_rc "cheap overrides explicit build-matrix exit 0" 0 "$rc" +check "cheap overrides explicit build-matrix" "$hetzner_leg" "$out" + +run "" "" "" "cheap" +check_rc "cheap ignores empty visibility exit 0" 0 "$rc" +check "cheap with no other source still resolves hetzner leg" "$hetzner_leg" "$out" + +run "" "" "public" "CHEAP" +check_rc "cheap is case-insensitive exit 0" 0 "$rc" +check "cheap is case-insensitive" "$hetzner_leg" "$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 From dbdb65dcc2589e27bb9bfb9ca4b99235f58d9573 Mon Sep 17 00:00:00 2001 From: monsieurleberre Date: Wed, 17 Jun 2026 08:04:12 +0100 Subject: [PATCH 2/4] docs(changelog): reference PR #27 for cheap matrix mode --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 38e7959..818e438 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### 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: `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`. (#PR) +- 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: `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`. (#27) ## [2.2.0] - 2026-06-14 From 082d2c69456b081605f307c70de083cb9cbdcee9 Mon Sep 17 00:00:00 2001 From: monsieurleberre Date: Wed, 17 Jun 2026 08:15:41 +0100 Subject: [PATCH 3/4] feat(ci): restrict cheap matrix mode to private/internal repos Self-hosted Hetzner runners must not run public/fork-PR workloads, so cheap mode now falls through to the normal matrix (with a warning) on public repos. The reusable workflows always resolve repository visibility before invoking the normalizer so the guard can apply. --- .github/workflows/csharp-ci.yaml | 3 ++- .github/workflows/scala-ci.yaml | 3 ++- CHANGELOG.md | 2 +- README.md | 13 ++++++++++--- scripts/normalize-ci-matrix.sh | 9 ++++++--- test/normalize-ci-matrix_test.sh | 18 ++++++++++++++++-- 6 files changed, 37 insertions(+), 11 deletions(-) diff --git a/.github/workflows/csharp-ci.yaml b/.github/workflows/csharp-ci.yaml index 49d5484..b7ad013 100644 --- a/.github/workflows/csharp-ci.yaml +++ b/.github/workflows/csharp-ci.yaml @@ -222,8 +222,9 @@ jobs: 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 [ "$MATRIX_MODE" != "cheap" ] && [ -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 diff --git a/.github/workflows/scala-ci.yaml b/.github/workflows/scala-ci.yaml index b3717aa..3bee5e4 100644 --- a/.github/workflows/scala-ci.yaml +++ b/.github/workflows/scala-ci.yaml @@ -180,8 +180,9 @@ jobs: 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 [ "$MATRIX_MODE" != "cheap" ] && [ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 818e438..02e2f5e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### 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: `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`. (#27) +- 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: `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 diff --git a/README.md b/README.md index eaff175..286336b 100644 --- a/README.md +++ b/README.md @@ -539,11 +539,18 @@ 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`. + 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: @@ -560,9 +567,9 @@ the org variable is `cheap`: ```yaml jobs: csharp-ci: - uses: peacefulstudio/github-actions/.github/workflows/csharp-ci.yaml@v1 + uses: peacefulstudio/github-actions/.github/workflows/csharp-ci.yaml@v2 with: - matrix-mode: cheap + matrix-mode: full ``` ## Pinning diff --git a/scripts/normalize-ci-matrix.sh b/scripts/normalize-ci-matrix.sh index 63558e4..b76d854 100755 --- a/scripts/normalize-ci-matrix.sh +++ b/scripts/normalize-ci-matrix.sh @@ -13,9 +13,12 @@ private_default='[{"name":"linux","runner":["self-hosted","hetzner"],"coverage": case "$(printf '%s' "$matrix_mode" | tr '[:upper:]' '[:lower:]')" in cheap) - build_matrix="$private_default" - os_list="" - visibility="" + if [ "$visibility" = "public" ]; then + echo "::warning::matrix-mode=cheap ignored on public repo (self-hosted runners must not run public/fork workloads); using normal matrix" >&2 + else + build_matrix="$private_default" + os_list="" + fi ;; ""|full) ;; *) diff --git a/test/normalize-ci-matrix_test.sh b/test/normalize-ci-matrix_test.sh index b01efa8..6e539b5 100755 --- a/test/normalize-ci-matrix_test.sh +++ b/test/normalize-ci-matrix_test.sh @@ -149,19 +149,33 @@ 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"]' "public" "cheap" +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 ignores empty visibility exit 0" 0 "$rc" check "cheap with no other source still resolves hetzner leg" "$hetzner_leg" "$out" -run "" "" "public" "CHEAP" +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 '[{"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" \ From 93ade78bbf342aa8a6b2f4df5697e2cbfd0dbd38 Mon Sep 17 00:00:00 2001 From: monsieurleberre Date: Wed, 17 Jun 2026 08:26:04 +0100 Subject: [PATCH 4/4] fix(ci): fail closed when cheap mode lacks a known visibility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Internal review: the cheap-mode guard checked only "= public", so any non-public value (empty, garbled gh api response) collapsed to the self-hosted Hetzner leg — fail-open on a security boundary. Switch to an allowlist: private/internal collapse, public warns and falls through, anything else errors and refuses to route to self-hosted runners. Also qualify the README/CHANGELOG matrix-mode summaries as private/internal only, and add an uppercase-CHEAP-on-public regression test. --- CHANGELOG.md | 2 +- README.md | 4 ++-- scripts/normalize-ci-matrix.sh | 19 +++++++++++++------ test/normalize-ci-matrix_test.sh | 9 +++++++-- 4 files changed, 23 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 02e2f5e..a56a591 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### 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: `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) +- 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 diff --git a/README.md b/README.md index 286336b..2883ab3 100644 --- a/README.md +++ b/README.md @@ -135,7 +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`)* | `cheap` collapses the matrix to a single free self-hosted Hetzner shard, overriding `os-list` / `build-matrix`; `full` forces the normal matrix; empty defers to the org/repo variable `CI_MATRIX_MODE`. See [Cheap matrix mode](#cheap-matrix-mode). | +| `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). | @@ -248,7 +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`)* | `cheap` collapses the matrix to a single free self-hosted Hetzner shard, overriding `os-list` / `build-matrix`; `full` forces the normal matrix; empty defers to the org/repo variable `CI_MATRIX_MODE`. See [Cheap matrix mode](#cheap-matrix-mode). | +| `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. | diff --git a/scripts/normalize-ci-matrix.sh b/scripts/normalize-ci-matrix.sh index b76d854..89f64ca 100755 --- a/scripts/normalize-ci-matrix.sh +++ b/scripts/normalize-ci-matrix.sh @@ -13,12 +13,19 @@ private_default='[{"name":"linux","runner":["self-hosted","hetzner"],"coverage": case "$(printf '%s' "$matrix_mode" | tr '[:upper:]' '[:lower:]')" in cheap) - if [ "$visibility" = "public" ]; then - echo "::warning::matrix-mode=cheap ignored on public repo (self-hosted runners must not run public/fork workloads); using normal matrix" >&2 - else - build_matrix="$private_default" - os_list="" - fi + 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) ;; *) diff --git a/test/normalize-ci-matrix_test.sh b/test/normalize-ci-matrix_test.sh index 6e539b5..e74f6a5 100755 --- a/test/normalize-ci-matrix_test.sh +++ b/test/normalize-ci-matrix_test.sh @@ -160,8 +160,8 @@ 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 ignores empty visibility exit 0" 0 "$rc" -check "cheap with no other source still resolves hetzner leg" "$hetzner_leg" "$out" +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" @@ -172,6 +172,11 @@ 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"