diff --git a/.github/workflows/csharp-ci.yaml b/.github/workflows/csharp-ci.yaml index f83d90e..b7ad013 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,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: diff --git a/.github/workflows/scala-ci.yaml b/.github/workflows/scala-ci.yaml index 5780b32..3bee5e4 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,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: diff --git a/CHANGELOG.md b/CHANGELOG.md index b68d253..a56a591 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: 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 diff --git a/README.md b/README.md index 2b4e6f2..2883ab3 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`)* | 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). | @@ -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. | @@ -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 diff --git a/scripts/normalize-ci-matrix.sh b/scripts/normalize-ci-matrix.sh index aa32c54..89f64ca 100755 --- a/scripts/normalize-ci-matrix.sh +++ b/scripts/normalize-ci-matrix.sh @@ -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 diff --git a/test/normalize-ci-matrix_test.sh b/test/normalize-ci-matrix_test.sh index bc5f06f..e74f6a5 100755 --- a/test/normalize-ci-matrix_test.sh +++ b/test/normalize-ci-matrix_test.sh @@ -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