diff --git a/.github/workflows/csharp-ci.yaml b/.github/workflows/csharp-ci.yaml index fd56966..a1cf97c 100644 --- a/.github/workflows/csharp-ci.yaml +++ b/.github/workflows/csharp-ci.yaml @@ -19,7 +19,7 @@ on: `[{ "name", "os", "arch", "passed" }]`, one entry per matrix shard, suitable to feed (after tagging each entry with a `lang`) into the `update-badges` reusable workflow's `matrix-data` input. - value: ${{ jobs.matrix-output.outputs.matrix-status }} + value: ${{ jobs.coverage-output.outputs.matrix-status }} secrets: BOT_GITHUB_TOKEN: description: >- @@ -184,6 +184,22 @@ on: required: false type: string default: 'nupkg' + plumbing-runner: + description: >- + Runner for the small plumbing jobs (`normalize matrix`, + `coverage-output`, `pack`) that do no build work. Each such job is + billed as at least one full minute on GitHub-hosted runners, so they + are worth keeping off the paid pool. A plain label (e.g. + 'ubuntu-latest') or a JSON array string (e.g. + '["self-hosted","hetzner"]'). Empty (the default) follows + `matrix-mode` / the `CI_MATRIX_MODE` variable: `cheap` on a + private or internal repository routes them to the self-hosted + Hetzner pool, anything else (including every public repository) + keeps `ubuntu-latest`. Independent of the `runner` of each + build-matrix shard. + required: false + type: string + default: '' artifact-prefix: description: >- Prefix applied to per-run artifact names (coverage-percent, @@ -202,7 +218,7 @@ concurrency: jobs: normalize: name: normalize matrix - runs-on: ubuntu-latest + runs-on: ${{ inputs.plumbing-runner != '' && (startsWith(inputs.plumbing-runner, '[') && fromJSON(inputs.plumbing-runner) || inputs.plumbing-runner) || ((inputs.matrix-mode != '' && inputs.matrix-mode || vars.CI_MATRIX_MODE) == 'cheap' && github.event.repository.private == true && fromJSON('["self-hosted","hetzner"]') || 'ubuntu-latest') }} permissions: contents: read outputs: @@ -428,46 +444,23 @@ jobs: coverage-output: name: coverage-output needs: [normalize, build-and-test] - if: ${{ success() }} - runs-on: ubuntu-latest + if: ${{ !cancelled() }} + runs-on: ${{ inputs.plumbing-runner != '' && (startsWith(inputs.plumbing-runner, '[') && fromJSON(inputs.plumbing-runner) || inputs.plumbing-runner) || ((inputs.matrix-mode != '' && inputs.matrix-mode || vars.CI_MATRIX_MODE) == 'cheap' && github.event.repository.private == true && fromJSON('["self-hosted","hetzner"]') || 'ubuntu-latest') }} permissions: + actions: read contents: read - env: - COVERAGE_EXPECTED: ${{ contains(fromJSON(needs.normalize.outputs.matrix).*.coverage, true) }} + pull-requests: write outputs: coverage: ${{ steps.read.outputs.coverage }} + matrix-status: ${{ steps.aggregate.outputs.matrix-status }} steps: - - name: Download coverage value - if: ${{ env.COVERAGE_EXPECTED == 'true' }} - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - name: ${{ inputs.artifact-prefix }}-coverage-percent - path: coverage - - - name: Read coverage value - id: read + - name: Clear stale outputs from earlier jobs on this runner + env: + OUTPUT_DIR: ${{ runner.temp }}/coverage-output run: | set -euo pipefail - coverage="" - if [ -f coverage/coverage-percent.txt ]; then - coverage=$(tr -d '[:space:]' < coverage/coverage-percent.txt) - fi - if [ "$COVERAGE_EXPECTED" = "true" ] && [ -z "$coverage" ]; then - echo "::error::the coverage shard uploaded a coverage-percent artifact but no coverage value reached coverage-output — failing instead of emitting a blank coverage badge" - exit 1 - fi - echo "coverage=$coverage" >> "$GITHUB_OUTPUT" + rm -rf "$OUTPUT_DIR" - matrix-output: - name: matrix-output - needs: build-and-test - if: ${{ !cancelled() }} - runs-on: ubuntu-latest - permissions: - contents: read - outputs: - matrix-status: ${{ steps.aggregate.outputs.matrix-status }} - steps: - name: Checkout CI helpers uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: @@ -479,54 +472,75 @@ jobs: uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: pattern: ${{ inputs.artifact-prefix }}-ci-result-* - path: ci-results + path: ${{ runner.temp }}/coverage-output/ci-results merge-multiple: true - name: Aggregate shard results id: aggregate + env: + RESULTS_DIR: ${{ runner.temp }}/coverage-output/ci-results run: | set -euo pipefail - status="$(.github-actions-helpers/scripts/aggregate_matrix_status.py ci-results)" + status="$(.github-actions-helpers/scripts/aggregate_matrix_status.py "$RESULTS_DIR")" echo "matrix-status=$status" >> "$GITHUB_OUTPUT" - matrix-comment: - name: matrix-comment (${{ inputs.artifact-prefix }}) - needs: build-and-test - if: ${{ !cancelled() && inputs.build-matrix != '' && github.event_name == 'pull_request' && github.actor != 'dependabot[bot]' }} - runs-on: ubuntu-latest - permissions: - actions: read - contents: read - pull-requests: write - steps: - - name: Checkout CI helpers - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Download coverage value + if: ${{ needs.build-and-test.result == 'success' && contains(fromJSON(needs.normalize.outputs.matrix || '[]').*.coverage, true) }} + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: - repository: peacefulstudio/github-actions - ref: ${{ job.workflow_sha }} - path: .github-actions-helpers + name: ${{ inputs.artifact-prefix }}-coverage-percent + path: ${{ runner.temp }}/coverage-output/coverage + + - name: Read coverage value + id: read + env: + COVERAGE_EXPECTED: ${{ needs.build-and-test.result == 'success' && contains(fromJSON(needs.normalize.outputs.matrix || '[]').*.coverage, true) }} + COVERAGE_FILE: ${{ runner.temp }}/coverage-output/coverage/coverage-percent.txt + run: | + set -euo pipefail + coverage="" + if [ -f "$COVERAGE_FILE" ]; then + coverage=$(tr -d '[:space:]' < "$COVERAGE_FILE") + fi + echo "coverage=$coverage" >> "$GITHUB_OUTPUT" + if [ "$COVERAGE_EXPECTED" = "true" ] && [ -z "$coverage" ]; then + echo "::error::the coverage shard uploaded a coverage-percent artifact but no coverage value reached coverage-output — failing instead of emitting a blank coverage badge" + exit 1 + fi + - name: Render build-matrix table + id: render + if: ${{ !cancelled() && inputs.build-matrix != '' && github.event_name == 'pull_request' && github.actor != 'dependabot[bot]' }} + continue-on-error: true env: GH_TOKEN: ${{ github.token }} - SELF_JOB_NAME: matrix-comment (${{ inputs.artifact-prefix }}) MATRIX_TITLE: 'C# build matrix' + COMMENT_FILE: ${{ runner.temp }}/coverage-output/build-matrix-results.md run: | set -euo pipefail gh api --paginate "repos/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}/jobs" \ - | .github-actions-helpers/scripts/render_matrix_comment.py "$SELF_JOB_NAME" "$MATRIX_TITLE" \ - > build-matrix-results.md + | .github-actions-helpers/scripts/render_matrix_comment.py "coverage-output" "$MATRIX_TITLE" "$RUNNER_NAME" \ + > "$COMMENT_FILE" + - name: Add build-matrix PR comment + id: post + if: ${{ !cancelled() && steps.render.outcome == 'success' }} + continue-on-error: true uses: marocchino/sticky-pull-request-comment@0ea0beb66eb9baf113663a64ec522f60e49231c0 # v3.0.4 with: recreate: true header: ${{ inputs.artifact-prefix }}-build-matrix - path: build-matrix-results.md + path: ${{ runner.temp }}/coverage-output/build-matrix-results.md + + - name: Warn when the build-matrix comment was not posted + if: ${{ !cancelled() && (steps.render.outcome == 'failure' || steps.post.outcome == 'failure') }} + run: echo "::warning::the build-matrix PR comment was not rendered or posted; coverage-output outputs and result are unaffected" pack: name: pack if: ${{ inputs.pack }} needs: build-and-test - runs-on: ubuntu-latest + runs-on: ${{ inputs.plumbing-runner != '' && (startsWith(inputs.plumbing-runner, '[') && fromJSON(inputs.plumbing-runner) || inputs.plumbing-runner) || ((inputs.matrix-mode != '' && inputs.matrix-mode || vars.CI_MATRIX_MODE) == 'cheap' && github.event.repository.private == true && fromJSON('["self-hosted","hetzner"]') || 'ubuntu-latest') }} permissions: contents: read packages: read diff --git a/.github/workflows/scala-ci.yaml b/.github/workflows/scala-ci.yaml index ab85640..b291991 100644 --- a/.github/workflows/scala-ci.yaml +++ b/.github/workflows/scala-ci.yaml @@ -16,7 +16,7 @@ on: description: >- JSON array of per-shard build results `[{ "name", "os", "arch", "passed" }]`, one entry per matrix shard. - value: ${{ jobs.matrix-output.outputs.matrix-status }} + value: ${{ jobs.coverage-output.outputs.matrix-status }} inputs: working-directory: description: >- @@ -119,6 +119,22 @@ on: required: false type: string default: 'scala-coverage' + plumbing-runner: + description: >- + Runner for the small plumbing jobs (`normalize matrix`, + `coverage-output`) that do no build work. Each such job is + billed as at least one full minute on GitHub-hosted runners, so they + are worth keeping off the paid pool. A plain label (e.g. + 'ubuntu-latest') or a JSON array string (e.g. + '["self-hosted","hetzner"]'). Empty (the default) follows + `matrix-mode` / the `CI_MATRIX_MODE` variable: `cheap` on a + private or internal repository routes them to the self-hosted + Hetzner pool, anything else (including every public repository) + keeps `ubuntu-latest`. Independent of the `runner` of each + build-matrix shard. + required: false + type: string + default: '' artifact-prefix: description: >- Prefix applied to per-run artifact names (coverage-percent, @@ -159,7 +175,7 @@ on: jobs: normalize: name: normalize matrix - runs-on: ubuntu-latest + runs-on: ${{ inputs.plumbing-runner != '' && (startsWith(inputs.plumbing-runner, '[') && fromJSON(inputs.plumbing-runner) || inputs.plumbing-runner) || ((inputs.matrix-mode != '' && inputs.matrix-mode || vars.CI_MATRIX_MODE) == 'cheap' && github.event.repository.private == true && fromJSON('["self-hosted","hetzner"]') || 'ubuntu-latest') }} permissions: contents: read outputs: @@ -358,46 +374,23 @@ jobs: coverage-output: name: coverage-output needs: [normalize, build-and-test] - if: ${{ success() }} - runs-on: ubuntu-latest + if: ${{ !cancelled() }} + runs-on: ${{ inputs.plumbing-runner != '' && (startsWith(inputs.plumbing-runner, '[') && fromJSON(inputs.plumbing-runner) || inputs.plumbing-runner) || ((inputs.matrix-mode != '' && inputs.matrix-mode || vars.CI_MATRIX_MODE) == 'cheap' && github.event.repository.private == true && fromJSON('["self-hosted","hetzner"]') || 'ubuntu-latest') }} permissions: + actions: read contents: read - env: - COVERAGE_EXPECTED: ${{ contains(fromJSON(needs.normalize.outputs.matrix).*.coverage, true) }} + pull-requests: write outputs: coverage: ${{ steps.read.outputs.coverage }} + matrix-status: ${{ steps.aggregate.outputs.matrix-status }} steps: - - name: Download coverage value - if: ${{ env.COVERAGE_EXPECTED == 'true' }} - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - name: ${{ inputs.artifact-prefix }}-coverage-percent - path: coverage - - - name: Read coverage value - id: read + - name: Clear stale outputs from earlier jobs on this runner + env: + OUTPUT_DIR: ${{ runner.temp }}/coverage-output run: | set -euo pipefail - coverage="" - if [ -f coverage/coverage-percent.txt ]; then - coverage=$(tr -d '[:space:]' < coverage/coverage-percent.txt) - fi - if [ "$COVERAGE_EXPECTED" = "true" ] && [ -z "$coverage" ]; then - echo "::error::the coverage shard uploaded a coverage-percent artifact but no coverage value reached coverage-output — failing instead of emitting a blank coverage badge" - exit 1 - fi - echo "coverage=$coverage" >> "$GITHUB_OUTPUT" + rm -rf "$OUTPUT_DIR" - matrix-output: - name: matrix-output - needs: build-and-test - if: ${{ !cancelled() }} - runs-on: ubuntu-latest - permissions: - contents: read - outputs: - matrix-status: ${{ steps.aggregate.outputs.matrix-status }} - steps: - name: Checkout CI helpers uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: @@ -409,45 +402,67 @@ jobs: uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: pattern: ${{ inputs.artifact-prefix }}-ci-result-* - path: ci-results + path: ${{ runner.temp }}/coverage-output/ci-results merge-multiple: true - name: Aggregate shard results id: aggregate + env: + RESULTS_DIR: ${{ runner.temp }}/coverage-output/ci-results run: | set -euo pipefail - status="$(.github-actions-helpers/scripts/aggregate_matrix_status.py ci-results)" + status="$(.github-actions-helpers/scripts/aggregate_matrix_status.py "$RESULTS_DIR")" echo "matrix-status=$status" >> "$GITHUB_OUTPUT" - matrix-comment: - name: matrix-comment (${{ inputs.artifact-prefix }}) - needs: build-and-test - if: ${{ !cancelled() && inputs.build-matrix != '' && github.event_name == 'pull_request' && github.actor != 'dependabot[bot]' }} - runs-on: ubuntu-latest - permissions: - actions: read - contents: read - pull-requests: write - steps: - - name: Checkout CI helpers - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Download coverage value + if: ${{ needs.build-and-test.result == 'success' && contains(fromJSON(needs.normalize.outputs.matrix || '[]').*.coverage, true) }} + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: - repository: peacefulstudio/github-actions - ref: ${{ job.workflow_sha }} - path: .github-actions-helpers + name: ${{ inputs.artifact-prefix }}-coverage-percent + path: ${{ runner.temp }}/coverage-output/coverage + + - name: Read coverage value + id: read + env: + COVERAGE_EXPECTED: ${{ needs.build-and-test.result == 'success' && contains(fromJSON(needs.normalize.outputs.matrix || '[]').*.coverage, true) }} + COVERAGE_FILE: ${{ runner.temp }}/coverage-output/coverage/coverage-percent.txt + run: | + set -euo pipefail + coverage="" + if [ -f "$COVERAGE_FILE" ]; then + coverage=$(tr -d '[:space:]' < "$COVERAGE_FILE") + fi + echo "coverage=$coverage" >> "$GITHUB_OUTPUT" + if [ "$COVERAGE_EXPECTED" = "true" ] && [ -z "$coverage" ]; then + echo "::error::the coverage shard uploaded a coverage-percent artifact but no coverage value reached coverage-output — failing instead of emitting a blank coverage badge" + exit 1 + fi + - name: Render build-matrix table + id: render + if: ${{ !cancelled() && inputs.build-matrix != '' && github.event_name == 'pull_request' && github.actor != 'dependabot[bot]' }} + continue-on-error: true env: GH_TOKEN: ${{ github.token }} - SELF_JOB_NAME: matrix-comment (${{ inputs.artifact-prefix }}) MATRIX_TITLE: 'Scala build matrix' + COMMENT_FILE: ${{ runner.temp }}/coverage-output/build-matrix-results.md run: | set -euo pipefail gh api --paginate "repos/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}/jobs" \ - | .github-actions-helpers/scripts/render_matrix_comment.py "$SELF_JOB_NAME" "$MATRIX_TITLE" \ - > build-matrix-results.md + | .github-actions-helpers/scripts/render_matrix_comment.py "coverage-output" "$MATRIX_TITLE" "$RUNNER_NAME" \ + > "$COMMENT_FILE" + - name: Add build-matrix PR comment + id: post + if: ${{ !cancelled() && steps.render.outcome == 'success' }} + continue-on-error: true uses: marocchino/sticky-pull-request-comment@0ea0beb66eb9baf113663a64ec522f60e49231c0 # v3.0.4 with: recreate: true header: ${{ inputs.artifact-prefix }}-build-matrix - path: build-matrix-results.md + path: ${{ runner.temp }}/coverage-output/build-matrix-results.md + + - name: Warn when the build-matrix comment was not posted + if: ${{ !cancelled() && (steps.render.outcome == 'failure' || steps.post.outcome == 'failure') }} + run: echo "::warning::the build-matrix PR comment was not rendered or posted; coverage-output outputs and result are unaffected" + diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f23b39..5a7eade 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- `csharp-ci.yaml` and `scala-ci.yaml` gain an optional `plumbing-runner` input (plain label or JSON array string, default empty) selecting the runner for the small plumbing jobs (`normalize matrix`, `coverage-output`, and `csharp-ci`'s `pack`). Empty follows the existing cost switch: when `matrix-mode` (or, if that is empty, the `CI_MATRIX_MODE` variable) is `cheap` and the caller repository is private or internal, they run on `["self-hosted","hetzner"]`; in every other case (public repositories, `full`, unset) they keep `ubuntu-latest`, so callers not on `cheap` see no change. Because the org-level `CI_MATRIX_MODE=cheap` variable applies to every private repository, private `@v2` consumers switch to Hetzner plumbing as soon as the floating `v2` tag moves; set `plumbing-runner: ubuntu-latest` to opt out. A `cheap` run therefore bills no GitHub-hosted minutes; each of these jobs takes seconds but is billed as a full minute. + +### Changed + +- `csharp-ci.yaml` and `scala-ci.yaml` fold the `matrix-output` and `matrix-comment` jobs into `coverage-output`, cutting the plumbing jobs of a run from four to two (`normalize matrix`, `coverage-output`) and the rounded-up billed minutes with them. The `coverage` and `matrix-status` workflow outputs are unchanged (both now come from `coverage-output`), and `coverage-output` keeps its name so branch-protection checks such as `csharp-ci / coverage-output` still resolve. `coverage-output` now runs on `!cancelled()` instead of `success()`: after a failed shard it succeeds with an empty `coverage` (the called workflow still fails through `build-and-test`), and the missing-coverage-value error is raised after both outputs are written. `coverage-output` reads only what the current job downloaded (into a fresh directory under `$RUNNER_TEMP`), so files left by earlier jobs on a persistent self-hosted workspace cannot leak into `coverage` or `matrix-status`. Rendering or posting the PR comment is non-fatal: on failure a warning annotation is emitted and the job result and outputs are unaffected. The `matrix-comment ()` job no longer exists; its PR comment is posted by the last steps of `coverage-output`, under the same `-build-matrix` header and with the same conditions. `scripts/render_matrix_comment.py` takes an optional third argument (the runner name) to identify the calling job when several invocations share the `coverage-output` display name. + ### Fixed - Fix `csharp-ci.yaml`'s coverage shard crashing in `dotnet-coverage merge` (exit 134, `FileNotFoundException` for `Microsoft.CodeCoverage.Instrumentation`) when another job on the same self-hosted host installs a different `dotnet-coverage` version at the same moment. The `Install dotnet-coverage` step installed the tool globally (`dotnet tool install -g`), and every runner slot on a shared host (e.g. the four Hetzner slots) runs as the same Unix user, so they all shared one `~/.dotnet/tools`. Consumers pin different versions through `Microsoft.Testing.Extensions.CodeCoverage`, and the 2.3.x fix (#31) made each job uninstall and reinstall the global tool whenever its pin differed from what was there, pulling the binary out from under any other slot's `dotnet-coverage merge` that was running. `scripts/install-dotnet-coverage.sh` now installs the pinned version with `--tool-path` into `$RUNNER_TEMP/dotnet-coverage-`, which is private to the job and wiped between jobs, and prepends that directory to `GITHUB_PATH`. It never touches the global tool, so no job can affect another job's copy, and whatever version an earlier job left globally no longer matters. The step fails loud if `RUNNER_TEMP` or `GITHUB_PATH` is unset. GitHub-hosted consumers see no change beyond the tool's install location. diff --git a/README.md b/README.md index a5b73d3..9415dbb 100644 --- a/README.md +++ b/README.md @@ -136,6 +136,7 @@ choosing which shard carries coverage — use `build-matrix` instead (see | `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). | +| `plumbing-runner` | *(empty → follows `matrix-mode`)* | Runner for the small plumbing jobs (`normalize matrix`, `coverage-output`, `pack`). A plain label or JSON array string. Empty: `cheap` (via `matrix-mode` or `CI_MATRIX_MODE`) on a private/internal repo routes them to the Hetzner pool; otherwise `ubuntu-latest`. 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). | @@ -262,6 +263,7 @@ coverage — use `build-matrix` instead (see | `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). | +| `plumbing-runner` | *(empty → follows `matrix-mode`)* | Runner for the small plumbing jobs (`normalize matrix`, `coverage-output`). A plain label or JSON array string. Empty: `cheap` (via `matrix-mode` or `CI_MATRIX_MODE`) on a private/internal repo routes them to the Hetzner pool; otherwise `ubuntu-latest`. 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. | @@ -573,6 +575,12 @@ CI_MATRIX_MODE = full # all callers run the normal matrix # unset # callers run the normal matrix ``` +The small plumbing jobs (`normalize matrix` and `coverage-output`, plus `pack` +in `csharp-ci`) follow the same switch: under `cheap` on a private or internal +repository they run on the Hetzner pool too, so a fully-cheap run bills no +GitHub-hosted minutes. Override them independently with `plumbing-runner`. +They stay on `ubuntu-latest` for public repositories and under `full`. + 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`: diff --git a/scripts/render_matrix_comment.py b/scripts/render_matrix_comment.py index afcf944..cec0f2b 100755 --- a/scripts/render_matrix_comment.py +++ b/scripts/render_matrix_comment.py @@ -6,7 +6,9 @@ Reads the concatenated JSON pages emitted by ``gh api --paginate repos//actions/runs//jobs`` on stdin, anchors on the calling aggregator job's own display name to resolve the -reusable-workflow name prefix, and prints a ``shard | result | duration`` +reusable-workflow name prefix (when several invocations share that display +name, the optional runner name picks the one still in progress on this +runner), and prints a ``shard | result | duration`` table covering the sibling ``build-and-test ()`` jobs of the same workflow invocation. """ @@ -42,11 +44,16 @@ def parse_job_pages(text): return jobs -def workflow_prefix(jobs, self_job_name): +def is_running_on(job, runner_name): + return job.get('status') == 'in_progress' and job.get('runner_name') == runner_name + + +def workflow_prefix(jobs, self_job_name, runner_name=None): matches = [ job['name'] for job in jobs - if job['name'] == self_job_name or job['name'].endswith(f' / {self_job_name}') + if (job['name'] == self_job_name or job['name'].endswith(f' / {self_job_name}')) + and (runner_name is None or is_running_on(job, runner_name)) ] if not matches: raise ValueError(f'no job named {self_job_name!r} found in this run') @@ -94,8 +101,8 @@ def format_duration(job): return f'{minutes}m {remainder:02d}s' -def render(jobs, self_job_name, title): - prefix = workflow_prefix(jobs, self_job_name) +def render(jobs, self_job_name, title, runner_name=None): + prefix = workflow_prefix(jobs, self_job_name, runner_name) shards = shard_jobs(jobs, prefix) if not shards: raise ValueError( @@ -117,13 +124,14 @@ def render(jobs, self_job_name, title): def main(argv): - if len(argv) != 3: + if len(argv) not in (3, 4): print( - 'error: usage: render_matrix_comment.py ', + 'error: usage: render_matrix_comment.py <self-job-name> <title> [<runner-name>]', file=sys.stderr, ) return 2 - print(render(parse_job_pages(sys.stdin.read()), argv[1], argv[2]), end='') + runner_name = argv[3] if len(argv) == 4 else None + print(render(parse_job_pages(sys.stdin.read()), argv[1], argv[2], runner_name), end='') return 0 diff --git a/test/render_matrix_comment_test.py b/test/render_matrix_comment_test.py index c371c35..076e477 100644 --- a/test/render_matrix_comment_test.py +++ b/test/render_matrix_comment_test.py @@ -19,6 +19,8 @@ def job( started='2026-07-10T12:00:00Z', completed='2026-07-10T12:03:42Z', url='https://github.test/run/1/job/1', + status='completed', + runner_name=None, ): return { 'name': name, @@ -26,6 +28,8 @@ def job( 'started_at': started, 'completed_at': completed, 'html_url': url, + 'status': status, + 'runner_name': runner_name, } @@ -93,6 +97,36 @@ def test_duplicate_self_job_fails_loudly(self): with self.assertRaises(ValueError): render_matrix_comment.render(jobs, SELF_JOB_NAME, TITLE) + def test_runner_name_disambiguates_identically_named_self_jobs(self): + jobs = [ + job('csharp / build-and-test (linux-x64)', url='https://github.test/j/csharp'), + job('scala / build-and-test (linux-x64)', conclusion='failure', url='https://github.test/j/scala'), + job('csharp / coverage-output', conclusion=None, status='in_progress', runner_name='runner-a'), + job('scala / coverage-output', conclusion=None, status='in_progress', runner_name='runner-b'), + ] + rendered = render_matrix_comment.render(jobs, 'coverage-output', TITLE, 'runner-a') + self.assertIn('https://github.test/j/csharp', rendered) + self.assertNotIn('https://github.test/j/scala', rendered) + + def test_runner_name_ignores_finished_jobs_of_the_same_runner(self): + jobs = [ + job('csharp / build-and-test (linux-x64)', url='https://github.test/j/csharp'), + job('scala / build-and-test (linux-x64)', conclusion='failure', url='https://github.test/j/scala'), + job('scala / coverage-output', status='completed', runner_name='runner-a'), + job('csharp / coverage-output', conclusion=None, status='in_progress', runner_name='runner-a'), + ] + rendered = render_matrix_comment.render(jobs, 'coverage-output', TITLE, 'runner-a') + self.assertIn('https://github.test/j/csharp', rendered) + self.assertNotIn('https://github.test/j/scala', rendered) + + def test_runner_name_without_matching_running_job_fails_loudly(self): + jobs = [ + job('ci / build-and-test (a)'), + job('ci / coverage-output', conclusion=None, status='in_progress', runner_name='runner-a'), + ] + with self.assertRaises(ValueError): + render_matrix_comment.render(jobs, 'coverage-output', TITLE, 'runner-z') + def test_no_shard_jobs_fails_loudly(self): with self.assertRaises(ValueError): render_matrix_comment.render(